Skip to content
123 linesCodeBlameRaw
1/**
2 * Slides (`kind: "slides"`): a deck's model and the changes agents make to
3 * it. Wire shapes are snake_case.
4 *
5 * In the Yjs document: `Y.Map("deck")` holds `SlidesDeck`; `Y.Array("slides")`
6 * holds one `Y.Map` per slide (`SlideMeta`); each slide region is a root
7 * `XmlFragment` named by `slideFragment(id, region)`, a BlockNote editor of
8 * its own.
9 *
10 * Agents, templates, export and the text rendition use the Markdown form:
11 * slides separated by `---` lines, each starting `<!-- slide: <layout> -->`,
12 * `::: left` / `::: right` for columns, and `Note:` for speaker notes.
13 */
14
15export type SlideLayout = "title" | "title-body" | "two-column" | "section" | "image" | "quote" | "big-number" | "blank";
16export const SLIDE_LAYOUTS: readonly SlideLayout[] = ["title", "title-body", "two-column", "section", "image", "quote", "big-number", "blank"];
17
18export const SLIDE_LAYOUT_LABELS: Record<SlideLayout, string> = {
19 title: "Title",
20 "title-body": "Title and body",
21 "two-column": "Two columns",
22 section: "Section",
23 image: "Image",
24 quote: "Quote",
25 "big-number": "Big number",
26 blank: "Blank",
27};
28
29export type SlideRegion = "title" | "body" | "left" | "right" | "notes";
30
31/** The regions each layout has; every slide also has `notes`. */
32export const SLIDE_LAYOUT_REGIONS: Record<SlideLayout, readonly SlideRegion[]> = {
33 title: ["title", "body", "notes"],
34 "title-body": ["title", "body", "notes"],
35 "two-column": ["title", "left", "right", "notes"],
36 section: ["title", "notes"],
37 image: ["title", "body", "notes"],
38 quote: ["body", "notes"],
39 "big-number": ["title", "body", "notes"],
40 blank: ["body", "notes"],
41};
42
43export type SlidesAspect = "16:9" | "4:3";
44export const SLIDES_ASPECTS: readonly SlidesAspect[] = ["16:9", "4:3"];
45
46/** The deck's settings: `Y.Map("deck")`. `theme` names one of the editor's themes. */
47export type SlidesDeck = { theme: string; aspect: SlidesAspect; accent: string | null };
48
49/** One slide's own fields: an entry of `Y.Array("slides")`. */
50export type SlideMeta = { id: string; layout: SlideLayout; background: string | null; hidden: boolean; transition: "none" };
51
52/** The Yjs names the deck uses. */
53export const SLIDES_DECK_MAP = "deck";
54export const SLIDES_ARRAY = "slides";
55
56/** The root fragment that holds one region of one slide. */
57export function slideFragment(slideId: string, region: SlideRegion): string {
58 return `slide:${slideId}:${region}`;
59}
60
61/** The most slides in a deck. */
62export const SLIDES_MAX = 300;
63
64/** What a card shows of a deck: its first slide, never more. */
65export type SlidesPreview = { kind: "slides"; aspect: SlidesAspect; count: number; layout: SlideLayout; title: string };
66
67/** A change an agent makes to a deck. Markdown is in the deck's Markdown form. */
68export type SlidesOp =
69 /** Replace every slide. */
70 | { op: "replace_deck"; markdown: string }
71 /** Add slides after one (null: at the start). */
72 | { op: "insert_slides"; after_slide_id: string | null; markdown: string }
73 /** Replace one slide with what the Markdown holds (one slide or more). */
74 | { op: "replace_slide"; slide_id: string; markdown: string }
75 | { op: "delete_slides"; slide_ids: string[] }
76 /** Move a slide after another (null: to the start). */
77 | { op: "move_slide"; slide_id: string; after_slide_id: string | null }
78 | { op: "set_notes"; slide_id: string; markdown: string }
79 | { op: "set_theme"; theme: string; accent: string | null };
80
81export const SLIDES_OPS: readonly SlidesOp["op"][] = ["replace_deck", "insert_slides", "replace_slide", "delete_slides", "move_slide", "set_notes", "set_theme"];
82
83/** The largest Markdown one op carries, in characters. */
84export const SLIDES_MAX_MARKDOWN = 200_000;
85
86const THEME = /^[a-z0-9-]{1,40}$/;
87const COLOR = /^#[0-9a-f]{6}$/i;
88
89/** What is wrong with a slides op, or null. Checks its shape; whether its ids exist is the room's to say. */
90export function slidesOpError(op: unknown): string | null {
91 if (!isObject(op) || typeof op.op !== "string") return "A slides change has an op.";
92 const markdown = () => {
93 if (typeof op.markdown !== "string") return `${op.op} needs markdown.`;
94 if (op.markdown.length > SLIDES_MAX_MARKDOWN) return `${op.op}'s markdown is too long.`;
95 return null;
96 };
97 const id = (key: string, nullable = false) =>
98 (nullable && op[key] === null) || (typeof op[key] === "string" && (op[key] as string).length > 0) ? null : `${op.op} needs ${key}.`;
99 switch (op.op) {
100 case "replace_deck":
101 return markdown();
102 case "insert_slides":
103 return id("after_slide_id", true) ?? markdown();
104 case "replace_slide":
105 case "set_notes":
106 return id("slide_id") ?? markdown();
107 case "delete_slides":
108 return Array.isArray(op.slide_ids) && op.slide_ids.length > 0 && op.slide_ids.every((item) => typeof item === "string" && item.length > 0)
109 ? null
110 : "delete_slides needs slide_ids.";
111 case "move_slide":
112 return id("slide_id") ?? id("after_slide_id", true);
113 case "set_theme":
114 if (typeof op.theme !== "string" || !THEME.test(op.theme)) return "set_theme needs a theme name.";
115 return op.accent === null || (typeof op.accent === "string" && COLOR.test(op.accent)) ? null : "accent is a colour like #8b7cf6, or null.";
116 default:
117 return `There is no slides op called ${op.op}.`;
118 }
119}
120
121function isObject(value: unknown): value is Record<string, unknown> {
122 return typeof value === "object" && value !== null && !Array.isArray(value);
123}