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