diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 54e93ec6..f3a84d0a 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -552,6 +552,16 @@ Defined in `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, - Each `mermaidBlock` stores the original fenced Markdown plus the diagram body, so raw-mode entry and saves can restore the canonical source instead of serializing generated SVG. - The rich editor renders diagrams with the `mermaid` package and uses the original source as an inline fallback when rendering fails. - `serializeMermaidAwareBlocks()` wraps the math-aware serializer so math, wikilinks, and diagrams share the same Markdown-first save path. +- The `/mermaid` slash command inserts a placeholder rectangle diagram using the same schema-backed Markdown storage path, avoiding an invalid empty diagram state. + +### Tldraw Whiteboards + +Defined in `src/utils/tldrawMarkdown.ts`, `src/components/TldrawWhiteboard.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`: + +- Fenced `tldraw` blocks become `tldrawBlock` schema nodes before BlockNote sees the Markdown body. +- Each `tldrawBlock` stores a stable `boardId` plus the tldraw document snapshot JSON. Session state such as camera, selected tool, and current selection is not persisted into the note. +- The rich editor renders the block with the `tldraw` package and saves debounced document snapshot changes back into the block props, so normal Tolaria autosave writes the board into the `.md` file. +- The `/whiteboard` slash command inserts an empty tldraw block using the same Markdown-durable storage path. Preview images are intentionally omitted; thumbnails can be added later as derived cache artifacts. ### Formatting Surface Policy @@ -561,7 +571,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola - The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing. - Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction. - `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path. -- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model. +- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, list blocks, Mermaid diagrams, and whiteboards. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model. - The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the rendered text range for the hovered block, so H1/H2 typography, line-height, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references. - BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Add/remove row and column actions also validate the table position and cell indexes before resolving a ProseMirror `CellSelection`, so reloads or menu lag cannot turn stale handles into invalid table-selection positions. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation. - `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags. @@ -574,18 +584,19 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola ```mermaid flowchart LR A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"] - B --> C["preProcessMermaidMarkdown(body)\nmermaid fence → token"] - C --> D["preProcessWikilinks(body)\n[[target]] → ‹token›"] - D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"] - E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"] - F --> G["injectWikilinks + injectMathInBlocks + injectMermaidInBlocks\n tokens → schema nodes"] - G --> H["editor.replaceBlocks()\n→ rendered editor"] + B --> C["preProcessTldrawMarkdown(body)\ntldraw fence → token"] + C --> D["preProcessMermaidMarkdown(body)\nmermaid fence → token"] + D --> E["preProcessWikilinks(body)\n[[target]] → ‹token›"] + E --> F["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"] + F --> G["tryParseMarkdownToBlocks()\n→ BlockNote block tree"] + G --> H["injectWikilinks + injectMathInBlocks + injectMermaidInBlocks + injectTldrawInBlocks\n tokens → schema nodes"] + H --> I["editor.replaceBlocks()\n→ rendered editor"] style A fill:#f8f9fa,stroke:#6c757d,color:#000 - style H fill:#d4edda,stroke:#28a745,color:#000 + style I fill:#d4edda,stroke:#28a745,color:#000 ``` -> Wikilink placeholder tokens use `\u2039` and `\u203A`; math and Mermaid placeholder tokens use ASCII sentinels with URI-encoded payloads. +> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, and tldraw placeholder tokens use ASCII sentinels with URI-encoded payloads. ### BlockNote-to-Markdown Pipeline (Save) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5e9d12c..ef49be75 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -115,6 +115,7 @@ The note list opportunistically preloads visible and adjacent markdown/text entr | Editor | BlockNote | 0.46.2 | | Code block highlighting | @blocknote/code-block | 0.46.2 | | Diagram rendering | Mermaid | 11.14.0 | +| Whiteboard rendering | tldraw | 4.5.10 | | Raw editor | CodeMirror 6 | - | | Styling | Tailwind CSS v4 + CSS variables | 4.1.18 | | UI primitives | Radix UI + shadcn/ui | - | diff --git a/docs/adr/0107-markdown-durable-tldraw-whiteboards.md b/docs/adr/0107-markdown-durable-tldraw-whiteboards.md new file mode 100644 index 00000000..703f9ca1 --- /dev/null +++ b/docs/adr/0107-markdown-durable-tldraw-whiteboards.md @@ -0,0 +1,43 @@ +--- +type: ADR +id: "0107" +title: "Markdown-durable tldraw whiteboards in notes" +status: active +date: 2026-05-03 +--- + +## Context + +Tolaria notes are durable Markdown files, while whiteboard editing needs an interactive canvas with structured shape data. tldraw provides a mature React whiteboard runtime and exposes snapshot APIs that can persist the document without using browser-local `persistenceKey` storage. + +The storage decision needs to preserve Tolaria's filesystem source-of-truth rule: deleting local caches must not lose a board, raw mode must expose the canonical source, and Git should track the board together with the surrounding note. + +## Decision + +**Tolaria will support whiteboards as Markdown-durable fenced `tldraw` blocks backed by tldraw document snapshots.** + +The implementation: + +- Converts fenced `tldraw` blocks into temporary placeholders before BlockNote parses Markdown. +- Replaces those placeholders with `tldrawBlock` schema blocks that store a stable board id and tldraw document snapshot JSON. +- Renders each block with the `tldraw` package inside the rich editor. +- Debounces tldraw document changes into BlockNote block props so Tolaria's normal autosave writes the snapshot into the `.md` file. +- Serializes `tldrawBlock` nodes back to fenced Markdown before save, raw-mode entry, and editor-position snapshots. +- Adds a `/whiteboard` slash command that inserts the same block format. + +Session state such as camera position, selected shapes, and selected tools is not persisted into the note. Preview images are intentionally omitted from the initial design; they may be introduced later as derived cache artifacts for note lists, search results, or graph cards. + +## Options considered + +- **Markdown fenced block with tldraw document snapshot** (chosen): keeps the board in the note file, matches Tolaria's Mermaid/math round-trip model, and works for both full whiteboard notes and embedded boards inside larger notes. +- **tldraw `persistenceKey` / IndexedDB**: simplest app integration, but violates the vault-as-source-of-truth rule and would lose boards when browser storage is cleared. +- **Separate `.tldr` files embedded from Markdown**: keeps JSON out of prose notes, but fragments note ownership, makes embedded boards harder to move with their parent note, and complicates Git history for small board edits. +- **Preview-image-first storage**: useful for thumbnails, but not editable source. It would make the PNG an attractive but stale source of truth. + +## Consequences + +- `src/utils/tldrawMarkdown.ts` is the canonical parser/serializer bridge for whiteboard blocks. +- `src/components/TldrawWhiteboard.tsx` owns the tldraw runtime integration and only persists document snapshots. +- Raw mode remains a direct source editor for the fenced JSON. +- Embedded whiteboards and future full-note whiteboard templates share the same storage format. +- Asset support is deferred; when added, asset bytes should live in vault-relative attachment paths referenced from the tldraw asset records. diff --git a/docs/adr/README.md b/docs/adr/README.md index 88acda3b..f4254fe7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -159,4 +159,5 @@ proposed → active → superseded | [0104](0104-tauri-frontend-readiness-watchdog.md) | Tauri frontend readiness watchdog | active | | [0105](0105-editor-correctness-and-responsiveness-contract.md) | Editor correctness and responsiveness contract | active | | [0106](0106-shared-app-command-manifest.md) | Shared app command manifest | active | +| [0107](0107-markdown-durable-tldraw-whiteboards.md) | Markdown-durable tldraw whiteboards in notes | active | | [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active | diff --git a/package.json b/package.json index 90ffd5f2..e923d007 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.1", "tailwindcss": "^4.1.18", + "tldraw": "^4.5.10", "tw-animate-css": "^1.4.0", "unicode-emoji-json": "^0.8.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98c7fef7..d85609ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: tailwindcss: specifier: ^4.1.18 version: 4.1.18 + tldraw: + specifier: ^4.5.10 + version: 4.5.10(@floating-ui/dom@1.7.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -2266,22 +2269,63 @@ packages: peerDependencies: '@tiptap/pm': ^3.19.0 + '@tiptap/core@3.22.5': + resolution: {integrity: sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==} + peerDependencies: + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-blockquote@3.22.5': + resolution: {integrity: sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/extension-bold@3.19.0': resolution: {integrity: sha512-UZgb1d0XK4J/JRIZ7jW+s4S6KjuEDT2z1PPM6ugcgofgJkWQvRZelCPbmtSFd3kwsD+zr9UPVgTh9YIuGQ8t+Q==} peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-bold@3.22.5': + resolution: {integrity: sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/extension-bubble-menu@3.19.0': resolution: {integrity: sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==} peerDependencies: '@tiptap/core': ^3.19.0 '@tiptap/pm': ^3.19.0 + '@tiptap/extension-bullet-list@3.22.5': + resolution: {integrity: sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==} + peerDependencies: + '@tiptap/extension-list': 3.22.5 + + '@tiptap/extension-code-block@3.22.5': + resolution: {integrity: sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + '@tiptap/extension-code@3.19.0': resolution: {integrity: sha512-2kqqQIXBXj2Or+4qeY3WoE7msK+XaHKL6EKOcKlOP2BW8eYqNTPzNSL+PfBDQ3snA7ljZQkTs/j4GYDj90vR1A==} peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-code@3.22.5': + resolution: {integrity: sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-document@3.22.5': + resolution: {integrity: sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-dropcursor@3.22.5': + resolution: {integrity: sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==} + peerDependencies: + '@tiptap/extensions': 3.22.5 + '@tiptap/extension-floating-menu@3.19.0': resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==} peerDependencies: @@ -2289,52 +2333,139 @@ packages: '@tiptap/core': ^3.19.0 '@tiptap/pm': ^3.19.0 + '@tiptap/extension-gapcursor@3.22.5': + resolution: {integrity: sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==} + peerDependencies: + '@tiptap/extensions': 3.22.5 + + '@tiptap/extension-hard-break@3.22.5': + resolution: {integrity: sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-heading@3.22.5': + resolution: {integrity: sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-highlight@3.22.5': + resolution: {integrity: sha512-byWruAOKcqRN0OuzVSKqLLrced3M9AZaR2pD1BV3aUZHzMzeBjLBfByh8s4lExH2Z547xQUdHHnUflBQ572I5A==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/extension-horizontal-rule@3.19.0': resolution: {integrity: sha512-iqUHmgMGhMgYGwG6L/4JdelVQ5Mstb4qHcgTGd/4dkcUOepILvhdxajPle7OEdf9sRgjQO6uoAU5BVZVC26+ng==} peerDependencies: '@tiptap/core': ^3.19.0 '@tiptap/pm': ^3.19.0 + '@tiptap/extension-horizontal-rule@3.22.5': + resolution: {integrity: sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + '@tiptap/extension-italic@3.19.0': resolution: {integrity: sha512-6GffxOnS/tWyCbDkirWNZITiXRta9wrCmrfa4rh+v32wfaOL1RRQNyqo9qN6Wjyl1R42Js+yXTzTTzZsOaLMYA==} peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-italic@3.22.5': + resolution: {integrity: sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/extension-link@3.19.0': resolution: {integrity: sha512-HEGDJnnCPfr7KWu7Dsq+eRRe/mBCsv6DuI+7fhOCLDJjjKzNgrX2abbo/zG3D/4lCVFaVb+qawgJubgqXR/Smw==} peerDependencies: '@tiptap/core': ^3.19.0 '@tiptap/pm': ^3.19.0 + '@tiptap/extension-link@3.22.5': + resolution: {integrity: sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-list-item@3.22.5': + resolution: {integrity: sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==} + peerDependencies: + '@tiptap/extension-list': 3.22.5 + + '@tiptap/extension-list-keymap@3.22.5': + resolution: {integrity: sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==} + peerDependencies: + '@tiptap/extension-list': 3.22.5 + + '@tiptap/extension-list@3.22.5': + resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-ordered-list@3.22.5': + resolution: {integrity: sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==} + peerDependencies: + '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-paragraph@3.19.0': resolution: {integrity: sha512-xWa6gj82l5+AzdYyrSk9P4ynySaDzg/SlR1FarXE5yPXibYzpS95IWaVR0m2Qaz7Rrk+IiYOTGxGRxcHLOelNg==} peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-paragraph@3.22.5': + resolution: {integrity: sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/extension-strike@3.19.0': resolution: {integrity: sha512-xYpabHsv7PccLUBQaP8AYiFCnYbx6P93RHPd0lgNwhdOjYFd931Zy38RyoxPHAgbYVmhf1iyx7lpuLtBnhS5dA==} peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-strike@3.22.5': + resolution: {integrity: sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/extension-text@3.19.0': resolution: {integrity: sha512-K95+SnbZy0h6hNFtfy23n8t/nOcTFEf69In9TSFVVmwn/Nwlke+IfiESAkqbt1/7sKJeegRXYO7WzFEmFl9Q/g==} peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-text@3.22.5': + resolution: {integrity: sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/extension-underline@3.19.0': resolution: {integrity: sha512-800MGEWfG49j10wQzAFiW/ele1HT04MamcL8iyuPNu7ZbjbGN2yknvdrJlRy7hZlzIrVkZMr/1tz62KN33VHIw==} peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-underline@3.22.5': + resolution: {integrity: sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/extensions@3.19.0': resolution: {integrity: sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==} peerDependencies: '@tiptap/core': ^3.19.0 '@tiptap/pm': ^3.19.0 + '@tiptap/extensions@3.22.5': + resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + '@tiptap/pm@3.19.0': resolution: {integrity: sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==} + '@tiptap/pm@3.22.5': + resolution: {integrity: sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==} + '@tiptap/react@3.19.0': resolution: {integrity: sha512-GQQMUUXMpNd8tRjc1jDK3tDRXFugJO7C928EqmeBcBzTKDrFIJ3QUoZKEPxUNb6HWhZ2WL7q00fiMzsv4DNSmg==} peerDependencies: @@ -2345,6 +2476,41 @@ packages: react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tiptap/starter-kit@3.22.5': + resolution: {integrity: sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==} + + '@tldraw/editor@4.5.10': + resolution: {integrity: sha512-kM11sDK1ADXATE/wthBDY3ZOriT5Nzpieq1EoPz/HwSMVFuLq5NVmaOPKF+Pt/n43T1S5eLOfMOc7i3zojNc3g==} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + react-dom: ^18.2.0 || ^19.2.1 + + '@tldraw/state-react@4.5.10': + resolution: {integrity: sha512-gT173dPZUmHksVGDFFNlinai/CfIgvY3ECXGGXMEwpISbHpxInn1u6U7E60z/hmG7MQ2e6kKQq1H7w+VkMeQmg==} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + react-dom: ^18.2.0 || ^19.2.1 + + '@tldraw/state@4.5.10': + resolution: {integrity: sha512-c7l3/5T16M0p7EJ2GLjpCA2B69abn1790spjsHsfaIWlEzeo29LmTNa906dtkx8b6qPrSUguX6ibE5mEOw7IzA==} + + '@tldraw/store@4.5.10': + resolution: {integrity: sha512-qxOCIRslNBO/02wyN+spC0iwJmNrtEFsPd2j9DoDWJqF1dh1+tneNCbrjJLe/kQHEP4mYugI3jIGPbHrqIr07g==} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + + '@tldraw/tlschema@4.5.10': + resolution: {integrity: sha512-QXrCy0+jc5c1Ld5kvvRkUQscX7/bVPL86H+KWz4Of4ajRevketjBGEH6AgoDOGzDoYXwDOrkYjR1W70Xb8/tQA==} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + react-dom: ^18.2.0 || ^19.2.1 + + '@tldraw/utils@4.5.10': + resolution: {integrity: sha512-aRxpxmx0lIJx/xuwMaBgNxgcYi+okOElWSdZnnV+ZdUrqAfavpe5e1U6qoOdIhn9/iVfOz+PfmEINUNObi/eLg==} + + '@tldraw/validate@4.5.10': + resolution: {integrity: sha512-yEdFMXBD1OoyQrwBz4kgsw6ZtuxA1VL9vDGlsEhmUZx9y8VWODpEDIFMiIZOIBrYB33RLD6Md0tei66AoWKklw==} + '@translated/lara-cli@1.3.2': resolution: {integrity: sha512-AVJMvUztFcwc+GdxzlcsbkSU70JrunuTIzRM34MDxK1ZXvJWWTPc7bnS3brll/RLXuzR+c3A8YsDvWc8ck0i3w==} engines: {node: '>=20'} @@ -2595,6 +2761,14 @@ packages: '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@use-gesture/core@10.3.1': + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} + + '@use-gesture/react@10.3.1': + resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} + peerDependencies: + react: '>= 16.8.0' + '@vitejs/plugin-react@5.1.4': resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2826,6 +3000,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -3301,6 +3478,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -3405,6 +3585,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3559,6 +3743,9 @@ packages: resolution: {integrity: sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==} engines: {node: '>=16.9.0'} + hotkeys-js@3.13.15: + resolution: {integrity: sha512-gHh8a/cPTCpanraePpjRxyIlxDFrIhYqjuh01UHWEwDpglJKCnvLW8kqSx5gQtOuSsJogNZXLhOdbSExpgUiqg==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3600,6 +3787,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3680,6 +3870,10 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -3720,6 +3914,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jittered-fractional-indexing@1.0.1: + resolution: {integrity: sha512-OpKFkVr4hU5ivd1ZCjZfHvVpWekraJvcePcMusBmgBmCVQK5JiRCA+4TT1vAUTLqGD9MkhqFwO0l3QspvlZgzw==} + engines: {node: '>=18.0.0'} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -3895,9 +4093,22 @@ packages: lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isequalwith@4.4.0: + resolution: {integrity: sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -4433,6 +4644,9 @@ packages: query-selector-shadow-dom@1.0.1: resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + quickselect@2.0.0: + resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} + radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} peerDependencies: @@ -4454,6 +4668,9 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rbush@3.0.1: + resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==} + react-day-picker@9.13.2: resolution: {integrity: sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==} engines: {node: '>=18'} @@ -4798,6 +5015,12 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tldraw@4.5.10: + resolution: {integrity: sha512-dVtS8MVuB3EJ5Gs75mBOxZ8XrKhRMizXrqP5MK3Xv05eJsqUe0R8GqEzowObqYFlu1kkUqIWNkVByUcvYFr+pg==} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + react-dom: ^18.2.0 || ^19.2.1 + tldts-core@7.0.23: resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} @@ -7263,10 +7486,22 @@ snapshots: dependencies: '@tiptap/pm': 3.19.0 + '@tiptap/core@3.22.5(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-blockquote@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/extension-bold@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-bold@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: '@floating-ui/dom': 1.7.5 @@ -7274,10 +7509,31 @@ snapshots: '@tiptap/pm': 3.19.0 optional: true + '@tiptap/extension-bullet-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + '@tiptap/extension-code@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-code@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-document@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-dropcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: '@floating-ui/dom': 1.7.5 @@ -7285,42 +7541,116 @@ snapshots: '@tiptap/pm': 3.19.0 optional: true + '@tiptap/extension-gapcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-hard-break@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-heading@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-highlight@3.22.5(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-horizontal-rule@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) '@tiptap/pm': 3.19.0 + '@tiptap/extension-horizontal-rule@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + '@tiptap/extension-italic@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-italic@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/extension-link@3.19.0(patch_hash=fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) '@tiptap/pm': 3.19.0 linkifyjs: 4.3.2 + '@tiptap/extension-link@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + linkifyjs: 4.3.2 + + '@tiptap/extension-list-item@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-list-keymap@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-list@3.22.5(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + + '@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-ordered-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-paragraph@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/extension-strike@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/extension-text@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-text@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/extension-underline@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-underline@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) '@tiptap/pm': 3.19.0 + '@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + '@tiptap/pm@3.19.0': dependencies: prosemirror-changeset: 2.4.0 @@ -7342,6 +7672,21 @@ snapshots: prosemirror-transform: 1.11.0 prosemirror-view: 1.41.6 + '@tiptap/pm@3.22.5': + dependencies: + prosemirror-changeset: 2.4.0 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.0 + prosemirror-history: 1.5.0 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b) + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + '@tiptap/react@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) @@ -7359,6 +7704,95 @@ snapshots: transitivePeerDependencies: - '@floating-ui/dom' + '@tiptap/starter-kit@3.22.5': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/extension-blockquote': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-bold': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-bullet-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-code': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-code-block': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-document': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-dropcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-gapcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-hard-break': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-heading': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-horizontal-rule': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-italic': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-link': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list-item': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-list-keymap': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-ordered-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-paragraph': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-strike': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-text': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-underline': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + + '@tldraw/editor@4.5.10(@floating-ui/dom@1.7.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/react': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tldraw/state': 4.5.10 + '@tldraw/state-react': 4.5.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tldraw/store': 4.5.10(react@19.2.4) + '@tldraw/tlschema': 4.5.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tldraw/utils': 4.5.10 + '@tldraw/validate': 4.5.10 + '@use-gesture/react': 10.3.1(react@19.2.4) + classnames: 2.5.1 + eventemitter3: 4.0.7 + idb: 7.1.1 + is-plain-object: 5.0.0 + rbush: 3.0.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@floating-ui/dom' + - '@types/react' + - '@types/react-dom' + + '@tldraw/state-react@4.5.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tldraw/state': 4.5.10 + '@tldraw/utils': 4.5.10 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tldraw/state@4.5.10': + dependencies: + '@tldraw/utils': 4.5.10 + + '@tldraw/store@4.5.10(react@19.2.4)': + dependencies: + '@tldraw/state': 4.5.10 + '@tldraw/utils': 4.5.10 + react: 19.2.4 + + '@tldraw/tlschema@4.5.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tldraw/state': 4.5.10 + '@tldraw/store': 4.5.10(react@19.2.4) + '@tldraw/utils': 4.5.10 + '@tldraw/validate': 4.5.10 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tldraw/utils@4.5.10': + dependencies: + jittered-fractional-indexing: 1.0.1 + lodash.isequal: 4.5.0 + lodash.isequalwith: 4.4.0 + lodash.throttle: 4.1.1 + lodash.uniq: 4.5.0 + + '@tldraw/validate@4.5.10': + dependencies: + '@tldraw/utils': 4.5.10 + '@translated/lara-cli@1.3.2(@types/node@24.10.13)': dependencies: '@babel/parser': 7.29.2 @@ -7694,6 +8128,13 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + '@use-gesture/core@10.3.1': {} + + '@use-gesture/react@10.3.1(react@19.2.4)': + dependencies: + '@use-gesture/core': 10.3.1 + react: 19.2.4 + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 @@ -7940,6 +8381,8 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -8438,6 +8881,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + events@3.3.0: {} eventsource-parser@3.0.6: {} @@ -8563,6 +9008,8 @@ snapshots: forwarded@0.2.0: {} + fractional-indexing@3.2.0: {} + fresh@2.0.0: {} fsevents@2.3.2: @@ -8799,6 +9246,8 @@ snapshots: hono@4.12.3: {} + hotkeys-js@3.13.15: {} + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.14.1 @@ -8845,6 +9294,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb@7.1.1: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -8897,6 +9348,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -8928,6 +9381,10 @@ snapshots: jiti@2.6.1: {} + jittered-fractional-indexing@1.0.1: + dependencies: + fractional-indexing: 3.2.0 + jose@6.1.3: {} js-tokens@10.0.0: {} @@ -9083,8 +9540,16 @@ snapshots: lodash-es@4.18.1: {} + lodash.isequal@4.5.0: {} + + lodash.isequalwith@4.4.0: {} + lodash.merge@4.6.2: {} + lodash.throttle@4.1.1: {} + + lodash.uniq@4.5.0: {} + log-symbols@6.0.0: dependencies: chalk: 5.6.2 @@ -9877,6 +10342,8 @@ snapshots: query-selector-shadow-dom@1.0.1: {} + quickselect@2.0.0: {} + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@radix-ui/primitive': 1.1.3 @@ -9949,6 +10416,10 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rbush@3.0.1: + dependencies: + quickselect: 2.0.0 + react-day-picker@9.13.2(react@19.2.4): dependencies: '@date-fns/tz': 1.4.1 @@ -10381,6 +10852,29 @@ snapshots: tinyrainbow@3.0.3: {} + tldraw@4.5.10(@floating-ui/dom@1.7.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-code': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-highlight': 3.22.5(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/react': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tiptap/starter-kit': 3.22.5 + '@tldraw/editor': 4.5.10(@floating-ui/dom@1.7.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tldraw/store': 4.5.10(react@19.2.4) + classnames: 2.5.1 + hotkeys-js: 3.13.15 + idb: 7.1.1 + lz-string: 1.5.0 + radix-ui: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@floating-ui/dom' + - '@types/react' + - '@types/react-dom' + tldts-core@7.0.23: {} tldts@7.0.23: diff --git a/src/components/EditorTheme.css b/src/components/EditorTheme.css index b798ce52..9f4f8705 100644 --- a/src/components/EditorTheme.css +++ b/src/components/EditorTheme.css @@ -402,6 +402,75 @@ font-size: 0.85rem; } +.editor__blocknote-container .tldraw-whiteboard { + position: relative; + width: min(var(--tldraw-whiteboard-width, 100%), 100%); + max-width: 100%; + height: var(--tldraw-whiteboard-height, 520px); + overflow: hidden; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card); +} + +.editor__blocknote-container .tldraw-whiteboard .tl-container { + --color-background: var(--card); + --tl-layer-panels: 30; + --tl-layer-menus: 40; + --tl-layer-toasts: 50; + --tl-layer-cursor: 60; + --tl-layer-header-footer: 70; + --tl-layer-following-indicator: 80; +} + +.editor__blocknote-container .mantine-Popover-dropdown, +.editor__blocknote-container .bn-menu, +.editor__blocknote-container .bn-suggestion-menu { + z-index: 1000 !important; +} + +.editor__blocknote-container .tldraw-whiteboard__resize-handle { + position: absolute; + z-index: 2; + background: transparent; + touch-action: none; +} + +.editor__blocknote-container .tldraw-whiteboard__resize-handle--width { + top: 0; + right: 0; + width: 12px; + height: 100%; + cursor: ew-resize; +} + +.editor__blocknote-container .tldraw-whiteboard__resize-handle--height { + bottom: 0; + left: 0; + width: 100%; + height: 12px; + cursor: ns-resize; +} + +.editor__blocknote-container .tldraw-whiteboard__resize-handle--corner { + right: 0; + bottom: 0; + width: 18px; + height: 18px; + cursor: nwse-resize; +} + +.editor__blocknote-container .tldraw-whiteboard__resize-handle--corner::after { + position: absolute; + right: 4px; + bottom: 4px; + width: 8px; + height: 8px; + border-right: 2px solid color-mix(in srgb, var(--foreground) 42%, transparent); + border-bottom: 2px solid color-mix(in srgb, var(--foreground) 42%, transparent); + content: ''; +} + .editor__blocknote-container[data-follow-links] .bn-editor a, .editor__blocknote-container[data-follow-links] .wikilink { cursor: pointer; diff --git a/src/components/TldrawWhiteboard.tsx b/src/components/TldrawWhiteboard.tsx new file mode 100644 index 00000000..6a99eeb2 --- /dev/null +++ b/src/components/TldrawWhiteboard.tsx @@ -0,0 +1,209 @@ +import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react' +import { + Tldraw, + createTLStore, + getSnapshot, + loadSnapshot, + type TLStoreSnapshot, +} from 'tldraw' +import 'tldraw/tldraw.css' + +interface TldrawWhiteboardProps { + boardId: string + height: string + snapshot: string + width: string + onSnapshotChange: (snapshot: string) => void + onSizeChange: (size: TldrawWhiteboardSize) => void +} + +interface TldrawWhiteboardSize { + height: string + width: string +} + +interface PixelSize { + height: number + width: number | null +} + +interface ResizeStart { + height: number + pointerX: number + pointerY: number + width: number +} + +type ResizeMode = 'height' | 'width' | 'both' + +const DEFAULT_HEIGHT = 520 +const MIN_HEIGHT = 260 +const MIN_WIDTH = 360 + +function parsePixelValue(value: string, fallback: number): number { + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback +} + +function normalizeSize({ height, width }: TldrawWhiteboardSize): PixelSize { + return { + height: parsePixelValue(height, DEFAULT_HEIGHT), + width: width ? parsePixelValue(width, MIN_WIDTH) : null, + } +} + +function sizeToProps({ height, width }: PixelSize): TldrawWhiteboardSize { + return { + height: String(Math.max(MIN_HEIGHT, Math.round(height))), + width: width === null ? '' : String(Math.max(MIN_WIDTH, Math.round(width))), + } +} + +function cssSize({ height, width }: PixelSize): CSSProperties { + return { + '--tldraw-whiteboard-height': `${Math.max(MIN_HEIGHT, height)}px`, + '--tldraw-whiteboard-width': width === null ? '100%' : `${Math.max(MIN_WIDTH, width)}px`, + } as CSSProperties +} + +function parseSnapshot(source: string): TLStoreSnapshot | null { + if (!source.trim()) return null + + try { + return JSON.parse(source) as TLStoreSnapshot + } catch { + return null + } +} + +function serializeSnapshot(snapshot: TLStoreSnapshot): string { + return `${JSON.stringify(snapshot, null, 2)}\n` +} + +export function TldrawWhiteboard({ + boardId, + height, + snapshot, + width, + onSnapshotChange, + onSizeChange, +}: TldrawWhiteboardProps) { + const store = useMemo(() => createTLStore(), []) + const boardRef = useRef(null) + const savedSnapshotRef = useRef(null) + const onSnapshotChangeRef = useRef(onSnapshotChange) + const persistedSize = useMemo(() => normalizeSize({ height, width }), [height, width]) + const [resizingSize, setResizingSize] = useState(null) + const visibleSize = resizingSize ?? persistedSize + + useEffect(() => { + onSnapshotChangeRef.current = onSnapshotChange + }, [onSnapshotChange]) + + useEffect(() => { + if (snapshot === savedSnapshotRef.current) return + + const parsed = parseSnapshot(snapshot) + if (parsed) { + try { + loadSnapshot(store, parsed) + savedSnapshotRef.current = snapshot + return + } catch { + // Fall through to an empty board when legacy or hand-edited JSON is invalid. + } + } + + savedSnapshotRef.current = serializeSnapshot(getSnapshot(store).document) + }, [snapshot, store]) + + useEffect(() => { + let timeoutId: number | null = null + + const flushSnapshot = () => { + timeoutId = null + const nextSnapshot = serializeSnapshot(getSnapshot(store).document) + if (nextSnapshot === savedSnapshotRef.current) return + + savedSnapshotRef.current = nextSnapshot + onSnapshotChangeRef.current(nextSnapshot) + } + + const scheduleSnapshotFlush = () => { + if (timeoutId !== null) window.clearTimeout(timeoutId) + timeoutId = window.setTimeout(flushSnapshot, 350) + } + + const cleanup = store.listen(scheduleSnapshotFlush, { source: 'user', scope: 'document' }) + return () => { + cleanup() + if (timeoutId !== null) window.clearTimeout(timeoutId) + } + }, [store]) + + const startResize = (mode: ResizeMode) => (event: ReactPointerEvent) => { + event.preventDefault() + event.stopPropagation() + + const rect = boardRef.current?.getBoundingClientRect() + const start: ResizeStart = { + height: visibleSize.height, + pointerX: event.clientX, + pointerY: event.clientY, + width: visibleSize.width ?? rect?.width ?? MIN_WIDTH, + } + + const onPointerMove = (moveEvent: PointerEvent) => { + const nextSize = { + height: mode === 'width' ? start.height : start.height + moveEvent.clientY - start.pointerY, + width: mode === 'height' ? visibleSize.width : start.width + moveEvent.clientX - start.pointerX, + } + setResizingSize(normalizeSize(sizeToProps(nextSize))) + } + + const onPointerUp = (upEvent: PointerEvent) => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + + const finalSize = { + height: mode === 'width' ? start.height : start.height + upEvent.clientY - start.pointerY, + width: mode === 'height' ? visibleSize.width : start.width + upEvent.clientX - start.pointerX, + } + const nextProps = sizeToProps(normalizeSize(sizeToProps(finalSize))) + setResizingSize(null) + onSizeChange(nextProps) + } + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp, { once: true }) + } + + return ( +
+ +
+
+
+
+ ) +} diff --git a/src/components/editorSchema.tsx b/src/components/editorSchema.tsx index a6cef1cf..60b67c04 100644 --- a/src/components/editorSchema.tsx +++ b/src/components/editorSchema.tsx @@ -5,15 +5,21 @@ import { defaultInlineContentSpecs, } from '@blocknote/core' import { createReactBlockSpec, createReactInlineContentSpec } from '@blocknote/react' +import { lazy, Suspense } from 'react' import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors' import { resolveEntry } from '../utils/wikilink' import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, renderMathToHtml } from '../utils/mathMarkdown' import { MERMAID_BLOCK_TYPE, mermaidFenceSource } from '../utils/mermaidMarkdown' +import { TLDRAW_BLOCK_TYPE, TLDRAW_DEFAULT_HEIGHT } from '../utils/tldrawMarkdown' import type { VaultEntry } from '../types' import { createTolariaCodeBlockOptions } from './codeBlockOptions' import { NoteTitleIcon } from './NoteTitleIcon' import { MermaidDiagram } from './MermaidDiagram' +const TldrawWhiteboard = lazy(() => import('./TldrawWhiteboard').then(module => ({ + default: module.TldrawWhiteboard, +}))) + // Module-level cache so the WikiLink renderer (defined outside React) can access entries export const _wikilinkEntriesRef: { current: VaultEntry[] } = { current: [] } @@ -157,9 +163,58 @@ const MermaidBlock = createReactBlockSpec( }, ) +const TldrawBlock = createReactBlockSpec( + { + type: TLDRAW_BLOCK_TYPE, + propSchema: { + boardId: { default: '' }, + height: { default: TLDRAW_DEFAULT_HEIGHT }, + snapshot: { default: '{}' }, + width: { default: '' }, + }, + content: 'none', + }, + { + runsBefore: ['codeBlock'], + render: (props) => ( + }> + { + props.editor.updateBlock(props.block, { + type: TLDRAW_BLOCK_TYPE, + props: { + boardId: props.block.props.boardId, + height: props.block.props.height, + snapshot, + width: props.block.props.width, + }, + }) + }} + onSizeChange={(size) => { + props.editor.updateBlock(props.block, { + type: TLDRAW_BLOCK_TYPE, + props: { + boardId: props.block.props.boardId, + height: size.height, + snapshot: props.block.props.snapshot, + width: size.width, + }, + }) + }} + /> + + ), + }, +) + const codeBlock = createCodeBlockSpec(createTolariaCodeBlockOptions()) const mathBlock = MathBlock() const mermaidBlock = MermaidBlock() +const tldrawBlock = TldrawBlock() export const schema = BlockNoteSchema.create({ inlineContentSpecs: { @@ -171,6 +226,7 @@ export const schema = BlockNoteSchema.create({ blockSpecs: { mathBlock, mermaidBlock, + tldrawBlock, codeBlock, }, }) diff --git a/src/components/tolariaEditorFormatting.test.tsx b/src/components/tolariaEditorFormatting.test.tsx index a5871e89..f2317227 100644 --- a/src/components/tolariaEditorFormatting.test.tsx +++ b/src/components/tolariaEditorFormatting.test.tsx @@ -2,10 +2,13 @@ import { Children, isValidElement, type ReactElement } from 'react' import { describe, expect, it } from 'vitest' import { getFormattingToolbarItems } from '@blocknote/react' import { + addItemsToMediaGroup, filterTolariaFormattingToolbarItems, filterTolariaSlashMenuItems, getTolariaBlockTypeSelectItems, + MERMAID_SLASH_COMMAND_DIAGRAM, } from './tolariaEditorFormattingConfig' +import { mermaidFenceSource } from '../utils/mermaidMarkdown' describe('tolariaEditorFormatting', () => { it('keeps the markdown-safe toolbar controls and block type select', () => { @@ -106,4 +109,90 @@ describe('tolariaEditorFormatting', () => { 'fill', ]) }) + + it('keeps custom media slash-menu commands searchable', () => { + type TolariaSlashMenuTestItem = { + key: string + title: string + aliases?: string[] + onItemClick: () => void + } + + const items = filterTolariaSlashMenuItems([ + { + key: 'mermaid', + title: 'Mermaid', + aliases: ['diagram', 'flowchart', 'graph', 'chart'], + onItemClick: () => {}, + }, + { + key: 'whiteboard', + title: 'Whiteboard', + aliases: ['tldraw', 'drawing', 'canvas', 'sketch'], + onItemClick: () => {}, + }, + ] satisfies TolariaSlashMenuTestItem[]) + + expect(items[0]).toEqual(expect.objectContaining({ + key: 'mermaid', + title: 'Mermaid', + aliases: ['diagram', 'flowchart', 'graph', 'chart'], + })) + expect(items[1]).toEqual(expect.objectContaining({ + key: 'whiteboard', + title: 'Whiteboard', + aliases: ['tldraw', 'drawing', 'canvas', 'sketch'], + })) + expect(isValidElement(items[0]?.icon)).toBe(true) + expect(isValidElement(items[1]?.icon)).toBe(true) + }) + + it('uses a valid placeholder diagram for new Mermaid blocks', () => { + expect(MERMAID_SLASH_COMMAND_DIAGRAM).toBe([ + 'flowchart TD', + ' edit["Switch to the raw editor to edit"]', + ].join('\n')) + expect(mermaidFenceSource({ diagram: MERMAID_SLASH_COMMAND_DIAGRAM })).toBe([ + '```mermaid', + 'flowchart TD', + ' edit["Switch to the raw editor to edit"]', + '```', + ].join('\n')) + }) + + it('places custom media commands before the existing non-media slash-menu group', () => { + type TolariaSlashMenuTestItem = { + key: string + title: string + group: string + onItemClick: () => void + } + + const items = addItemsToMediaGroup([ + { key: 'image', title: 'Image', group: 'Media', onItemClick: () => {} }, + { key: 'file', title: 'File', group: 'Media', onItemClick: () => {} }, + { key: 'emoji', title: 'Emoji', group: 'Others', onItemClick: () => {} }, + ] satisfies TolariaSlashMenuTestItem[], [ + { + key: 'mermaid', + title: 'Mermaid', + group: 'Media', + onItemClick: () => {}, + }, + { + key: 'whiteboard', + title: 'Whiteboard', + group: 'Media', + onItemClick: () => {}, + }, + ]) + + expect(items.map(item => item.key)).toEqual([ + 'image', + 'file', + 'mermaid', + 'whiteboard', + 'emoji', + ]) + }) }) diff --git a/src/components/tolariaEditorFormattingConfig.ts b/src/components/tolariaEditorFormattingConfig.ts index 9c4851eb..9c5217e2 100644 --- a/src/components/tolariaEditorFormattingConfig.ts +++ b/src/components/tolariaEditorFormattingConfig.ts @@ -7,6 +7,7 @@ import { createElement, type ReactElement } from 'react' import { CodeBlock, File, + FlowArrow, ImageSquare, ListBullets, ListChecks, @@ -14,6 +15,7 @@ import { Minus, Paragraph, Quotes, + ScribbleLoop, Smiley, SpeakerHigh, Table, @@ -26,6 +28,8 @@ import { Video, type Icon as PhosphorIcon, } from '@phosphor-icons/react' +import { MERMAID_BLOCK_TYPE, mermaidFenceSource } from '../utils/mermaidMarkdown' +import { TLDRAW_BLOCK_TYPE, TLDRAW_DEFAULT_HEIGHT } from '../utils/tldrawMarkdown' type TolariaSlashMenuItem = DefaultReactSuggestionItem & { key: string } type TolariaBlockTypeSelectItem = { @@ -34,6 +38,22 @@ type TolariaBlockTypeSelectItem = { props?: Record icon: PhosphorIcon } +type SlashInsertEditor = { + getTextCursorPosition: () => { block: unknown } + updateBlock: (block: unknown, update: Record) => void +} +type BlockSlashMenuItemConfig = { + aliases: string[] + key: string + props: Record + title: string + type: string +} + +export const MERMAID_SLASH_COMMAND_DIAGRAM = [ + 'flowchart TD', + ' edit["Switch to the raw editor to edit"]', +].join('\n') const UNSUPPORTED_FORMATTING_TOOLBAR_KEYS = new Set([ 'underlineStyleButton', @@ -80,6 +100,7 @@ const TOLARIA_SLASH_MENU_ICONS: Partial> = { heading_2: TextHTwo, heading_3: TextHThree, image: ImageSquare, + mermaid: FlowArrow, numbered_list: ListNumbers, paragraph: Paragraph, quote: Quotes, @@ -89,6 +110,84 @@ const TOLARIA_SLASH_MENU_ICONS: Partial> = { toggle_heading_3: TextHThree, toggle_list: ListBullets, video: Video, + whiteboard: ScribbleLoop, +} + +function createBoardId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() + } + + return `whiteboard-${Date.now().toString(36)}` +} + +function createWhiteboardSlashMenuItem( + editor: Parameters[0], +): TolariaSlashMenuItem { + return createBlockSlashMenuItem(editor, { + key: 'whiteboard', + title: 'Whiteboard', + aliases: ['tldraw', 'drawing', 'canvas', 'sketch'], + type: TLDRAW_BLOCK_TYPE, + props: { + boardId: createBoardId(), + height: TLDRAW_DEFAULT_HEIGHT, + snapshot: '{}', + width: '', + }, + }) +} + +function createMermaidSlashMenuItem( + editor: Parameters[0], +): TolariaSlashMenuItem { + return createBlockSlashMenuItem(editor, { + key: 'mermaid', + title: 'Mermaid', + aliases: ['diagram', 'flowchart', 'graph', 'chart'], + type: MERMAID_BLOCK_TYPE, + props: { + diagram: MERMAID_SLASH_COMMAND_DIAGRAM, + source: mermaidFenceSource({ diagram: MERMAID_SLASH_COMMAND_DIAGRAM }), + }, + }) +} + +function createBlockSlashMenuItem( + editor: Parameters[0], + config: BlockSlashMenuItemConfig, +): TolariaSlashMenuItem { + const blockEditor = editor as unknown as SlashInsertEditor + + return { + key: config.key, + title: config.title, + aliases: config.aliases, + group: 'Media', + onItemClick: () => { + const block = blockEditor.getTextCursorPosition().block + blockEditor.updateBlock(block, { + type: config.type, + props: config.props, + }) + }, + } as TolariaSlashMenuItem +} + +export function addItemsToMediaGroup( + items: TolariaSlashMenuItem[], + mediaItems: TolariaSlashMenuItem[], +): TolariaSlashMenuItem[] { + const nextItems = [...items] + const insertIndex = nextItems.findIndex((item) => item.key === 'emoji') + + if (insertIndex === -1) { + nextItems.push(...mediaItems) + return nextItems + } + + nextItems.splice(insertIndex, 0, ...mediaItems) + return nextItems } function createTolariaSlashMenuIcon(Icon: PhosphorIcon) { @@ -142,9 +241,17 @@ export function getTolariaSlashMenuItems( editor: Parameters[0], query: string, ) { + const items = addItemsToMediaGroup( + getDefaultReactSlashMenuItems(editor) as TolariaSlashMenuItem[], + [ + createMermaidSlashMenuItem(editor), + createWhiteboardSlashMenuItem(editor), + ], + ) + return filterSuggestionItems( filterTolariaSlashMenuItems( - getDefaultReactSlashMenuItems(editor) as TolariaSlashMenuItem[], + items, ), query, ) diff --git a/src/hooks/editorBlockResolution.ts b/src/hooks/editorBlockResolution.ts index 2dfffb22..bbd996dd 100644 --- a/src/hooks/editorBlockResolution.ts +++ b/src/hooks/editorBlockResolution.ts @@ -2,6 +2,7 @@ import type { useCreateBlockNote } from '@blocknote/react' import { preProcessWikilinks, injectWikilinks } from '../utils/wikilinks' import { preProcessMathMarkdown, injectMathInBlocks } from '../utils/mathMarkdown' import { preProcessMermaidMarkdown, injectMermaidInBlocks } from '../utils/mermaidMarkdown' +import { preProcessTldrawMarkdown, injectTldrawInBlocks } from '../utils/tldrawMarkdown' import { resolveImageUrls } from '../utils/vaultImages' import { repairMalformedEditorBlocks } from './editorBlockRepair' import { @@ -132,7 +133,8 @@ async function parseMarkdownBlocks( } function preProcessEditorMarkdown(markdown: MarkdownBody, vaultPath?: VaultPath): PreprocessedMarkdown { - const withMermaid = preProcessMermaidMarkdown({ markdown }) + const withTldraw = preProcessTldrawMarkdown({ markdown }) + const withMermaid = preProcessMermaidMarkdown({ markdown: withTldraw }) const withImages = vaultPath ? resolveImageUrls(withMermaid, vaultPath) : withMermaid const withWikilinks = preProcessWikilinks(withImages) return preProcessMathMarkdown({ markdown: withWikilinks }) @@ -141,7 +143,8 @@ function preProcessEditorMarkdown(markdown: MarkdownBody, vaultPath?: VaultPath) function injectEditorMarkdownBlocks(blocks: EditorBlocks): EditorBlocks { const withWikilinks = injectWikilinks(blocks) const withMath = injectMathInBlocks(withWikilinks) - return injectMermaidInBlocks(withMath) as EditorBlocks + const withMermaid = injectMermaidInBlocks(withMath) + return injectTldrawInBlocks(withMermaid) as EditorBlocks } function repairParsedMarkdownBlocks(parsed: MarkdownParseResult): EditorBlocks { diff --git a/src/utils/mermaidMarkdown.test.ts b/src/utils/mermaidMarkdown.test.ts index a90a2ef0..a8448e7a 100644 --- a/src/utils/mermaidMarkdown.test.ts +++ b/src/utils/mermaidMarkdown.test.ts @@ -5,6 +5,7 @@ import { preProcessMermaidMarkdown, serializeMermaidAwareBlocks, } from './mermaidMarkdown' +import { TLDRAW_BLOCK_TYPE } from './tldrawMarkdown' describe('mermaid markdown round-trip', () => { it('injects fenced Mermaid source into dedicated diagram blocks', () => { @@ -132,4 +133,25 @@ describe('mermaid markdown round-trip', () => { '```mermaid\nflowchart LR\nA --> B\n```', ) }) + + it('serializes tldraw blocks beside Mermaid and ordinary Markdown', () => { + const editor = { + blocksToMarkdownLossy: vi.fn((blocks: unknown[]) => { + return (blocks as Array<{ content?: Array<{ text?: string }> }>) + .map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '') + .join('\n\n') + }), + } + const blocks = [ + { type: 'paragraph', content: [{ type: 'text', text: 'Intro' }], children: [] }, + { type: TLDRAW_BLOCK_TYPE, props: { boardId: 'map', height: '640', snapshot: '{ "store": {} }', width: '900' }, children: [] }, + { type: MERMAID_BLOCK_TYPE, props: { source: '', diagram: 'flowchart LR\nA --> B' }, children: [] }, + ] + + expect(serializeMermaidAwareBlocks(editor, blocks)).toBe([ + 'Intro', + '```tldraw id="map" height="640" width="900"\n{ "store": {} }\n```', + '```mermaid\nflowchart LR\nA --> B\n```', + ].join('\n\n')) + }) }) diff --git a/src/utils/mermaidMarkdown.ts b/src/utils/mermaidMarkdown.ts index 3c904cff..4008dfc2 100644 --- a/src/utils/mermaidMarkdown.ts +++ b/src/utils/mermaidMarkdown.ts @@ -1,4 +1,5 @@ import { serializeMathAwareBlocks } from './mathMarkdown' +import { isTldrawBlock, tldrawMarkdown } from './tldrawMarkdown' export const MERMAID_BLOCK_TYPE = 'mermaidBlock' @@ -289,6 +290,9 @@ export function serializeMermaidAwareBlocks(editor: MarkdownSerializer, blocks: if (isMermaidBlock(block)) { flushPending() chunks.push(mermaidMarkdown(block)) + } else if (isTldrawBlock(block)) { + flushPending() + chunks.push(tldrawMarkdown(block)) } else { pending.push(block) } diff --git a/src/utils/tldrawMarkdown.test.ts b/src/utils/tldrawMarkdown.test.ts new file mode 100644 index 00000000..6d55fa5c --- /dev/null +++ b/src/utils/tldrawMarkdown.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { + TLDRAW_BLOCK_TYPE, + injectTldrawInBlocks, + preProcessTldrawMarkdown, + tldrawFenceSource, +} from './tldrawMarkdown' + +describe('tldraw markdown round-trip', () => { + it('injects fenced tldraw source into dedicated whiteboard blocks', () => { + const snapshot = '{ "store": {} }' + const markdown = [ + '```tldraw id="onboarding-flow"', + snapshot, + '```', + ].join('\n') + const preprocessed = preProcessTldrawMarkdown({ markdown }) + const blocks = [{ + type: 'paragraph', + content: [{ type: 'text', text: preprocessed, styles: {} }], + children: [], + }] + + const [block] = injectTldrawInBlocks(blocks) as Array<{ + type: string + props: { boardId: string; height: string; snapshot: string; width: string } + }> + + expect(block.type).toBe(TLDRAW_BLOCK_TYPE) + expect(block.props.boardId).toBe('onboarding-flow') + expect(block.props.height).toBe('520') + expect(block.props.snapshot).toBe(snapshot) + expect(block.props.width).toBe('') + }) + + it('reads persisted whiteboard dimensions from fence metadata', () => { + const preprocessed = preProcessTldrawMarkdown({ + markdown: [ + '```tldraw id="map" height="720" width="980"', + '{}', + '```', + ].join('\n'), + }) + + const [block] = injectTldrawInBlocks([{ + type: 'paragraph', + content: [{ type: 'text', text: preprocessed, styles: {} }], + children: [], + }]) as Array<{ + props: { height: string; width: string } + }> + + expect(block.props.height).toBe('720') + expect(block.props.width).toBe('980') + }) + + it('preserves ordinary and unclosed fences as normal Markdown', () => { + const markdown = [ + '```ts', + 'const language = "tldraw"', + '```', + '', + '```tldraw', + '{ "store": {} }', + ].join('\n') + + expect(preProcessTldrawMarkdown({ markdown })).toBe(markdown) + }) + + it('injects parsed tldraw code blocks into dedicated whiteboard blocks', () => { + const [block] = injectTldrawInBlocks([{ + type: 'codeBlock', + props: { language: 'tldraw' }, + content: [{ type: 'text', text: '{ "store": {} }', styles: {} }], + children: [], + }]) as Array<{ + type: string + props: { height: string; snapshot: string; width: string } + }> + + expect(block.type).toBe(TLDRAW_BLOCK_TYPE) + expect(block.props.height).toBe('520') + expect(block.props.snapshot).toBe('{ "store": {} }') + expect(block.props.width).toBe('') + }) + + it('keeps ordinary code blocks unchanged', () => { + const [block] = injectTldrawInBlocks([{ + type: 'codeBlock', + props: { language: 'json' }, + content: [{ type: 'text', text: '{ "store": {} }', styles: {} }], + children: [], + }]) as Array<{ type: string }> + + expect(block.type).toBe('codeBlock') + }) + + it('uses a longer fence when the board JSON contains backticks', () => { + expect(tldrawFenceSource({ + boardId: 'quoted', + height: '640', + snapshot: '{ "text": "```" }', + width: '900', + })).toBe([ + '````tldraw id="quoted" height="640" width="900"', + '{ "text": "```" }', + '````', + ].join('\n')) + }) +}) diff --git a/src/utils/tldrawMarkdown.ts b/src/utils/tldrawMarkdown.ts new file mode 100644 index 00000000..8c68e16d --- /dev/null +++ b/src/utils/tldrawMarkdown.ts @@ -0,0 +1,310 @@ +export const TLDRAW_BLOCK_TYPE = 'tldrawBlock' +export const TLDRAW_DEFAULT_HEIGHT = '520' + +const TOKEN_PREFIX = '@@TOLARIA_TLDRAW_BLOCK:' +const TOKEN_SUFFIX = '@@' + +interface InlineItem { + type: string + text?: string + props?: Record + content?: unknown + [key: string]: unknown +} + +interface BlockLike { + type?: string + content?: InlineItem[] + props?: Record + children?: BlockLike[] + [key: string]: unknown +} + +interface TldrawPayload { + boardId: string + height: string + snapshot: string + width: string +} + +interface TldrawFenceStart { + character: '`' | '~' + length: number + boardId: string + height: string + width: string +} + +interface MarkdownLine { + line: string +} + +interface EncodedPayload { + encoded: string +} + +interface TokenText { + text: string +} + +interface FenceSearch { + lines: string[] + start: number + opening: TldrawFenceStart +} + +interface FenceRange { + lines: string[] + start: number + end: number + opening: TldrawFenceStart +} + +interface SnapshotSource { + snapshot: string +} + +interface FenceAttribute { + value: string +} + +interface CodeBlockSource { + block: BlockLike +} + +interface FenceMetadata { + info: string +} + +interface FenceAttributeRequest { + info: string + name: 'height' | 'id' | 'width' +} + +function lineEnding({ line }: MarkdownLine): string { + if (line.endsWith('\r\n')) return '\r\n' + return line.endsWith('\n') ? '\n' : '' +} + +function lineText({ line }: MarkdownLine): string { + const ending = lineEnding({ line }) + return ending ? line.slice(0, -ending.length) : line +} + +function splitMarkdownLines({ markdown }: { markdown: string }): string[] { + const lines = markdown.match(/[^\n]*(?:\n|$)/g) ?? [] + return lines.filter((line, index) => line !== '' || index < lines.length - 1) +} + +function encodePayload(payload: TldrawPayload): string { + return encodeURIComponent(JSON.stringify(payload)) +} + +function decodePayload({ encoded }: EncodedPayload): TldrawPayload | null { + try { + const payload = JSON.parse(decodeURIComponent(encoded)) as Partial + if (typeof payload.boardId !== 'string') return null + if (typeof payload.snapshot !== 'string') return null + return { + boardId: payload.boardId, + height: typeof payload.height === 'string' ? payload.height : TLDRAW_DEFAULT_HEIGHT, + snapshot: payload.snapshot, + width: typeof payload.width === 'string' ? payload.width : '', + } + } catch { + return null + } +} + +function tldrawToken(payload: TldrawPayload): string { + return `${TOKEN_PREFIX}${encodePayload(payload)}${TOKEN_SUFFIX}` +} + +function readTldrawToken({ text }: TokenText): TldrawPayload | null { + const trimmed = text.trim() + if (!trimmed.startsWith(TOKEN_PREFIX) || !trimmed.endsWith(TOKEN_SUFFIX)) return null + return decodePayload({ encoded: trimmed.slice(TOKEN_PREFIX.length, -TOKEN_SUFFIX.length) }) +} + +function readFenceAttribute({ info, name }: FenceAttributeRequest): string { + const match = new RegExp(`\\b${name}=(?:"([^"]+)"|'([^']+)'|([^\\s]+))`, 'u').exec(info) + return match?.[1] ?? match?.[2] ?? match?.[3] ?? '' +} + +function readFenceMetadata({ info }: FenceMetadata): Pick { + return { + boardId: readFenceAttribute({ info, name: 'id' }), + height: readFenceAttribute({ info, name: 'height' }) || TLDRAW_DEFAULT_HEIGHT, + width: readFenceAttribute({ info, name: 'width' }), + } +} + +function readTldrawFenceStart({ line }: MarkdownLine): TldrawFenceStart | null { + const match = /^( {0,3})(`{3,}|~{3,})[ \t]*(.*)$/.exec(line) + if (!match) return null + + const fence = match[2] + const [language = '', ...infoParts] = match[3].trim().split(/\s+/u) + if (language.toLowerCase() !== 'tldraw') return null + const metadata = readFenceMetadata({ info: infoParts.join(' ') }) + + return { + character: fence[0] as '`' | '~', + length: fence.length, + ...metadata, + } +} + +function isClosingFence({ line, opening }: MarkdownLine & { opening: TldrawFenceStart }): boolean { + const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line) + if (!match) return false + + const fence = match[2] + return fence[0] === opening.character && fence.length >= opening.length +} + +function findClosingFence({ lines, start, opening }: FenceSearch): number { + for (let index = start + 1; index < lines.length; index++) { + if (isClosingFence({ line: lineText({ line: lines[index] }), opening })) return index + } + + return -1 +} + +function buildPayload({ lines, start, end, opening }: FenceRange): TldrawPayload { + return { + boardId: opening.boardId, + height: opening.height, + snapshot: lines.slice(start + 1, end).join('').trim(), + width: opening.width, + } +} + +export function preProcessTldrawMarkdown({ markdown }: { markdown: string }): string { + const lines = splitMarkdownLines({ markdown }) + const result: string[] = [] + + for (let index = 0; index < lines.length; index++) { + const opening = readTldrawFenceStart({ line: lineText({ line: lines[index] }) }) + if (!opening) { + result.push(lines[index]) + continue + } + + const closingIndex = findClosingFence({ lines, start: index, opening }) + if (closingIndex === -1) { + result.push(lines[index]) + continue + } + + result.push(`${tldrawToken(buildPayload({ lines, start: index, end: closingIndex, opening }))}${lineEnding({ line: lines[closingIndex] })}`) + index = closingIndex + } + + return result.join('') +} + +function readTldrawPayload(content: InlineItem[] | undefined): TldrawPayload | null { + const onlyItem = content?.length === 1 ? content[0] : null + if (onlyItem?.type !== 'text' || typeof onlyItem.text !== 'string') return null + return readTldrawToken({ text: onlyItem.text }) +} + +function buildTldrawBlock(block: BlockLike, payload: TldrawPayload): BlockLike { + return { + ...block, + type: TLDRAW_BLOCK_TYPE, + props: { + ...(block.props ?? {}), + boardId: payload.boardId, + height: payload.height, + snapshot: payload.snapshot, + width: payload.width, + }, + content: undefined, + children: [], + } +} + +function readCodeBlockLanguage({ block }: CodeBlockSource): string | null { + const language = block.props?.language + if (typeof language !== 'string') return null + + return language.trim().split(/\s+/u)[0]?.toLowerCase() ?? null +} + +function readInlineText(content: InlineItem[] | undefined): string | null { + if (!Array.isArray(content)) return null + return content.map((item) => ( + item.type === 'text' && typeof item.text === 'string' ? item.text : '' + )).join('') +} + +function readTldrawCodeBlock({ block }: CodeBlockSource): TldrawPayload | null { + if (block.type !== 'codeBlock') return null + if (readCodeBlockLanguage({ block }) !== 'tldraw') return null + + const snapshot = readInlineText(block.content) + if (snapshot === null) return null + + return { + boardId: '', + height: TLDRAW_DEFAULT_HEIGHT, + snapshot: snapshot.trim(), + width: '', + } +} + +function injectTldrawInBlock(block: BlockLike): BlockLike { + const payload = readTldrawPayload(block.content) + if (payload) return buildTldrawBlock(block, payload) + + const codeBlockPayload = readTldrawCodeBlock({ block }) + if (codeBlockPayload) return buildTldrawBlock(block, codeBlockPayload) + + const children = Array.isArray(block.children) ? block.children.map(injectTldrawInBlock) : block.children + return { ...block, children } +} + +function fenceLengthForSnapshot({ snapshot }: SnapshotSource): number { + const longestRun = Math.max(0, ...Array.from(snapshot.matchAll(/`+/gu), match => match[0].length)) + return Math.max(3, longestRun + 1) +} + +function escapeFenceAttribute({ value }: FenceAttribute): string { + return value.replace(/"/gu, '"') +} + +export function tldrawFenceSource({ boardId, height, snapshot, width }: TldrawPayload): string { + const fence = '`'.repeat(fenceLengthForSnapshot({ snapshot })) + const metadata = tldrawFenceMetadata({ boardId, height, width }) + const body = snapshot.endsWith('\n') ? snapshot : `${snapshot}\n` + return `${fence}tldraw${metadata}\n${body}${fence}` +} + +function tldrawFenceMetadata({ boardId, height, width }: Omit): string { + const attributes: string[] = [] + if (boardId) attributes.push(`id="${escapeFenceAttribute({ value: boardId })}"`) + if (height) attributes.push(`height="${escapeFenceAttribute({ value: height })}"`) + if (width) attributes.push(`width="${escapeFenceAttribute({ value: width })}"`) + return attributes.length > 0 ? ` ${attributes.join(' ')}` : '' +} + +export function injectTldrawInBlocks(blocks: unknown[]): unknown[] { + return (blocks as BlockLike[]).map(injectTldrawInBlock) +} + +export function isTldrawBlock(block: BlockLike): boolean { + return block.type === TLDRAW_BLOCK_TYPE + && typeof block.props?.snapshot === 'string' + && typeof block.props?.boardId === 'string' +} + +export function tldrawMarkdown(block: BlockLike): string { + return tldrawFenceSource({ + boardId: block.props?.boardId ?? '', + height: block.props?.height ?? TLDRAW_DEFAULT_HEIGHT, + snapshot: block.props?.snapshot ?? '{}', + width: block.props?.width ?? '', + }) +} diff --git a/tests/smoke/tldraw-whiteboard-notes.spec.ts b/tests/smoke/tldraw-whiteboard-notes.spec.ts new file mode 100644 index 00000000..dfb13527 --- /dev/null +++ b/tests/smoke/tldraw-whiteboard-notes.spec.ts @@ -0,0 +1,77 @@ +import { expect, test, type Page } from '@playwright/test' +import fs from 'fs' +import path from 'path' +import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault' +import { executeCommand, openCommandPalette } from './helpers' + +let tempVaultDir: string + +const WHITEBOARD_NOTE = [ + '# Whiteboard Embed', + '', + 'Context before the board.', + '', + '```tldraw id="planning-map"', + '{}', + '```', + '', + 'Context after the board.', + '', +].join('\n') + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(90_000) + tempVaultDir = createFixtureVaultCopy() + fs.writeFileSync(path.join(tempVaultDir, 'note', 'whiteboard-embed.md'), WHITEBOARD_NOTE) + await openFixtureVault(page, tempVaultDir) +}) + +test.afterEach(async () => { + removeFixtureVaultCopy(tempVaultDir) +}) + +async function openNote(page: Page, title: string): Promise { + await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click() + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) +} + +async function toggleRawMode(page: Page, visibleSelector: '.bn-editor' | '.cm-content'): Promise { + await openCommandPalette(page) + await executeCommand(page, 'Toggle Raw') + await expect(page.locator(visibleSelector)).toBeVisible({ timeout: 5_000 }) +} + +async function getRawEditorContent(page: Page): Promise { + return page.evaluate(() => { + type CodeMirrorHost = Element & { + cmTile?: { + view?: { + state: { + doc: { + toString(): string + } + } + } + } + } + + const host = document.querySelector('.cm-content') as CodeMirrorHost | null + return host?.cmTile?.view?.state.doc.toString() ?? host?.textContent ?? '' + }) +} + +test('tldraw whiteboard fences render as embedded canvases and remain Markdown-durable', async ({ page }) => { + await openNote(page, 'Whiteboard Embed') + + await expect(page.locator('.tldraw-whiteboard')).toBeVisible({ timeout: 20_000 }) + await expect(page.locator('.tldraw-whiteboard .tl-container')).toBeVisible({ timeout: 20_000 }) + await expect(page.locator('.bn-editor')).toContainText('Context before the board.') + await expect(page.locator('.bn-editor')).toContainText('Context after the board.') + + await toggleRawMode(page, '.cm-content') + const rawAfterRichMode = await getRawEditorContent(page) + + expect(rawAfterRichMode).toContain('```tldraw id="planning-map" height="520"') + expect(rawAfterRichMode).toContain('{}') + expect(rawAfterRichMode).not.toContain('@@TOLARIA_TLDRAW') +})