| 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 | |
| 15 | export type SlideLayout = "title" | "title-body" | "two-column" | "section" | "image" | "quote" | "big-number" | "blank"; |
| 16 | export const SLIDE_LAYOUTS: readonly SlideLayout[] = ["title", "title-body", "two-column", "section", "image", "quote", "big-number", "blank"]; |
| 17 | |
| 18 | export 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 | |
| 29 | export type SlideRegion = "title" | "body" | "left" | "right" | "notes"; |
| 30 | |
| 31 | /** The regions each layout has; every slide also has `notes`. */ |
| 32 | export 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 | |
| 43 | export type SlidesAspect = "16:9" | "4:3"; |
| 44 | export 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. */ |
| 47 | export type SlidesDeck = { theme: string; aspect: SlidesAspect; accent: string | null }; |
| 48 | |
| 49 | /** One slide's own fields: an entry of `Y.Array("slides")`. */ |
| 50 | export type SlideMeta = { id: string; layout: SlideLayout; background: string | null; hidden: boolean; transition: "none" }; |
| 51 | |
| 52 | /** The Yjs names the deck uses. */ |
| 53 | export const SLIDES_DECK_MAP = "deck"; |
| 54 | export const SLIDES_ARRAY = "slides"; |
| 55 | |
| 56 | /** The root fragment that holds one region of one slide. */ |
| 57 | export function slideFragment(slideId: string, region: SlideRegion): string { |
| 58 | return `slide:${slideId}:${region}`; |
| 59 | } |
| 60 | |
| 61 | /** The most slides in a deck. */ |
| 62 | export const SLIDES_MAX = 300; |
| 63 | |
| 64 | /** What a card shows of a deck: its first slide, never more. */ |
| 65 | export 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. */ |
| 68 | export 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 | |
| 81 | export 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. */ |
| 84 | export const SLIDES_MAX_MARKDOWN = 200_000; |
| 85 | |
| 86 | const THEME = /^[a-z0-9-]{1,40}$/; |
| 87 | const 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. */ |
| 90 | export 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 | |
| 121 | function isObject(value: unknown): value is Record<string, unknown> { |
| 122 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 123 | } |