From 4cead7fc524d0741f7e59b4c249e2fa140dd3020 Mon Sep 17 00:00:00 2001 From: Tosin Amuda Date: Mon, 24 Aug 2026 11:31:59 +0200 Subject: [PATCH] chore: consume the editor from npm, not from packages/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor now lives in getlatentic/live-markdown and publishes itself, so Compose depends on `@latentic/live-markdown@^0.1.1` like any other consumer. 20,462 lines leave this repository; the history came with them via `git subtree split`, all 115 commits. Consuming it the way everyone else does is the point. The workspace arrangement resolved the package to its TypeScript SOURCE through a Vite alias and a matching tsconfig path, so Compose never once exercised the thing npm ships — which is how a broken declaration build sat unnoticed while every check here was green, and how the package's own README came to promise that mathematics needs no CSS import. `pnpm-workspace.yaml` stays, with no members, because deleting it was worse than keeping it: pnpm then searches UPWARD for a workspace to join, and silently captured a git worktree nested under the primary checkout — resolving the dependency back to that checkout's `packages/rich-editor` and reporting success. The file now exists to stop the search. Supersedes #182. Those two commits touched nothing outside the package and are already released in 0.1.0, so the fixes arrive here through the registry instead: * a `[…]` keeps its brackets until it resolves to a link * a table cell renders what the paragraph above it renders Verified against the published tarball rather than a link: typecheck clean, 548 tests passing, `pnpm build` succeeds, and the shipped bundle really does contain the bracket fix. The editor-package build step leaves CI with it. That gate has moved to the editor's own three-platform CI, where it belongs — and where, given a second platform for the first time, it immediately found two bugs the macOS-only browser tier had been hiding. --- .github/workflows/ci.yml | 8 - package.json | 5 +- packages/rich-editor/.gitignore | 2 - packages/rich-editor/LICENSE | 21 - packages/rich-editor/README.md | 265 ------- packages/rich-editor/docs/interaction-spec.md | 227 ------ packages/rich-editor/package.json | 90 --- .../CodeMirrorMarkdownEditor.test.tsx | 200 ----- .../codemirror/CodeMirrorMarkdownEditor.tsx | 744 ----------------- .../src/codemirror/byteOffset.test.ts | 64 -- .../rich-editor/src/codemirror/byteOffset.ts | 28 - .../src/codemirror/caretLayer.test.ts | 43 - .../rich-editor/src/codemirror/caretLayer.ts | 68 -- .../clipboard/balancedSlice.test.ts | 72 -- .../clipboard/clipboard.browser.test.ts | 133 ---- .../src/codemirror/clipboard/copyRich.ts | 119 --- .../clipboard/htmlToMarkdown.test.ts | 106 --- .../codemirror/clipboard/htmlToMarkdown.ts | 127 --- .../src/codemirror/clipboard/pasteMarkdown.ts | 61 -- .../src/codemirror/code/codeHighlight.ts | 18 - .../src/codemirror/code/codeLangAffordance.ts | 102 --- .../code/codeLanguage.browser.test.ts | 129 --- .../codemirror/code/codeLanguageMenu.test.ts | 85 -- .../src/codemirror/code/codeLanguageMenu.ts | 261 ------ .../src/codemirror/code/codeLanguages.test.ts | 65 -- .../src/codemirror/code/codePalette.ts | 32 - .../codemirror/code/fenceAutoClose.test.ts | 229 ------ .../src/codemirror/code/fenceAutoClose.ts | 286 ------- .../codemirror/code/fenceCaretGuard.test.ts | 110 --- .../src/codemirror/code/fenceCaretGuard.ts | 100 --- .../codemirror/code/fenceDeleteGuards.test.ts | 125 --- .../src/codemirror/code/fenceDeleteGuards.ts | 155 ---- .../codemirror/code/fenceLineTyping.test.ts | 84 -- .../codemirror/code/fenceOpenerTyping.test.ts | 93 --- .../src/codemirror/code/fenceTabIndent.ts | 59 -- .../codemirror/code/highlightFence.test.ts | 35 - .../src/codemirror/code/highlightFence.ts | 74 -- .../rich-editor/src/codemirror/code/index.ts | 12 - .../src/codemirror/core/codeContext.test.ts | 28 - .../src/codemirror/core/codeContext.ts | 48 -- .../src/codemirror/core/editorTestHarness.ts | 104 --- .../src/codemirror/core/editorTheme.test.ts | 22 - .../src/codemirror/core/editorTheme.ts | 534 ------------- .../src/codemirror/core/hostFacets.test.ts | 33 - .../src/codemirror/core/hostFacets.ts | 75 -- .../src/codemirror/core/hrWidget.test.ts | 20 - .../src/codemirror/core/hrWidget.ts | 39 - .../rich-editor/src/codemirror/core/index.ts | 20 - .../src/codemirror/core/lineStructure.ts | 119 --- .../rich-editor/src/codemirror/core/paint.ts | 139 ---- .../core/parseToEnd.browser.test.ts | 84 -- .../src/codemirror/core/parseToEnd.test.ts | 83 -- .../src/codemirror/core/parseToEnd.ts | 68 -- .../src/codemirror/core/plugin.test.ts | 87 -- .../rich-editor/src/codemirror/core/plugin.ts | 243 ------ .../src/codemirror/core/registry.test.ts | 81 -- .../src/codemirror/core/registry.ts | 167 ---- .../codemirror/core/renderedOutput.test.ts | 183 ----- .../codemirror/core/treeAt.browser.test.ts | 134 ---- .../rich-editor/src/codemirror/core/treeAt.ts | 42 - .../extensions/footnoteExtension.ts | 10 - .../extensions/highlightExtension.ts | 10 - .../src/codemirror/extensions/index.ts | 8 - .../src/codemirror/extensions/loader.ts | 42 - .../codemirror/extensions/mathExtension.ts | 10 - .../codemirror/extensions/mermaidExtension.ts | 11 - .../codemirror/extensions/tableExtension.ts | 43 - .../src/codemirror/extensions/types.ts | 40 - .../extensions/wikilinkExtension.ts | 10 - .../codemirror/features/blockCommandSteps.ts | 131 --- .../features/blockCommands.feature.test.ts | 30 - .../codemirror/features/blockquote.feature | 51 -- .../features/browser/table-editing.feature | 189 ----- .../codemirror/features/code-block.feature | 30 - .../src/codemirror/features/headings.feature | 86 -- .../src/codemirror/features/indent.feature | 68 -- .../src/codemirror/features/lists.feature | 146 ---- .../src/codemirror/features/table.feature | 48 -- .../src/codemirror/features/tasks.feature | 62 -- .../footnote/footnotePlugin.test.ts | 44 -- .../src/codemirror/footnote/footnotePlugin.ts | 85 -- .../src/codemirror/footnote/index.ts | 2 - .../codemirror/format/blockCommands.test.ts | 216 ----- .../src/codemirror/format/blockCommands.ts | 263 ------- .../codemirror/format/formatCommands.test.ts | 129 --- .../src/codemirror/format/formatCommands.ts | 209 ----- .../src/codemirror/format/index.ts | 7 - .../highlight/highlightPlugin.test.ts | 42 - .../codemirror/highlight/highlightPlugin.ts | 70 -- .../src/codemirror/highlight/index.ts | 2 - .../src/codemirror/html/htmlWidget.test.ts | 39 - .../src/codemirror/html/htmlWidget.ts | 91 --- .../rich-editor/src/codemirror/html/index.ts | 6 - .../codemirror/image/imageActionMenu.test.ts | 78 -- .../src/codemirror/image/imageActionMenu.ts | 133 ---- .../src/codemirror/image/imageEditEvent.ts | 19 - .../image/imageInsertHandlers.test.ts | 77 -- .../codemirror/image/imageInsertHandlers.ts | 80 -- .../src/codemirror/image/imageWidget.test.ts | 43 - .../src/codemirror/image/imageWidget.ts | 131 --- .../rich-editor/src/codemirror/image/index.ts | 8 - .../interaction/boldFlankingConflict.test.ts | 23 - .../codemirror/interaction/clickModel.test.ts | 129 --- .../src/codemirror/interaction/clickModel.ts | 170 ---- .../interaction/cursorModel.test.ts | 36 - .../src/codemirror/interaction/cursorModel.ts | 87 -- .../interaction/deleteNormalizer.test.ts | 202 ----- .../interaction/deleteNormalizer.ts | 271 ------- .../interaction/flankingGuard.test.ts | 100 --- .../codemirror/interaction/flankingGuard.ts | 80 -- .../src/codemirror/interaction/index.ts | 11 - .../interaction/interactionMatrix.test.ts | 214 ----- .../interaction/markerBackspace.test.ts | 65 -- .../interaction/realGeometry.browser.test.ts | 71 -- .../interaction/reproBoldWhitespace.test.ts | 36 - .../interaction/visiblePosition.test.ts | 44 -- .../codemirror/interaction/visiblePosition.ts | 157 ---- .../src/codemirror/list/bulletWidget.test.ts | 37 - .../src/codemirror/list/bulletWidget.ts | 129 --- .../rich-editor/src/codemirror/list/index.ts | 6 - .../codemirror/list/listContinuation.test.ts | 104 --- .../src/codemirror/list/listContinuation.ts | 98 --- .../src/codemirror/list/listIndent.test.ts | 88 --- .../src/codemirror/list/listIndent.ts | 139 ---- .../list/taskCheckboxWidget.test.ts | 32 - .../src/codemirror/list/taskCheckboxWidget.ts | 71 -- .../codemirror/list/taskMarkerSpacing.test.ts | 70 -- .../rich-editor/src/codemirror/math/index.ts | 2 - .../src/codemirror/math/mathPlugin.test.ts | 83 -- .../src/codemirror/math/mathPlugin.ts | 85 -- .../src/codemirror/math/mathWidget.test.ts | 29 - .../src/codemirror/math/mathWidget.ts | 34 - .../src/codemirror/mermaid/index.ts | 6 - .../mermaid/mermaid.browser.test.ts | 139 ---- .../codemirror/mermaid/mermaidPlugin.test.ts | 191 ----- .../src/codemirror/mermaid/mermaidPlugin.ts | 130 --- .../src/codemirror/mermaid/mermaidRender.ts | 184 ----- .../src/codemirror/mermaid/mermaidWidget.ts | 195 ----- .../codemirror/selectionLayer.browser.test.ts | 251 ------ .../src/codemirror/selectionLayer.test.ts | 88 --- .../src/codemirror/selectionLayer.ts | 207 ----- .../table/cellDividerWidget.test.ts | 20 - .../src/codemirror/table/cellDividerWidget.ts | 16 - .../rich-editor/src/codemirror/table/index.ts | 8 - .../src/codemirror/table/tableArmed.test.ts | 43 - .../src/codemirror/table/tableArmed.ts | 73 -- .../src/codemirror/table/tableCell.ts | 24 - .../src/codemirror/table/tableCellNav.test.ts | 69 -- .../src/codemirror/table/tableCellNav.ts | 86 -- .../codemirror/table/tableContextMenu.test.ts | 112 --- .../src/codemirror/table/tableContextMenu.ts | 197 ----- .../table/tableEditCommands.test.ts | 96 --- .../src/codemirror/table/tableEditCommands.ts | 124 --- .../src/codemirror/table/tableExcerpt.test.ts | 64 -- .../src/codemirror/table/tableExcerpt.ts | 62 -- .../src/codemirror/table/tableField.test.ts | 32 - .../src/codemirror/table/tableField.ts | 41 - .../codemirror/table/tableGeometry.test.ts | 25 - .../src/codemirror/table/tableGeometry.ts | 53 -- .../src/codemirror/table/tableInline.test.ts | 70 -- .../src/codemirror/table/tableInline.ts | 149 ---- .../src/codemirror/table/tableModel.test.ts | 143 ---- .../src/codemirror/table/tableModel.ts | 193 ----- .../codemirror/table/tableModelGfm.test.ts | 79 -- .../codemirror/tablev2/bridgeRules.test.ts | 132 ---- .../src/codemirror/tablev2/bridgeRules.ts | 84 -- .../tablev2/caretVisibility.browser.test.ts | 73 -- .../codemirror/tablev2/cellEditingSurface.ts | 147 ---- .../src/codemirror/tablev2/cellText.ts | 15 - .../codemirror/tablev2/inlineCellSurface.ts | 202 ----- .../tablev2/interaction.browser.test.ts | 214 ----- .../codemirror/tablev2/overlayCellSurface.ts | 187 ----- .../tablev2/selection.browser.test.ts | 194 ----- .../tablev2/surfaceGate.browser.test.ts | 182 ----- .../tableEditing.feature.browser.test.ts | 28 - .../codemirror/tablev2/tableEditingSteps.ts | 572 -------------- .../tablev2/tableV2HoverControls.ts | 128 --- .../codemirror/tablev2/tableV2Interaction.ts | 279 ------- .../tablev2/tableV2Selection.test.ts | 41 - .../codemirror/tablev2/tableV2Selection.ts | 105 --- .../src/codemirror/tablev2/tableV2Sync.ts | 18 - .../codemirror/tablev2/tableWidgetV2.test.ts | 59 -- .../src/codemirror/tablev2/tableWidgetV2.ts | 181 ----- .../src/codemirror/updateBus.test.ts | 78 -- .../rich-editor/src/codemirror/updateBus.ts | 47 -- .../src/codemirror/wikilink/index.ts | 5 - .../wikilink/wikilinkPlugin.test.ts | 64 -- .../src/codemirror/wikilink/wikilinkPlugin.ts | 141 ---- packages/rich-editor/src/frontmatter.test.ts | 161 ---- packages/rich-editor/src/frontmatter.ts | 151 ---- packages/rich-editor/src/imageInsert.test.ts | 108 --- packages/rich-editor/src/imageInsert.ts | 194 ----- .../rich-editor/src/imageSrcResolver.test.ts | 85 -- packages/rich-editor/src/imageSrcResolver.ts | 99 --- packages/rich-editor/src/index.ts | 141 ---- .../rich-editor/src/links/wikilink.test.ts | 62 -- packages/rich-editor/src/links/wikilink.ts | 84 -- .../src/links/workspaceLink.test.ts | 83 -- .../rich-editor/src/links/workspaceLink.ts | 104 --- packages/rich-editor/src/styles.css | 36 - packages/rich-editor/src/types.ts | 20 - packages/rich-editor/tsconfig.json | 20 - packages/rich-editor/tsup.config.ts | 15 - pnpm-lock.yaml | 745 +++++++----------- pnpm-workspace.yaml | 7 +- tsconfig.json | 8 - vite.config.ts | 9 - vitest.bench.config.ts | 4 - 208 files changed, 312 insertions(+), 20462 deletions(-) delete mode 100644 packages/rich-editor/.gitignore delete mode 100644 packages/rich-editor/LICENSE delete mode 100644 packages/rich-editor/README.md delete mode 100644 packages/rich-editor/docs/interaction-spec.md delete mode 100644 packages/rich-editor/package.json delete mode 100644 packages/rich-editor/src/codemirror/CodeMirrorMarkdownEditor.test.tsx delete mode 100644 packages/rich-editor/src/codemirror/CodeMirrorMarkdownEditor.tsx delete mode 100644 packages/rich-editor/src/codemirror/byteOffset.test.ts delete mode 100644 packages/rich-editor/src/codemirror/byteOffset.ts delete mode 100644 packages/rich-editor/src/codemirror/caretLayer.test.ts delete mode 100644 packages/rich-editor/src/codemirror/caretLayer.ts delete mode 100644 packages/rich-editor/src/codemirror/clipboard/balancedSlice.test.ts delete mode 100644 packages/rich-editor/src/codemirror/clipboard/clipboard.browser.test.ts delete mode 100644 packages/rich-editor/src/codemirror/clipboard/copyRich.ts delete mode 100644 packages/rich-editor/src/codemirror/clipboard/htmlToMarkdown.test.ts delete mode 100644 packages/rich-editor/src/codemirror/clipboard/htmlToMarkdown.ts delete mode 100644 packages/rich-editor/src/codemirror/clipboard/pasteMarkdown.ts delete mode 100644 packages/rich-editor/src/codemirror/code/codeHighlight.ts delete mode 100644 packages/rich-editor/src/codemirror/code/codeLangAffordance.ts delete mode 100644 packages/rich-editor/src/codemirror/code/codeLanguage.browser.test.ts delete mode 100644 packages/rich-editor/src/codemirror/code/codeLanguageMenu.test.ts delete mode 100644 packages/rich-editor/src/codemirror/code/codeLanguageMenu.ts delete mode 100644 packages/rich-editor/src/codemirror/code/codeLanguages.test.ts delete mode 100644 packages/rich-editor/src/codemirror/code/codePalette.ts delete mode 100644 packages/rich-editor/src/codemirror/code/fenceAutoClose.test.ts delete mode 100644 packages/rich-editor/src/codemirror/code/fenceAutoClose.ts delete mode 100644 packages/rich-editor/src/codemirror/code/fenceCaretGuard.test.ts delete mode 100644 packages/rich-editor/src/codemirror/code/fenceCaretGuard.ts delete mode 100644 packages/rich-editor/src/codemirror/code/fenceDeleteGuards.test.ts delete mode 100644 packages/rich-editor/src/codemirror/code/fenceDeleteGuards.ts delete mode 100644 packages/rich-editor/src/codemirror/code/fenceLineTyping.test.ts delete mode 100644 packages/rich-editor/src/codemirror/code/fenceOpenerTyping.test.ts delete mode 100644 packages/rich-editor/src/codemirror/code/fenceTabIndent.ts delete mode 100644 packages/rich-editor/src/codemirror/code/highlightFence.test.ts delete mode 100644 packages/rich-editor/src/codemirror/code/highlightFence.ts delete mode 100644 packages/rich-editor/src/codemirror/code/index.ts delete mode 100644 packages/rich-editor/src/codemirror/core/codeContext.test.ts delete mode 100644 packages/rich-editor/src/codemirror/core/codeContext.ts delete mode 100644 packages/rich-editor/src/codemirror/core/editorTestHarness.ts delete mode 100644 packages/rich-editor/src/codemirror/core/editorTheme.test.ts delete mode 100644 packages/rich-editor/src/codemirror/core/editorTheme.ts delete mode 100644 packages/rich-editor/src/codemirror/core/hostFacets.test.ts delete mode 100644 packages/rich-editor/src/codemirror/core/hostFacets.ts delete mode 100644 packages/rich-editor/src/codemirror/core/hrWidget.test.ts delete mode 100644 packages/rich-editor/src/codemirror/core/hrWidget.ts delete mode 100644 packages/rich-editor/src/codemirror/core/index.ts delete mode 100644 packages/rich-editor/src/codemirror/core/lineStructure.ts delete mode 100644 packages/rich-editor/src/codemirror/core/paint.ts delete mode 100644 packages/rich-editor/src/codemirror/core/parseToEnd.browser.test.ts delete mode 100644 packages/rich-editor/src/codemirror/core/parseToEnd.test.ts delete mode 100644 packages/rich-editor/src/codemirror/core/parseToEnd.ts delete mode 100644 packages/rich-editor/src/codemirror/core/plugin.test.ts delete mode 100644 packages/rich-editor/src/codemirror/core/plugin.ts delete mode 100644 packages/rich-editor/src/codemirror/core/registry.test.ts delete mode 100644 packages/rich-editor/src/codemirror/core/registry.ts delete mode 100644 packages/rich-editor/src/codemirror/core/renderedOutput.test.ts delete mode 100644 packages/rich-editor/src/codemirror/core/treeAt.browser.test.ts delete mode 100644 packages/rich-editor/src/codemirror/core/treeAt.ts delete mode 100644 packages/rich-editor/src/codemirror/extensions/footnoteExtension.ts delete mode 100644 packages/rich-editor/src/codemirror/extensions/highlightExtension.ts delete mode 100644 packages/rich-editor/src/codemirror/extensions/index.ts delete mode 100644 packages/rich-editor/src/codemirror/extensions/loader.ts delete mode 100644 packages/rich-editor/src/codemirror/extensions/mathExtension.ts delete mode 100644 packages/rich-editor/src/codemirror/extensions/mermaidExtension.ts delete mode 100644 packages/rich-editor/src/codemirror/extensions/tableExtension.ts delete mode 100644 packages/rich-editor/src/codemirror/extensions/types.ts delete mode 100644 packages/rich-editor/src/codemirror/extensions/wikilinkExtension.ts delete mode 100644 packages/rich-editor/src/codemirror/features/blockCommandSteps.ts delete mode 100644 packages/rich-editor/src/codemirror/features/blockCommands.feature.test.ts delete mode 100644 packages/rich-editor/src/codemirror/features/blockquote.feature delete mode 100644 packages/rich-editor/src/codemirror/features/browser/table-editing.feature delete mode 100644 packages/rich-editor/src/codemirror/features/code-block.feature delete mode 100644 packages/rich-editor/src/codemirror/features/headings.feature delete mode 100644 packages/rich-editor/src/codemirror/features/indent.feature delete mode 100644 packages/rich-editor/src/codemirror/features/lists.feature delete mode 100644 packages/rich-editor/src/codemirror/features/table.feature delete mode 100644 packages/rich-editor/src/codemirror/features/tasks.feature delete mode 100644 packages/rich-editor/src/codemirror/footnote/footnotePlugin.test.ts delete mode 100644 packages/rich-editor/src/codemirror/footnote/footnotePlugin.ts delete mode 100644 packages/rich-editor/src/codemirror/footnote/index.ts delete mode 100644 packages/rich-editor/src/codemirror/format/blockCommands.test.ts delete mode 100644 packages/rich-editor/src/codemirror/format/blockCommands.ts delete mode 100644 packages/rich-editor/src/codemirror/format/formatCommands.test.ts delete mode 100644 packages/rich-editor/src/codemirror/format/formatCommands.ts delete mode 100644 packages/rich-editor/src/codemirror/format/index.ts delete mode 100644 packages/rich-editor/src/codemirror/highlight/highlightPlugin.test.ts delete mode 100644 packages/rich-editor/src/codemirror/highlight/highlightPlugin.ts delete mode 100644 packages/rich-editor/src/codemirror/highlight/index.ts delete mode 100644 packages/rich-editor/src/codemirror/html/htmlWidget.test.ts delete mode 100644 packages/rich-editor/src/codemirror/html/htmlWidget.ts delete mode 100644 packages/rich-editor/src/codemirror/html/index.ts delete mode 100644 packages/rich-editor/src/codemirror/image/imageActionMenu.test.ts delete mode 100644 packages/rich-editor/src/codemirror/image/imageActionMenu.ts delete mode 100644 packages/rich-editor/src/codemirror/image/imageEditEvent.ts delete mode 100644 packages/rich-editor/src/codemirror/image/imageInsertHandlers.test.ts delete mode 100644 packages/rich-editor/src/codemirror/image/imageInsertHandlers.ts delete mode 100644 packages/rich-editor/src/codemirror/image/imageWidget.test.ts delete mode 100644 packages/rich-editor/src/codemirror/image/imageWidget.ts delete mode 100644 packages/rich-editor/src/codemirror/image/index.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/boldFlankingConflict.test.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/clickModel.test.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/clickModel.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/cursorModel.test.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/cursorModel.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/deleteNormalizer.test.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/deleteNormalizer.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/flankingGuard.test.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/flankingGuard.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/index.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/interactionMatrix.test.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/markerBackspace.test.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/realGeometry.browser.test.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/reproBoldWhitespace.test.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/visiblePosition.test.ts delete mode 100644 packages/rich-editor/src/codemirror/interaction/visiblePosition.ts delete mode 100644 packages/rich-editor/src/codemirror/list/bulletWidget.test.ts delete mode 100644 packages/rich-editor/src/codemirror/list/bulletWidget.ts delete mode 100644 packages/rich-editor/src/codemirror/list/index.ts delete mode 100644 packages/rich-editor/src/codemirror/list/listContinuation.test.ts delete mode 100644 packages/rich-editor/src/codemirror/list/listContinuation.ts delete mode 100644 packages/rich-editor/src/codemirror/list/listIndent.test.ts delete mode 100644 packages/rich-editor/src/codemirror/list/listIndent.ts delete mode 100644 packages/rich-editor/src/codemirror/list/taskCheckboxWidget.test.ts delete mode 100644 packages/rich-editor/src/codemirror/list/taskCheckboxWidget.ts delete mode 100644 packages/rich-editor/src/codemirror/list/taskMarkerSpacing.test.ts delete mode 100644 packages/rich-editor/src/codemirror/math/index.ts delete mode 100644 packages/rich-editor/src/codemirror/math/mathPlugin.test.ts delete mode 100644 packages/rich-editor/src/codemirror/math/mathPlugin.ts delete mode 100644 packages/rich-editor/src/codemirror/math/mathWidget.test.ts delete mode 100644 packages/rich-editor/src/codemirror/math/mathWidget.ts delete mode 100644 packages/rich-editor/src/codemirror/mermaid/index.ts delete mode 100644 packages/rich-editor/src/codemirror/mermaid/mermaid.browser.test.ts delete mode 100644 packages/rich-editor/src/codemirror/mermaid/mermaidPlugin.test.ts delete mode 100644 packages/rich-editor/src/codemirror/mermaid/mermaidPlugin.ts delete mode 100644 packages/rich-editor/src/codemirror/mermaid/mermaidRender.ts delete mode 100644 packages/rich-editor/src/codemirror/mermaid/mermaidWidget.ts delete mode 100644 packages/rich-editor/src/codemirror/selectionLayer.browser.test.ts delete mode 100644 packages/rich-editor/src/codemirror/selectionLayer.test.ts delete mode 100644 packages/rich-editor/src/codemirror/selectionLayer.ts delete mode 100644 packages/rich-editor/src/codemirror/table/cellDividerWidget.test.ts delete mode 100644 packages/rich-editor/src/codemirror/table/cellDividerWidget.ts delete mode 100644 packages/rich-editor/src/codemirror/table/index.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableArmed.test.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableArmed.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableCell.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableCellNav.test.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableCellNav.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableContextMenu.test.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableContextMenu.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableEditCommands.test.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableEditCommands.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableExcerpt.test.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableExcerpt.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableField.test.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableField.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableGeometry.test.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableGeometry.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableInline.test.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableInline.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableModel.test.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableModel.ts delete mode 100644 packages/rich-editor/src/codemirror/table/tableModelGfm.test.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/bridgeRules.test.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/bridgeRules.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/caretVisibility.browser.test.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/cellEditingSurface.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/cellText.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/inlineCellSurface.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/interaction.browser.test.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/overlayCellSurface.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/selection.browser.test.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/surfaceGate.browser.test.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/tableEditing.feature.browser.test.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/tableEditingSteps.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/tableV2HoverControls.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/tableV2Interaction.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/tableV2Selection.test.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/tableV2Selection.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/tableV2Sync.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/tableWidgetV2.test.ts delete mode 100644 packages/rich-editor/src/codemirror/tablev2/tableWidgetV2.ts delete mode 100644 packages/rich-editor/src/codemirror/updateBus.test.ts delete mode 100644 packages/rich-editor/src/codemirror/updateBus.ts delete mode 100644 packages/rich-editor/src/codemirror/wikilink/index.ts delete mode 100644 packages/rich-editor/src/codemirror/wikilink/wikilinkPlugin.test.ts delete mode 100644 packages/rich-editor/src/codemirror/wikilink/wikilinkPlugin.ts delete mode 100644 packages/rich-editor/src/frontmatter.test.ts delete mode 100644 packages/rich-editor/src/frontmatter.ts delete mode 100644 packages/rich-editor/src/imageInsert.test.ts delete mode 100644 packages/rich-editor/src/imageInsert.ts delete mode 100644 packages/rich-editor/src/imageSrcResolver.test.ts delete mode 100644 packages/rich-editor/src/imageSrcResolver.ts delete mode 100644 packages/rich-editor/src/index.ts delete mode 100644 packages/rich-editor/src/links/wikilink.test.ts delete mode 100644 packages/rich-editor/src/links/wikilink.ts delete mode 100644 packages/rich-editor/src/links/workspaceLink.test.ts delete mode 100644 packages/rich-editor/src/links/workspaceLink.ts delete mode 100644 packages/rich-editor/src/styles.css delete mode 100644 packages/rich-editor/src/types.ts delete mode 100644 packages/rich-editor/tsconfig.json delete mode 100644 packages/rich-editor/tsup.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bce34b5..eccc324 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,14 +47,6 @@ jobs: - name: Frontend tests run: pnpm test - # `tsc --noEmit` does not emit declarations, so the errors that only - # occur while writing a `.d.ts` — an exported anonymous class with - # private fields, an inferred type naming a transitive dependency by its - # pnpm path — are invisible to every other check here. This is the build - # `npm publish` runs, and it was failing while the app was green. - - name: Editor package build - run: pnpm --filter @latentic/live-markdown build - - name: Rust tests working-directory: src-tauri run: cargo test --no-fail-fast diff --git a/package.json b/package.json index 9467ea0..65941c1 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "dev": "vite", "build": "pnpm build:wasm && pnpm typecheck && vite build", "build:wasm": "wasm-pack build crates/workspace-index-wasm --target web --out-dir ../../src/wasm/workspace_index_pkg --no-pack && rm -f src/wasm/workspace_index_pkg/.gitignore", - "build:editor": "pnpm --filter @latentic/live-markdown build", "preview": "vite preview", "tauri": "tauri", "clean": "rm -rf target dist", @@ -20,7 +19,7 @@ "test:rust": "cargo test", "test:coverage": "vitest run --coverage --coverage.provider=v8 --coverage.include='src/**' --coverage.include='packages/*/src/**' --coverage.exclude='**/*.test.{ts,tsx}' --coverage.exclude='**/*.d.ts' --coverage.reporter=text-summary", "coverage:rust": "cd src-tauri && cargo llvm-cov --workspace --summary-only", - "check": "pnpm build:editor && pnpm build && pnpm test && pnpm test:rust", + "check": "pnpm build && pnpm test && pnpm test:rust", "test:browser": "vitest run --config vitest.browser.config.ts" }, "dependencies": { @@ -30,13 +29,13 @@ "@codemirror/language": "^6.12.3", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", + "@latentic/live-markdown": "^0.1.1", "@tanstack/react-virtual": "^3.14.3", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-updater": "^2", - "@latentic/live-markdown": "workspace:*", "comlink": "^4.4.2", "decode-named-character-reference": "1.3.0", "dompurify": "^3.4.12", diff --git a/packages/rich-editor/.gitignore b/packages/rich-editor/.gitignore deleted file mode 100644 index 3be4a7b..0000000 --- a/packages/rich-editor/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist/ -*.tgz diff --git a/packages/rich-editor/LICENSE b/packages/rich-editor/LICENSE deleted file mode 100644 index 3a4ad6f..0000000 --- a/packages/rich-editor/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Tosin Amuda - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/rich-editor/README.md b/packages/rich-editor/README.md deleted file mode 100644 index 6d348f5..0000000 --- a/packages/rich-editor/README.md +++ /dev/null @@ -1,265 +0,0 @@ -# @latentic/live-markdown - -A rich markdown editor for React — write like Notion, store as plain `.md`. - -Built on [CodeMirror 6](https://codemirror.net/). Headings, bold, italic, lists, tables, images, math, footnotes, and code blocks render inline as you type. The file on disk is always standard markdown — no proprietary format, no AST translation layer, no lock-in. - -```tsx -import { CodeMirrorMarkdownEditor } from "@latentic/live-markdown"; - - -``` - ---- - -## Why @latentic/live-markdown - -Most markdown editors fall into two camps: - -| Camp | Examples | Trade-off | -|------|----------|-----------| -| **Raw editors** | CodeMirror, Monaco, Ace | Fast, plain text — but users see `## Heading`, not a heading | -| **Rich editors** | Tiptap, ProseMirror, Slate, Lexical | WYSIWYG — but the internal model is a custom AST, not markdown. Round-tripping to `.md` is lossy or fragile | - -**@latentic/live-markdown sits in between.** The source of truth is the raw markdown string. CodeMirror parses it with Lezer, and a decoration engine replaces syntax tokens with rendered widgets in real time — `## Heading` becomes a styled heading, `- item` becomes a bullet, `![alt](src)` becomes an inline image. You get the editing experience of Notion or Google Docs, but `value` in and `onChange` out is always a plain markdown string. No AST translation, no serialization bugs, no format lock-in. - -The boundary semantics this demands — what every keystroke does at every construct edge — are specified in [docs/interaction-spec.md](docs/interaction-spec.md) and enforced by its conformance matrix (`interactionMatrix.test.ts`); block-level behaviors are specified executably in `src/codemirror/features/*.feature`. - -### How it compares - -| Feature | @latentic/live-markdown | Tiptap / ProseMirror | ink-mde | @mdxeditor/editor | Novel | -|---------|-----------|---------------------|---------|-------------------|-------| -| Source of truth | Markdown string | Custom AST | Markdown string | MDX AST | ProseMirror AST | -| Rich rendering | Inline decorations | DOM nodes | Syntax highlight only | DOM nodes | DOM nodes | -| Round-trip fidelity | Byte-for-byte | Lossy (serializer) | Byte-for-byte | MDX subset | Lossy | -| YAML frontmatter | Preserved, never stripped | Plugin (varies) | No | Plugin | No | -| LaTeX math | Inline KaTeX | Plugin | No | Plugin | No | -| Large files (1 MB+) | Fast (CodeMirror) | Slow (DOM-per-node) | Fast | Slow | Slow | -| Tables | GFM, cell navigation | Plugin | Basic | Plugin | Slash command | -| Image paste/drop | Built-in | Plugin | No | Plugin | Plugin | -| Framework | React | React / Vue / vanilla | Vanilla / adapters | React | React | -| Bundle size | ~80 KB (gzip, editor core) | ~120 KB+ | ~40 KB | ~150 KB+ | ~200 KB+ | - -### Key differentiators - -- **Markdown in, markdown out.** No AST translation layer. The `value` prop is a markdown string; `onChange` returns a markdown string. What you put in is exactly what you get out, byte for byte. YAML frontmatter is preserved and never stripped. - -- **Rich editing without leaving markdown.** Headings, bold, italic, strikethrough, inline code, block code, bullet lists, ordered lists, task lists, tables, horizontal rules, images, footnotes, LaTeX math, and wikilinks all render inline. The user never sees raw syntax unless they switch to source mode. - -- **Fast on large files.** Built on CodeMirror 6's virtual viewport — only visible lines are rendered. A 1 MB document opens instantly and scrolls at 60 fps. ProseMirror/Tiptap-based editors create a DOM node per text node and degrade on large documents. - -- **Per-tab state caching.** When used with tabbed interfaces, editor state (cursor position, scroll offset, undo history) is cached per file and restored instantly on tab switch — a pointer swap, not a re-parse. - -- **Extension system.** Add custom markdown rendering by contributing `NodeRule`s — one function per syntax node deciding how it paints (line class, styled span, hidden marker, or widget). Built-in extensions: highlight marks, footnotes, math (KaTeX), tables (cell navigation + resize), wikilinks. - -- **Toolbar slot, not toolbar opinions.** The editor renders a host-supplied `toolbar` around the live `EditorView`, and `selectionActions` around the current selection — both are render props (`(ctx) => ReactNode`). The host app owns Save, Export, Comments, or whatever actions it needs — the editor stays agnostic. - ---- - -## Features - -### Inline rendering -- **Headings** (ATX `#` only — setext `---`/`===` disabled for predictability) -- **Bold**, **italic**, **strikethrough**, **inline code** -- **Block code** with language label -- **Bullet lists**, **ordered lists**, **task lists** (interactive checkboxes) -- **Tables** — GFM syntax, cell-by-cell Tab navigation, column resize -- **Images** — inline preview, drag-and-drop, clipboard paste -- **Horizontal rules** -- **Footnotes** — inline marker with hover preview -- **LaTeX math** — inline `$...$` and display `$$...$$` via KaTeX -- **Wikilinks** — `[[Page]]` and `[[Page|alias]]` with Cmd/Ctrl-click navigation -- **Links** — Cmd/Ctrl-click to open, auto-detection - -### Editing -- **Rich / Source mode toggle** — switch between rendered and raw markdown -- **Smart list continuation** — Enter continues the current list item tightly (no blank-line gap) -- **Cursor model** — arrow keys skip hidden syntax markers, land on visible content -- **Delete normalizer** — Backspace/Delete removes visible content, collapses empty format spans -- **Format commands** — Cmd+B (bold), Cmd+I (italic), Cmd+E (inline code) -- **Block commands** — Cmd+1..3 (heading levels), Cmd+Shift+7..9 (lists, blockquote) -- **Click model** — Cmd/Ctrl-click on links and wikilinks to navigate; single click places caret at visible content, never inside a hidden marker -- **Image insert** — toolbar button opens a file picker, inserts at caret - -### Data -- **Frontmatter** — YAML frontmatter is parsed, held aside during editing, and recombined on save. Never stripped, never corrupted, never shown in the editor body. -- **Autosave debounce** — `onChange` fires 500ms after the last keystroke (configurable) -- **State caching** — per-file `EditorState` cache for instant tab switching - ---- - -## Installation - -```sh -npm install @latentic/live-markdown -# or -pnpm add @latentic/live-markdown -``` - -Peer dependencies: `react` and `react-dom` (18+). - -All *in-editor* styling (headings, code, lists, tables, image widgets, math, links) ships with the editor as a CodeMirror theme and applies automatically — no CSS import needed. For the outer container layout (so the editor fills its parent and scrolls), import the small stylesheet once: - -```ts -import "@latentic/live-markdown/styles.css"; -``` - -Skip it if your app already lays the editor out as a flex child. - ---- - -## Usage - -### Basic - -```tsx -import { useState } from "react"; -import { CodeMirrorMarkdownEditor } from "@latentic/live-markdown"; - -function Editor() { - const [doc, setDoc] = useState("# Hello\n\nStart writing..."); - - return ( - setDoc(newValue)} - /> - ); -} -``` - -### Source mode - -```tsx - -``` - -### With toolbar actions - -The `toolbar` render prop receives the live `EditorView`, so host buttons can drive editor commands: - -```tsx - } -/> -``` - -### Wikilinks - -```tsx - openFile(path)} -/> -``` - ---- - -## Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `value` | `string` | — | The markdown content (controlled) | -| `onChange` | `(value: string, changes: DocumentTextChange[]) => void` | — | Called after edits, debounced | -| `mode` | `"wysiwyg" \| "source"` | `"wysiwyg"` | Rich rendering or raw markdown | -| `toolbar` | `(ctx: { view: EditorView }) => ReactNode` | — | Host-rendered toolbar, given the live editor view | -| `selectionActions` | `(ctx: { selection, dismiss }) => ReactNode` | — | Host-rendered actions for the current selection (e.g. a comment bubble) | -| `linkTargets` | `ReadonlySet` | — | Known file paths for wikilink resolution | -| `onNavigateToLink` | `(path: string) => void` | — | Called on Cmd/Ctrl-click of an internal link | -| `workspaceRoot` | `string` | — | Root path for resolving relative image URLs | -| `filePath` | `string` | — | Current file path (for image resolution context) | -| `resolveImageSrc` | `ResolveImageSrc` | render as-is | Map a markdown image `src` to a loadable URL | -| `saveImageBytes` | `SaveImageBytes` | inline as `data:` | Persist a pasted/dropped image at a workspace-relative path | -| `onOpenExternalUrl` | `OpenExternalUrl` | new browser tab | Open a clicked external link | - ---- - -## Extensions - -A `MarkdownExtension` bundles everything a feature needs: `NodeRule`s (how the syntax nodes its grammar introduces should paint), CM6 `extensions`, a `keymap`, and optional `toolbar` contributions. Rules are merged into the painter through a facet, so an extension adds a construct without touching the core rules table. - -Built-in extensions: -- `highlightExtension` — highlight/mark rendering -- `footnoteExtension` — footnote markers with inline preview -- `mathExtension` — LaTeX math via KaTeX -- `tableExtension` — GFM tables with cell navigation -- `wikilinkExtension` — `[[wikilink]]` rendering and navigation - -```tsx -import { composeExtensions, mathExtension, tableExtension } from "@latentic/live-markdown"; - -const composed = composeExtensions([mathExtension, tableExtension]); -// composed.extensions — CM6 Extension[] (each extension's node rules ride -// along via a facet, so this is all the editor needs) -// composed.toolbar — merged ToolbarContribution[] -``` - -### A custom extension - -A rule is one function per Lezer node name, returning how that node paints. The `mark` combinator covers the common "style this span" case. Rules merge last-wins, so an extension can introduce a construct from its own grammar or deliberately restyle a built-in one: - -```tsx -import { composeExtensions, mark, type MarkdownExtension } from "@latentic/live-markdown"; - -const fancyEmphasis: MarkdownExtension = { - name: "fancy-emphasis", - version: "1.0.0", - rules: { - // override how the built-in Emphasis node paints - Emphasis: mark("cm-fancy-emphasis"), - }, -}; - -const composed = composeExtensions([fancyEmphasis]); -``` - -`Paint` is a closed set — line class, span mark, hide, widget, or nothing — while node names grow, so styling a construct is always one rule in one place. - ---- - -## Architecture - -``` -value (markdown string) - │ - ├─ parseFrontmatter() ─→ frontmatter (held in ref) + body - │ - body ─→ CodeMirror EditorState - │ - ├─ Lezer markdown parser (tokenizes) - ├─ Paint engine (each node's NodeRule → Decoration) - ├─ Extension plugins (math, tables, footnotes, ...) - └─ EditorView (renders only the visible viewport) - │ - onChange ─→ serializeMarkdown(frontmatter + body) - │ - └─ markdown string out -``` - -The decoration engine walks the Lezer syntax tree on every document change, asks each node's `NodeRule` how to render, and applies the returned paint as `Decoration.replace`, `Decoration.mark`, or `Decoration.line`. Widgets are stateless — they read from the document and write `dispatch` calls back. No intermediate AST, no custom document model. - ---- - -## Roadmap - -- [x] Host-injected toolbar and selection-action slots (bring your own UI) -- [x] Host-environment seams (image storage/resolution, link opening) with browser defaults -- [x] Self-themed editor surface (CodeMirror theme ships with the package) -- [x] Public extension API for contributing node rules (`MarkdownExtension.rules` → `nodeRulesFacet`) -- [ ] Frontmatter as a toggleable extension (default on, disable via prop) -- [ ] Collaborative editing (CM6 collab extension) -- [ ] Slash commands (`/` menu for inserting blocks) - ---- - -## License - -[MIT](LICENSE) diff --git a/packages/rich-editor/docs/interaction-spec.md b/packages/rich-editor/docs/interaction-spec.md deleted file mode 100644 index 678a412..0000000 --- a/packages/rich-editor/docs/interaction-spec.md +++ /dev/null @@ -1,227 +0,0 @@ -# Rich-editor interaction spec - -The editor is a WYSIWYG surface over a markdown source of truth. Every -keystroke, click, and command is a **source edit**; the Lezer tree re-parses -and the decorations re-render. This spec defines what those edits must be at -every construct boundary — the semantics that regexes and ad-hoc position -math kept getting wrong (#61, #84, #88, #92, #94, #95). - -Section numbers are stable and cited from code comments (`spec §8.2` etc.). -Sections 6–8 restore the numbering the cursor/click/delete modules were built -against; 9–10 are new. Block-level behaviors (lists, headings, quotes, -fences, tables, tasks) are specified executably in -`src/codemirror/features/*.feature`; this document owns the **inline -constructs and boundary semantics**, enforced by -`src/codemirror/decorations/interactionMatrix.test.ts`. - -## §1 Architecture — the compiler pipeline - -The editor is organized as a compiler whose program is the markdown source: - -- **Front-end**: Lezer parses the source incrementally; the syntax tree is - the ONLY semantic authority (I3). `lineStructure` and `resolveInner` - ancestor walks are the query layer; no regex may decide what text *is*. -- **Middle**: commands are tree-queried, source-emitted transforms — they - read node ranges (marker positions, construct extents) and emit minimal - source edits whose re-parse yields the intended tree (`blockCommands`, - `formatCommands`, `deleteNormalizer`, `flankingGuard`, `listContinuation`, - `fenceAutoClose`). -- **Back-end**: the decoration registry maps every node type to its visual - treatment (`registry.ts` — audited for completeness against the parser's - node set), rendered as CM decorations/widgets. - -Conformance status of the remaining non-tree components (#61): -wikilinks still use a parallel regex scanner (→ `WikiLink` grammar node, -sub-task 2); table geometry keeps a hand-maintained coordinate map -(→ single `docPos ↔ (row, col, cellOffset)` source map, sub-task 3). - -## §5 Invariants - -- **I1 — No raw markers by side effect.** A rich-mode edit must never turn a - rendered construct into literal marker text. If an insertion would - invalidate a construct's own delimiters, the edit is re-sited or the - construct re-emitted in valid form. Raw markdown appears only when the - user asks for it (RAW mode) or types markers deliberately. -- **I2 — Visually identical states behave identically.** Two caret positions - that render at the same visual spot (either side of a hidden marker) must - not produce different outcomes for the same key. Where the grammar forces - a difference, one position is canonical (§6.4). -- **I3 — The grammar decides structure.** What a line or span *is* comes - from the syntax tree (`lineStructure`, `resolveInner`), never from a regex - over the text. (#61) -- **I4 — Source edits are minimal.** Prefer the smallest source change that - satisfies I1 (re-site one char) over rewriting the construct. - -## §6 Caret & motion - -- **6.1** The caret never rests strictly inside a hidden marker range. -- **6.2** All ranges reported to hosts are UTF-8 byte offsets - (`byteOffset.ts`); editor-internal positions are CM char offsets. -- **6.3** Arrow motion steps between *visible* positions: one visible - grapheme per press, hopping whole hidden ranges and whole atomic blocks - (tables) in a single step (`visiblePosition.ts`, `cursorModel.ts`). -- **6.4** *(open decision)* Boundary canonicalization: at a construct's - content end, the inside position (before the closing marker) and the - outside position (after it) render identically but type differently - (extend vs. plain — I2 tension). Candidate rule, Word-convention: cursor - placement normalizes to the **inside** position (sticky end), and typing - plain text after a trailing construct is reached by →/End (which stays - outside) or by toggling the format off. Not yet implemented; tracked with - the toolbar-pressed-state implications in the conformance matrix. - -## §7 Pointer - -- **7.1** Clicks land on visible positions; hidden ranges render at zero - width, so a click cannot target them. A click past the end of a line lands - after any trailing hidden markers (the outside boundary — see §6.4). -- **7.2** Cmd/Ctrl-click on a link or wikilink follows it (`clickModel.ts`). -- **7.3** Right-click expands to the word under the pointer when no - selection covers it, so the comment bubble has a target. -- **7.4** *(rule; #90 open against it)* Drag selection endpoints may snap - outward over hidden markers, but never to positions the pointer did not - cross — a drag over mid-line words must not flash or commit a whole-line - selection because an intermediate sample touched the line's hidden prefix - or crossed the line's vertical edge. - -- **7.5** *(open cells)* Selection across an atomic object's boundary - (prose→code block, prose→table): typing over or deleting such a selection - must treat the atomic object as a unit — either the whole object is - inside the selection (and is removed with it) or the selection stops at - its boundary; a delete may never remove PART of a fence pair or table - source. Conformance cells tracked in the matrix; behavior to be defined - alongside §12. - -## §8 Deletion - -- **8.1** The unit of deletion is the **visible grapheme** adjacent to the - caret — computed by skipping hidden ranges (`visibleCharBefore/After`), - never folding a marker into the range. Holds from BOTH sides of a hidden - marker (I2). (#88/#92) -- **8.2** Backspace at the visible start of a line joins lines: it removes - the newline plus the line's **block prefix** (list/heading/quote marker), - keeping inline content and its hidden markers intact. Mirror for Delete at - a visible line end. -- **8.2a** When nothing visible stands between the caret and the line's own - marker (checkbox, bullet, number, hashes — wherever the caret sits among - hidden ranges, I2), Backspace removes that marker in place: the line - becomes plain text where it stands, and joining up is the NEXT press - (Word/Notion convention). The marker is never nibbled character by - character; its trailing space is part of its atomic range at any nesting - depth. -- **8.3** A deletion that empties a styled span removes the whole construct - — markers included — never leaving `****` husks. -- **8.4** Tables delete in two steps: first press parks the caret at the - table's edge and arms it; the second press removes the whole block. The - hidden `| … |` source is never partially edited. - -## §9 Insertion - -- **9.1** Typing a non-whitespace character at a construct's content edge - extends the construct (types inside the markers). -- **9.2 — Flanking guard.** Whitespace (space, newline) typed at the content - edge of a flanking-sensitive construct (bold, italic, strikethrough) lands - **outside** its markers — `**Compose** ` — because CommonMark makes a - whitespace-flanked delimiter literal (I1). Hops outward through nesting. - Inline code is exempt: no flanking rule, edge spaces meaningful. (#94) -- **9.3** Enter mid-construct: emphasis, inline code, and link text all - legitimately span a soft line break in CommonMark — the construct stays - valid and the conformance matrix asserts it survives the parse. The rule - has teeth only for genuinely single-line constructs: wikilinks (regex- - scanned today; conformance deferred until the `WikiLink` grammar node - lands, #61) and table cells (handled by the cell subview's single-line - filter). For those, Enter must split into valid halves or move the break - outside — never emit a broken half-construct (I1). -- **9.4** Splitting a list/task item at its content start yields a valid - empty item above (task: `- [ ] ` with its space — the grammar's minimal - parseable spelling) and moves the content down with its marker and state. - (#95) -- **9.5** A just-typed fence auto-closes on Enter with the caret inside; - content below is released, not swallowed. (#91) -- **9.6** In-cell edits escape what the cell grammar requires (`\|`), and - fence wrapping sizes its markers past the content's longest run (#85). - -## §10 Conformance - -`interactionMatrix.test.ts` enumerates construct × position × operation -cells and asserts the rules above. Cells whose current behavior diverges -from an accepted rule are tracked as issues and marked `it.fails` — the test -flips red the moment a fix lands, forcing the marker's removal. Open -decisions (§6.4) are characterized, not asserted. - -## §11 Tables - -Block-level table behavior is specified executably in -`features/table.feature`; the machinery has its own suites -(`tableGeometry`, `tableCellNav`, `tableCellSubview`, `tableSelection`, -`tableEditCommands`). The interaction rules that govern them here: - -- **11.1** A table is one atomic block in the main editor: the caret never - enters the hidden `| … |` source; arrows step over the whole grid (§6.3) - and deletion is the two-step arm-then-remove (§8.4). -- **11.2** Cell editing happens in a per-cell subview holding the cell's - UNESCAPED text; commits re-escape (`\|`) so a typed pipe can't shift - columns (§9.6). Cells are single-line by transaction filter; Tab/arrow - navigation at cell edges moves between cells (`tableCellNav`). -- **11.3** Structural edits (insert/delete row/column, header toggles) go - through the table model, never through positional string surgery — the - coordinate map consolidation is #61 sub-task 3. - -## §12 Block boundaries (fenced code) - -The fence lines are STRUCTURE, not text: no character-level edit may merge -a fence line with its neighbors — that corrupts the pair and re-pairs the -opener with a later fence, swallowing unrelated content (I1). - -- **12.1 Solid walls from inside.** Backspace at the first content line's - start and Delete at the last content line's end are no-ops. Exception: - when the block has no content (or only empty content), the edit deletes - the WHOLE block — markers included — like an empty styled span (§8.3). -- **12.2 Two-step approach from outside.** Backspace at the visible start - of the line after a block parks the caret at the block's content end - (first press) rather than joining text onto the closing fence; Delete at - the end of the line before a block parks at the content start. An empty - block is deleted whole instead of parked into. -- **12.3 Empty line above.** Backspace on an empty line directly above a - block removes that line — the block visually moves up. (Plain line-join; - already conformant.) -- **12.4 Auto-close is grammar-positioned.** The keystroke completing a - fence opener at a line's CONTENT start (per `lineStructure` — top level, - inside a list item, inside a quote) inserts the matching closer at the - same content column plus an empty content line, and the caret lands ON - the content line: the first visible row of a new block accepts code - immediately. Typing before the third backtick supplies the language; - the info string renders visibly (a small tag on the opener row), never - as invisible text. -- **12.5 Enter on the opener of a closed block** whose first content line - is empty moves the caret onto that line instead of inserting another. -- **12.6 Exit.** Enter on the block's empty last content line exits below - the closing fence (§9.5). -- **12.7 Typing on the closing line re-sites.** A closing fence carries no - info string in CommonMark — any trailing text stops it closing and the - block re-opens over everything below. A keystroke landing on the closer - line therefore goes to a fresh content line before the closer (the intent - of typing on the block's last row is code at the end of the block). The - opener line stays typeable: text there is the language tag, rendered as a - visibly distinct chip (§12.4). -- **12.7 Fence rows re-site typing.** On a CLOSED block, characters typed on - the closing line land on a fresh content line before the closer (trailing - text would stop it closing and swallow the document below), and characters - typed on the opener line land at the first content line's start — the - block's first gray row means "code" to users, not the language tag. An - UNCLOSED opener still types in place (the language flow for a pasted - fence); editing an existing tag is a RAW-mode operation. This also closes - the old gap where lengthening a closed opener re-opened the block. -- **12.8 Tab indents in code.** Inside a block's content, Tab inserts an - indent unit and Shift-Tab removes leading indent — never focus - navigation. Outside code both decline (lists keep `listIndent`, - accessibility keeps the default). -- **12.9 Marker rows refuse the caret.** On a CLOSED block with content, the - caret never rests on the opener/closer lines: clicks land on the nearest - content edge (opener → first content start, closer → last content end), - forward motion crosses into content or out below the block, backward - motion exits above or back to content. Unclosed openers keep the caret - (language typing); no-content blocks are exempt (§12.7 covers typing). - Range selections and multi-cursor are untouched. - -Open: the same wall/park rules for other multi-line atomic objects -(tables already conform via §8.4 two-step; HTML blocks unaudited). diff --git a/packages/rich-editor/package.json b/packages/rich-editor/package.json deleted file mode 100644 index 374ea83..0000000 --- a/packages/rich-editor/package.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "name": "@latentic/live-markdown", - "version": "0.0.1", - "type": "module", - "license": "MIT", - "description": "A rich markdown editor for React built on CodeMirror 6 — write like Notion, store as plain .md. Headings, lists, tables, images, math, and footnotes render inline while the file stays markdown.", - "keywords": [ - "markdown", - "editor", - "rich-text", - "wysiwyg", - "codemirror", - "codemirror-6", - "react", - "markdown-editor", - "rich-markdown-editor", - "notion-like", - "live-preview", - "inline-preview", - "live-markdown", - "frontmatter", - "wikilinks", - "gfm", - "latex", - "katex", - "math", - "tables", - "footnotes", - "local-first" - ], - "repository": { - "type": "git", - "url": "https://github.com/getlatentic/compose", - "directory": "packages/rich-editor" - }, - "homepage": "https://github.com/getlatentic/compose/tree/main/packages/rich-editor#readme", - "bugs": "https://github.com/getlatentic/compose/issues", - "author": "Tosin Amuda ", - "main": "dist/index.js", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./styles.css": "./dist/styles.css" - }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "sideEffects": [ - "*.css" - ], - "scripts": { - "build": "tsup && cp src/styles.css dist/styles.css", - "typecheck": "tsc --noEmit", - "prepublishOnly": "pnpm build" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "dependencies": { - "@codemirror/commands": "^6.10.3", - "@codemirror/lang-markdown": "^6.5.0", - "@codemirror/language": "^6.12.3", - "@codemirror/language-data": "^6.5.2", - "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.43.0", - "@joplin/turndown-plugin-gfm": "^1.0.67", - "@lezer/highlight": "^1.2.3", - "dompurify": "^3.4.12", - "katex": "^0.17.0", - "mermaid": "^11.16.1", - "turndown": "^7.2.4", - "yaml": "^2.9.0" - }, - "devDependencies": { - "@amiceli/vitest-cucumber": "^7.0.0", - "@types/katex": "^0.16.8", - "@types/react": "^18.3.23", - "@types/turndown": "^5.0.6", - "fast-check": "^4.8.0", - "tsup": "^8.5.0", - "typescript": "~5.8.3" - } -} diff --git a/packages/rich-editor/src/codemirror/CodeMirrorMarkdownEditor.test.tsx b/packages/rich-editor/src/codemirror/CodeMirrorMarkdownEditor.test.tsx deleted file mode 100644 index 49abf76..0000000 --- a/packages/rich-editor/src/codemirror/CodeMirrorMarkdownEditor.test.tsx +++ /dev/null @@ -1,200 +0,0 @@ -// @vitest-environment jsdom -// -// Integration test for the editor React component: mounts the real -// component (effects + a live CodeMirror EditorView) and exercises the -// glue the unit tests can't — frontmatter split, the wysiwyg/source -// extension switch, the debounced onChange autosave, the synchronous -// flush bridge, and external-value patching. -// -// Mounted with react-dom/client + React 18.3's `act` (no RTL dep). The -// live view is captured through the `toolbar` slot. The selection-poll -// rAF is stubbed to a no-op so it can't churn state mid-assertion; only -// `setTimeout` is faked, to drive the autosave debounce deterministically. -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { EditorView } from "@codemirror/view"; - -import { - CodeMirrorMarkdownEditor, - type CodeMirrorMarkdownEditorProps, -} from "./CodeMirrorMarkdownEditor"; -import { markdownDecorationsPlugin } from "./core/plugin"; - -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -let container: HTMLDivElement; -let root: Root; -let view: EditorView | null; - -function renderEditor( - props: Partial & { value: string }, -): { onChange: ReturnType } { - const onChange = (props.onChange as ReturnType) ?? vi.fn(); - act(() => { - root.render( - { - view = v; - return null; - }} - />, - ); - }); - return { onChange }; -} - -function content(): string { - return container.querySelector(".cm-content")?.textContent ?? ""; -} - -function typeAtEnd(insert: string): void { - act(() => { - view!.dispatch({ changes: { from: view!.state.doc.length, insert } }); - }); -} - -beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - view = null; - vi.stubGlobal("requestAnimationFrame", () => 0); - vi.stubGlobal("cancelAnimationFrame", () => {}); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); -}); - -afterEach(() => { - act(() => root.unmount()); - container.remove(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -describe("CodeMirrorMarkdownEditor (integration)", () => { - it("mounts a live CodeMirror editor rendering the markdown body", () => { - renderEditor({ value: "# Hello world" }); - expect(container.querySelector(".cm-editor")).not.toBeNull(); - expect(view).not.toBeNull(); - expect(content()).toContain("Hello world"); - }); - - it("holds YAML frontmatter out of the editing surface", () => { - renderEditor({ value: "---\ntitle: Secret Title\n---\n\nVisible body" }); - expect(content()).toContain("Visible body"); - expect(content()).not.toContain("Secret Title"); - }); - - it("wires decorations on in wysiwyg mode and off in source mode", () => { - renderEditor({ value: "# H", mode: "wysiwyg" }); - expect(view!.plugin(markdownDecorationsPlugin)).not.toBeNull(); - renderEditor({ value: "# H", mode: "source" }); - expect(view!.plugin(markdownDecorationsPlugin)).toBeNull(); - }); - - it("emits onChange with the edited content after the autosave debounce", () => { - const { onChange } = renderEditor({ value: "hello" }); - typeAtEnd(" world"); - expect(onChange).not.toHaveBeenCalled(); // still within the debounce window - act(() => vi.advanceTimersByTime(600)); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange.mock.calls[0][0]).toBe("hello world"); - }); - - it("recombines frontmatter into the saved value on autosave", () => { - const { onChange } = renderEditor({ - value: "---\ntitle: Secret Title\n---\n\nBody", - }); - typeAtEnd(" edited"); - act(() => vi.advanceTimersByTime(600)); - expect(onChange).toHaveBeenCalledTimes(1); - const saved = onChange.mock.calls[0][0] as string; - expect(saved).toContain("title: Secret Title"); - expect(saved).toContain("Body edited"); - }); - - it("flushes pending content synchronously via onFlushReady, cancelling the debounce", () => { - let flush: (() => void) | null = null; - const { onChange } = renderEditor({ - value: "hello", - onFlushReady: (f) => { - flush = f; - }, - }); - expect(flush).toBeTypeOf("function"); - typeAtEnd(" world"); - act(() => flush!()); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange.mock.calls[0][0]).toBe("hello world"); - // The pending debounce was cancelled — no second (echoed) save fires. - act(() => vi.advanceTimersByTime(600)); - expect(onChange).toHaveBeenCalledTimes(1); - }); - - it("patches the document when the external value prop changes", () => { - renderEditor({ value: "first body" }); - expect(content()).toContain("first body"); - renderEditor({ value: "second body" }); - expect(content()).toContain("second body"); - expect(content()).not.toContain("first body"); - }); -}); - -describe("first edit after a programmatic state swap still saves (#108)", () => { - it("a single edit made right after switching to source mode autosaves", () => { - const onChange = vi.fn(); - renderEditor({ value: "keep\nstray", mode: "wysiwyg", onChange }); - renderEditor({ value: "keep\nstray", mode: "source", onChange }); - // The user's lone RAW-mode edit: delete the second line. - act(() => { - view!.dispatch({ - changes: { from: "keep".length, to: view!.state.doc.length, insert: "" }, - }); - }); - act(() => vi.advanceTimersByTime(600)); - expect(onChange).toHaveBeenCalled(); - expect(onChange.mock.calls[onChange.mock.calls.length - 1][0]).toBe("keep"); - }); - - it("a single edit right after a tab switch autosaves", () => { - const onChange = vi.fn(); - renderEditor({ value: "alpha", filePath: "a.md", onChange }); - renderEditor({ value: "beta", filePath: "b.md", onChange }); - typeAtEnd("!"); - act(() => vi.advanceTimersByTime(600)); - expect(onChange).toHaveBeenCalled(); - expect(onChange.mock.calls[onChange.mock.calls.length - 1][0]).toBe("beta!"); - }); - - it("Cmd+S flush right after a lone source-mode edit persists it", () => { - const onChange = vi.fn(); - let flush: (() => void) | null = null; - renderEditor({ - value: "keep\nstray", - mode: "wysiwyg", - onChange, - onFlushReady: (f) => { - flush = f; - }, - }); - renderEditor({ - value: "keep\nstray", - mode: "source", - onChange, - onFlushReady: (f) => { - flush = f; - }, - }); - act(() => { - view!.dispatch({ - changes: { from: "keep".length, to: view!.state.doc.length, insert: "" }, - }); - }); - act(() => flush!()); - expect(onChange).toHaveBeenCalled(); - expect(onChange.mock.calls[onChange.mock.calls.length - 1][0]).toBe("keep"); - }); -}); diff --git a/packages/rich-editor/src/codemirror/CodeMirrorMarkdownEditor.tsx b/packages/rich-editor/src/codemirror/CodeMirrorMarkdownEditor.tsx deleted file mode 100644 index eadfc04..0000000 --- a/packages/rich-editor/src/codemirror/CodeMirrorMarkdownEditor.tsx +++ /dev/null @@ -1,744 +0,0 @@ -/** - * Phase-1 CodeMirror 6 markdown editor — the spike behind the - * `compose.editorEngine.v1 = "codemirror"` flag. - * - * What is wired: - * * @codemirror/lang-markdown for tokenization + the small built-in - * Markdown keymap (continue-list-on-Enter, etc). - * * `markdownDecorationsPlugin` for live-preview headings + bold + - * italic. Lists, links, wikilinks, images, comments, tables are - * Phase 2+. - * * Same `value` / `onChange` contract as `TiptapMarkdownEditor`, - * same frontmatter split, same hash-based loop guard. Switching - * engines mid-session is safe — the file on disk is unchanged. - * * `mode === "source"` disables decorations so the user sees raw - * markdown. Same toggle, same shortcut, just no marker-hiding. - * - * Deliberately NOT wired in Phase 1 (so this file stays small and - * the spike actually proves the scaling claim): - * * Toolbar formatting buttons (B / I / H1…) — `EditorFileActions` - * still renders so Save / History / Export / Comments / Chat - * work from the toolbar. Formatting buttons port in Phase 2. - * * Image paste / drop, link picker, wikilink rendering, comment - * overlay, selection-to-chat bubble. The Tiptap editor handles - * these today; the CodeMirror editor will once the spike clears. - * - * Public surface mirrors `TiptapMarkdownEditorProps` so AppShell's - * dispatch is symmetric; Phase-2-or-later callbacks are accepted - * (and ignored) here rather than guarded at every call site. - */ - -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; -import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; -import { markdown, markdownKeymap, markdownLanguage } from "@codemirror/lang-markdown"; -import { languages } from "@codemirror/language-data"; -import { Annotation, EditorSelection, EditorState, type Extension } from "@codemirror/state"; -import { EditorView, keymap } from "@codemirror/view"; - -import { parseFrontmatter, serializeMarkdown, type Frontmatter } from "../frontmatter"; -import type { DocumentTextChange, SourceRange } from "../types"; -import { byteRangeOf } from "./byteOffset"; -import { drawnCaret } from "./caretLayer"; -import { drawnSelection } from "./selectionLayer"; -import { markdownPaste } from "./clipboard/pasteMarkdown"; -import { - renderClipboardHtmlFacet, - richCopy, - type RenderClipboardHtml, -} from "./clipboard/copyRich"; -import { onEditorUpdate, updateBus } from "./updateBus"; -import { - codeHighlight, - codeLanguageUI, - fenceAutoCloseKeymap, - fenceCaretGuard, - fenceTabKeymap, - fenceTypeAutoClose, -} from "./code"; -import { - commentOnExcerptFacet, - editorBaseTheme, - markdownDecorationsPlugin, - openExternalUrlFacet, - parseToEnd, - resolveImageSrcFacet, - saveImageBytesFacet, - type CommentOnExcerpt, - type OpenExternalUrl, - type ResolveImageSrc, - type SaveImageBytes, -} from "./core"; -import { - clickModel, - cursorModelKeymap, - deleteNormalizerKeymap, - flankingGuard, - navigateToFacet, -} from "./interaction"; -import { tightListKeymap, listIndentKeymap } from "./list"; -import { formatCommandsKeymap, blockCommandsKeymap } from "./format"; -import { imageContextFacet, imageInsertHandlers } from "./image"; -import { computeFileDir, type ImageResolveContext } from "../imageSrcResolver"; -import { wikilinkFromPathFacet, wikilinkTargetsFacet } from "./wikilink"; -import { - composeExtensions, - footnoteExtension, - highlightExtension, - mathExtension, - mermaidExtension, - tableExtension, - wikilinkExtension, -} from "./extensions"; - -export type CodeMirrorEditorMode = "wysiwyg" | "source"; - -/** A non-empty editor selection, in document byte offsets. */ -export interface EditorSelectionSnapshot { - range: SourceRange; - text: string; -} - -export interface CodeMirrorMarkdownEditorProps { - mode?: CodeMirrorEditorMode; - onChange: (value: string, changes: DocumentTextChange[]) => void; - value: string; - workspaceRoot?: string; - filePath?: string; - linkTargets?: ReadonlySet; - onNavigateToLink?: (path: string) => void; - /** - * Host-rendered toolbar. The editor owns the live `EditorView` and hands it to - * the slot; the host builds whatever toolbar UI it wants (formatting buttons, - * file actions, …) around it. Omit for a chromeless editor. Return a STABLE - * element shape so the host's own memoisation can hold across keystrokes. - */ - toolbar?: (ctx: { view: EditorView }) => ReactNode; - /** - * Host-rendered actions for the current text selection (e.g. a comment / ask - * bubble). Called with the live selection (or `null` when collapsed) and a - * `dismiss` that collapses the selection back to a caret. Omit for none. - */ - selectionActions?: (ctx: { - selection: EditorSelectionSnapshot | null; - dismiss: () => void; - }) => ReactNode; - // ── Host-environment seams (all optional; browser-friendly defaults) ─────── - /** - * Map a markdown image `src` to a loadable URL. Default: render the reference - * as-is. A desktop host maps workspace-relative paths onto its asset protocol. - */ - resolveImageSrc?: ResolveImageSrc; - /** - * Persist a pasted/dropped image's bytes at a workspace-relative path. - * Default: omitted ⇒ the image is inlined as a `data:` URL. - */ - saveImageBytes?: SaveImageBytes; - /** - * Open a clicked external link. Default: a new browser tab. A desktop host - * overrides this to leave the app's webview. - */ - onOpenExternalUrl?: OpenExternalUrl; - /** - * Comment on a selected table row/column — wired to the table context menu's - * "Comment on this row / column". The host opens its comment composer at the - * given anchor, seeded with the excerpt. Default: omitted ⇒ no such menu items. - */ - onCommentOnExcerpt?: CommentOnExcerpt; - /** - * Render a markdown selection to HTML for the clipboard, so a copy pastes - * formatted into Google Docs / Slack / Word (the markdown source always - * rides along as text/plain). Default: omitted ⇒ plain-only copies. - */ - renderClipboardHtml?: RenderClipboardHtml; - /** - * Called once, right after a tab-switch content swap commits and paints. - * Used by the host for latency instrumentation; no-op by default. - */ - onAfterContentSwap?: () => void; - /** - * Receives a synchronous `flush()` that pulls the editor's live (debounce- - * lagged) content into the last `onChange` immediately — the host calls it - * before persisting. `null` is passed on unmount. Lets a host avoid writing - * stale buffers on Cmd+S / tab close. - */ - onFlushReady?: (flush: (() => void) | null) => void; -} - -/** Tags the editor's own programmatic content swaps (external value patch, - * save echo) so the autosave listener can tell them from user edits. A - * cross-update boolean can't do this job: `setState` swaps fire NO update to - * consume it, and the stale flag then swallowed the user's next real edit — - * a lone RAW-mode fix looked saved but never was (#108). */ -const programmaticSwap = Annotation.define(); - -const AUTOSAVE_DEBOUNCE_MS = 500; - -// Same tiny FNV-1a hash the Tiptap editor uses — dedup of save -// loops only, never used for identity. Kept duplicated rather than -// importing across feature folders; a shared util can land later if -// a third caller appears. -function hashString(s: string): string { - let h = 0x811c9dc5; - for (let i = 0; i < s.length; i += 1) { - h ^= s.charCodeAt(i); - h = (h * 0x01000193) >>> 0; - } - return h.toString(16); -} - -function CodeMirrorMarkdownEditorInner({ - mode = "wysiwyg", - onChange, - value, - workspaceRoot, - filePath, - linkTargets, - onNavigateToLink, - toolbar, - selectionActions, - resolveImageSrc, - saveImageBytes, - onOpenExternalUrl, - onCommentOnExcerpt, - renderClipboardHtml, - onAfterContentSwap, - onFlushReady, -}: CodeMirrorMarkdownEditorProps) { - // Frontmatter split — same shape as the Tiptap editor. The - // editor surface only ever sees the body; YAML is held aside in - // a ref and recombined on save. - const parsedRef = useRef(parseFrontmatter(value)); - const frontmatterRef = useRef(parsedRef.current.frontmatter); - const bodyRef = useRef(parsedRef.current.body); - - // Loop guard: hash of the last markdown WE emitted. When `value` - // comes back matching this hash the change is our own echo and - // we skip the reload (otherwise file watcher → setContent → - // update → save → file watcher loops). - const lastEmittedHashRef = useRef(hashString(value)); - - // Debounce timer for autosave. - const autosaveTimerRef = useRef(null); - - // The mount node — given to CM6's `parent` option. - const hostRef = useRef(null); - // The live editor instance, held in a ref because React's - // ref-callback pattern is enough for setup/teardown and we never - // need a re-render on view changes. - const viewRef = useRef(null); - // Toolbar needs a re-renderable handle on the view so its pressed- - // state buttons can subscribe to caret motion. Mirror the ref into - // state once the view mounts. - const [viewForToolbar, setViewForToolbar] = useState(null); - - // Selection snapshot driving the floating comment bubble. Same - // shape as the Tiptap editor's `bubbleSelection`. Updated on - // every selection change (rAF-polled); cleared when selection is - // empty. - const [bubbleSelection, setBubbleSelection] = useState< - { range: SourceRange; text: string } | null - >(null); - - const onChangeRef = useRef(onChange); - useLayoutEffect(function syncLatestOnChangeRef() { - onChangeRef.current = onChange; - }); - - // Host-seam callbacks are read through refs so the facet values (baked into - // each per-tab EditorState at build time) always call the LATEST prop, never - // a stale closure — even though the state is rebuilt only on mode/tab change. - const resolveImageSrcRef = useRef(resolveImageSrc); - const saveImageBytesRef = useRef(saveImageBytes); - const openExternalUrlRef = useRef(onOpenExternalUrl); - const onCommentOnExcerptRef = useRef(onCommentOnExcerpt); - const renderClipboardHtmlRef = useRef(renderClipboardHtml); - const onAfterContentSwapRef = useRef(onAfterContentSwap); - const onFlushReadyRef = useRef(onFlushReady); - useLayoutEffect(function syncHostSeamRefs() { - resolveImageSrcRef.current = resolveImageSrc; - saveImageBytesRef.current = saveImageBytes; - openExternalUrlRef.current = onOpenExternalUrl; - onCommentOnExcerptRef.current = onCommentOnExcerpt; - renderClipboardHtmlRef.current = renderClipboardHtml; - onAfterContentSwapRef.current = onAfterContentSwap; - onFlushReadyRef.current = onFlushReady; - }); - - // Latest mode kept in a ref so the editor lifecycle effect - // doesn't tear down and rebuild on a wysiwyg ↔ source toggle. - // Decorations are added or removed via `Compartment` in the - // Phase-2 cut; for the spike, we rebuild the view on mode change - // (rare) but NOT on every keystroke. - const decorationsEnabled = mode === "wysiwyg"; - - // Image src resolution context — passed to the decoration plugin - // via a CM6 facet so the inline image widget can turn workspace- - // relative paths into asset:// URLs the webview can fetch. - const imageCtx = useMemo( - () => ({ fileDir: computeFileDir(workspaceRoot, filePath) }), - [workspaceRoot, filePath], - ); - - // Per-tab EditorState cache. The dominant tab-switch cost on a 1 MB - // note was CodeMirror re-parsing the whole document on every switch - // (measured ~140ms p99, see docs/perf-spec.md §5). We keep the live - // `EditorView` mounted across tab switches and swap its `EditorState` - // instead — `view.setState(cached)` is a pointer swap, not a re-parse. - // Switching back to an already-open tab restores its cursor, scroll, - // and undo history for free. - // - // Keyed by file path. `syncedHashRef` records the content hash each - // cached state reflects so we can tell "the store's value still - // matches this cached state" (fast restore) from "the file changed - // under us" (rebuild for correctness). - const editorStatesRef = useRef>(new Map()); - const syncedHashRef = useRef>(new Map()); - const currentFileRef = useRef(filePath); - // Tracks the decoration mode the live state was built for, so the - // mode-change effect can skip its initial run (mount already built - // the correct state). - const modeInitializedRef = useRef(decorationsEnabled); - - // Build the full extension list from the CURRENT props. Called when a - // tab's state is first created (and on mode change). Each per-file - // state bakes in that file's path-dependent facets (image dir, - // wikilink source path), so a restored state stays correct for its - // own file. - function buildExtensions(): Extension[] { - const base: Extension[] = [ - history(), - // Update fan-out for reactive chrome (toolbar pressed-states, selection - // bubble). Must ride the BASE extensions: anything appended to a single - // state's config dies on the next setState (tab switch) and the chrome - // freezes at the previous document's context. - updateBus, - // Whitespace typed at a bold/italic/strike content edge lands outside - // the markers, or the closing delimiter stops parsing (#94). - flankingGuard, - // Caret and selection both draw from the editor's own selection state: - // the native contentEditable caret double-paints at atomic widget - // boundaries (#62), and native ::selection can only highlight rendered - // DOM, so a whole-document selection loses its highlight outside the - // virtualized viewport (#166). Table cells run their own subview - // without either, keeping their native caret and selection. - drawnCaret, - drawnSelection, - // Tight list continuation — Enter drops the next bullet directly below, - // never with a blank-line gap (overrides the stock markdown Enter for - // non-empty list items; see listContinuation.ts). - tightListKeymap, - // Enter on a just-typed ``` closes the fence with the caret inside — - // an unclosed fence would swallow the rest of the document (#91). - fenceAutoCloseKeymap, - // The keystroke completing a bare ``` closes the fence immediately, so - // an unclosed opener never swallows the document below (§9.5). - fenceTypeAutoClose, - // Tab indents inside a code block (Shift-Tab dedents) — otherwise the - // browser's focus navigation steals the key mid-code (§12.8). - fenceTabKeymap, - // The caret never parks on a fence's marker rows: clicks land on the - // nearest content edge, arrow motion crosses out of the block (§12.9). - fenceCaretGuard, - // Opener-row language pill (+ "plain" placeholder) opens the chooser; - // right-click a block for set-language / copy-code (ADR 0002). - codeLanguageUI, - // Tab / Shift-Tab nest / promote a list item by the parent marker width - // (list-aware; falls through to normal Tab outside a list — listIndent.ts). - listIndentKeymap, - keymap.of([...defaultKeymap, ...historyKeymap, ...markdownKeymap]), - // markdownLanguage = CommonMark + GFM (tables, task lists, - // strikethrough). Without `base`, `markdown()` uses bare - // CommonMark — Lezer never produces Table / Task / Strikethrough - // nodes and our registry can't decorate them. - // - // `remove: ["SetextHeading"]` disables setext headings — the rule that a - // line of text immediately followed by a line of `---` (or `===`) turns - // the text into a heading. That rule silently rewrites the line ABOVE - // when you type `-` under a paragraph, which is surprising in a rich - // editor (and fought our "lone `-` is literal until a space" bullet - // rule). ATX `#` headings are unaffected. - // `codeLanguages` gives fenced blocks a real nested parse (lazily - // loaded per info string) — actual syntax highlighting via - // codeHighlight, which styles only code-emitted tags (ADR 0002). - markdown({ - base: markdownLanguage, - codeLanguages: languages, - extensions: [{ remove: ["SetextHeading"] }], - }), - codeHighlight, - EditorView.lineWrapping, - // Editor-internal styling (font, line-height, heading sizes, - // marker widget styling) lives in `editorBaseTheme` so it - // participates in CM6's line-metric measurement cycle. Don't - // duplicate these rules in `global.scss` — that's where the - // earlier click-drift bug came from. - editorBaseTheme, - // Image-src resolution context for the inline image widget. - imageContextFacet.of(imageCtx), - // Image paste / drop pipeline. Pulls bytes off the clipboard - // or DataTransfer, saves through `insertImageBlob`, inserts - // `![alt](path)` at the caret. - imageInsertHandlers, - // Clipboard interop (#134/#135): rich pastes convert to markdown - // (image-file pastes stay with the handler above); copies carry - // markdown + rendered HTML. After imageInsertHandlers so file pastes - // resolve there first. - markdownPaste, - richCopy, - renderClipboardHtmlFacet.of((markdown) => - (renderClipboardHtmlRef.current ?? (() => null))(markdown), - ), - // Host-environment seams. Registered as stable wrappers (reading the - // latest prop through a ref) only when the host provides the capability; - // otherwise the facet's browser default applies. See hostFacets.ts. - ...(resolveImageSrc - ? [ - resolveImageSrcFacet.of((rawSrc, ctx) => - (resolveImageSrcRef.current ?? ((s: string) => s))(rawSrc, ctx), - ), - ] - : []), - ...(saveImageBytes - ? [ - saveImageBytesFacet.of((relPath, bytes) => { - const fn = saveImageBytesRef.current; - return fn ? fn(relPath, bytes) : Promise.reject(new Error("no saver")); - }), - ] - : []), - ...(onOpenExternalUrl - ? [ - openExternalUrlFacet.of((url) => { - openExternalUrlRef.current?.(url); - }), - ] - : []), - ...(onCommentOnExcerpt - ? [ - commentOnExcerptFacet.of((excerpt, anchor) => { - onCommentOnExcerptRef.current?.(excerpt, anchor); - }), - ] - : []), - // Wikilink resolution facets stay in both modes so Cmd-click - // can navigate from Raw view too — only the decorating plugin - // itself is gated by `decorationsEnabled` below. - wikilinkFromPathFacet.of(filePath), - wikilinkTargetsFacet.of(linkTargets ?? new Set()), - ...(onNavigateToLink ? [navigateToFacet.of(onNavigateToLink)] : []), - // Cursor model: arrow/shift+arrow → next visible source - // position, skipping hidden markdown syntax in one step. - cursorModelKeymap, - // Backspace / Delete: removes visible content; collapses an - // emptied styled span (no dangling `****`). - deleteNormalizerKeymap, - // Cmd+B / Cmd+I / Cmd+E: toggle bold / italic / inline code. - formatCommandsKeymap, - // Cmd+1..3 / Cmd+Shift+7..9: heading levels, lists, blockquote. - blockCommandsKeymap, - // Click model: Cmd/Ctrl-click on a link opens it; caret - // placement never lands inside a hidden marker (snap-to-content). - clickModel, - // The editor invariant: the syntax tree covers the whole document, not - // just what the viewport rendered — widgets, affordances and structural - // commands anywhere in the note are wrong without it. - parseToEnd, - EditorView.updateListener.of((update) => { - if (!update.docChanged) return; - // Our own content swaps must not echo a save; anything involving a - // real user transaction must. - if (update.transactions.every((tr) => tr.annotation(programmaticSwap))) { - return; - } - if (autosaveTimerRef.current !== null) { - window.clearTimeout(autosaveTimerRef.current); - } - autosaveTimerRef.current = window.setTimeout(() => { - autosaveTimerRef.current = null; - const body = update.view.state.doc.toString(); - bodyRef.current = body; - const full = serializeMarkdown({ - frontmatter: frontmatterRef.current, - body, - }); - const hash = hashString(full); - if (hash === lastEmittedHashRef.current) return; - lastEmittedHashRef.current = hash; - if (currentFileRef.current !== undefined) { - syncedHashRef.current.set(currentFileRef.current, hash); - } - onChangeRef.current(full, []); - }, AUTOSAVE_DEBOUNCE_MS); - }), - ]; - if (decorationsEnabled) { - base.push(markdownDecorationsPlugin); - const composed = composeExtensions([ - wikilinkExtension, - highlightExtension, - footnoteExtension, - mathExtension, - mermaidExtension, - tableExtension(), - ]); - base.push(...composed.extensions); - } - return base; - } - // `buildExtensions` is intentionally a plain closure read inside - // effects (not a hook dep) — the effects below decide WHEN a state is - // rebuilt, so we don't want a fresh `extensions` identity to force a - // remount the way it did before this cache existed. - const buildExtensionsRef = useRef(buildExtensions); - useLayoutEffect(function syncBuildExtensionsRef() { - buildExtensionsRef.current = buildExtensions; - }); - - // Mount the view ONCE. Subsequent tab switches swap state (below) - // rather than tearing down — that's the whole point of the cache. - useEffect(function mountEditorView() { - const host = hostRef.current; - if (!host) return; - const initial = EditorState.create({ - doc: bodyRef.current, - extensions: buildExtensionsRef.current(), - }); - if (currentFileRef.current !== undefined) { - editorStatesRef.current.set(currentFileRef.current, initial); - syncedHashRef.current.set(currentFileRef.current, lastEmittedHashRef.current); - } - const view = new EditorView({ state: initial, parent: host }); - viewRef.current = view; - setViewForToolbar(view); - return function teardownEditorView() { - bodyRef.current = view.state.doc.toString(); - view.destroy(); - viewRef.current = null; - setViewForToolbar(null); - }; - // Mount-once: deps intentionally empty. Tab switches go through the - // sync effect; mode changes through the rebuild effect below. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(function syncEditorToActiveFile() { - const view = viewRef.current; - if (!view) return; - const incomingHash = hashString(value); - const switchingFile = currentFileRef.current !== filePath; - - if (!switchingFile) { - // Same file, external value change (LLM write / file watcher) or - // our own save echo. Patch the live doc in place. - if (incomingHash === lastEmittedHashRef.current) return; - const parsed = parseFrontmatter(value); - frontmatterRef.current = parsed.frontmatter; - bodyRef.current = parsed.body; - lastEmittedHashRef.current = incomingHash; - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: parsed.body }, - annotations: programmaticSwap.of(true), - }); - if (filePath !== undefined) syncedHashRef.current.set(filePath, incomingHash); - return; - } - - // --- Tab switch --- - const prev = currentFileRef.current; - - // Flush any pending autosave for the OUTGOING file FIRST. The view - // now persists across switches, so a stale timer would otherwise - // fire against the incoming file's state and write the wrong - // content. We run the save synchronously with the outgoing doc + - // frontmatter (still current at this point). - if (autosaveTimerRef.current !== null) { - window.clearTimeout(autosaveTimerRef.current); - autosaveTimerRef.current = null; - const outgoingBody = view.state.doc.toString(); - const outgoingFull = serializeMarkdown({ - frontmatter: frontmatterRef.current, - body: outgoingBody, - }); - const outgoingHash = hashString(outgoingFull); - if (outgoingHash !== lastEmittedHashRef.current) { - if (prev !== undefined) syncedHashRef.current.set(prev, outgoingHash); - onChangeRef.current(outgoingFull, []); - } - } - - // Stash the outgoing tab's live state (cursor + scroll + undo + any - // edits) for instant restore later. - if (prev !== undefined) editorStatesRef.current.set(prev, view.state); - - const parsed = parseFrontmatter(value); - frontmatterRef.current = parsed.frontmatter; - bodyRef.current = parsed.body; - lastEmittedHashRef.current = incomingHash; - currentFileRef.current = filePath; - - const key = filePath ?? ""; - const cached = editorStatesRef.current.get(key); - const cachedHash = syncedHashRef.current.get(key); - if (cached && cachedHash === incomingHash) { - // FAST PATH — pointer swap, no parse. ~5ms regardless of doc size. - view.setState(cached); - } else { - // Cache miss (first open) or the file changed under us — build a - // fresh state and cache it. - const state = EditorState.create({ - doc: parsed.body, - extensions: buildExtensionsRef.current(), - }); - editorStatesRef.current.set(key, state); - syncedHashRef.current.set(key, incomingHash); - view.setState(state); - } - // Content-swap end signal: rAF puts us after CM6's measure + - // browser paint, so a host's latency probe covers the full "click - // → new content visible" interval (see docs/perf-spec.md §5). - requestAnimationFrame(() => onAfterContentSwapRef.current?.()); - }, [filePath, value]); - - // Mode toggle (Rich ↔ Raw) changes the decoration extensions, so - // every cached state is stale. Rebuild the active file's state from - // its live doc and drop the cache; other tabs rebuild lazily on next - // visit. Rare event — not on the hot path. - useEffect(function rebuildOnModeChange() { - const view = viewRef.current; - if (!view) return; - // Skip the initial run (mount already built the right state). - if (modeInitializedRef.current === decorationsEnabled) return; - modeInitializedRef.current = decorationsEnabled; - const liveBody = view.state.doc.toString(); - editorStatesRef.current.clear(); - syncedHashRef.current.clear(); - const state = EditorState.create({ - doc: liveBody, - extensions: buildExtensionsRef.current(), - }); - if (currentFileRef.current !== undefined) { - editorStatesRef.current.set(currentFileRef.current, state); - syncedHashRef.current.set(currentFileRef.current, lastEmittedHashRef.current); - } - view.setState(state); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [decorationsEnabled]); - - // Pull the live editor content into the store buffer synchronously, - // cancelling the pending 500ms autosave debounce. The store's save path - // (`saveActiveFile` — Cmd+S / autosave / close) calls this first so it - // never persists the stale, debounce-lagged buffer. Reads only refs, so - // a single stable identity is fine. - const flushPendingToBuffer = useCallback(function flushPendingToBuffer() { - const view = viewRef.current; - if (!view) return; - if (autosaveTimerRef.current !== null) { - window.clearTimeout(autosaveTimerRef.current); - autosaveTimerRef.current = null; - } - const body = view.state.doc.toString(); - bodyRef.current = body; - const full = serializeMarkdown({ frontmatter: frontmatterRef.current, body }); - const hash = hashString(full); - if (hash === lastEmittedHashRef.current) return; - lastEmittedHashRef.current = hash; - onChangeRef.current(full, []); - }, []); - - useEffect(function registerFlushBridge() { - onFlushReadyRef.current?.(flushPendingToBuffer); - return function unregisterFlushBridge() { - onFlushReadyRef.current?.(null); - }; - }, [flushPendingToBuffer]); - - useEffect(function cancelPendingAutosaveOnUnmount() { - return function clearAutosaveTimer() { - if (autosaveTimerRef.current !== null) { - window.clearTimeout(autosaveTimerRef.current); - } - }; - }, []); - - useEffect( - function trackSelectionForCommentBubble() { - const view = viewForToolbar; - if (!view) return; - const refresh = () => { - const main = view.state.selection.main; - if (main.empty) { - setBubbleSelection(null); - } else { - setBubbleSelection({ - range: byteRangeOf(view.state, main.from, main.to), - text: view.state.sliceDoc(main.from, main.to), - }); - } - }; - refresh(); - // Subscribe via the update bus (not an appended updateListener — that - // dies on the next setState and the bubble freezes; see updateBus.ts). - return onEditorUpdate(view, (update) => { - if (update.docChanged || update.selectionSet) refresh(); - }); - }, - [viewForToolbar], - ); - - // Collapse the selection back to a caret — handed to the selection-actions - // slot so a host can dismiss its bubble after an action lands. Stable (reads - // the view ref), so it doesn't churn the host's memoised slot. - const dismissSelection = useCallback(() => { - const view = viewRef.current; - if (!view) return; - const head = view.state.selection.main.head; - view.dispatch({ selection: EditorSelection.cursor(head) }); - }, []); - - // The toolbar slot is invoked through a memo keyed on the (mount-stable) view - // and the host's slot identity — NOT on every render. So when a keystroke - // re-renders this leaf, the toolbar element is referentially unchanged and a - // memoised host toolbar holds (the perf win we'd otherwise lose by calling - // the slot inline every render). - const toolbarNode = useMemo( - () => (viewForToolbar && toolbar ? toolbar({ view: viewForToolbar }) : null), - [viewForToolbar, toolbar], - ); - // The selection slot, by contrast, SHOULD refresh when the selection moves — - // that's its input. Memoised on the selection so it's stable between - // selection changes but updates when one happens. - const selectionNode = useMemo( - () => - selectionActions - ? selectionActions({ selection: bubbleSelection, dismiss: dismissSelection }) - : null, - [selectionActions, bubbleSelection, dismissSelection], - ); - - // `cm-editor-host` owns the editor surface layout (toolbar row + scroll area - // + selection overlay). The toolbar and selection UI are host-rendered slots - // — the editor stays agnostic about save / export / comments / chat. - return ( -
- {toolbarNode} -
- {selectionNode} -
- ); -} - -/** - * Memoised export — same reason as the Tiptap editor. AppShell - * re-renders on every chat-thread token; without memoisation each - * token would blow through the editor's render path. - */ -export const CodeMirrorMarkdownEditor = memo(CodeMirrorMarkdownEditorInner); diff --git a/packages/rich-editor/src/codemirror/byteOffset.test.ts b/packages/rich-editor/src/codemirror/byteOffset.test.ts deleted file mode 100644 index 32e7f25..0000000 --- a/packages/rich-editor/src/codemirror/byteOffset.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -// @vitest-environment jsdom -import { EditorState } from "@codemirror/state"; -import fc from "fast-check"; -import { afterEach, describe, expect, it } from "vitest"; - -import { byteOffsetAt, byteRangeOf } from "./byteOffset"; -import { destroyEditors, makeEditor } from "./core/editorTestHarness"; - -describe("byteOffset", () => { - afterEach(destroyEditors); - - it("is the identity for ASCII text", () => { - const state = makeEditor("hello world", 0).state; - expect(byteOffsetAt(state, 0)).toBe(0); - expect(byteOffsetAt(state, 5)).toBe(5); - expect(byteOffsetAt(state, 11)).toBe(11); - }); - - it("counts a 3-byte char (→) as 3 bytes but 1 code unit", () => { - const state = makeEditor("a→b", 0).state; // → = U+2192, 3 UTF-8 bytes - expect(byteOffsetAt(state, 1)).toBe(1); // after "a" - expect(byteOffsetAt(state, 2)).toBe(4); // after "a→" - expect(byteOffsetAt(state, 3)).toBe(5); // after "a→b" - }); - - it("counts an astral char (😀, surrogate pair) as 4 bytes / 2 code units", () => { - const state = makeEditor("a😀b", 0).state; // 😀 = U+1F600, 4 bytes, 2 UTF-16 units - expect(byteOffsetAt(state, 1)).toBe(1); // after "a" - expect(byteOffsetAt(state, 3)).toBe(5); // after "a😀" (code units 1→3) - expect(byteOffsetAt(state, 4)).toBe(6); // after "a😀b" - }); - - it("byteRangeOf maps both ends past a multi-byte run", () => { - const state = makeEditor("→→x", 0).state; // two 3-byte arrows - expect(byteRangeOf(state, 2, 3)).toEqual({ start: 6, end: 7 }); - }); -}); - -// Well-formed code points only (no lone surrogates), mixing all four UTF-8 -// widths: ASCII (1 byte), Latin/Greek (2), BMP symbols (3), astral/emoji (4). -const codePointArb = fc.integer({ min: 0, max: 0x10ffff }).filter((c) => c < 0xd800 || c > 0xdfff); -const unicodeText = fc.array(codePointArb, { maxLength: 40 }).map((cps) => String.fromCodePoint(...cps)); -const utf8Len = (s: string) => new TextEncoder().encode(s).length; - -describe("byteOffset (property)", () => { - it("equals the platform UTF-8 encoder at every code-point boundary", () => { - fc.assert( - fc.property(unicodeText, (text) => { - const state = EditorState.create({ doc: text }); - // Walk code-point boundaries — what real selections and excerpts produce - // (UI cursor motion and hit-testing land on grapheme/code-point breaks). - // At each, the byte offset must equal the platform UTF-8 encoder for the - // prefix — the UTF-16↔UTF-8 conversion that drifted before. A byte offset - // *inside* a surrogate pair is undefined, so it's out of scope by design. - let pos = 0; - for (const ch of text) { - expect(byteOffsetAt(state, pos)).toBe(utf8Len(text.slice(0, pos))); - pos += ch.length; // 1 (BMP) or 2 (astral) UTF-16 units - } - expect(byteOffsetAt(state, text.length)).toBe(utf8Len(text)); - }), - ); - }); -}); diff --git a/packages/rich-editor/src/codemirror/byteOffset.ts b/packages/rich-editor/src/codemirror/byteOffset.ts deleted file mode 100644 index c28a2b9..0000000 --- a/packages/rich-editor/src/codemirror/byteOffset.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { type EditorState } from "@codemirror/state"; - -import { type SourceRange } from "../types"; - -/** - * The editor's public {@link SourceRange} is contracted in UTF-8 byte offsets - * (see types.ts) — that's what the host persists and maps with (chat excerpt - * line:col, comment anchoring). CodeMirror, though, indexes the document in - * UTF-16 code units. Convert at the one boundary where a range leaves the - * editor (the selection snapshot, the table excerpt) so a multi-byte character - * before the position doesn't skew the reported offset. For all-ASCII text the - * two coincide and this is the identity. - */ -export function byteOffsetAt(state: EditorState, pos: number): number { - const prefix = state.sliceDoc(0, pos); - let bytes = 0; - for (let i = 0; i < prefix.length; ) { - const codePoint = prefix.codePointAt(i)!; - bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4; - i += codePoint >= 0x10000 ? 2 : 1; - } - return bytes; -} - -/** A {@link SourceRange} (byte offsets) for the CM code-unit span `[from, to)`. */ -export function byteRangeOf(state: EditorState, from: number, to: number): SourceRange { - return { start: byteOffsetAt(state, from), end: byteOffsetAt(state, to) }; -} diff --git a/packages/rich-editor/src/codemirror/caretLayer.test.ts b/packages/rich-editor/src/codemirror/caretLayer.test.ts deleted file mode 100644 index 8052d93..0000000 --- a/packages/rich-editor/src/codemirror/caretLayer.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -// @vitest-environment jsdom -import { EditorState } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it } from "vitest"; - -import { drawnCaret } from "./caretLayer"; - -const views: EditorView[] = []; - -afterEach(() => { - for (const view of views.splice(0)) { - view.dom.parentElement?.remove(); - view.destroy(); - } -}); - -function makeView(doc: string): EditorView { - const parent = document.createElement("div"); - document.body.appendChild(parent); - const view = new EditorView({ - state: EditorState.create({ doc, extensions: [drawnCaret] }), - parent, - }); - views.push(view); - return view; -} - -describe("drawnCaret", () => { - it("mounts a cursor layer; range painting is selectionLayer's job", () => { - const view = makeView("- [ ] task line"); - expect(view.dom.querySelector(".cm-cursorLayer")).not.toBeNull(); - // drawnCaret alone carries no range layer — drawnSelection (its sibling - // extension) owns that, and the editor shell wires both. - expect(view.dom.querySelector(".cm-selectionLayer")).toBeNull(); - }); - - it("survives selection changes without throwing (jsdom has no layout)", () => { - const view = makeView("hello world"); - view.dispatch({ selection: { anchor: 2, head: 8 } }); - view.dispatch({ selection: { anchor: 5 } }); - expect(view.state.selection.main.head).toBe(5); - }); -}); diff --git a/packages/rich-editor/src/codemirror/caretLayer.ts b/packages/rich-editor/src/codemirror/caretLayer.ts deleted file mode 100644 index f967f59..0000000 --- a/packages/rich-editor/src/codemirror/caretLayer.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Drawn caret (#62 — interaction-spec §7.4). - * - * The caret must be drawn from `EditorState.selection`: WKWebView paints the - * native contentEditable caret on BOTH sides of an atomic widget boundary - * (#62). Selection RANGES are painted by the sibling layer in - * `selectionLayer.ts` — also from logical state, since native ::selection - * can't survive the virtualized viewport — using forward-geometry rects that - * avoid `drawSelection()`'s far-edge `posAtCoords` probe (the #90 flashing). - * - * The layer keeps the `cm-cursorLayer` class so existing styling hooks - * (table arming's caret-hide, the base theme's blink animation) apply. - */ - -import { Prec, type Extension } from "@codemirror/state"; -import { EditorView, layer, RectangleMarker, type ViewUpdate } from "@codemirror/view"; - -function relevant(update: ViewUpdate): boolean { - return ( - update.docChanged || - update.selectionSet || - update.geometryChanged || - update.viewportChanged || - update.focusChanged - ); -} - -const caretLayer = layer({ - above: true, - class: "cm-cursorLayer", - update(update, dom) { - // Restart the blink so a just-moved caret is immediately visible. - if (update.selectionSet) { - dom.style.animationName = dom.style.animationName === "cm-blink" ? "cm-blink2" : "cm-blink"; - } - return relevant(update); - }, - markers(view) { - const markers: RectangleMarker[] = []; - for (const range of view.state.selection.ranges) { - if (!range.empty) continue; - const cls = - range === view.state.selection.main - ? "cm-cursor cm-cursor-primary" - : "cm-cursor cm-cursor-secondary"; - markers.push(...RectangleMarker.forRange(view, cls, range)); - } - return markers; - }, -}); - -// The drawn caret replaces the native one; the native SELECTION stays. -const hideNativeCaret = Prec.highest( - EditorView.theme({ - ".cm-content": { caretColor: "transparent" }, - // Native-caret islands: tablev2 cells edit with the BROWSER caret (no - // drawn caret of their own), so they win the color back — a direct rule - // on the element beats the inherited transparent. - '.cm-content [contenteditable="plaintext-only"]': { - caretColor: "var(--cds-text-primary, #161616)", - }, - // While a cell edit is active the drawn caret would ghost at the parked - // main-selection position; the surface stamps this class on the editor. - "&.cm-tablev2-editing .cm-cursorLayer": { display: "none" }, - }), -); - -export const drawnCaret: Extension = [caretLayer, hideNativeCaret]; diff --git a/packages/rich-editor/src/codemirror/clipboard/balancedSlice.test.ts b/packages/rich-editor/src/codemirror/clipboard/balancedSlice.test.ts deleted file mode 100644 index 98eed35..0000000 --- a/packages/rich-editor/src/codemirror/clipboard/balancedSlice.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -// balancedSlice (#140): a selection made by eye starts at the first VISIBLE -// character — in source that's just past the hidden opening marker — and the -// naive slice shipped an orphaned `**` to every receiver. The reporter's -// exact case: copying `**Priorties**` from its visible start pasted -// "Priorties**" into Slack and Docs. -import { describe, expect, it } from "vitest"; -import { EditorState } from "@codemirror/state"; -import { ensureSyntaxTree } from "@codemirror/language"; -import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; - -import { balancedSlice } from "./copyRich"; - -function stateOf(doc: string): EditorState { - const state = EditorState.create({ - doc, - extensions: [markdown({ base: markdownLanguage })], - }); - ensureSyntaxTree(state, doc.length, 5000); - return state; -} - -describe("balancedSlice", () => { - it("repairs the opening marker when the selection starts at the visible start of bold (#140)", () => { - const doc = "**Priorties**\n\nGoal"; - const state = stateOf(doc); - // The visible 'P' is at source offset 2 — after the hidden `**`. - expect(balancedSlice(state, 2, doc.indexOf("\n"))).toBe("**Priorties**"); - }); - - it("repairs the closing marker when the selection ends at the visible end", () => { - const state = stateOf("intro **bold tail**"); - // Select from doc start to just before the hidden closing `**`. - expect(balancedSlice(state, 0, "intro **bold tail".length)).toBe("intro **bold tail**"); - }); - - it("keeps formatting for a strictly-inside selection — the screen shows it bold", () => { - const state = stateOf("**Priorties**"); - // "riort" renders bold in the editor, so the copy carries bold too. - expect(balancedSlice(state, 3, 8)).toBe("**riort**"); - }); - - it("leaves fully-covered constructs alone", () => { - const doc = "a **b** c"; - const state = stateOf(doc); - expect(balancedSlice(state, 0, doc.length)).toBe(doc); - }); - - it("repairs nested emphasis outer-to-inner so the result stays well-formed", () => { - const doc = "***both***"; - const state = stateOf(doc); - // Visible start of bold+italic content: after `***`. - expect(balancedSlice(state, 3, doc.length)).toBe("***both***"); - }); - - it("preserves the construct's own delimiter style", () => { - const strike = stateOf("~~gone~~"); - expect(balancedSlice(strike, 2, 8)).toBe("~~gone~~"); - - const underscore = stateOf("_soft_"); - expect(balancedSlice(underscore, 1, 6)).toBe("_soft_"); - - const code = stateOf("`let x`"); - expect(balancedSlice(code, 1, 7)).toBe("`let x`"); - }); - - it("repairs both edges of one construct independently", () => { - const doc = "**wide bold phrase**"; - const state = stateOf(doc); - // Visible-only selection: after opening ** to before closing **. - expect(balancedSlice(state, 2, doc.length - 2)).toBe("**wide bold phrase**"); - }); -}); diff --git a/packages/rich-editor/src/codemirror/clipboard/clipboard.browser.test.ts b/packages/rich-editor/src/codemirror/clipboard/clipboard.browser.test.ts deleted file mode 100644 index 76de37c..0000000 --- a/packages/rich-editor/src/codemirror/clipboard/clipboard.browser.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Clipboard interop handlers (#134/#135) in real WebKit — the engine Compose - * ships on — because jsdom has no DataTransfer constructor. Events are - * synthetic but carry REAL DataTransfer objects, so what's asserted is our - * handlers' flavor logic, not the OS pasteboard (that part is live-verified - * in the packaged app). - */ - -import { afterEach, describe, expect, it } from "vitest"; - -import { destroyEditors, makeEditor, text } from "../core/editorTestHarness"; -import { markdownPaste } from "./pasteMarkdown"; -import { renderClipboardHtmlFacet, richCopy } from "./copyRich"; -import { COMPOSE_CLIPBOARD_ATTR } from "./htmlToMarkdown"; -import { EditorSelection } from "@codemirror/state"; - -afterEach(destroyEditors); - -function pasteEvent(flavors: Record): ClipboardEvent { - const data = new DataTransfer(); - for (const [type, value] of Object.entries(flavors)) { - data.setData(type, value); - } - return new ClipboardEvent("paste", { clipboardData: data, cancelable: true, bubbles: true }); -} - -function clipboardEvent(kind: "copy" | "cut"): ClipboardEvent { - return new ClipboardEvent(kind, { - clipboardData: new DataTransfer(), - cancelable: true, - bubbles: true, - }); -} - -describe("markdownPaste (#134)", () => { - it("converts an HTML paste to markdown at the caret", () => { - const view = makeEditor("start ", 6, [markdownPaste]); - - const event = pasteEvent({ - "text/html": "

bold and fine

", - "text/plain": "bold and fine", - }); - view.contentDOM.dispatchEvent(event); - - expect(event.defaultPrevented).toBe(true); - expect(text(view)).toBe("start **bold** and *fine*"); - }); - - it("leaves Compose's own copies to the plain-markdown path", () => { - const view = makeEditor("", 0, [markdownPaste]); - - const event = pasteEvent({ - "text/html": `

rendered

`, - "text/plain": "**source**", - }); - view.contentDOM.dispatchEvent(event); - - // Our handler declines; CM's native paste inserts the text/plain flavor. - expect(text(view)).toBe("**source**"); - }); - - it("leaves plain-only pastes to the native path", () => { - const view = makeEditor("", 0, [markdownPaste]); - - const event = pasteEvent({ "text/plain": "just text" }); - view.contentDOM.dispatchEvent(event); - - expect(text(view)).toBe("just text"); - }); -}); - -describe("richCopy (#135)", () => { - const render = renderClipboardHtmlFacet.of( - (markdown) => `

rendered:${markdown.length}

`, - ); - - it("writes both flavors, marks the HTML as Compose's own", () => { - const view = makeEditor("# Title\n\nBody here", 0, [richCopy, render]); - view.dispatch({ selection: EditorSelection.range(0, 7) }); - - const event = clipboardEvent("copy"); - view.contentDOM.dispatchEvent(event); - - expect(event.defaultPrevented).toBe(true); - expect(event.clipboardData?.getData("text/plain")).toBe("# Title"); - const html = event.clipboardData?.getData("text/html") ?? ""; - expect(html).toContain(COMPOSE_CLIPBOARD_ATTR); - expect(html).toContain("rendered:7"); - expect(text(view)).toBe("# Title\n\nBody here"); - }); - - it("copies plain-only when the host provides no renderer", () => { - const view = makeEditor("hello world", 0, [richCopy]); - view.dispatch({ selection: EditorSelection.range(0, 5) }); - - const event = clipboardEvent("copy"); - view.contentDOM.dispatchEvent(event); - - expect(event.clipboardData?.getData("text/plain")).toBe("hello"); - expect(event.clipboardData?.getData("text/html")).toBe(""); - }); - - it("cut removes the selection after writing the flavors", () => { - const view = makeEditor("cut me please", 0, [richCopy, render]); - view.dispatch({ selection: EditorSelection.range(0, 7) }); - - const event = clipboardEvent("cut"); - view.contentDOM.dispatchEvent(event); - - expect(event.clipboardData?.getData("text/plain")).toBe("cut me "); - expect(text(view)).toBe("please"); - }); - - it("stands down when another handler already claimed the event (table TSV copy)", () => { - const view = makeEditor("abc", 0, [richCopy, render]); - view.dispatch({ selection: EditorSelection.range(0, 3) }); - - const event = clipboardEvent("copy"); - event.preventDefault(); - view.contentDOM.dispatchEvent(event); - - expect(event.clipboardData?.getData("text/plain")).toBe(""); - }); - - it("ignores empty selections (native line-copy behavior stays)", () => { - const view = makeEditor("abc", 1, [richCopy, render]); - - const event = clipboardEvent("copy"); - view.contentDOM.dispatchEvent(event); - - expect(event.clipboardData?.getData("text/html")).toBe(""); - }); -}); diff --git a/packages/rich-editor/src/codemirror/clipboard/copyRich.ts b/packages/rich-editor/src/codemirror/clipboard/copyRich.ts deleted file mode 100644 index ec822ec..0000000 --- a/packages/rich-editor/src/codemirror/clipboard/copyRich.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Rich copy/cut (#135): every copy writes BOTH clipboard flavors — - * - * text/plain — the markdown SOURCE of the selection (lossless: editors, - * terminals, and Compose→Compose round trips); - * text/html — the selection rendered by the host (Google Docs, Slack, - * Word, and Gmail paste it formatted). - * - * The HTML carries {@link COMPOSE_CLIPBOARD_ATTR} so our own paste handler - * prefers the markdown flavor instead of re-converting our rendering. - * - * Stays out of the way of: the table surface's TSV cell-selection copy (a - * capture-phase document listener that preventDefaults first), and CM's - * native empty-selection behavior. Without a host renderer the handler still - * runs — plain-only, but through one code path. - */ - -import { EditorView } from "@codemirror/view"; -import { Facet, type EditorState, type Extension } from "@codemirror/state"; - -import { COMPOSE_CLIPBOARD_ATTR } from "./htmlToMarkdown"; -import { treeAt } from "../core/treeAt"; - -/** Host-supplied markdown → HTML renderer for clipboard writes. Null (the - * default) copies plain markdown only. Wired like the other host seams — - * see hostFacets.ts. */ -export type RenderClipboardHtml = (markdown: string) => string | null; - -export const renderClipboardHtmlFacet = Facet.define< - RenderClipboardHtml | null, - RenderClipboardHtml | null ->({ - combine: (values) => values[0] ?? null, -}); - -/** Inline constructs whose delimiters hide in rich mode. A selection made by - * eye starts at the first VISIBLE character — in source, that's just past - * the opening marker — so a naive slice ships an orphaned `**` (#140). */ -const MARKER_PAIRED = new Set(["Emphasis", "StrongEmphasis", "Strikethrough", "InlineCode"]); - -/** - * Slice the selection as balanced markdown: when an edge lands inside a - * marker-paired node, the missing delimiter is synthesized onto that edge — - * copy what the user SAW (`**Priorties**`), not where the hidden markers - * happened to fall. A mid-content selection (neither marker covered) stays a - * plain slice; delimiter style (`*` vs `_`, `~~`, backticks) is preserved by - * copying the node's actual mark text. - */ -export function balancedSlice(state: EditorState, from: number, to: number): string { - const tree = treeAt(state, to); - let prefix = ""; - let suffix = ""; - tree.iterate({ - from, - to, - enter: (node) => { - if (!MARKER_PAIRED.has(node.name)) return; - if (node.from >= from && node.to <= to) return false; // marks travel with the slice - const first = node.node.firstChild; - const last = node.node.lastChild; - if (!first || !last || !first.name.endsWith("Mark") || !last.name.endsWith("Mark")) { - return; - } - // An edge landing in the CONTENT region (between the marks) gets that - // side's marker synthesized. Each edge repairs independently: the - // screen shows the selected span formatted, so the copy carries the - // formatting even when both marks fall outside the selection. An edge - // inside a marker itself (raw-mode oddity) is left exactly as sliced. - const contentStart = first.to; - const contentEnd = last.from; - // Outer nodes visit first: opening repairs append (outer→inner order), - // closing repairs prepend (inner→outer), so nesting stays well-formed. - if (from >= contentStart && from < contentEnd) { - prefix = prefix + state.sliceDoc(first.from, first.to); - } - if (to > contentStart && to <= contentEnd) { - suffix = state.sliceDoc(last.from, last.to) + suffix; - } - }, - }); - return prefix + state.sliceDoc(from, to) + suffix; -} - -function handleClipboard(event: ClipboardEvent, view: EditorView, cut: boolean): boolean { - if (event.defaultPrevented) return false; - const data = event.clipboardData; - if (!data) return false; - const ranges = view.state.selection.ranges.filter((range) => !range.empty); - if (ranges.length === 0) return false; - - const markdown = ranges - .map((range) => balancedSlice(view.state, range.from, range.to)) - .join(view.state.lineBreak); - data.setData("text/plain", markdown); - const render = view.state.facet(renderClipboardHtmlFacet); - const html = render?.(markdown); - if (html) { - data.setData("text/html", `
${html}
`); - } - event.preventDefault(); - - if (cut && !view.state.readOnly) { - view.dispatch(view.state.replaceSelection(""), { - userEvent: "delete.cut", - scrollIntoView: true, - }); - } - return true; -} - -/** The copy half of clipboard interop. */ -export const richCopy: Extension = EditorView.domEventHandlers({ - copy(event, view) { - return handleClipboard(event, view, false); - }, - cut(event, view) { - return handleClipboard(event, view, true); - }, -}); diff --git a/packages/rich-editor/src/codemirror/clipboard/htmlToMarkdown.test.ts b/packages/rich-editor/src/codemirror/clipboard/htmlToMarkdown.test.ts deleted file mode 100644 index 7087ae2..0000000 --- a/packages/rich-editor/src/codemirror/clipboard/htmlToMarkdown.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -// @vitest-environment jsdom -// -// The paste converter (#134) against the clipboard HTML that real sources -// produce. Google Docs is the hostile one: a guid-carrying wrapper whose -// font-weight is NORMAL around the whole fragment, and every piece of -// formatting expressed as — the naive conversion bolds the -// entire paste and loses all emphasis. -import { describe, expect, it } from "vitest"; - -import { - COMPOSE_CLIPBOARD_ATTR, - htmlToMarkdown, - isComposeClipboardHtml, -} from "./htmlToMarkdown"; - -describe("htmlToMarkdown", () => { - it("converts a Google Docs fragment: guid wrapper unwrapped, styled spans become emphasis", () => { - const gdocs = - `` + - `` + - `

Bold lead` + - ` then plain and ` + - `italic

` + - `
  • first item

  • ` + - `
  • struck item

` + - `
`; - - const markdown = htmlToMarkdown(gdocs); - - expect(markdown).toContain("**Bold lead**"); - expect(markdown).toContain("then plain and"); - expect(markdown).toContain("*italic*"); - expect(markdown).toContain("- first item"); - expect(markdown).toContain("~~struck item~~"); - // The guid wrapper must NOT bold the whole paste. - expect(markdown.startsWith("**Bold lead** then plain")).toBe(true); - }); - - it("converts Word-flavored HTML: headings, real strong/em, mso classes ignored", () => { - const word = - `

Chapter

` + - `

Bold and italic prose.

` + - `

Section

`; - - const markdown = htmlToMarkdown(word); - - expect(markdown).toContain("# Chapter"); - expect(markdown).toContain("**Bold** and *italic* prose."); - expect(markdown).toContain("## Section"); - }); - - it("keeps nested and ordered lists aligned under tight markers", () => { - const html = - `
  • outer
    • inner
` + - `
  1. third
  2. fourth
`; - - const markdown = htmlToMarkdown(html); - - expect(markdown).toContain("- outer"); - expect(markdown).toContain(" - inner"); - expect(markdown).toContain("3. third"); - expect(markdown).toContain("4. fourth"); - }); - - it("converts tables to pipes and keeps links", () => { - const html = - `` + - `
PartFocus
Adetails
`; - - const markdown = htmlToMarkdown(html); - - expect(markdown).toContain("| Part | Focus |"); - expect(markdown).toContain("[details](https://example.com)"); - }); - - it("degrades images honestly: remote becomes a link, data: keeps only alt", () => { - const html = - `

chart

` + - `

pasted blob

`; - - const markdown = htmlToMarkdown(html); - - expect(markdown).toContain("[chart](https://cdn.example.com/pic.png)"); - expect(markdown).not.toContain("!["); - expect(markdown).not.toContain("data:image"); - expect(markdown).toContain("pasted blob"); - }); - - it("strips style/script blocks entirely", () => { - const html = `

kept

`; - expect(htmlToMarkdown(html)).toBe("kept"); - }); - - it("returns empty for whitespace-only fragments so callers fall back to plain paste", () => { - expect(htmlToMarkdown("
\n
")).toBe(""); - }); -}); - -describe("isComposeClipboardHtml", () => { - it("recognizes our own marker and nothing else", () => { - expect(isComposeClipboardHtml(`

x

`)).toBe( - true, - ); - expect(isComposeClipboardHtml("

ordinary

")).toBe(false); - }); -}); diff --git a/packages/rich-editor/src/codemirror/clipboard/htmlToMarkdown.ts b/packages/rich-editor/src/codemirror/clipboard/htmlToMarkdown.ts deleted file mode 100644 index 88b9642..0000000 --- a/packages/rich-editor/src/codemirror/clipboard/htmlToMarkdown.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Clipboard HTML → Markdown (#134), the paste half of clipboard interop. - * - * Real-world clipboard HTML is hostile: Google Docs wraps everything in a - * `` and expresses ALL - * formatting as ``; Word exports `mso-` styled soup. The DOM is - * normalized here first — styled spans become real `//` - * elements, wrapper lies are unwrapped — so turndown converts semantics, not - * vendor quirks. - */ - -import TurndownService from "turndown"; -// @ts-expect-error — no published types; the joplin fork is the maintained -// GFM ruleset (tables, strikethrough, task lists). -import { gfm } from "@joplin/turndown-plugin-gfm"; - -/** Marks HTML that Compose itself put on the clipboard (see copyRich.ts). - * The paste handler sees it and uses the lossless text/plain markdown - * instead of re-converting our own rendering. */ -export const COMPOSE_CLIPBOARD_ATTR = "data-compose-markdown"; - -export function isComposeClipboardHtml(html: string): boolean { - return html.includes(COMPOSE_CLIPBOARD_ATTR); -} - -function isBoldStyle(style: CSSStyleDeclaration): boolean { - const weight = style.fontWeight; - const numeric = Number(weight); - return weight === "bold" || weight === "bolder" || (!Number.isNaN(numeric) && numeric >= 600); -} - -/** Rewrite vendor styling into semantic elements, in place. */ -function normalizeVendorDom(root: HTMLElement): void { - // Google Docs signs its fragments with a guid-carrying whose - // font-weight is NORMAL — unwrap it or the whole paste turns bold. - root.querySelectorAll("b[id^='docs-internal-guid']").forEach((wrapper) => { - wrapper.replaceWith(...Array.from(wrapper.childNodes)); - }); - - // Docs also wraps every list item's content in a

, which converts as a - // LOOSE list (`- item` + blank lines). Unwrap sole-child paragraphs so - // lists come out tight, the house style. - root.querySelectorAll("li > p:only-child").forEach((p) => { - p.replaceWith(...Array.from(p.childNodes)); - }); - - // Styled spans → semantic elements (Docs never emits /). - // Nested wrappers so bold+italic+strike combinations all survive. - root.querySelectorAll("span").forEach((span) => { - const style = span.style; - if (!style) return; - const tags: string[] = []; - if (isBoldStyle(style)) tags.push("strong"); - if (style.fontStyle === "italic") tags.push("em"); - if (style.textDecoration.includes("line-through")) tags.push("s"); - if (tags.length === 0) return; - const doc = span.ownerDocument; - const outermost = doc.createElement(tags[0]); - let innermost = outermost; - for (const tag of tags.slice(1)) { - const next = doc.createElement(tag); - innermost.appendChild(next); - innermost = next; - } - innermost.append(...Array.from(span.childNodes)); - span.replaceWith(outermost); - }); -} - -function buildTurndown(): TurndownService { - const service = new TurndownService({ - headingStyle: "atx", - hr: "---", - bulletListMarker: "-", - codeBlockStyle: "fenced", - emDelimiter: "*", - strongDelimiter: "**", - }); - service.use(gfm); - // Stock turndown pads every bullet to `- ` (a 4-char unit). House style - // is the tight `- item` / `1. item`, with continuation lines indented to - // the marker's own width — still CommonMark-correct for nesting. - service.addRule("tightListItem", { - filter: "li", - replacement: (content, node, options) => { - const parent = node.parentNode as HTMLElement; - let prefix = `${options.bulletListMarker} `; - if (parent.nodeName === "OL") { - const items = Array.from(parent.children).filter((child) => child.nodeName === "LI"); - const start = Number(parent.getAttribute("start") ?? "1"); - prefix = `${start + items.indexOf(node as Element)}. `; - } - const indent = " ".repeat(prefix.length); - const inner = content - .replace(/^\n+/, "") - .replace(/\n+$/, "\n") - .replace(/\n/gm, `\n${indent}`); - return prefix + inner + (node.nextSibling && !/\n$/.test(inner) ? "\n" : ""); - }, - }); - // Images degrade honestly (#134): a remote image becomes a LINK — never a - // hot-loading `![...]` embed — and an inline data: blob keeps only its alt - // text (a base64 wall would bury the document). - service.addRule("imagesAsLinks", { - filter: "img", - replacement: (_content, node) => { - const img = node as HTMLImageElement; - const alt = img.getAttribute("alt")?.trim() || "image"; - const src = img.getAttribute("src") ?? ""; - if (!src || src.startsWith("data:")) return alt === "image" ? "" : alt; - return `[${alt}](${src})`; - }, - }); - return service; -} - -let service: TurndownService | null = null; - -/** Convert clipboard HTML to house-style Markdown. Returns "" when nothing - * convertible remains (caller falls back to the native plain paste). */ -export function htmlToMarkdown(html: string): string { - const doc = new DOMParser().parseFromString(html, "text/html"); - doc.body.querySelectorAll("style,script,meta,head,title").forEach((el) => el.remove()); - normalizeVendorDom(doc.body); - service ??= buildTurndown(); - return service.turndown(doc.body.innerHTML).trim(); -} diff --git a/packages/rich-editor/src/codemirror/clipboard/pasteMarkdown.ts b/packages/rich-editor/src/codemirror/clipboard/pasteMarkdown.ts deleted file mode 100644 index 89c2f22..0000000 --- a/packages/rich-editor/src/codemirror/clipboard/pasteMarkdown.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Rich paste (#134): clipboard HTML converts to Markdown at the caret, so a - * paste from Google Docs / Word / the web keeps headings, emphasis, lists, - * links, and tables instead of flattening to plain text. - * - * Precedence contract: - * - image files on the clipboard belong to imageInsertHandlers — skipped; - * - Compose's own copies carry {@link COMPOSE_CLIPBOARD_ATTR} — skipped, so - * the native path pastes the lossless text/plain markdown; - * - Mod-Shift-v pastes verbatim plain text (the escape hatch). - */ - -import { EditorView, keymap } from "@codemirror/view"; -import { type Extension } from "@codemirror/state"; - -import { htmlToMarkdown, isComposeClipboardHtml } from "./htmlToMarkdown"; - -function insertText(view: EditorView, text: string): void { - view.dispatch(view.state.replaceSelection(text), { - userEvent: "input.paste", - scrollIntoView: true, - }); -} - -const pasteHandler = EditorView.domEventHandlers({ - paste(event, view) { - if (event.defaultPrevented) return false; - const data = event.clipboardData; - if (!data) return false; - // Image bytes → the image pipeline, not text conversion. - if (data.files.length > 0) return false; - const html = data.getData("text/html"); - if (!html || isComposeClipboardHtml(html)) return false; - const markdown = htmlToMarkdown(html); - if (!markdown) return false; - event.preventDefault(); - insertText(view, markdown); - return true; - }, -}); - -const verbatimPasteKeymap = keymap.of([ - { - key: "Mod-Shift-v", - run: (view) => { - // Async clipboard read — allowed here because it rides a user gesture. - // A denial (or an empty clipboard) quietly does nothing rather than - // erroring into the document. - void navigator.clipboard - .readText() - .then((text) => { - if (text) insertText(view, text); - }) - .catch(() => {}); - return true; - }, - }, -]); - -/** The paste half of clipboard interop; compose after imageInsertHandlers. */ -export const markdownPaste: Extension = [pasteHandler, verbatimPasteKeymap]; diff --git a/packages/rich-editor/src/codemirror/code/codeHighlight.ts b/packages/rich-editor/src/codemirror/code/codeHighlight.ts deleted file mode 100644 index 1f0aeba..0000000 --- a/packages/rich-editor/src/codemirror/code/codeHighlight.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Syntax colors for fenced-code content (ADR 0002). The markdown grammar's - * own constructs (headings, emphasis, links…) are styled by the decoration - * registry + editorTheme, NOT here — so this style deliberately covers only - * tags that code languages emit and markdown does not. The palette itself - * lives in [codePalette](./codePalette.ts), shared with the clipboard's - * inline-styled renderer. - */ - -import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; - -import { CODE_PALETTE } from "./codePalette"; - -const style = HighlightStyle.define( - CODE_PALETTE.map((spec) => ({ tag: spec.tag, color: spec.color, fontStyle: spec.fontStyle })), -); - -export const codeHighlight = syntaxHighlighting(style); diff --git a/packages/rich-editor/src/codemirror/code/codeLangAffordance.ts b/packages/rich-editor/src/codemirror/code/codeLangAffordance.ts deleted file mode 100644 index ea5b8f5..0000000 --- a/packages/rich-editor/src/codemirror/code/codeLangAffordance.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * The opener-row language affordance (ADR 0002). - * - * A CLOSED fence with a language shows its styled CodeInfo text (the pill); - * one without gets a "plain" placeholder pill widget so every block has a - * click target. Clicking either opens the searchable language chooser; - * right-clicking anywhere in a block opens the block menu (set language, - * copy code). Pointer handling is delegated — one listener per editor — and - * clicks are swallowed on mousedown so the fence caret guard (§12.9) never - * sees them. - */ - -import { syntaxTree } from "@codemirror/language"; -import { type EditorState, type Extension, type Range, StateField } from "@codemirror/state"; -import { Decoration, type DecorationSet, EditorView, ViewPlugin, WidgetType } from "@codemirror/view"; - -import { fenceAtLoose, showCodeBlockMenu, showLanguageMenu } from "./codeLanguageMenu"; - -class PlainLangPill extends WidgetType { - override eq(): boolean { - return true; - } - - override toDOM(): HTMLElement { - const pill = document.createElement("span"); - pill.className = "cm-code-info cm-code-info--unset"; - pill.textContent = "plain"; - return pill; - } - - override ignoreEvent(): boolean { - return false; - } -} - -const PILL = Decoration.widget({ widget: new PlainLangPill(), side: 1 }); - -function buildPills(state: EditorState): DecorationSet { - const ranges: Range[] = []; - syntaxTree(state).iterate({ - enter(node) { - if (node.name !== "FencedCode") return; - const marks = node.node.getChildren("CodeMark"); - if (marks.length < 2) return; // unclosed: the language flow is typing - if (node.node.getChildren("CodeInfo").length > 0) return; - ranges.push(PILL.range(marks[0].to)); - }, - }); - return Decoration.set(ranges, true); -} - -const plainPillField = StateField.define({ - create: buildPills, - update(value, tr) { - if (tr.docChanged || syntaxTree(tr.state) !== syntaxTree(tr.startState)) { - return buildPills(tr.state); - } - return value; - }, - provide: (f) => EditorView.decorations.from(f), -}); - -/** The fence containing the event point, resolved through the view. */ -function fenceAtEvent(view: EditorView, event: MouseEvent): number | null { - const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); - if (pos === null) return null; - const node = fenceAtLoose(view.state, pos); - return node ? node.from : null; -} - -const interactionPlugin = ViewPlugin.define((view) => { - const onMouseDown = (event: MouseEvent): void => { - if (event.button !== 0) return; - const target = event.target as HTMLElement; - if (!target.closest?.(".cm-code-info")) return; - const fencePos = fenceAtEvent(view, event); - if (fencePos === null) return; - // Swallow the press: the pill is a control, not text — the caret must - // not move (and §12.9 must not re-site it). - event.preventDefault(); - event.stopPropagation(); - showLanguageMenu({ view, x: event.clientX, y: event.clientY, fencePos }); - }; - - const onContextMenu = (event: MouseEvent): void => { - const fencePos = fenceAtEvent(view, event); - if (fencePos === null) return; - event.preventDefault(); - showCodeBlockMenu({ view, x: event.clientX, y: event.clientY, fencePos }); - }; - - view.dom.addEventListener("mousedown", onMouseDown, true); - view.dom.addEventListener("contextmenu", onContextMenu); - return { - destroy() { - view.dom.removeEventListener("mousedown", onMouseDown, true); - view.dom.removeEventListener("contextmenu", onContextMenu); - }, - }; -}); - -export const codeLanguageUI: Extension = [plainPillField, interactionPlugin]; diff --git a/packages/rich-editor/src/codemirror/code/codeLanguage.browser.test.ts b/packages/rich-editor/src/codemirror/code/codeLanguage.browser.test.ts deleted file mode 100644 index 0ff88c1..0000000 --- a/packages/rich-editor/src/codemirror/code/codeLanguage.browser.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * @browser: the code-block language UI (ADR 0002) — pill click opens the - * chooser, choosing writes the info string as one undo step, right-click - * offers set-language and copy-code. Real WebKit, real events. - */ - -import { history, historyKeymap } from "@codemirror/commands"; -import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; -import { ensureSyntaxTree } from "@codemirror/language"; -import { EditorState } from "@codemirror/state"; -import { EditorView, keymap } from "@codemirror/view"; -import { userEvent } from "@vitest/browser/context"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { codeLanguageUI } from "./codeLangAffordance"; - -let view: EditorView | null = null; - -function makeView(doc: string): EditorView { - const state = EditorState.create({ - doc, - extensions: [ - history(), - keymap.of(historyKeymap), - markdown({ base: markdownLanguage }), - codeLanguageUI, - ], - }); - ensureSyntaxTree(state, doc.length, 5000); - view = new EditorView({ state, parent: document.body }); - return view; -} - -afterEach(() => { - view?.destroy(); - view = null; - document.querySelectorAll(".cm-code-menu").forEach((el) => el.remove()); - vi.restoreAllMocks(); -}); - -function click(el: Element): void { - const rect = el.getBoundingClientRect(); - const at = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; - for (const type of ["mousedown", "mouseup", "click"] as const) { - el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, ...at })); - } -} - -const menu = () => document.querySelector(".cm-code-menu"); -const menuItem = (label: string) => - Array.from(document.querySelectorAll(".cm-code-menu button")).find( - (b) => b.textContent?.startsWith(label), - ); - -describe("language pill", () => { - it("clicking the placeholder pill and choosing a language writes the info string", async () => { - const v = makeView("```\nconst x = 1\n```"); - const pill = v.dom.querySelector(".cm-code-info--unset"); - expect(pill).not.toBeNull(); - click(pill!); - expect(menu()).not.toBeNull(); - - await userEvent.fill(menu()!.querySelector("input")!, "typescript"); - menuItem("TypeScript")!.click(); - - expect(v.state.doc.toString()).toBe("```ts\nconst x = 1\n```"); - expect(menu()).toBeNull(); - }); - - it("the change is one undo step", async () => { - const v = makeView("```\ncode\n```"); - click(v.dom.querySelector(".cm-code-info--unset")!); - menuItem("Plain text")!.click(); - // Plain on plain: no doc change dispatched at all. - expect(v.state.doc.toString()).toBe("```\ncode\n```"); - - click(v.dom.querySelector(".cm-code-info--unset")!); - await userEvent.fill(menu()!.querySelector("input")!, "javascript"); - menuItem("JavaScript")!.click(); - expect(v.state.doc.toString()).toBe("```js\ncode\n```"); - - v.focus(); - await userEvent.keyboard("{Meta>}z{/Meta}"); - expect(v.state.doc.toString()).toBe("```\ncode\n```"); - }); - - it("Enter in the search picks the top match", async () => { - const v = makeView("```\ncode\n```"); - click(v.dom.querySelector(".cm-code-info--unset")!); - const input = menu()!.querySelector("input")!; - await userEvent.fill(input, "rust"); - await userEvent.keyboard("{Enter}"); - expect(v.state.doc.toString()).toBe("```rust\ncode\n```"); - }); -}); - -describe("block right-click menu", () => { - it("offers Set language… and Copy code; copy writes the block content", async () => { - const v = makeView("```js\nconst x = f(1)\n```"); - const written: string[] = []; - vi.spyOn(navigator.clipboard, "writeText").mockImplementation(async (t: string) => { - written.push(t); - }); - - const line = v.dom.querySelector(".cm-line"); - line!.dispatchEvent( - new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 40, clientY: 40 }), - ); - // The handler resolves the fence from event coords; aim at the code line. - const codeCoords = v.coordsAtPos(v.state.doc.toString().indexOf("const"))!; - document.querySelectorAll(".cm-code-menu").forEach((el) => el.remove()); - v.dom - .querySelector(".cm-content")! - .dispatchEvent( - new MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - clientX: codeCoords.left + 2, - clientY: (codeCoords.top + codeCoords.bottom) / 2, - }), - ); - expect(menu()).not.toBeNull(); - expect(menuItem("Set language…")).toBeDefined(); - - menuItem("Copy code")!.click(); - expect(written).toEqual(["const x = f(1)"]); - expect(menu()).toBeNull(); - }); -}); diff --git a/packages/rich-editor/src/codemirror/code/codeLanguageMenu.test.ts b/packages/rich-editor/src/codemirror/code/codeLanguageMenu.test.ts deleted file mode 100644 index fe25b1a..0000000 --- a/packages/rich-editor/src/codemirror/code/codeLanguageMenu.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, describe, expect, it } from "vitest"; - -import { destroyEditors, makeEditor, text } from "../core/editorTestHarness"; -import { fenceContent, infoFor, languageEntries, setFenceInfo } from "./codeLanguageMenu"; -import { codeLanguageUI } from "./codeLangAffordance"; - -describe("setFenceInfo", () => { - afterEach(destroyEditors); - - it("adds a language to a plain fence", () => { - const view = makeEditor("```\ncode\n```", 0); - view.dispatch({ changes: setFenceInfo(view.state, 0, "js")! }); - expect(text(view)).toBe("```js\ncode\n```"); - }); - - it("replaces an existing language (and stray trailing text)", () => { - const view = makeEditor("```js extra\ncode\n```", 0); - view.dispatch({ changes: setFenceInfo(view.state, 2, "ts")! }); - expect(text(view)).toBe("```ts\ncode\n```"); - }); - - it("clears the language with null", () => { - const view = makeEditor("```js\ncode\n```", 0); - view.dispatch({ changes: setFenceInfo(view.state, 0, null)! }); - expect(text(view)).toBe("```\ncode\n```"); - }); - - it("returns null outside any fence", () => { - const view = makeEditor("plain prose", 0); - expect(setFenceInfo(view.state, 2, "js")).toBeNull(); - }); -}); - -describe("fenceContent", () => { - afterEach(destroyEditors); - - it("returns the block's code and empty for a bare pair", () => { - const view = makeEditor("```js\na\nb\n```", 0); - expect(fenceContent(view.state, 0)).toBe("a\nb"); - const bare = makeEditor("```\n```", 0); - expect(fenceContent(bare.state, 0)).toBe(""); - }); -}); - -describe("infoFor", () => { - it("writes the shortest idiomatic alias", () => { - expect(infoFor("TypeScript")).toBe("ts"); - expect(infoFor("JavaScript")).toBe("js"); - expect(infoFor("NoSuchLanguage")).toBeNull(); - }); -}); - -describe("languageEntries", () => { - it("offers renderer-backed tags (Mermaid) alongside the grammars", () => { - const mermaid = languageEntries().find((entry) => entry.label === "Mermaid"); - expect(mermaid?.info).toBe("mermaid"); - }); - - it("keeps Plain text first and the rest sorted A→Z (Mermaid merged in, not appended)", () => { - const entries = languageEntries(); - expect(entries[0]).toMatchObject({ label: "Plain text", info: null }); - const labels = entries.slice(1).map((entry) => entry.label); - expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b))); - }); -}); - -describe("plain-pill affordance", () => { - afterEach(destroyEditors); - - it("a languageless CLOSED fence gets the placeholder pill", () => { - const view = makeEditor("```\ncode\n```", 0, [codeLanguageUI]); - expect(view.dom.querySelectorAll(".cm-code-info--unset")).toHaveLength(1); - }); - - it("a fence WITH a language gets no placeholder", () => { - const view = makeEditor("```js\ncode\n```", 0, [codeLanguageUI]); - expect(view.dom.querySelectorAll(".cm-code-info--unset")).toHaveLength(0); - }); - - it("an UNCLOSED fence gets no placeholder (typing owns the flow)", () => { - const view = makeEditor("```\ncode below", 0, [codeLanguageUI]); - expect(view.dom.querySelectorAll(".cm-code-info--unset")).toHaveLength(0); - }); -}); diff --git a/packages/rich-editor/src/codemirror/code/codeLanguageMenu.ts b/packages/rich-editor/src/codemirror/code/codeLanguageMenu.ts deleted file mode 100644 index f852269..0000000 --- a/packages/rich-editor/src/codemirror/code/codeLanguageMenu.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Language + block menus for fenced code (ADR 0002). - * - * Typing the language right after ``` works while the block is still empty - * (§12.4 keeps the caret on the opener) — these menus are the POINTER path, - * and the only path once a block has content: the opener-row pill (real or - * "plain" placeholder) opens a searchable chooser, and right-click anywhere - * in a block offers "Set language…" and "Copy code". Menus mount on - * document.body with inline styles (outside the editor's scoped theme), the - * same pattern as the table menu. - */ - -import { EditorSelection, type ChangeSpec, type EditorState } from "@codemirror/state"; -import { type EditorView } from "@codemirror/view"; -import { languages } from "@codemirror/language-data"; - -import { fenceAt } from "./fenceAutoClose"; - -/** `fenceAt` resolves side -1 and misses the fence's own from-boundary — the - * very position callers naturally pass (node.from). Probe one step in. */ -export function fenceAtLoose(state: EditorState, pos: number) { - return fenceAt(state, pos) ?? fenceAt(state, Math.min(pos + 1, state.doc.length)); -} - -/** Replace the fence's info string (everything after the opening marks on the - * opener line) with `info`; null clears it. Null result = no fence at pos. */ -export function setFenceInfo( - state: EditorState, - fencePos: number, - info: string | null, -): ChangeSpec | null { - const node = fenceAtLoose(state, fencePos); - if (!node) return null; - const opener = node.getChildren("CodeMark")[0]; - if (!opener) return null; - const openerLine = state.doc.lineAt(node.from); - return { from: opener.to, to: openerLine.to, insert: info ?? "" }; -} - -/** The block's code content (between opener and closer lines). */ -export function fenceContent(state: EditorState, fencePos: number): string | null { - const node = fenceAtLoose(state, fencePos); - if (!node) return null; - const marks = node.getChildren("CodeMark"); - if (marks.length < 2) return null; - const openerLine = state.doc.lineAt(node.from); - const closerLine = state.doc.lineAt(marks[marks.length - 1].from); - if (closerLine.from - 1 <= openerLine.to) return ""; - return state.sliceDoc(openerLine.to + 1, closerLine.from - 1); -} - -/** Write the block's content to the clipboard; returns it (for tests). */ -export function copyFenceCode(view: EditorView, fencePos: number): string | null { - const content = fenceContent(view.state, fencePos); - if (content === null) return null; - void navigator.clipboard?.writeText(content).catch(() => { - // Clipboard API denied (rare in the webview): execCommand fallback. - const area = document.createElement("textarea"); - area.value = content; - document.body.appendChild(area); - area.select(); - document.execCommand("copy"); - area.remove(); - }); - return content; -} - -interface MenuHandle { - root: HTMLElement; - destroy(): void; -} - -function mountMenu(x: number, y: number): MenuHandle { - const root = document.createElement("div"); - root.className = "cm-code-menu"; - root.setAttribute("role", "menu"); - root.style.cssText = - "position:fixed;z-index:1000;min-width:12rem;padding:0.25rem;" + - "background:var(--cds-layer-01,#ffffff);border:0.0625rem solid var(--cds-border-subtle-01,#e0e0e0);" + - "border-radius:0.375rem;box-shadow:0 0.125rem 0.75rem rgba(0,0,0,0.18);font-size:0.875rem;"; - root.style.left = `${x}px`; - root.style.top = `${y}px`; - document.body.appendChild(root); - - const onOutside = (event: MouseEvent): void => { - if (!root.contains(event.target as Node)) destroy(); - }; - const onEscape = (event: KeyboardEvent): void => { - if (event.key === "Escape") destroy(); - }; - function destroy(): void { - root.remove(); - document.removeEventListener("mousedown", onOutside, true); - document.removeEventListener("keydown", onEscape, true); - } - // Defer registration past the event that opened the menu. - setTimeout(() => { - document.addEventListener("mousedown", onOutside, true); - document.addEventListener("keydown", onEscape, true); - }, 0); - - const clamp = () => { - const rect = root.getBoundingClientRect(); - if (rect.right > window.innerWidth) root.style.left = `${x - rect.width}px`; - if (rect.bottom > window.innerHeight) root.style.top = `${Math.max(0, y - rect.height)}px`; - }; - queueMicrotask(clamp); - return { root, destroy }; -} - -function itemButton(label: string, hint?: string): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.className = "cm-code-menu__item"; - button.style.cssText = - "display:flex;justify-content:space-between;gap:1rem;width:100%;padding:0.3rem 0.6rem;" + - "border:none;background:none;text-align:left;cursor:pointer;border-radius:0.25rem;color:inherit;"; - button.addEventListener("mouseenter", () => (button.style.background = "var(--cds-layer-hover-01,#e8e8e8)")); - button.addEventListener("mouseleave", () => (button.style.background = "none")); - const name = document.createElement("span"); - name.textContent = label; - button.appendChild(name); - if (hint) { - const alias = document.createElement("span"); - alias.textContent = hint; - alias.style.cssText = "color:var(--cds-text-secondary,#6f6f6f);font-size:0.75rem;"; - button.appendChild(alias); - } - return button; -} - -/** The markdown info string we write for a chosen language: its shortest - * alias (`ts`, `js`, `py`) — the idiomatic fence tag. */ -export function infoFor(name: string): string | null { - const lang = languages.find((l) => l.name === name); - if (!lang) return null; - return [...lang.alias, lang.name.toLowerCase()].sort((a, b) => a.length - b.length)[0]; -} - -export interface LanguageEntry { - label: string; - hint?: string; - info: string | null; - haystack: string; -} - -/** Fence tags Compose RENDERS rather than parses — no CodeMirror grammar - * exists for them, so they'd otherwise be absent from the chooser, and - * type-time auto-close (§12.4) makes this menu the only way to give a - * from-scratch fence its language. */ -const RENDERED_FENCE_TAGS: LanguageEntry[] = [ - { - label: "Mermaid", - hint: "diagram", - info: "mermaid", - haystack: "mermaid diagram flowchart sequence graph chart", - }, -]; - -/** Every choosable entry: Plain text first, then grammars and rendered tags - * merged A→Z. */ -export function languageEntries(): LanguageEntry[] { - return [ - { label: "Plain text", info: null, haystack: "plain text none" }, - ...[ - ...languages.map((l) => ({ - label: l.name, - hint: infoFor(l.name) ?? undefined, - info: infoFor(l.name), - haystack: `${l.name} ${l.alias.join(" ")}`.toLowerCase(), - })), - ...RENDERED_FENCE_TAGS, - ].sort((a, b) => a.label.localeCompare(b.label)), - ]; -} - -export interface LanguageMenuArgs { - view: EditorView; - x: number; - y: number; - /** Any position inside the target fence. */ - fencePos: number; -} - -/** Searchable language chooser; picking dispatches the info-string change as - * one undo step. */ -export function showLanguageMenu(args: LanguageMenuArgs): void { - const menu = mountMenu(args.x, args.y); - - const search = document.createElement("input"); - search.type = "text"; - search.placeholder = "Search languages…"; - search.className = "cm-code-menu__search"; - search.style.cssText = - "width:100%;box-sizing:border-box;margin-bottom:0.25rem;padding:0.3rem 0.5rem;" + - "border:0.0625rem solid var(--cds-border-subtle-01,#e0e0e0);border-radius:0.25rem;font:inherit;outline-color:var(--cds-focus,#0f62fe);"; - menu.root.appendChild(search); - - const list = document.createElement("div"); - list.style.cssText = "max-height:16rem;overflow-y:auto;"; - menu.root.appendChild(list); - - const apply = (info: string | null): void => { - const change = setFenceInfo(args.view.state, args.fencePos, info) as { - from: number; - to: number; - insert: string; - } | null; - menu.destroy(); - if (change) { - // Language chosen → drop the caret at the start of the first content - // line, ready to type the code (or diagram) itself. - const newLength = args.view.state.doc.length - (change.to - change.from) + change.insert.length; - const anchor = Math.min(change.from + change.insert.length + 1, newLength); - args.view.dispatch({ - changes: change, - selection: EditorSelection.cursor(anchor), - userEvent: "input.code.language", - }); - } - args.view.focus(); - }; - - const entries = languageEntries(); - - const render = (filter: string): void => { - list.textContent = ""; - const needle = filter.trim().toLowerCase(); - for (const entry of entries.filter((e) => !needle || e.haystack.includes(needle))) { - const button = itemButton(entry.label, entry.hint); - button.addEventListener("click", () => apply(entry.info)); - list.appendChild(button); - } - }; - render(""); - search.addEventListener("input", () => render(search.value)); - // Enter picks the top visible match. - search.addEventListener("keydown", (event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - list.querySelector("button")?.click(); - }); - search.focus(); -} - -/** Right-click menu for a code block: set language, copy content. */ -export function showCodeBlockMenu(args: LanguageMenuArgs): void { - const menu = mountMenu(args.x, args.y); - const language = itemButton("Set language…"); - language.addEventListener("click", () => { - menu.destroy(); - showLanguageMenu(args); - }); - const copy = itemButton("Copy code"); - copy.addEventListener("click", () => { - copyFenceCode(args.view, args.fencePos); - menu.destroy(); - args.view.focus(); - }); - menu.root.append(language, copy); -} diff --git a/packages/rich-editor/src/codemirror/code/codeLanguages.test.ts b/packages/rich-editor/src/codemirror/code/codeLanguages.test.ts deleted file mode 100644 index e110e34..0000000 --- a/packages/rich-editor/src/codemirror/code/codeLanguages.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// @vitest-environment jsdom -/** - * Fenced code gets a real NESTED parse from its info string (ADR 0002): the - * `codeLanguages` wiring mounts the code grammar inside CodeText, which is - * what the codeHighlight style colors. Probed via resolveInner — mounted - * overlay trees are invisible to a plain tree iterate. The lazy-load path - * (no preload) rides the view's idle work loop, which jsdom doesn't drive; - * preloading reproduces the state the app reaches after the import lands. - */ -import { forceParsing, syntaxTree } from "@codemirror/language"; -import { languages } from "@codemirror/language-data"; -import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; -import { EditorState } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it } from "vitest"; - -let view: EditorView | null = null; -afterEach(() => { - view?.destroy(); - view = null; -}); - -function makeView(doc: string): EditorView { - view = new EditorView({ - state: EditorState.create({ - doc, - extensions: [markdown({ base: markdownLanguage, codeLanguages: languages })], - }), - parent: document.body, - }); - forceParsing(view, view.state.doc.length, 1000); - return view; -} - -/** Ancestor chain names at `pos` (side +1), innermost first. */ -function chainAt(v: EditorView, pos: number): string[] { - const names: string[] = []; - for ( - let node: { name: string; parent: unknown } | null = syntaxTree(v.state).resolveInner(pos, 1); - node; - node = node.parent as { name: string; parent: unknown } | null - ) { - names.push(node.name); - } - return names; -} - -describe("fenced-code nested parsing", () => { - it("a ```js fence parses its content with the JavaScript grammar", async () => { - await languages.find((l) => l.name === "JavaScript")!.load(); - const doc = "```js\nconst x = f(1)\n```"; - const v = makeView(doc); - const chain = chainAt(v, doc.indexOf("const") + 1); - expect(chain).toContain("VariableDeclaration"); - expect(chain).toContain("Script"); - }); - - it("an unknown language stays plain fenced code (no crash)", () => { - const doc = "```nosuchlang\nwhatever\n```"; - const v = makeView(doc); - const chain = chainAt(v, doc.indexOf("whatever") + 1); - expect(chain).toContain("CodeText"); - expect(chain).not.toContain("Script"); - }); -}); diff --git a/packages/rich-editor/src/codemirror/code/codePalette.ts b/packages/rich-editor/src/codemirror/code/codePalette.ts deleted file mode 100644 index 6b1aaac..0000000 --- a/packages/rich-editor/src/codemirror/code/codePalette.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * The one code-syntax palette (One Light values — ADR 0002), consumed by both - * renderers so code looks the same everywhere it appears: - * - * - the EDITOR, via `codeHighlight.ts` (a CodeMirror `HighlightStyle` built - * from these specs), and - * - the CLIPBOARD, via `highlightFence.ts` (inline-styled spans — pasted HTML - * carries no stylesheet, so classes would be dead weight there). - */ - -import { tags, type Tag } from "@lezer/highlight"; - -export interface CodeStyleSpec { - tag: Tag | readonly Tag[]; - color: string; - fontStyle?: string; -} - -export const CODE_PALETTE: readonly CodeStyleSpec[] = [ - { tag: [tags.keyword, tags.modifier, tags.operatorKeyword], color: "#a626a4" }, - { tag: [tags.string, tags.special(tags.string)], color: "#50a14f" }, - { tag: tags.comment, color: "#a0a1a7", fontStyle: "italic" }, - { tag: [tags.number, tags.bool, tags.null, tags.atom], color: "#986801" }, - { tag: [tags.function(tags.variableName), tags.function(tags.propertyName)], color: "#4078f2" }, - { tag: [tags.typeName, tags.className, tags.namespace], color: "#c18401" }, - { tag: tags.definition(tags.variableName), color: "#e45649" }, - { tag: tags.propertyName, color: "#4078f2" }, - { tag: [tags.tagName, tags.self], color: "#e45649" }, - { tag: tags.attributeName, color: "#986801" }, - { tag: [tags.regexp, tags.escape], color: "#0184bc" }, - { tag: tags.invalid, color: "#ca1243" }, -]; diff --git a/packages/rich-editor/src/codemirror/code/fenceAutoClose.test.ts b/packages/rich-editor/src/codemirror/code/fenceAutoClose.test.ts deleted file mode 100644 index 3982567..0000000 --- a/packages/rich-editor/src/codemirror/code/fenceAutoClose.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, describe, expect, it } from "vitest"; - -import { destroyEditors, makeEditor, text } from "../core/editorTestHarness"; -import type { EditorView } from "@codemirror/view"; - -import { fenceAutoClose, fenceExitBlock, fenceTypeAutoClose } from "./fenceAutoClose"; - -describe("fenceAutoClose — Enter on a just-typed fence (#91)", () => { - afterEach(destroyEditors); - - it("closes the fence and puts the caret inside the empty block", () => { - const view = makeEditor("```", 3); - expect(fenceAutoClose(view)).toBe(true); - expect(text(view)).toBe("```\n\n```"); - expect(view.state.selection.main.head).toBe(4); - }); - - it("keeps the language info and matches the fence length", () => { - const js = makeEditor("```js", 5); - expect(fenceAutoClose(js)).toBe(true); - expect(text(js)).toBe("```js\n\n```"); - - const long = makeEditor("````", 4); - expect(fenceAutoClose(long)).toBe(true); - expect(text(long)).toBe("````\n\n````"); - }); - - it("releases content below instead of swallowing it", () => { - // The user's report: an unclosed fence runs to the end of the document, - // so everything below turned into code. Closing right after the opening - // line hands it back to prose. - const view = makeEditor("```\nexisting text below", 3); - expect(fenceAutoClose(view)).toBe(true); - expect(text(view)).toBe("```\n\n```\nexisting text below"); - expect(view.state.selection.main.head).toBe(4); - }); - - it("declines on an already-closed fence's opening line", () => { - const view = makeEditor("```\ncode\n```", 3); - expect(fenceAutoClose(view)).toBe(false); - expect(text(view)).toBe("```\ncode\n```"); - }); - - it("declines mid-line and on non-fence lines", () => { - const mid = makeEditor("```js", 3); - expect(fenceAutoClose(mid)).toBe(false); - - const prose = makeEditor("plain", 5); - expect(fenceAutoClose(prose)).toBe(false); - }); - - it("steps onto an empty first content line instead of inserting another (§12.5)", () => { - const doc = "```\n\n```"; - const view = makeEditor(doc, 3, [fenceTypeAutoClose]); - expect(fenceAutoClose(view)).toBe(true); - expect(text(view)).toBe(doc); // no edit — just the caret move - expect(view.state.selection.main.head).toBe("```\n".length); - }); - - it("declines inside the code content of an unclosed fence", () => { - // Enter while typing code must stay a plain newline — only the opening - // line auto-closes. - const doc = "```\nlet x = 1"; - const view = makeEditor(doc, doc.length); - expect(fenceAutoClose(view)).toBe(false); - }); -}); - -describe("fenceTypeAutoClose — the completing keystroke closes the fence (§9.5)", () => { - afterEach(destroyEditors); - - function typeChar(view: EditorView, ch: string): void { - const head = view.state.selection.main.head; - view.dispatch({ - changes: { from: head, insert: ch }, - selection: { anchor: head + 1 }, - userEvent: "input.type", - }); - } - - it("typing the third backtick closes the fence with the caret kept on the opener (§12.4)", () => { - const view = makeEditor("``", 2, [fenceTypeAutoClose]); - typeChar(view, "`"); - expect(text(view)).toBe("```\n\n```"); - expect(view.state.selection.main.head).toBe("```".length); - }); - - it("content below is never hijacked, not even transiently", () => { - const doc = "``\nexisting text"; - const view = makeEditor(doc, 2, [fenceTypeAutoClose]); - typeChar(view, "`"); - expect(text(view)).toBe("```\n\n```\nexisting text"); - }); - - it("typing straight through gives the language: ```mermaid, then Enter steps in", () => { - // The universal fence idiom — the language pill renders the tag live, so - // typing here is never invisible. - const view = makeEditor("``", 2, [fenceTypeAutoClose]); - typeChar(view, "`"); - for (const ch of "mermaid") typeChar(view, ch); - expect(text(view)).toBe("```mermaid\n\n```"); - expect(view.state.selection.main.head).toBe("```mermaid".length); - - expect(fenceAutoClose(view)).toBe(true); // Enter → §12.5 step-in - expect(view.state.selection.main.head).toBe("```mermaid\n".length); - for (const ch of "graph LR") typeChar(view, ch); - expect(text(view)).toBe("```mermaid\ngraph LR\n```"); - }); - - it("language typing on a fresh block re-sites once the block has content", () => { - // The click-the-gray-row-means-code protection (§12.7) is only relaxed - // while the block is EMPTY. - const doc = "```js\ncode\n```"; - const view = makeEditor(doc, "```js".length, [fenceTypeAutoClose]); - typeChar(view, "x"); - expect(text(view)).toBe("```js\nxcode\n```"); - }); - - it("a language typed before the third backtick is kept on the opener", () => { - // ``js| → caret before the js? No — the flow is ``` then edit; the - // supported language flow is typing the info on the opener line later or - // before completing the fence: `` + ` typed with js already present is - // NOT a bare fence line, so no auto-close fires and typing continues. - const view = makeEditor("``js", 2, [fenceTypeAutoClose]); - typeChar(view, "`"); - expect(text(view)).toBe("```js"); - }); - - it("tilde fences close the same way", () => { - const view = makeEditor("~~", 2, [fenceTypeAutoClose]); - typeChar(view, "~"); - expect(text(view)).toBe("~~~\n\n~~~"); - }); - - it("indented openers keep their indent on the content line and closer", () => { - const view = makeEditor(" ``", 4, [fenceTypeAutoClose]); - typeChar(view, "`"); - expect(text(view)).toBe(" ```\n \n ```"); - expect(view.state.selection.main.head).toBe(" ```".length); - }); - - it("a fence as a task item's direct content closes inside the item (§12.4)", () => { - const doc = "- [ ] ``"; - const view = makeEditor(doc, doc.length, [fenceTypeAutoClose]); - typeChar(view, "`"); - expect(text(view)).toBe("- [ ] ```\n \n ```"); - expect(view.state.selection.main.head).toBe("- [ ] ```".length); - }); - - it("a fence inside a blockquote carries the quote prefix onto both lines (§12.4)", () => { - const doc = "> ``"; - const view = makeEditor(doc, doc.length, [fenceTypeAutoClose]); - typeChar(view, "`"); - expect(text(view)).toBe("> ```\n> \n> ```"); - }); - - it("backticks typed inside an existing code block stay literal", () => { - const doc = "```\nco``\n```"; - const view = makeEditor(doc, doc.indexOf("co``") + 4, [fenceTypeAutoClose]); - typeChar(view, "`"); - expect(text(view)).toBe("```\nco```\n```"); - }); - - it("a 4th backtick on a closed opener becomes code content, never fence surgery", () => { - const view = makeEditor("```\n\n```", 3, [fenceTypeAutoClose]); - typeChar(view, "`"); - expect(text(view)).toBe("```\n`\n```"); - }); - - it("pasting a fence does not trigger the close", () => { - const view = makeEditor("", 0, [fenceTypeAutoClose]); - view.dispatch({ - changes: { from: 0, insert: "```" }, - selection: { anchor: 3 }, - userEvent: "input.paste", - }); - expect(text(view)).toBe("```"); - }); -}); - -describe("fenceAutoClose — §12.5 step-in inside containers", () => { - afterEach(destroyEditors); - - it("Enter on a quote-nested opener steps onto the existing '> ' content line", () => { - const doc = "> ```js\n> \n> ```"; - const openerEnd = doc.indexOf("\n"); - const view = makeEditor(doc, openerEnd); - expect(fenceAutoClose(view)).toBe(true); - expect(view.state.doc.toString()).toBe(doc); - const contentLineEnd = doc.indexOf("\n", openerEnd + 1); - expect(view.state.selection.main.head).toBe(contentLineEnd); - }); -}); - -describe("fenceExitBlock — Enter on the empty last line leaves the block (§9.5)", () => { - afterEach(destroyEditors); - - it("exits to the line after the closing fence", () => { - const doc = "```\ncode\n\n```\nafter"; - const view = makeEditor(doc, doc.indexOf("\n\n```") + 1); - expect(fenceExitBlock(view)).toBe(true); - expect(text(view)).toBe("```\ncode\n```\nafter"); - expect(view.state.selection.main.head).toBe(text(view).indexOf("after")); - }); - - it("creates the line below when the block ends the document", () => { - const doc = "```\ncode\n\n```"; - const view = makeEditor(doc, doc.indexOf("\n\n```") + 1); - expect(fenceExitBlock(view)).toBe(true); - expect(text(view)).toBe("```\ncode\n```\n"); - expect(view.state.selection.main.head).toBe(text(view).length); - }); - - it("declines on an empty line mid-block (Enter should add a code line)", () => { - const doc = "```\n\ncode\n```"; - const view = makeEditor(doc, doc.indexOf("\n\ncode") + 1); - expect(fenceExitBlock(view)).toBe(false); - }); - - it("declines in an unclosed block and on non-empty lines", () => { - const unclosed = makeEditor("```\n\nswallowed", 4); - expect(fenceExitBlock(unclosed)).toBe(false); - - const doc = "```\ncode\n```"; - const nonEmpty = makeEditor(doc, doc.indexOf("code") + 4); - expect(fenceExitBlock(nonEmpty)).toBe(false); - }); -}); diff --git a/packages/rich-editor/src/codemirror/code/fenceAutoClose.ts b/packages/rich-editor/src/codemirror/code/fenceAutoClose.ts deleted file mode 100644 index 65ecca8..0000000 --- a/packages/rich-editor/src/codemirror/code/fenceAutoClose.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Fence lifecycle (#91, interaction-spec §9.5, §12.4–.6). - * - * An unclosed fence runs to the end of the document per CommonMark, so the - * moment ``` is typed everything below renders as one giant code block — - * grammar-correct, wrong WYSIWYG. The behaviors here keep blocks bounded, - * enterable, and escapable: - * - * - TYPE-TIME close (§12.4): the keystroke completing a fence opener at a - * line's CONTENT start — top level, inside a list item, inside a quote - * (positions from `lineStructure`, not a line regex) — inserts an empty - * content line plus the matching closer at the same content column. The - * caret STAYS on the opener, so typing continues the language tag - * (```mermaid — the universal fence idiom, rendered live as the visible - * pill) and Enter steps onto the content line (§12.5). Quote prefixes are - * carried onto the inserted lines; list prefixes become continuation - * indent. - * - ENTER close: Enter at the end of an unclosed opener line (e.g. pasted) - * closes it with the caret inside. - * - ENTER step-in (§12.5): Enter on a CLOSED block's opener whose first - * content line is empty moves the caret onto that line instead of - * inserting another. - * - ENTER exit (§12.6): Enter on the block's empty last content line - * removes that line and moves the caret below the closing fence, - * creating the line when the block ends the document. - * - * Typing on a CLOSED fence's own rows re-sites into content (§12.7) — see - * resiteFenceLineTyping — which also closes the old gap where lengthening - * the opener in place re-opened the block. - */ - -import { EditorSelection, EditorState, Prec, type Transaction } from "@codemirror/state"; -import { keymap, type Command } from "@codemirror/view"; - -import { lineStructure } from "../core/lineStructure"; -import { treeAt } from "../core/treeAt"; - -/** The FencedCode syntax node, as a structural stand-in (`@lezer/common` stays - * a transitive dep). Exported because `fenceAt` returns it. */ -export type FenceNode = { - readonly name: string; - readonly from: number; - readonly to: number; - readonly parent: FenceNode | null; - getChildren(type: string): readonly FenceNode[]; -}; - -/** Continuation prefix for a line inserted inside the same containers as - * `line` up to `contentStart`: quote marks carry over, everything else - * becomes plain indent. */ -function containerPrefix(text: string): string { - return [...text].map((c) => (c === ">" ? ">" : " ")).join(""); -} - -/** §12.7 — typing on a CLOSED fence's own rows can never edit the fence. - * On the closing line, trailing text stops it closing (CommonMark allows no - * info string there) and the block re-opens, swallowing everything below. - * On the opener, typing extends the language tag — which users hit when they - * click the block's first gray row meaning to type CODE. Both re-site: the - * closer row onto a fresh content line before the closer; the opener row to - * the start of the first content line. (An UNCLOSED opener still types in - * place — that's the language flow for a pasted fence.) Editing an existing - * language tag moves to RAW mode, spec-noted. Returns the replacement spec, - * or null when the insertion is elsewhere. */ -function resiteFenceLineTyping( - tr: Transaction, - from: number, - text: string, -): { changes: { from: number; insert: string }; selection: ReturnType; userEvent: string } | null { - const state = tr.startState; - let node = fenceAt(state, from); - if (!node) { - // Column 0 of the opener row sits ON the fence node's from-boundary, - // where side -1 resolves to the sibling before the block. Probe from the - // line's end instead; only accept a fence whose opener IS this line. - const line = state.doc.lineAt(from); - if (from !== line.from || line.to === line.from) return null; - const probed = fenceAt(state, line.to); - if (!probed || state.doc.lineAt(probed.from).from !== line.from) return null; - node = probed; - } - const marks = node.getChildren("CodeMark"); - if (marks.length < 2) return null; - const openerLine = state.doc.lineAt(node.from); - const closer = marks[marks.length - 1]; - const closerLine = state.doc.lineAt(closer.from); - if (from >= openerLine.from && from <= openerLine.to) { - // Fresh-block language flow (§12.4): while the block holds no code yet, - // typing after the opening marks extends the info string IN PLACE — - // ```mermaid⏎ — with the visible pill as live feedback. A fence character - // is excluded (a 4th backtick must not grow the marks), and a block WITH - // content keeps the clicked-the-gray-row-means-code re-site below. - const bodyIsEmpty = - closerLine.number - openerLine.number < 2 || - state.doc - .sliceString(openerLine.to, closerLine.from) - .split("\n") - .every((lineText) => /^[>\s]*$/.test(lineText)); - if ( - bodyIsEmpty && - from >= marks[0].to && - from <= openerLine.to && - text !== "`" && - text !== "~" - ) { - return null; - } - // No content line exists in a bare ```/``` pair post-§12.4, but a pasted - // block may lack one — fall back to a fresh line after the opener. - if (openerLine.to + 1 <= closerLine.from - 1) { - const contentStart = openerLine.to + 1; - return { - changes: { from: contentStart, insert: text }, - selection: EditorSelection.cursor(contentStart + text.length), - userEvent: "input.type", - }; - } - const prefix = containerPrefix(state.doc.sliceString(openerLine.from, node.from)); - const insert = `${state.lineBreak}${prefix}${text}`; - return { - changes: { from: openerLine.to, insert }, - selection: EditorSelection.cursor(openerLine.to + insert.length), - userEvent: "input.type", - }; - } - if (from < closerLine.from || from > closerLine.to) return null; - const prefix = containerPrefix(state.doc.sliceString(closerLine.from, closer.from)); - const insert = `${state.lineBreak}${prefix}${text}`; - const at = closerLine.from - 1; - return { - changes: { from: at, insert }, - selection: EditorSelection.cursor(at + insert.length), - userEvent: "input.type", - }; -} - -/** The keystroke that completes a ```/~~~ opener at a line's content start - * closes the fence below, before the unclosed state can swallow the rest of - * the document; a keystroke on a closed fence's closing line re-sites onto a - * fresh content line (§12.7). */ -export const fenceTypeAutoClose = EditorState.transactionFilter.of((tr: Transaction) => { - if (!tr.docChanged || !tr.isUserEvent("input.type") || tr.isUserEvent("input.type.compose")) { - return tr; - } - let single: { from: number; ch: string } | null = null; - let eligible = true; - tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { - const ch = inserted.toString(); - if (single || fromA !== toA || ch.includes("\n")) eligible = false; - else single = { from: fromA, ch }; - }); - if (!eligible || !single) return tr; - - const resited = resiteFenceLineTyping(tr, (single as { from: number }).from, (single as { ch: string }).ch); - if (resited) return [resited]; - - const { ch: typed } = single as { from: number; ch: string }; - if (typed !== "`" && typed !== "~") return tr; - const { from } = single as { from: number }; - - // The line must have been prose before the keystroke — a backtick typed - // inside an existing block is literal, and an opener line of a closed - // fence (typing a 4th backtick) must not stack another closer. - const oldLine = tr.startState.doc.lineAt(from); - const info = lineStructure(tr.startState, oldLine); - if (info.inCode) return tr; - - // The fence must occupy the line's whole CONTENT — everything after the - // block markers the grammar sees (list/task marker, quote marks). - const contentStart = info.list ? info.list.markTo : info.contentFrom; - const newLine = tr.newDoc.lineAt(from + 1); - const content = tr.newDoc.sliceString(contentStart, newLine.to); - if (!/^(`{3,}|~{3,})$/.test(content) || from + 1 !== newLine.to) return tr; - - // Continuation prefix for the inserted lines: quote marks carry over, - // everything else (list markers, indent) becomes plain indent so the new - // lines stay inside the same container at the fence's column. - const prefix = containerPrefix(tr.newDoc.sliceString(newLine.from, contentStart)); - const brk = tr.startState.lineBreak; - return [ - tr, - { - changes: { from: newLine.to, insert: `${brk}${prefix}${brk}${prefix}${content}` }, - // The caret STAYS at the end of the opener: typing continues the - // language tag (```mermaid), Enter steps into the body (§12.5). - selection: EditorSelection.cursor(newLine.to), - sequential: true, - }, - ]; -}); - -/** The FencedCode ancestor at `pos`, or null. - * - * Resolves from {@link treeAt}, not `syntaxTree`: a tree that stops short of - * `pos` reports it as bare `Document`, and the delete guards then merge a code - * block into the prose above it. */ -export function fenceAt(state: EditorState, pos: number): FenceNode | null { - let node = treeAt(state, pos).resolveInner(pos, -1) as unknown as FenceNode | null; - while (node && node.name !== "FencedCode") node = node.parent; - return node; -} - -export const fenceAutoClose: Command = (view) => { - const { state } = view; - const { main } = state.selection; - if (!main.empty) return false; - const line = state.doc.lineAt(main.head); - if (main.head !== line.to) return false; - - const node = fenceAt(state, main.head); - // Only on the fence's OPENING line. - if (!node || node.from < line.from) return false; - const marks = node.getChildren("CodeMark"); - const open = marks[0]; - if (!open) return false; - - if (marks.length >= 2) { - // §12.5 — closed block: step onto an existing empty first content line. - // "Empty" includes container prefixes — inside a blockquote that line is - // "> ", the same shape bodyIsEmpty accepts above. - if (line.to < state.doc.length) { - const next = state.doc.lineAt(line.to + 1); - const closerLine = state.doc.lineAt(marks[marks.length - 1].from); - if (next.number < closerLine.number && /^[>\s]*$/.test(next.text)) { - view.dispatch({ - selection: EditorSelection.cursor(next.to), - scrollIntoView: true, - userEvent: "select", - }); - return true; - } - } - return false; - } - - const fence = state.sliceDoc(open.from, open.to); - const indent = state.sliceDoc(line.from, open.from); - view.dispatch({ - changes: { from: line.to, insert: `${state.lineBreak}${state.lineBreak}${indent}${fence}` }, - selection: EditorSelection.cursor(line.to + state.lineBreak.length), - scrollIntoView: true, - userEvent: "input", - }); - return true; -}; - -/** Enter on the block's empty last content line exits below the fence. */ -export const fenceExitBlock: Command = (view) => { - const { state } = view; - const { main } = state.selection; - if (!main.empty) return false; - const line = state.doc.lineAt(main.head); - if (line.from !== line.to) return false; // only a fully empty line exits - - const node = fenceAt(state, main.head); - if (!node || node.from >= line.from) return false; - const marks = node.getChildren("CodeMark"); - if (marks.length < 2) return false; // unclosed: Enter just adds code lines - const closing = marks[marks.length - 1]; - // The empty line must sit directly above the closing fence line. - if (state.doc.lineAt(closing.from).number !== line.number + 1) return false; - - const docLen = state.doc.length; - const changes = [{ from: line.from - 1, to: line.to, insert: "" }] as { - from: number; - to?: number; - insert: string; - }[]; - if (node.to === docLen) changes.push({ from: docLen, insert: state.lineBreak }); - const removed = line.to - line.from + 1; - view.dispatch({ - changes, - // Just past the (shifted) closing fence's newline — the line below. - selection: EditorSelection.cursor(node.to - removed + 1), - scrollIntoView: true, - userEvent: "input", - }); - return true; -}; - -export const fenceAutoCloseKeymap = Prec.highest( - keymap.of([ - { key: "Enter", run: fenceAutoClose }, - { key: "Enter", run: fenceExitBlock }, - ]), -); diff --git a/packages/rich-editor/src/codemirror/code/fenceCaretGuard.test.ts b/packages/rich-editor/src/codemirror/code/fenceCaretGuard.test.ts deleted file mode 100644 index 4f4b7e8..0000000 --- a/packages/rich-editor/src/codemirror/code/fenceCaretGuard.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -// @vitest-environment jsdom -/** - * §12.9 — the caret never parks on a closed fence's marker rows. - * - * User report: the opener row accepted the caret, and the first keystroke - * visibly jumped to the second line (the §12.7 re-site). Instead, clicks on - * a marker row land on the nearest content edge and arrow motion crossing a - * marker row exits the block, so typing always happens where the caret is. - */ -import { afterEach, describe, expect, it } from "vitest"; -import { EditorSelection } from "@codemirror/state"; -import type { EditorView } from "@codemirror/view"; - -import { destroyEditors, makeEditor } from "../core/editorTestHarness"; -import { fenceCaretGuard } from "./fenceCaretGuard"; - -function place(view: EditorView, pos: number, userEvent = "select"): number { - view.dispatch({ selection: EditorSelection.cursor(pos), userEvent }); - return view.state.selection.main.head; -} - -describe("caret placement on fence marker rows (§12.9)", () => { - afterEach(destroyEditors); - - const doc = "alpha\n```js\ncode\nmore\n```\nomega"; - const openerStart = doc.indexOf("```js"); - const contentStart = doc.indexOf("code"); - const lastContentEnd = doc.indexOf("more") + "more".length; - const closerStart = doc.indexOf("\n```\n") + 1; - - it("a click on the opener row enters the first content line", () => { - const view = makeEditor(doc, 0, [fenceCaretGuard]); - expect(place(view, openerStart + 2, "select.pointer")).toBe(contentStart); - }); - - it("a click on the closer row lands at the last content line's end", () => { - const view = makeEditor(doc, 0, [fenceCaretGuard]); - expect(place(view, closerStart + 1, "select.pointer")).toBe(lastContentEnd); - }); - - it("forward motion onto the opener continues into content", () => { - const view = makeEditor(doc, "alpha".length, [fenceCaretGuard]); - expect(place(view, openerStart + 1)).toBe(contentStart); - }); - - it("backward motion onto the opener exits above the block", () => { - const view = makeEditor(doc, contentStart, [fenceCaretGuard]); - expect(place(view, openerStart + 2)).toBe("alpha".length); - }); - - it("forward motion onto the closer exits below the block", () => { - const view = makeEditor(doc, lastContentEnd, [fenceCaretGuard]); - expect(place(view, closerStart + 1)).toBe(doc.indexOf("omega")); - }); - - it("at a document-ending block, forward motion holds at the content edge", () => { - const endDoc = "```\ncode\n```"; - const view = makeEditor(endDoc, endDoc.indexOf("code") + 4, [fenceCaretGuard]); - expect(place(view, endDoc.length - 1)).toBe(endDoc.indexOf("code") + 4); - }); - - it("an unclosed opener keeps accepting the caret (language flow)", () => { - const view = makeEditor("```j\ntext below", 0, [fenceCaretGuard]); - expect(place(view, 4, "select.pointer")).toBe(4); - }); - - it("a block with no content line is left alone", () => { - const bare = "```\n```"; - const view = makeEditor(bare, 0, [fenceCaretGuard]); - expect(place(view, 2, "select.pointer")).toBe(2); - }); - - it("range selections across the block are untouched", () => { - const view = makeEditor(doc, 0, [fenceCaretGuard]); - view.dispatch({ - selection: EditorSelection.range(0, closerStart + 2), - userEvent: "select.pointer", - }); - expect(view.state.selection.main.to).toBe(closerStart + 2); - }); -}); - -describe("no-content blocks are exempt — the language-typing state (§12.9/§12.4)", () => { - afterEach(destroyEditors); - - it("arrowing within the opener row of an empty-body fence keeps the caret", () => { - const doc = "```mermiad\n\n```"; - const openerEnd = doc.indexOf("\n"); - const view = makeEditor(doc, openerEnd, [fenceCaretGuard]); - // A selection-only backward step (ArrowLeft toward the typo) must not be - // re-sited off the row. - expect(place(view, openerEnd - 1)).toBe(openerEnd - 1); - expect(place(view, openerEnd - 4)).toBe(openerEnd - 4); - }); - - it("the exemption covers quote-prefixed blank bodies too", () => { - const doc = "> ```js\n> \n> ```"; - const openerEnd = doc.indexOf("\n"); - const view = makeEditor(doc, openerEnd, [fenceCaretGuard]); - expect(place(view, openerEnd - 1)).toBe(openerEnd - 1); - }); - - it("a fence WITH content still evicts the caret from its opener row", () => { - const doc = "```js\ncode\n```"; - const view = makeEditor(doc, 0, [fenceCaretGuard]); - const contentStart = doc.indexOf("code"); - expect(place(view, 2, "select.pointer")).toBe(contentStart); - }); -}); - diff --git a/packages/rich-editor/src/codemirror/code/fenceCaretGuard.ts b/packages/rich-editor/src/codemirror/code/fenceCaretGuard.ts deleted file mode 100644 index fd141cd..0000000 --- a/packages/rich-editor/src/codemirror/code/fenceCaretGuard.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * §12.9 — a closed fence's marker rows never hold the caret. - * - * The opener/closer lines are block chrome: a caret parked there types - * somewhere else (the §12.7 re-site), which reads as a jump. Instead the - * caret is re-placed the moment a selection lands on a marker row: - * - * - pointer clicks: nearest content edge (opener → first content line's - * start, closer → last content line's end) - * - forward motion (Down/Right): through the row into the block, or out - * below it; holds at the content edge when nothing follows the block - * - backward motion (Up/Left): out above the block, or back to content - * - * A fence currently rendered as a BLOCK WIDGET (a mermaid diagram) inverts - * the rule: moving the caret INTO its content would reveal the source — so a - * click landing beside the widget flipped the diagram to code. Those fences - * snap the caret to the boundary OUTSIDE the block instead; entering one is - * a deliberate act (double-click / Edit chip / click-to-edit). - * - * Unclosed fences keep the caret — typing a language on a pasted opener - * needs it — and blocks with no content line are left alone (§12.7 re-sites - * any typing there safely). Range selections and multi-cursor pass through. - */ - -import { EditorSelection, EditorState, type Transaction } from "@codemirror/state"; - -import { fenceAt } from "./fenceAutoClose"; -import { mermaidField } from "../mermaid/mermaidPlugin"; - -/** Is this fence currently replaced by a rendered block widget? */ -function widgetCovered(state: EditorState, from: number, to: number): boolean { - const field = state.field(mermaidField, false); - if (!field) return false; - let covered = false; - field.decorations.between(from, to, () => { - covered = true; - return false; - }); - return covered; -} - -export const fenceCaretGuard = EditorState.transactionFilter.of((tr: Transaction) => { - if (tr.docChanged || !tr.selection) return tr; - if (tr.selection.ranges.length > 1) return tr; - const sel = tr.selection.main; - if (!sel.empty) return tr; - - const state = tr.startState; - const line = state.doc.lineAt(sel.head); - if (line.to === line.from) return tr; - const node = fenceAt(state, line.to); - if (!node) return tr; - const marks = node.getChildren("CodeMark"); - if (marks.length < 2) return tr; - - const openerLine = state.doc.lineAt(node.from); - const closerLine = state.doc.lineAt(marks[marks.length - 1].from); - if (closerLine.number - openerLine.number < 2) return tr; - const onOpener = line.number === openerLine.number; - if (!onOpener && line.number !== closerLine.number) return tr; - - const covered = widgetCovered(state, node.from, node.to); - // §12.9 exempts no-content blocks, and a body of only blank (or - // quote-prefixed blank) lines is no content — the shape type-time - // auto-close leaves while the language is still being typed on the - // opener, where arrowing over a typo must not throw the caret out. - // Widget-rendered fences keep the outside-snap: their opener is hidden - // chrome regardless of body. - if (!covered) { - let bodyEmpty = true; - for (let n = openerLine.number + 1; n < closerLine.number; n += 1) { - if (!/^[>\s]*$/.test(state.doc.line(n).text)) { - bodyEmpty = false; - break; - } - } - if (bodyEmpty) return tr; - } - - const oldHead = state.selection.main.head; - const pointer = tr.isUserEvent("select.pointer"); - const backward = !pointer && sel.head < oldHead; - - let target: number; - if (covered) { - target = onOpener ? openerLine.from : closerLine.to; - } else { - const contentStart = openerLine.to + 1; - const lastContentEnd = closerLine.from - 1; - if (onOpener) { - target = backward && openerLine.from > 0 ? openerLine.from - 1 : contentStart; - } else if (pointer || backward) { - target = lastContentEnd; - } else { - target = closerLine.to < state.doc.length ? closerLine.to + 1 : lastContentEnd; - } - } - if (target === sel.head) return tr; - return [tr, { selection: EditorSelection.cursor(target), sequential: true }]; -}); diff --git a/packages/rich-editor/src/codemirror/code/fenceDeleteGuards.test.ts b/packages/rich-editor/src/codemirror/code/fenceDeleteGuards.test.ts deleted file mode 100644 index 024ba09..0000000 --- a/packages/rich-editor/src/codemirror/code/fenceDeleteGuards.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -// @vitest-environment jsdom -// interaction-spec §12.1–.3 — fence lines are structure, not text. -import { afterEach, describe, expect, it } from "vitest"; - -import { visibleBackspace, visibleDeleteForward } from "../interaction/deleteNormalizer"; -import { destroyEditors, makeEditor, text } from "../core/editorTestHarness"; - -describe("fence delete walls (§12.1)", () => { - afterEach(destroyEditors); - - it("backspace at the first content line start is a no-op", () => { - const doc = "above\n```\ncode\n```"; - const view = makeEditor(doc, doc.indexOf("code")); - expect(visibleBackspace(view)).toBe(true); - expect(text(view)).toBe(doc); - }); - - it("forward-delete at the last content line end is a no-op", () => { - const doc = "```\ncode\n```\nafter"; - const view = makeEditor(doc, doc.indexOf("code") + 4); - expect(visibleDeleteForward(view)).toBe(true); - expect(text(view)).toBe(doc); - }); - - it("interior code lines join plainly — no list-prefix eating", () => { - // "- looks like a bullet" inside code is code; the join must remove just - // the newline, never a pseudo list marker. - const doc = "```\nfirst\n- second\n```"; - const view = makeEditor(doc, doc.indexOf("- second")); - visibleBackspace(view); - expect(text(view)).toBe("```\nfirst- second\n```"); - }); - - it("backspacing into an effectively empty block deletes it whole", () => { - const doc = "above\n```\n\n```"; - const view = makeEditor(doc, doc.indexOf("\n\n```") + 1); // caret on the empty content line - visibleBackspace(view); - // A trailing block takes its preceding newline with it — no dangling - // empty line; the caret rests at the end of the text above. - expect(text(view)).toBe("above"); - expect(view.state.selection.main.head).toBe("above".length); - }); -}); - -describe("fence two-step approach (§12.2)", () => { - afterEach(destroyEditors); - - it("backspace after the block parks at the content end, no edit", () => { - const doc = "```\ncode\n```\nafter"; - const view = makeEditor(doc, doc.indexOf("after")); - expect(visibleBackspace(view)).toBe(true); - expect(text(view)).toBe(doc); - expect(view.state.selection.main.head).toBe(doc.indexOf("code") + 4); - }); - - it("a second backspace (now inside) deletes code, not the fence", () => { - const doc = "```\ncode\n```\nafter"; - const view = makeEditor(doc, doc.indexOf("after")); - visibleBackspace(view); // park - visibleBackspace(view); // delete 'e' - expect(text(view)).toBe("```\ncod\n```\nafter"); - }); - - it("forward-delete before the block parks at the content start", () => { - const doc = "before\n```\ncode\n```"; - const view = makeEditor(doc, "before".length); - expect(visibleDeleteForward(view)).toBe(true); - expect(text(view)).toBe(doc); - expect(view.state.selection.main.head).toBe(doc.indexOf("code")); - }); - - it("an empty block approached from below is deleted whole", () => { - const doc = "```\n\n```\nafter"; - const view = makeEditor(doc, doc.indexOf("after")); - visibleBackspace(view); - expect(text(view)).toBe("after"); - }); -}); - -describe("fence line above (§12.3)", () => { - afterEach(destroyEditors); - - it("backspace on an empty line above a block removes that line", () => { - const doc = "above\n\n```\ncode\n```"; - const view = makeEditor(doc, doc.indexOf("\n```")); - visibleBackspace(view); - expect(text(view)).toBe("above\n```\ncode\n```"); - }); -}); - -describe("fences below the fold (§12.1)", () => { - afterEach(destroyEditors); - - /** A document whose code block sits past the parsed viewport. */ - function longDocWithFenceAtEnd() { - const filler = Array.from({ length: 400 }, (_, i) => `paragraph line ${i}`).join("\n\n"); - return `${filler}\n\n\`\`\`\ncode\n\`\`\``; - } - - it("walls the same at the end of a long document as at the top of a short one", () => { - // `syntaxTree` only returns what the viewport has driven the parser - // through — about 3,000 characters here, against a document of 8,000 — so - // asking it about a fence further down answered "there is no fence" and - // backspace merged the block into the prose above. - // - // The same gap is what made these tests flaky: jsdom reports a viewport of - // a few hundred characters whatever the document holds, so whether the - // parse reached the caret came down to timing under load. - const doc = longDocWithFenceAtEnd(); - const view = makeEditor(doc, doc.lastIndexOf("code")); - - expect(visibleBackspace(view)).toBe(true); - expect(text(view)).toBe(doc); - }); - - it("still deletes inside that block rather than walling everything", () => { - // The wall must not become "backspace does nothing down here". - const doc = longDocWithFenceAtEnd(); - const view = makeEditor(doc, doc.lastIndexOf("code") + 4); - - visibleBackspace(view); - expect(text(view)).toBe(doc.replace(/code$/m, "cod")); - }); -}); - diff --git a/packages/rich-editor/src/codemirror/code/fenceDeleteGuards.ts b/packages/rich-editor/src/codemirror/code/fenceDeleteGuards.ts deleted file mode 100644 index bb1c250..0000000 --- a/packages/rich-editor/src/codemirror/code/fenceDeleteGuards.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Fence walls for deletion (interaction-spec §12.1–.3). - * - * Fence lines are structure, not text: a character-level join that merges a - * fence line with its neighbor corrupts the pair — content becomes the - * opener's invisible info string, or the closer gains trailing text and - * stops closing, re-pairing the opener with a later fence and swallowing - * unrelated content. The delete normalizer consults these guards whenever a - * Backspace/Delete would cross a line boundary; they answer with the §12 - * behavior (wall, park, plain code-line join, or whole-block deletion) and - * report whether they handled the press. - */ - -import { EditorSelection, type EditorState } from "@codemirror/state"; -import type { EditorView } from "@codemirror/view"; - -import { fenceAt } from "./fenceAutoClose"; - -interface BlockInfo { - openerLine: { number: number; to: number }; - closerLine: { number: number; from: number } | null; - /** First/last content positions (line-granular). */ - contentFrom: number; - contentTo: number; - contentEmpty: boolean; -} - -type FenceNode = NonNullable>; - -function blockInfo(state: EditorState, node: FenceNode): BlockInfo { - const marks = node.getChildren("CodeMark"); - const openerLine = state.doc.lineAt(node.from); - const closerMark = marks.length >= 2 ? marks[marks.length - 1] : null; - const closerLine = closerMark ? state.doc.lineAt(closerMark.from) : null; - const contentFrom = Math.min(openerLine.to + 1, node.to); - const contentTo = closerLine ? Math.max(closerLine.from - 1, contentFrom) : node.to; - const contentEmpty = - !closerLine || - contentFrom >= closerLine.from || - state.doc.sliceString(contentFrom, contentTo).trim() === ""; - return { openerLine, closerLine, contentFrom, contentTo, contentEmpty }; -} - -function park(view: EditorView, pos: number): true { - view.dispatch({ - selection: EditorSelection.cursor(pos), - scrollIntoView: true, - userEvent: "select", - }); - return true; -} - -function deleteWholeBlock(view: EditorView, node: FenceNode, userEvent: string): true { - const docLen = view.state.doc.length; - // Take one bounding newline with the block so no empty line is left behind. - const to = node.to < docLen ? node.to + 1 : node.to; - const from = node.to >= docLen && node.from > 0 ? node.from - 1 : node.from; - view.dispatch({ - changes: { from, to, insert: "" }, - selection: EditorSelection.cursor(from), - scrollIntoView: true, - userEvent, - }); - return true; -} - -function joinNewline(view: EditorView, at: number, userEvent: string): true { - view.dispatch({ - changes: { from: at, to: at + 1, insert: "" }, - selection: EditorSelection.cursor(at), - scrollIntoView: true, - userEvent, - }); - return true; -} - -/** Backspace whose deletion would cross upward out of `line`. True when the - * press was handled (edit, park, or deliberate wall no-op). */ -export function fenceBackspaceGuard(view: EditorView, pos: number): boolean { - const { state } = view; - const line = state.doc.lineAt(pos); - if (line.from === 0) return false; - const prevLine = state.doc.lineAt(line.from - 1); - - const inside = fenceAt(state, pos); - if (inside) { - const info = blockInfo(state, inside); - if (line.number === info.openerLine.number) { - // Caret on the opener line: joining it onto the prose above dissolves - // the fence. Move up instead. - return park(view, prevLine.to); - } - if (info.closerLine && line.number === info.closerLine.number) { - // Caret on the closer line: pulling it up corrupts the pair. - return park(view, prevLine.to); - } - if (prevLine.number === info.openerLine.number) { - // §12.1 — first content line: the wall is solid; an effectively empty - // block collapses whole instead. - if (info.contentEmpty) return deleteWholeBlock(view, inside, "delete.backward"); - return true; - } - // Interior code lines join plainly — never through blockPrefixLength, - // which would eat code that merely looks like a list marker. - return joinNewline(view, line.from - 1, "delete.backward"); - } - - const prevFence = fenceAt(state, prevLine.to); - if (prevFence) { - const info = blockInfo(state, prevFence); - if (info.closerLine && prevLine.number === info.closerLine.number) { - // §12.2 — approaching from below: park at the content end (or collapse - // an empty block whole). - if (info.contentEmpty) return deleteWholeBlock(view, prevFence, "delete.backward"); - return park(view, info.contentTo); - } - } - return false; -} - -/** Forward-delete whose deletion would cross downward out of `line`. */ -export function fenceDeleteGuard(view: EditorView, pos: number): boolean { - const { state } = view; - const line = state.doc.lineAt(pos); - if (line.to >= state.doc.length) return false; - const nextLine = state.doc.lineAt(line.to + 1); - - const inside = fenceAt(state, pos); - if (inside) { - const info = blockInfo(state, inside); - if (line.number === info.openerLine.number) { - // Caret on the opener: pulling content up makes it the invisible info - // string. Step into the block instead. - return park(view, nextLine.from); - } - if (info.closerLine && line.number === info.closerLine.number) { - return park(view, nextLine.from); - } - if (info.closerLine && nextLine.number === info.closerLine.number) { - // §12.1 mirror — last content line. - if (info.contentEmpty) return deleteWholeBlock(view, inside, "delete.forward"); - return true; - } - return joinNewline(view, line.to, "delete.forward"); - } - - const nextFence = fenceAt(state, Math.min(nextLine.to, state.doc.length)); - if (nextFence && state.doc.lineAt(nextFence.from).number === nextLine.number) { - const info = blockInfo(state, nextFence); - // §12.2 mirror — approaching from above: park at the content start. - if (info.contentEmpty) return deleteWholeBlock(view, nextFence, "delete.forward"); - return park(view, info.contentFrom); - } - return false; -} diff --git a/packages/rich-editor/src/codemirror/code/fenceLineTyping.test.ts b/packages/rich-editor/src/codemirror/code/fenceLineTyping.test.ts deleted file mode 100644 index 8bfe5d5..0000000 --- a/packages/rich-editor/src/codemirror/code/fenceLineTyping.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -// @vitest-environment jsdom -/** - * interaction-spec §12.7 (TDD, red-first) — typing on a fence's CLOSING line - * can never edit the fence: a closing fence with trailing text stops closing - * (CommonMark allows no info string there), the block re-opens and swallows - * everything below. The intent of typing on the block's last gray row is - * "code at the end of the block" — so the keystroke lands on a fresh content - * line before the closer. - */ -import { afterEach, describe, expect, it } from "vitest"; -import { syntaxTree } from "@codemirror/language"; -import type { EditorView } from "@codemirror/view"; - -import { destroyEditors, makeEditor, text } from "../core/editorTestHarness"; -import { fenceTypeAutoClose } from "./fenceAutoClose"; - -function typeChar(view: EditorView, ch: string): void { - const head = view.state.selection.main.head; - view.dispatch({ - changes: { from: head, insert: ch }, - selection: { anchor: head + ch.length }, - userEvent: "input.type", - }); -} - -/** Number of FencedCode nodes and the end of the first one. */ -function fenceShape(view: EditorView): { count: number; firstEnd: number } { - let count = 0; - let firstEnd = -1; - syntaxTree(view.state).iterate({ - enter: (n) => { - if (n.name === "FencedCode") { - count += 1; - if (firstEnd < 0) firstEnd = n.to; - } - }, - }); - return { count, firstEnd }; -} - -describe("typing on the closing fence line (§12.7)", () => { - afterEach(destroyEditors); - - it("a char typed at the closer's end lands on a new last content line", () => { - const doc = "```\ncode\n```\n\nCalibrate"; - const view = makeEditor(doc, doc.indexOf("\n\nCalibrate"), [fenceTypeAutoClose]); - typeChar(view, "l"); - expect(text(view)).toBe("```\ncode\nl\n```\n\nCalibrate"); - // The block still closes where it should — nothing below is swallowed. - const shape = fenceShape(view); - expect(shape.count).toBe(1); - expect(view.state.sliceDoc(shape.firstEnd)).toBe("\n\nCalibrate"); - }); - - it("a char typed at the closer's start lands the same way", () => { - const doc = "```\ncode\n```"; - const v = makeEditor(doc, doc.lastIndexOf("```"), [fenceTypeAutoClose]); - typeChar(v, "x"); - expect(text(v)).toBe("```\ncode\nx\n```"); - }); - - it("caret follows onto the new content line", () => { - const doc = "```\ncode\n```"; - const view = makeEditor(doc, doc.length, [fenceTypeAutoClose]); - typeChar(view, "z"); - const head = view.state.selection.main.head; - expect(view.state.doc.lineAt(head).text).toBe("z"); - expect(head).toBe(view.state.doc.lineAt(head).to); - }); - - it("quote-nested closer keeps the quote prefix on the new line", () => { - const doc = "> ```\n> code\n> ```"; - const view = makeEditor(doc, doc.length, [fenceTypeAutoClose]); - typeChar(view, "q"); - expect(text(view)).toBe("> ```\n> code\n> q\n> ```"); - }); - - it("typing on a closed opener row lands at the first content line (§12.7)", () => { - const doc = "```\ncode\n```"; - const view = makeEditor(doc, 3, [fenceTypeAutoClose]); - typeChar(view, "j"); - expect(text(view)).toBe("```\njcode\n```"); - }); -}); diff --git a/packages/rich-editor/src/codemirror/code/fenceOpenerTyping.test.ts b/packages/rich-editor/src/codemirror/code/fenceOpenerTyping.test.ts deleted file mode 100644 index a3d03f6..0000000 --- a/packages/rich-editor/src/codemirror/code/fenceOpenerTyping.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -// @vitest-environment jsdom -/** - * §12.7 extension + §12.8 (TDD, red-first). - * - * Opener row: users keep clicking a block's first gray row to type CODE — - * three separate reports. On a CLOSED fence, characters typed on the opener - * line land on the first content line instead of silently extending the - * (differently-styled) language tag. The pill still renders existing tags; - * editing one moves to RAW mode. - * - * Tab: inside a code block, Tab must indent (and Shift-Tab dedent) — with no - * binding, the browser's focus navigation stole the key and the caret - * "jumped out of the editor to the rich/raw button". - */ -import { afterEach, describe, expect, it } from "vitest"; -import type { EditorView } from "@codemirror/view"; - -import { destroyEditors, makeEditor, text } from "../core/editorTestHarness"; -import { fenceTypeAutoClose } from "./fenceAutoClose"; -import { fenceTabIndent, fenceTabDedent } from "./fenceTabIndent"; - -function typeChar(view: EditorView, ch: string): void { - const head = view.state.selection.main.head; - view.dispatch({ - changes: { from: head, insert: ch }, - selection: { anchor: head + ch.length }, - userEvent: "input.type", - }); -} - -describe("typing on a closed fence's opener row (§12.7)", () => { - afterEach(destroyEditors); - - it("a char at the opener's end lands at the start of the first content line", () => { - const doc = "```js\ncode\n```"; - const view = makeEditor(doc, "```js".length, [fenceTypeAutoClose]); - typeChar(view, "x"); - expect(text(view)).toBe("```js\nxcode\n```"); - const head = view.state.selection.main.head; - expect(head).toBe("```js\nx".length); - }); - - it("a char at the opener line's very START re-sites too (node-boundary click)", () => { - // Clicking the block's first gray row often lands the caret at column 0 — - // exactly on the fence node's from-boundary. Reported live: "kfj" typed - // there landed BEFORE the backticks and un-fenced the whole block. - const doc = "```\nfjfjf\n```"; - const view = makeEditor(doc, 0, [fenceTypeAutoClose]); - typeChar(view, "k"); - typeChar(view, "f"); - typeChar(view, "j"); - expect(text(view)).toBe("```\nkfjfjfjf\n```"); - }); - - it("opener-start typing works when the fence follows other content", () => { - const doc = "alpha\n```\ncode\n```"; - const view = makeEditor(doc, "alpha\n".length, [fenceTypeAutoClose]); - typeChar(view, "x"); - expect(text(view)).toBe("alpha\n```\nxcode\n```"); - }); - - it("an unclosed opener still accepts language typing (paste flow)", () => { - const doc = "```j\ncode below"; - const view = makeEditor(doc, "```j".length, [fenceTypeAutoClose]); - typeChar(view, "s"); - expect(text(view)).toBe("```js\ncode below"); - }); -}); - -describe("Tab inside a code block indents (§12.8)", () => { - afterEach(destroyEditors); - - it("Tab inserts an indent unit at the caret", () => { - const doc = "```\ncode\n```"; - const view = makeEditor(doc, doc.indexOf("code")); - expect(fenceTabIndent(view)).toBe(true); - expect(text(view)).toBe("```\n code\n```"); - }); - - it("Shift-Tab removes leading indent from the line", () => { - const doc = "```\n code\n```"; - const view = makeEditor(doc, doc.indexOf("code") + 2); - expect(fenceTabDedent(view)).toBe(true); - expect(text(view)).toBe("```\ncode\n```"); - }); - - it("outside code, both decline so lists and focus keep their behavior", () => { - const doc = "plain text"; - const view = makeEditor(doc, 2); - expect(fenceTabIndent(view)).toBe(false); - expect(fenceTabDedent(view)).toBe(false); - }); -}); diff --git a/packages/rich-editor/src/codemirror/code/fenceTabIndent.ts b/packages/rich-editor/src/codemirror/code/fenceTabIndent.ts deleted file mode 100644 index fd20d15..0000000 --- a/packages/rich-editor/src/codemirror/code/fenceTabIndent.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Tab inside a fenced code block indents; Shift-Tab dedents (§12.8). - * - * Without a binding, the browser's default Tab handling moves FOCUS — the - * caret "jumped out of the editor to the rich/raw button" mid-code. Both - * commands decline outside code so list indentation (`listIndent.ts`) and - * the accessibility default keep their behavior everywhere else. - */ - -import { EditorSelection, Prec } from "@codemirror/state"; -import { type Command, keymap } from "@codemirror/view"; - -import { fenceAt } from "./fenceAutoClose"; - -const INDENT = " "; - -/** Caret strictly inside a fence (not on the opener/closer lines). */ -function inFenceContent(view: Parameters[0]): boolean { - const { state } = view; - const head = state.selection.main.head; - const node = fenceAt(state, head); - if (!node) return false; - const line = state.doc.lineAt(head); - const marks = node.getChildren("CodeMark"); - if (line.from <= node.from) return false; - const closer = marks.length >= 2 ? marks[marks.length - 1] : null; - return !closer || line.to < state.doc.lineAt(closer.from).from; -} - -export const fenceTabIndent: Command = (view) => { - if (!inFenceContent(view)) return false; - const head = view.state.selection.main.head; - view.dispatch({ - changes: { from: head, insert: INDENT }, - selection: EditorSelection.cursor(head + INDENT.length), - userEvent: "input.type", - }); - return true; -}; - -export const fenceTabDedent: Command = (view) => { - if (!inFenceContent(view)) return false; - const { state } = view; - const line = state.doc.lineAt(state.selection.main.head); - const leading = line.text.match(/^ {1,2}/)?.[0].length ?? 0; - if (leading === 0) return true; - view.dispatch({ - changes: { from: line.from, to: line.from + leading, insert: "" }, - userEvent: "delete.dedent", - }); - return true; -}; - -export const fenceTabKeymap = Prec.high( - keymap.of([ - { key: "Tab", run: fenceTabIndent }, - { key: "Shift-Tab", run: fenceTabDedent }, - ]), -); diff --git a/packages/rich-editor/src/codemirror/code/highlightFence.test.ts b/packages/rich-editor/src/codemirror/code/highlightFence.test.ts deleted file mode 100644 index c8fa086..0000000 --- a/packages/rich-editor/src/codemirror/code/highlightFence.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -// @vitest-environment jsdom -import { describe, expect, it } from "vitest"; - -import { languages } from "@codemirror/language-data"; - -import { highlightFenceSpans } from "./highlightFence"; - -describe("highlightFenceSpans", () => { - it("returns inline-styled spans once the grammar is loaded (the sync path)", async () => { - await languages.find((l) => l.name === "JavaScript")!.load(); - - const spans = highlightFenceSpans("js", "const x = 'hi' // note"); - - expect(spans).not.toBeNull(); - const keyword = spans!.find((span) => span.text === "const"); - expect(keyword?.style).toContain("color:#a626a4"); - const string = spans!.find((span) => span.text === "'hi'"); - expect(string?.style).toContain("color:#50a14f"); - const comment = spans!.find((span) => span.text === "// note"); - expect(comment?.style).toContain("font-style:italic"); - // Round-trip: concatenated spans reproduce the source exactly. - expect(spans!.map((s) => s.text).join("")).toBe("const x = 'hi' // note"); - }); - - it("returns null for an unknown language", () => { - expect(highlightFenceSpans("nosuchlang", "x")).toBeNull(); - }); - - it("returns null (and kicks the load) for a not-yet-loaded grammar", () => { - // Erlang is obscure enough that nothing else in the suite loads it. - const cold = languages.find((l) => l.name === "Erlang")!; - expect(cold.support).toBeUndefined(); - expect(highlightFenceSpans("erlang", "x")).toBeNull(); - }); -}); diff --git a/packages/rich-editor/src/codemirror/code/highlightFence.ts b/packages/rich-editor/src/codemirror/code/highlightFence.ts deleted file mode 100644 index f32ec79..0000000 --- a/packages/rich-editor/src/codemirror/code/highlightFence.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Synchronous fence highlighting for the clipboard (#149). - * - * A copy event writes `text/html` synchronously, so highlighting must not - * await anything. The trick: the grammars are the SAME `@codemirror/language-data` - * singletons the editor's nested fence parse loads — any fence visible in the - * editor has its grammar warm, so highlighting the copied code is a sync parse. - * A cold grammar (copy from a doc whose fence never rendered) kicks the load - * and returns null: THIS copy ships plain, the next one is highlighted. - * - * Output is inline-styled spans (the palette from - * [codePalette](./codePalette.ts), same colors as the editor) — pasted HTML - * carries no stylesheet, so classes would be dead weight in Docs/Word. - */ - -import { LanguageDescription } from "@codemirror/language"; -import { languages } from "@codemirror/language-data"; -import { highlightCode, tagHighlighter } from "@lezer/highlight"; - -import { CODE_PALETTE } from "./codePalette"; - -export interface HighlightedSpan { - text: string; - /** Inline CSS for the span; absent for unstyled text (incl. line breaks). */ - style?: string; -} - -const styleByClass = new Map( - CODE_PALETTE.map((spec, index) => [ - `tok${index}`, - `color:${spec.color}${spec.fontStyle ? `;font-style:${spec.fontStyle}` : ""}`, - ]), -); - -// tagHighlighter implements lezer's tag-containment matching (a token tagged -// `function(variableName)` matches a spec on either tag) — hand-rolling that -// gets the precedence subtly wrong. -const paletteHighlighter = tagHighlighter( - CODE_PALETTE.map((spec, index) => ({ tag: spec.tag as never, class: `tok${index}` })), -); - -function styleFor(classes: string): string | undefined { - const styles = classes - .split(" ") - .map((cls) => styleByClass.get(cls)) - .filter(Boolean); - return styles.length > 0 ? styles.join(";") : undefined; -} - -/** Highlight `code` as `lang` into inline-styled spans, or null when no - * grammar matches or the grammar isn't loaded yet (the load is kicked so a - * later copy succeeds). Never throws — a parser hiccup falls back to null. */ -export function highlightFenceSpans(lang: string, code: string): HighlightedSpan[] | null { - const description = LanguageDescription.matchLanguageName(languages, lang, true); - if (!description) return null; - if (!description.support) { - void description.load().catch(() => {}); - return null; - } - try { - const parser = description.support.language.parser; - const spans: HighlightedSpan[] = []; - highlightCode( - code, - parser.parse(code), - paletteHighlighter, - (text, classes) => spans.push(classes ? { text, style: styleFor(classes) } : { text }), - () => spans.push({ text: "\n" }), - ); - return spans; - } catch { - return null; - } -} diff --git a/packages/rich-editor/src/codemirror/code/index.ts b/packages/rich-editor/src/codemirror/code/index.ts deleted file mode 100644 index 1b8a2b1..0000000 --- a/packages/rich-editor/src/codemirror/code/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Fenced code blocks: syntax colors, the opener-row language pill + chooser, - * fence typing/deletion/caret guards, Tab indentation, and the synchronous - * highlighter the clipboard uses. Internal: `codeLanguageMenu`, `codePalette`. - */ -export * from "./codeHighlight"; -export * from "./codeLangAffordance"; -export * from "./fenceAutoClose"; -export * from "./fenceCaretGuard"; -export * from "./fenceDeleteGuards"; -export * from "./fenceTabIndent"; -export * from "./highlightFence"; diff --git a/packages/rich-editor/src/codemirror/core/codeContext.test.ts b/packages/rich-editor/src/codemirror/core/codeContext.test.ts deleted file mode 100644 index e5b4efc..0000000 --- a/packages/rich-editor/src/codemirror/core/codeContext.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, describe, expect, it } from "vitest"; - -import { inCode, viewportTree } from "./codeContext"; -import { destroyEditors, makeEditor } from "./editorTestHarness"; - -describe("inCode — grammar-level code-context test", () => { - afterEach(destroyEditors); - - it("reports fenced, indented, and inline-code positions as code; prose as not", () => { - const doc = "prose start\n\n```js\nlet x = 1\n```\n\n indented\n\nmix `span` end"; - const view = makeEditor(doc); - const tree = viewportTree(view); - expect(inCode(tree, doc.indexOf("prose"))).toBe(false); - expect(inCode(tree, doc.indexOf("let"))).toBe(true); - expect(inCode(tree, doc.indexOf("indented"))).toBe(true); - expect(inCode(tree, doc.indexOf("span"))).toBe(true); - expect(inCode(tree, doc.indexOf("end"))).toBe(false); - }); - - it("sees a fence nested inside a container (list item)", () => { - const doc = "- item\n ```\n code here\n ```"; - const view = makeEditor(doc); - const tree = viewportTree(view); - expect(inCode(tree, doc.indexOf("item"))).toBe(false); - expect(inCode(tree, doc.indexOf("code here"))).toBe(true); - }); -}); diff --git a/packages/rich-editor/src/codemirror/core/codeContext.ts b/packages/rich-editor/src/codemirror/core/codeContext.ts deleted file mode 100644 index 4a0a816..0000000 --- a/packages/rich-editor/src/codemirror/core/codeContext.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Grammar-level code-context test for the regex-scanning inline plugins - * (wikilink, highlight, footnote, math). Lezer emits no nodes for those - * constructs, so the plugins scan raw text — but code is literal, and the - * scans must skip it, exactly as the export pre-pass does (`protected_regions` - * in `src-tauri/src/export/html.rs`, derived from comrak's parse). - * - * A plugin guards a match by its OPENER and CLOSER positions: either sitting - * in code disqualifies the match, while a code span strictly inside the body - * (`[[a `b` c]]`) does not — the same rule the export applies. - */ - -import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; -import type { EditorState } from "@codemirror/state"; -import type { EditorView } from "@codemirror/view"; - -type Tree = ReturnType; - -/** The slice of Lezer's SyntaxNode the ancestor walk touches. */ -type NodeLike = { readonly name: string; readonly parent: NodeLike | null }; - -const CODE_NODES = new Set(["FencedCode", "CodeBlock", "InlineCode"]); - -/** The tree parsed through the viewport — the painter's forced-parse bound - * (see `plugin.ts`), so a just-typed fence is already in the tree when a - * viewport-scanning plugin consults the guard. */ -export function viewportTree(view: EditorView): Tree { - return ensureSyntaxTree(view.state, view.viewport.to, 100) ?? syntaxTree(view.state); -} - -/** The tree parsed through the whole document, for doc-wide state-level scans - * (math). Falls back to the partial tree when the parse budget runs out — - * the guard then degrades to the unguarded behavior past the parse frontier. */ -export function docTree(state: EditorState): Tree { - return ensureSyntaxTree(state, state.doc.length, 20) ?? syntaxTree(state); -} - -/** Whether the character at `pos` is code — inside a fenced or indented code - * block or an inline code span. `resolveInner` descends into a fence's nested - * `codeLanguages` parse; the parent walk crosses back into the markdown tree, - * so the enclosing FencedCode still answers. */ -export function inCode(tree: Tree, pos: number): boolean { - let node = tree.resolveInner(pos, 1) as unknown as NodeLike | null; - for (; node; node = node.parent) { - if (CODE_NODES.has(node.name)) return true; - } - return false; -} diff --git a/packages/rich-editor/src/codemirror/core/editorTestHarness.ts b/packages/rich-editor/src/codemirror/core/editorTestHarness.ts deleted file mode 100644 index 316a3c9..0000000 --- a/packages/rich-editor/src/codemirror/core/editorTestHarness.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Test-only harness: a headless CodeMirror `EditorView` wired with the markdown - * language + the live-preview decoration plugin, for keystroke-level tests of - * the editor commands (cursor motion, delete, list continuation, formatting). - * - * CodeMirror runs under jsdom; each test FILE that uses this must declare - * `// @vitest-environment jsdom` at its top. The Lezer tree is built eagerly so - * the plugin's atomic/hidden ranges (which the commands consult) are present. - * - * Not shipped — nothing in the app imports it, so it tree-shakes out. - */ - -import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; -import { ensureSyntaxTree } from "@codemirror/language"; -import { EditorSelection, EditorState, type Extension } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; - -import { markdownDecorationsPlugin } from "./plugin"; -import { - composeExtensions, - footnoteExtension, - highlightExtension, - mathExtension, - mermaidExtension, - tableExtension, - wikilinkExtension, -} from "../extensions"; - -const live: EditorView[] = []; - -// The full rendering extension set the real editor composes — so a rendered- -// output test sees what the user sees (wikilinks, highlight, footnotes, math, -// tables), not just the base markdown decorations. -const FULL_EXTENSIONS = composeExtensions([ - wikilinkExtension, - highlightExtension, - footnoteExtension, - mathExtension, - mermaidExtension, - tableExtension(), -]).extensions; - -/** A headless editor over `doc` with the caret at `caret`. Extra extensions - * (e.g. a keymap under test) can be appended. Track-and-cleanup via - * {@link destroyEditors} in an `afterEach`. */ -export function makeEditor(doc: string, caret = 0, extra: Extension[] = []): EditorView { - const parent = document.createElement("div"); - document.body.appendChild(parent); - const state = EditorState.create({ - doc, - selection: EditorSelection.cursor(caret), - extensions: [markdown({ base: markdownLanguage }), markdownDecorationsPlugin, ...extra], - }); - // The commands under test read the plugin's atomic/hidden ranges, which are - // derived from the Lezer tree — so a tree that is not finished makes a guard - // silently not fire, and the test fails as a confusing content diff rather - // than as "the parse did not finish". `ensureSyntaxTree` reports that by - // returning null; ignoring it is how a parse timeout became a mystery. - if (ensureSyntaxTree(state, doc.length, 5000) === null) { - throw new Error( - `the markdown parse did not finish within 5s for a ${doc.length}-char document; ` + - "any assertion after this would be testing an unparsed editor", - ); - } - const view = new EditorView({ parent, state }); - // Again, on the view's own state. Creating the view starts CodeMirror's - // viewport-driven parse, and jsdom has no layout — so the viewport can be - // tiny and `syntaxTree(view.state)` returns a tree that stops short of the - // caret. A command asking "am I inside a fence?" then gets `null` and takes - // the plain-text path, which is a wrong answer rather than a slow one. - if (ensureSyntaxTree(view.state, doc.length, 5000) === null) { - throw new Error( - `the markdown parse did not finish within 5s for a ${doc.length}-char document; ` + - "any assertion after this would be testing an unparsed editor", - ); - } - live.push(view); - return view; -} - -/** Like {@link makeEditor} but with every rendering extension the real editor - * loads — for tests that assert the user-visible rendered output, not just the - * source. */ -export function makeFullEditor(doc: string, caret = 0, extra: Extension[] = []): EditorView { - return makeEditor(doc, caret, [...FULL_EXTENSIONS, ...extra]); -} - -/** Tear down every editor made since the last call. Call in `afterEach`. */ -export function destroyEditors(): void { - for (const view of live.splice(0)) { - view.destroy(); - view.dom.parentElement?.remove(); - } -} - -/** The current caret offset (head of the main selection). */ -export function caret(view: EditorView): number { - return view.state.selection.main.head; -} - -/** The current document text. */ -export function text(view: EditorView): string { - return view.state.doc.toString(); -} diff --git a/packages/rich-editor/src/codemirror/core/editorTheme.test.ts b/packages/rich-editor/src/codemirror/core/editorTheme.test.ts deleted file mode 100644 index 710e323..0000000 --- a/packages/rich-editor/src/codemirror/core/editorTheme.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, describe, expect, it } from "vitest"; - -import { destroyEditors, makeEditor } from "./editorTestHarness"; -import { editorBaseTheme } from "./editorTheme"; - -describe("editorBaseTheme", () => { - afterEach(destroyEditors); - - it("mounts as a valid theme extension and still renders the document", () => { - const view = makeEditor("# Heading", 0, [editorBaseTheme]); - expect(view.dom.querySelector(".cm-content")?.textContent).toContain("Heading"); - }); - - it("scopes its rules by adding a theme class to the editor wrapper", () => { - const plain = makeEditor("x", 0); - const themed = makeEditor("x", 0, [editorBaseTheme]); - // EditorView.theme injects a generated host class onto `.cm-editor`, - // so the themed wrapper carries one more class than the plain one. - expect(themed.dom.classList.length).toBeGreaterThan(plain.dom.classList.length); - }); -}); diff --git a/packages/rich-editor/src/codemirror/core/editorTheme.ts b/packages/rich-editor/src/codemirror/core/editorTheme.ts deleted file mode 100644 index 0e83422..0000000 --- a/packages/rich-editor/src/codemirror/core/editorTheme.ts +++ /dev/null @@ -1,534 +0,0 @@ -/** - * Editor theme. - * - * Lives in `EditorView.baseTheme` rather than in `global.scss` for - * three load-bearing reasons: - * - * 1. **Line-metric integrity.** CM6 measures line heights from the - * DOM through a `requestMeasure` cycle to compute click → byte - * and scroll → viewport mappings. Styles injected via - * `EditorView.theme` participate in that cycle at the right - * moment; styles from an external sheet can land *after* the - * first measurement, so clicks land on the wrong line until the - * next measure fires. (This was the click-drift the spike - * shipped — fixed by moving here.) - * 2. **Specificity that beats the base theme without `!important`.** - * CM6 ships its monospace-and-purple base theme via the same - * mechanism; layering `EditorView.theme` on top is the only - * collision-free pattern. - * 3. **No margin/padding on heading lines.** Margins between - * `.cm-line` siblings shift visual position without changing - * `offsetTop`, so the metric cache and the eye disagree — - * that's the canonical click-on-wrong-line bug. We change - * `font:` (which doesn't touch line-height), nothing else. - * - * Zettlr's `markdown-editor/theme/editor.ts` is the canonical - * reference for this pattern in the open-source ecosystem. - */ - -import { EditorView } from "@codemirror/view"; - -/** - * `EditorView.theme` (not `baseTheme`) — `baseTheme` is the - * low-priority slot meant for theme packages and gets out-specifity'd - * by any app-level CSS (Carbon's globals in our case). `theme` is the - * app-priority slot; our intent is to override CM6's defaults, so - * this is the right tier. - */ -export const editorBaseTheme = EditorView.theme({ - // CM6 paints a focus outline by default. The caret IS the focus - // indicator in Compose; the box would be visual noise. - "&.cm-focused": { - outline: "none", - }, - - // Body type. Override CM6's monospace default *here* (not in the - // external stylesheet) so the measurement cycle sees it. - ".cm-content": { - fontFamily: - "var(--cds-body-01-font-family, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif)", - fontSize: "1rem", - // Tight line-height: the caret height tracks the line box - // (line-height × font-size), so anything above 1.4 starts to - // feel disproportionate. 1.4 is the value Zettlr uses for the - // same reason. - lineHeight: "1.4", - color: "var(--cds-text-primary, #161616)", - maxWidth: "48rem", - margin: "0 auto", - }, - - // Scroller padding — kept here so it's part of the same theme - // bundle as the content metrics. - ".cm-scroller": { - padding: "1.5rem 3rem", - }, - - // Drawn selection (selectionLayer.ts) — ranges paint from logical state so - // the highlight survives the virtualized viewport. The widget tint sits - // ABOVE block widgets (their opaque backgrounds hide the below-layer band). - ".cm-selectionBackground": { - background: "var(--cds-highlight, #d0e2ff)", - }, - "&:not(.cm-focused) .cm-selectionBackground": { - background: "var(--cds-layer-accent-01, #e0e0e0)", - }, - ".cm-selectionWidgetTint": { - background: "rgba(15, 98, 254, 0.08)", - }, - - // Caret stroke. CM6 owns the height (it tracks the current line-box, - // so headings get a heading-sized caret automatically per spec 6.2). - // Width is spec's 1 px at the default root size, in rem so it follows the - // user's text-size preference; "thick caret" mode is a Phase-N follow-up. - ".cm-cursor, .cm-dropCursor": { - borderLeftWidth: "0.0625rem", - }, - - // Headings — `font:` shorthand changes size + weight *without* - // setting line-height (it inherits the content's 1.5). The vertical - // breathing room is `padding` not `margin` for the click-metric - // reason above — sibling margins drift, sibling padding doesn't. - ".cm-heading--1": { - fontWeight: "700", - fontSize: "2.0rem", - lineHeight: "1.3", - paddingTop: "0.6em", - paddingBottom: "0.2em", - }, - ".cm-heading--2": { - fontWeight: "700", - fontSize: "1.6rem", - lineHeight: "1.3", - paddingTop: "0.5em", - paddingBottom: "0.15em", - }, - ".cm-heading--3": { - fontWeight: "700", - fontSize: "1.3rem", - lineHeight: "1.3", - paddingTop: "0.4em", - paddingBottom: "0.1em", - }, - ".cm-heading--4": { fontWeight: "700", fontSize: "1.15rem", paddingTop: "0.3em" }, - ".cm-heading--5": { fontWeight: "700", fontSize: "1.05rem", paddingTop: "0.25em" }, - ".cm-heading--6": { - fontWeight: "700", - fontSize: "1.0rem", - color: "var(--cds-text-secondary, #525252)", - paddingTop: "0.2em", - }, - - // Inline / block styling. All sized by font + color only — never - // by margin/padding, never by line-height override. - ".cm-strong": { fontWeight: "700" }, - ".cm-emphasis": { fontStyle: "italic" }, - ".cm-strikethrough": { textDecoration: "line-through" }, - - // Code backgrounds are translucent, never opaque: the drawn selection - // (selectionLayer.ts) paints BELOW the content, so an opaque background here - // blanks the highlight on exactly these rows/chips while the prose around - // them tints. The alphas composite to the intended resting colors on the - // white canvas — #e0e0e0 for the inline chip, #f6f8fa (GitHub-style) for - // fence lines. - ".cm-inline-code": { - fontFamily: - "var(--cds-code-01-font-family, ui-monospace, \"SF Mono\", Menlo, monospace)", - fontSize: "0.92em", - padding: "0 0.25em", - background: "rgba(141, 141, 141, 0.27)", - borderRadius: "0.1875rem", - }, - - ".cm-fenced-code": { - fontFamily: - "var(--cds-code-01-font-family, ui-monospace, \"SF Mono\", Menlo, monospace)", - fontSize: "0.92em", - background: "rgba(30, 80, 130, 0.04)", - paddingLeft: "0.75em", - paddingBottom: "0", - }, - // Language tag on a fence's opener row — visible (never invisible typing) - // and rendered as a small pill so it reads as the block's chrome, not as a - // wrong-font first code line (§12.4/§12.7). - ".cm-code-info": { - color: "var(--cds-text-secondary, #6f6f6f)", - fontSize: "0.75em", - background: "var(--cds-layer-accent-01, #e8ebee)", - padding: "0.1em 0.5em", - borderRadius: "999rem", - verticalAlign: "0.1em", - cursor: "pointer", - }, - ".cm-code-info:hover": { - background: "var(--cds-layer-accent-hover-01, #d1d7dc)", - color: "var(--cds-text-primary, #161616)", - }, - // The languageless placeholder: same chip, dimmed, so every closed block - // has a discoverable language control. - ".cm-code-info--unset": { - opacity: "0.55", - fontStyle: "italic", - }, - - ".cm-blockquote": { - borderInlineStart: "0.1875rem solid var(--cds-border-subtle-02, #c6c6c6)", - paddingInlineStart: "0.75em", - color: "var(--cds-text-secondary, #525252)", - fontStyle: "italic", - }, - - // List-item line: small left indent so the bullet widget sits in - // its own column. Padding (not margin) keeps click metrics exact. - ".cm-list-line": { - paddingInlineStart: "0.5em", - }, - - // Bullet widget — styled here so the widget's HTML inherits the - // right colour against body type. - ".cm-bullet-widget": { - display: "inline-block", - width: "1em", - color: "var(--cds-text-secondary, #525252)", - fontWeight: "700", - }, - - // Ordered-list number (`1.`) — wider than a bullet's fixed 1em so multi-digit - // markers fit, with a small gap before the item text. Normal weight: a bold - // number reads as heavier than the body text it labels. - ".cm-ordered-marker": { - width: "auto", - minWidth: "1.2em", - marginRight: "0.3em", - fontWeight: "normal", - }, - - // Task list checkbox — drawn as a Carbon checkbox, not the native control: a - // 1rem square that fills with the icon token and shows a white tick when - // checked. `appearance: none` is what replaces WebKit's small rounded default; - // the box then matches the design system rather than approximating it with an - // accent colour over the native shape. - ".cm-task-checkbox": { - appearance: "none", - WebkitAppearance: "none", - boxSizing: "border-box", - position: "relative", - width: "1rem", - height: "1rem", - margin: "0 0.4em 0 0", - cursor: "pointer", - verticalAlign: "-0.15em", - border: "0.0625rem solid var(--cds-icon-primary, #161616)", - borderRadius: "0.0625rem", - background: "transparent", - }, - ".cm-task-checkbox:checked": { - background: "var(--cds-icon-primary, #161616)", - borderColor: "var(--cds-icon-primary, #161616)", - }, - // The tick: an L (right + bottom border) rotated 45° into a check. - ".cm-task-checkbox:checked::after": { - content: "''", - position: "absolute", - left: "0.3125rem", - top: "0.0625rem", - width: "0.25rem", - height: "0.5rem", - border: "solid var(--cds-icon-on-color, #ffffff)", - borderWidth: "0 0.125rem 0.125rem 0", - transform: "rotate(45deg)", - }, - ".cm-task-checkbox:focus-visible": { - outline: "0.125rem solid var(--cds-focus, #0f62fe)", - outlineOffset: "0.0625rem", - }, - - // Inline image widget. Constrained max-width so a single large - // image doesn't blow up the editor; lazy loading via ``. - ".cm-image-widget": { - display: "inline-block", - maxWidth: "min(100%, 32rem)", - height: "auto", - borderRadius: "0.25rem", - margin: "0.25em 0", - }, - - // Horizontal-rule widget. Inline span styled as a full-width - // border so the line stays inside CM6's line layout. - ".cm-hr-widget": { - display: "inline-block", - width: "100%", - height: "0", - borderTop: "0.0625rem solid var(--cds-border-subtle-02, #c6c6c6)", - verticalAlign: "middle", - }, - - ".cm-table-widget": { - borderCollapse: "collapse", - width: "100%", - // `fixed` distributes width evenly across columns instead of letting the - // auto algorithm shrink columns toward their content — with the inherited - // `cm-lineWrapping` word-breaking, an auto-shrunk column wraps one - // character per line ("T / o / p / i / c"). - tableLayout: "fixed", - }, - ".cm-table-widget th, .cm-table-widget td": { - border: "0.0625rem solid var(--cds-border-subtle-02, #c6c6c6)", - padding: "0.4em 0.75em", - verticalAlign: "top", - // No native text-selection: a drag would otherwise zig-zag a ragged - // selection across cells (uneven heights). `tablev2` tracks the - // drag and tints whole cells uniformly instead; the cell editor re-enables - // selection for its own content (below). - userSelect: "none", - WebkitUserSelect: "none", - // Wrap at word boundaries like prose (overriding the `word-break: break-word` - // cm-lineWrapping inherits onto `.cm-content`), but still break a single - // over-long token (e.g. `complementarity/orchestration`) so it can't spill - // past its fixed-width column into the neighbour. - overflowWrap: "break-word", - wordBreak: "normal", - }, - ".cm-table-widget thead th": { - background: "var(--cds-layer-accent-01, #e8e8e8)", - fontWeight: "600", - textAlign: "left", - }, - // The cell editor's own content stays selectable so a mounted cell edits - // normally (only the surrounding grid is locked, above). - ".cm-table-widget .cm-content": { - userSelect: "text", - WebkitUserSelect: "text", - }, - // Cell highlight: `--selected` is a drag-selection (tablev2); - // `--hover` previews the row/column a "Comment on this row/column" menu item - // targets; `--commenting` is held by the host while that comment composer is - // open. All read as the same even tint. - ".cm-table-cell--selected, .cm-table-cell--hover, .cm-table-cell--commenting": { - backgroundColor: "var(--cds-highlight, #d0e2ff)", - }, - - // Hover inserters (tablev2/tableV2HoverControls.ts). The wrapper reserves a top + left - // padding gutter; JS parks the two "+" circles in it — clear of the grid, so - // they never clip at a corner or sit under the header. - ".cm-table-wrap": { - position: "relative", - // Vertical spacing is PADDING, never margin — the same rule the headings and - // lists above follow. CM6 measures a block widget's height from its border - // box (margins excluded), so a margin here under-measures the table and - // drifts every click below it down onto the next line. The top padding - // doubles as the hover-"+" gutter. - paddingTop: "2.5em", - paddingBottom: "0.5em", - paddingLeft: "2em", - }, - - // Two-step delete (tableArmed.ts). The first Backspace/Delete next to a table - // parks the caret at its edge and arms it: the table gets a blue "selected" - // outline, and a green line is drawn at the armed edge — Zettlr's "green line - // cursor behind the table" cue, signalling the next press removes it. The - // caret is hidden while arming (it renders on the blank line just past the - // table, which reads as "the cursor never moved") by hiding the drawn cursor - // layer. - "&.cm-table-arming .cm-cursorLayer": { - display: "none", - }, - ".cm-table-armed .cm-table-widget": { - outline: "0.125rem solid var(--cds-border-interactive, #0f62fe)", - outlineOffset: "0.0625rem", - position: "relative", - }, - ".cm-table-wrap[data-armed-edge] .cm-table-widget::after": { - content: "''", - position: "absolute", - left: "0", - right: "0", - height: "0.1875rem", - background: "var(--cds-support-success, #24a148)", - pointerEvents: "none", - }, - ".cm-table-wrap[data-armed-edge=\"end\"] .cm-table-widget::after": { - bottom: "-0.375rem", - }, - ".cm-table-wrap[data-armed-edge=\"start\"] .cm-table-widget::after": { - top: "-0.375rem", - }, - ".cm-table-inserter": { - position: "absolute", - display: "none", - alignItems: "center", - justifyContent: "center", - width: "1.25rem", - height: "1.25rem", - borderRadius: "50%", - appearance: "none", - border: "none", - padding: "0", - cursor: "pointer", - zIndex: "3", - // Subtle at rest (a neutral chip); the primary accent is the hover - // affordance. The glyph is a centred SVG stroked with currentColor. - background: "var(--cds-layer-accent-01, #e0e0e0)", - color: "var(--cds-icon-secondary, #525252)", - boxShadow: "0 0.0625rem 0.125rem rgba(0, 0, 0, 0.12)", - transition: "background 80ms ease, color 80ms ease", - }, - ".cm-table-inserter:hover": { - background: "var(--cds-link-primary, #0f62fe)", - color: "#ffffff", - }, - - ".cm-image-menu": { - background: "var(--cds-layer-01, #ffffff)", - border: "0.0625rem solid var(--cds-border-subtle-01, #e0e0e0)", - borderRadius: "0.125rem", - boxShadow: "0 0.25rem 0.75rem rgba(0, 0, 0, 0.15)", - padding: "0.25rem 0", - minWidth: "11rem", - fontFamily: - "var(--cds-body-01-font-family, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif)", - fontSize: "0.875rem", - }, - ".cm-image-menu__item": { - appearance: "none", - background: "transparent", - border: "none", - textAlign: "start", - padding: "0.4rem 0.75rem", - cursor: "pointer", - width: "100%", - color: "var(--cds-text-primary, #161616)", - font: "inherit", - }, - ".cm-image-menu__item:hover": { - background: "var(--cds-layer-hover-01, #e8e8e8)", - }, - ".cm-image-menu__item--danger": { - color: "var(--cds-text-error, #da1e28)", - }, - - ".cm-math-inline": { - fontFamily: "KaTeX_Main, serif", - }, - ".cm-math-block": { - display: "block", - margin: "0.5em 0", - textAlign: "center", - fontFamily: "KaTeX_Main, serif", - }, - - // A diagram reads as CONTENT, not a control: no fill or pointer on hover — - // just a hairline frame plus a corner "Edit" chip as the click-to-edit cue. - ".cm-mermaid-block": { - position: "relative", - display: "flex", - justifyContent: "center", - margin: "0.5em 0", - padding: "0.75em", - borderRadius: "0.25rem", - }, - ".cm-mermaid-block:hover": { - outline: "0.0625rem solid var(--cds-border-subtle-01, #e0e0e0)", - }, - // The click-to-select state — native selection paints nothing over a block - // widget, so the widget carries its own "I'm selected, ⌘C copies me" look. - ".cm-mermaid-block--selected, .cm-mermaid-block--selected:hover": { - outline: "0.125rem solid var(--cds-focus, #0f62fe)", - outlineOffset: "-0.125rem", - background: "rgba(15, 98, 254, 0.06)", - }, - ".cm-mermaid-edit": { - position: "absolute", - insetBlockStart: "0.375rem", - insetInlineEnd: "0.375rem", - padding: "0.1rem 0.5rem", - fontSize: "0.75rem", - color: "var(--cds-text-secondary, #525252)", - background: "var(--cds-layer-01, #f4f4f4)", - border: "0.0625rem solid var(--cds-border-subtle-01, #e0e0e0)", - borderRadius: "999rem", - cursor: "pointer", - opacity: "0", - transition: "opacity 80ms ease", - }, - ".cm-mermaid-block:hover .cm-mermaid-edit": { - opacity: "1", - }, - ".cm-mermaid-block > svg": { - maxWidth: "100%", - height: "auto", - // Uniform click handling: SVG sub-elements otherwise swallow pointer - // events over parts of the diagram, making click-to-select land only on - // the background. - pointerEvents: "none", - }, - ".cm-mermaid-block--pending": { - color: "var(--cds-text-secondary, #525252)", - fontStyle: "italic", - }, - ".cm-mermaid-block--error": { - display: "block", - borderLeft: "0.1875rem solid var(--cds-support-error, #da1e28)", - background: "var(--cds-layer-01, #f4f4f4)", - }, - ".cm-mermaid-error__title": { - color: "var(--cds-text-error, #da1e28)", - fontSize: "0.875em", - }, - ".cm-mermaid-error__message": { - margin: "0.5em 0 0", - fontFamily: "'IBM Plex Mono', ui-monospace, monospace", - fontSize: "0.75em", - color: "var(--cds-text-secondary, #525252)", - whiteSpace: "pre-wrap", - overflowWrap: "anywhere", - }, - - ".cm-html-inline, .cm-html-block": { - display: "inline", - }, - ".cm-html-block": { - display: "block", - margin: "0.5em 0", - }, - - ".cm-link": { - color: "var(--cds-link-primary, #0f62fe)", - textDecoration: "underline", - }, - - // Wikilink label — distinguish from regular markdown links via a - // dashed underline so a non-technical reader can tell at a glance - // that this is a vault-internal target. Matches the Tiptap version - // (`.wikilink`). - ".cm-wikilink": { - color: "var(--cds-link-primary, #0f62fe)", - textDecoration: "underline dashed", - cursor: "pointer", - }, - - // Explicit yellow, not var(--cds-highlight): the app maps that token to the - // drawn-selection color, and a mark bound to it is indistinguishable from - // selected text. Translucent so the below-content selection band reads - // through; the alpha composites to the intended #fff8c5 resting yellow on - // the white canvas. - ".cm-highlight": { - background: "rgba(255, 235, 89, 0.35)", - padding: "0 0.1em", - borderRadius: "0.125rem", - }, - - ".cm-footnote-ref": { - fontSize: "0.75em", - verticalAlign: "super", - color: "var(--cds-link-primary, #0f62fe)", - cursor: "pointer", - }, - - ".cm-footnote-def": { - fontSize: "0.875em", - color: "var(--cds-text-secondary, #525252)", - paddingInlineStart: "0.5em", - borderInlineStart: "0.125rem solid var(--cds-border-subtle-02, #c6c6c6)", - }, -}); diff --git a/packages/rich-editor/src/codemirror/core/hostFacets.test.ts b/packages/rich-editor/src/codemirror/core/hostFacets.test.ts deleted file mode 100644 index 9d9406a..0000000 --- a/packages/rich-editor/src/codemirror/core/hostFacets.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -// @vitest-environment jsdom -import { EditorState } from "@codemirror/state"; -import { describe, expect, it, vi } from "vitest"; - -import { defaultResolveImageSrc } from "../../imageSrcResolver"; -import { openExternalUrlFacet, resolveImageSrcFacet, saveImageBytesFacet } from "./hostFacets"; - -describe("hostFacets — defaults and host overrides", () => { - it("resolveImageSrcFacet defaults to the passthrough resolver", () => { - const state = EditorState.create({ doc: "" }); - expect(state.facet(resolveImageSrcFacet)).toBe(defaultResolveImageSrc); - }); - - it("resolveImageSrcFacet uses the first registered override", () => { - const state = EditorState.create({ - doc: "", - extensions: [resolveImageSrcFacet.of((raw) => `c:${raw}`)], - }); - expect(state.facet(resolveImageSrcFacet)("img.png", { fileDir: null })).toBe("c:img.png"); - }); - - it("saveImageBytesFacet defaults to null (data-URL fallback)", () => { - const state = EditorState.create({ doc: "" }); - expect(state.facet(saveImageBytesFacet)).toBeNull(); - }); - - it("openExternalUrlFacet default opens a new browser tab", () => { - const open = vi.spyOn(window, "open").mockImplementation(() => null); - EditorState.create({ doc: "" }).facet(openExternalUrlFacet)("https://example.com"); - expect(open).toHaveBeenCalledWith("https://example.com", "_blank", "noopener,noreferrer"); - open.mockRestore(); - }); -}); diff --git a/packages/rich-editor/src/codemirror/core/hostFacets.ts b/packages/rich-editor/src/codemirror/core/hostFacets.ts deleted file mode 100644 index d33db70..0000000 --- a/packages/rich-editor/src/codemirror/core/hostFacets.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Host-environment injection seams. - * - * The editor surface is environment-agnostic: it knows how to render and edit - * markdown, but NOT how to read/write files, resolve asset URLs, or open links — - * those depend on where it's embedded (a Tauri desktop shell, a plain browser, a - * server-rendered preview). Each capability is a CM6 facet with a sensible - * browser default; the React host overrides it by setting the facet from a prop. - * - * Keeping these as facets (not React context) lets the non-React CM6 plugins and - * widgets — image paste handlers, the inline `` widget, the click model — - * read them straight off `view.state`. - */ - -import { Facet } from "@codemirror/state"; - -import { defaultResolveImageSrc, type ImageResolveContext } from "../../imageSrcResolver"; -import { type SourceRange } from "../../types"; - -export type ResolveImageSrc = (rawSrc: string, ctx: ImageResolveContext) => string; -export type SaveImageBytes = (relPath: string, bytes: Uint8Array) => Promise; -export type OpenExternalUrl = (url: string) => void; -/** Viewport point a comment composer should anchor to (the right-click point). */ -export type CommentAnchor = { x: number; y: number }; -export type CommentOnExcerpt = ( - excerpt: { text: string; range: SourceRange }, - anchor: CommentAnchor, -) => void; - -/** - * Turn a markdown image `src` into a URL the view can load. Default: pass the - * reference through unchanged (data URLs and absolute URLs render directly; a - * browser resolves relative refs against the page origin). A desktop host - * overrides this to map workspace-relative paths onto its asset protocol. - */ -export const resolveImageSrcFacet = Facet.define({ - combine: (values) => values[0] ?? defaultResolveImageSrc, -}); - -/** - * Persist pasted/dropped image bytes at a workspace-relative path. Default: - * `null` — the insert pipeline then inlines the image as a `data:` URL so it - * still survives a reload. A desktop host provides a writer that saves to disk - * and the markdown reference stays a portable relative path. - */ -export const saveImageBytesFacet = Facet.define({ - combine: (values) => values[0] ?? null, -}); - -/** - * Open a clicked external link. Default: a new browser tab. A desktop host - * overrides this to leave the app's webview via its shell-open API. - */ -export const openExternalUrlFacet = Facet.define({ - combine: (values) => values[0] ?? defaultOpenExternalUrl, -}); - -/** - * Comment on a selected table row/column — the table context menu hands its - * excerpt + anchor point here. Default: `null` — the menu then omits its - * "Comment on this row/column" items. A desktop host opens its comment composer - * (the same one a text selection uses) seeded with the excerpt. - */ -export const commentOnExcerptFacet = Facet.define< - CommentOnExcerpt | null, - CommentOnExcerpt | null ->({ - combine: (values) => values[0] ?? null, -}); - -function defaultOpenExternalUrl(url: string): void { - if (typeof window !== "undefined") { - window.open(url, "_blank", "noopener,noreferrer"); - } -} diff --git a/packages/rich-editor/src/codemirror/core/hrWidget.test.ts b/packages/rich-editor/src/codemirror/core/hrWidget.test.ts deleted file mode 100644 index a22f7cd..0000000 --- a/packages/rich-editor/src/codemirror/core/hrWidget.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, describe, expect, it } from "vitest"; - -import { destroyEditors, makeEditor } from "./editorTestHarness"; -import { HorizontalRuleWidget } from "./hrWidget"; - -describe("HorizontalRuleWidget", () => { - afterEach(destroyEditors); - - it("renders span.cm-hr-widget", () => { - const dom = new HorizontalRuleWidget().toDOM(makeEditor("x", 0)); - expect(dom.tagName).toBe("SPAN"); - expect(dom.className).toBe("cm-hr-widget"); - }); - - it("eq() is always true and ignoreEvent() is false", () => { - expect(new HorizontalRuleWidget().eq(new HorizontalRuleWidget())).toBe(true); - expect(new HorizontalRuleWidget().ignoreEvent()).toBe(false); - }); -}); diff --git a/packages/rich-editor/src/codemirror/core/hrWidget.ts b/packages/rich-editor/src/codemirror/core/hrWidget.ts deleted file mode 100644 index f11df6c..0000000 --- a/packages/rich-editor/src/codemirror/core/hrWidget.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Horizontal-rule widget — replaces `---` / `***` / `___` source - * with a styled inline `` that draws a horizontal line. - * - * Kept as an inline widget (not `block: true`) so the line still - * participates in CM6's normal line-metric measurement; CSS draws - * the rule via a full-width border. - */ - -import { Decoration, EditorView, WidgetType } from "@codemirror/view"; - -import { type NodeRule } from "./paint"; - -export class HorizontalRuleWidget extends WidgetType { - override eq(_other: HorizontalRuleWidget): boolean { - return true; - } - - override toDOM(_view: EditorView): HTMLElement { - const span = document.createElement("span"); - span.className = "cm-hr-widget"; - return span; - } - - override ignoreEvent(): boolean { - return false; - } -} - -/* ---------------- The HorizontalRule rule ---------------- */ - - - -// Stateless → one shared decoration; allocating per node per viewport build -// is pure GC pressure. -const HR_REPLACE = Decoration.replace({ widget: new HorizontalRuleWidget() }); - -/** `---` → a styled `


` widget. */ -export const horizontalRuleRule: NodeRule = () => ({ paint: "widget", deco: HR_REPLACE }); diff --git a/packages/rich-editor/src/codemirror/core/index.ts b/packages/rich-editor/src/codemirror/core/index.ts deleted file mode 100644 index 90289d5..0000000 --- a/packages/rich-editor/src/codemirror/core/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * The paint engine: the NodeRule/Paint rendering contract, the canonical - * rules table, the viewport painter, the base theme, and the host-environment - * facets. Every other feature folder builds on these. - * - * Folder front doors (this file and its siblings) are for composition roots — - * the package index, the editor shell, and `extensions/`. Feature-folder - * modules import each other by concrete module path instead, so module - * initialization order never depends on a barrel's export order. - * - * Engine-internal, deliberately not re-exported: `registry` (consumed by the - * painter and the table cell renderer), `lineStructure`, `hrWidget`, and - * `editorTestHarness` (test-only — it pulls the whole extension set). - */ -export * from "./paint"; -export * from "./parseToEnd"; -export * from "./treeAt"; -export * from "./plugin"; -export * from "./editorTheme"; -export * from "./hostFacets"; diff --git a/packages/rich-editor/src/codemirror/core/lineStructure.ts b/packages/rich-editor/src/codemirror/core/lineStructure.ts deleted file mode 100644 index a274c15..0000000 --- a/packages/rich-editor/src/codemirror/core/lineStructure.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * What the GRAMMAR says a line is — resolved from the Lezer syntax tree, not - * from regexes over the line's text (#61). Text lies where the tree doesn't: - * `- item` inside a code fence isn't a list item, a nested ` - item` is one - * even though its marker isn't at column 0, and ` - item` under a list is - * a nested item while the same text at top level is indented code. Commands - * that reshape a line consult this and edit the marker range the PARSER - * identified. - */ - -import type { EditorState, Line } from "@codemirror/state"; -import { treeAt } from "./treeAt"; - -export interface LineListInfo { - kind: "bullet" | "ordered"; - /** The item carries a task checkbox (`- [ ] …`). */ - task: boolean; - /** Source range of the list marker (plus checkbox for tasks), INCLUDING the - * single space that separates it from the content. */ - markFrom: number; - markTo: number; -} - -export interface LineStructure { - /** Line belongs to a BLOCK code context (fenced or indented code, including - * the fence lines themselves) — structure commands must not rewrite code. - * Inline code inside a paragraph does not count: the line is still prose. */ - inCode: boolean; - /** ATX heading when the line is one, with its mark range (hashes + - * following space). */ - heading: { level: number; markFrom: number; markTo: number } | null; - list: LineListInfo | null; - /** Outermost blockquote marker starting this line (`> `), when present. */ - quote: { markFrom: number; markTo: number } | null; - /** Where this line's own content starts — after indentation and any - * blockquote markers. A fresh line-type marker belongs here, so indented - * and quoted lines keep their prefix. */ - contentFrom: number; -} - -const HEADING = /^ATXHeading(\d)$/; - -/** The slice of Lezer's SyntaxNode these lookups touch. */ -type NodeLike = { - readonly name: string; - readonly from: number; - readonly to: number; - readonly parent: NodeLike | null; - getChild(type: string): NodeLike | null; -}; - -/** Extend a mark's end over the single following space, when present. */ -function withMarkerSpace(state: EditorState, to: number, lineTo: number): number { - return to < lineTo && state.sliceDoc(to, to + 1) === " " ? to + 1 : to; -} - -function firstNonWhitespace(state: EditorState, from: number, to: number): number { - const text = state.sliceDoc(from, to); - return from + (text.length - text.trimStart().length); -} - -export function lineStructure(state: EditorState, line: Line): LineStructure { - const result: LineStructure = { - inCode: false, - heading: null, - list: null, - quote: null, - contentFrom: firstNonWhitespace(state, line.from, line.to), - }; - - const tree = treeAt(state, result.contentFrom); - // Resolve at the first content character. Blockquote markers are container - // prefixes, not content — step past each one (`> > - x` → resolve at `-`) - // so the line's own structure is an ancestor of the resolve point. - let node = tree.resolveInner(result.contentFrom, 1) as unknown as NodeLike; - while (node.name === "QuoteMark" && node.from >= line.from) { - result.quote ??= { - markFrom: node.from, - markTo: withMarkerSpace(state, node.to, line.to), - }; - result.contentFrom = firstNonWhitespace(state, node.to, line.to); - node = tree.resolveInner(result.contentFrom, 1) as unknown as NodeLike; - } - - // Every structural fact about the resolve point is one of its ancestors. - for (let cur: NodeLike | null = node; cur; cur = cur.parent) { - const name = cur.name; - if (name === "FencedCode" || name === "CodeBlock") { - result.inCode = true; - continue; - } - const heading = HEADING.exec(name); - if (heading && cur.from >= line.from && cur.from <= result.contentFrom) { - const mark = cur.getChild("HeaderMark"); - if (mark) { - result.heading = { - level: Number(heading[1]), - markFrom: mark.from, - markTo: withMarkerSpace(state, mark.to, line.to), - }; - } - continue; - } - // The innermost ListItem STARTING on this line owns the line's marker; an - // item merely continuing here (wrapped paragraph) contributes nothing. - if (name === "ListItem" && !result.list && cur.from >= line.from && cur.from <= line.to) { - const mark = cur.getChild("ListMark"); - if (!mark || mark.from > line.to) continue; - const taskMark = cur.getChild("Task")?.getChild("TaskMarker") ?? null; - result.list = { - kind: cur.parent?.name === "OrderedList" ? "ordered" : "bullet", - task: Boolean(taskMark), - markFrom: mark.from, - markTo: withMarkerSpace(state, (taskMark ?? mark).to, line.to), - }; - } - } - return result; -} diff --git a/packages/rich-editor/src/codemirror/core/paint.ts b/packages/rich-editor/src/codemirror/core/paint.ts deleted file mode 100644 index f9d02ff..0000000 --- a/packages/rich-editor/src/codemirror/core/paint.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * The rendering contract: one polymorphic shape for "how does a Lezer node - * render", replacing three parallel dispatch surfaces (a data-entry union, a - * widget-name union + builders map, and a contextual-override switch). - * - * NodeRule = (ctx) => Paint — one function per node NAME - * Paint = which CM6 mechanism — a CLOSED union - * - * The openness is split on the right axis: CONSTRUCTS grow (every new node - * adds a rule — one entry, one place, usually one line via the combinators - * below), while the ways to PAINT don't (line class / span mark / hide / - * widget / nothing — CodeMirror's own vocabulary). The single switch over - * `Paint` lives in the painter (plugin.ts) and never changes when a - * construct is added. - * - * Context (the bare-URL lesson: a node name can mean different things in - * different parents) is not a bolt-on — every rule IS a function of context; - * simple rules just ignore it. - * - * Extensions contribute rules through {@link nodeRulesFacet}: a plugin that - * introduces node names ships its rules alongside its grammar, touching no - * core file. - */ - -import { Facet, type EditorState } from "@codemirror/state"; -import { type Decoration } from "@codemirror/view"; - -// Structural stand-in for a Lezer node (`@lezer/common` is a transitive dep -// this package deliberately doesn't import from). -export interface NodeLike { - readonly name: string; - readonly from: number; - readonly to: number; - readonly parent: NodeLike | null; - readonly firstChild: NodeLike | null; - readonly nextSibling: NodeLike | null; - readonly prevSibling: NodeLike | null; - getChild(type: string): NodeLike | null; -} - -/** Everything a rule may consult. */ -export interface NodeContext { - readonly name: string; - readonly from: number; - readonly to: number; - /** The parent node's name — the common contextual discriminator. */ - readonly parentName: string | undefined; - /** Full structural node, for rules that need siblings/children. */ - readonly node: NodeLike; - /** Rules are pure over state — no view dependency, so non-editor - * renderers (table cells) can invoke the same rules. */ - readonly state: EditorState; -} - -/** One painting instruction, in CodeMirror's own vocabulary. */ -export type Paint = - /** Stamp a line class — on the node's first line, or every spanned line. */ - | { readonly paint: "lineClass"; readonly className: string; readonly span: "first" | "all" } - /** Style the node's span. */ - | { readonly paint: "mark"; readonly className: string } - /** - * Hide a range (default: the node, plus its one separator space when - * line-leading) and make it atomic to caret motion. `atomicTo` widens the - * atom past the hidden range (marker + space move as one unit). - */ - | { - readonly paint: "hide"; - readonly range?: { readonly from: number; readonly to: number }; - readonly expandSpace?: boolean; - readonly atomicTo?: number; - } - /** Replace the (hidden) span with a widget decoration. */ - | { readonly paint: "widget"; readonly deco: Decoration; readonly atomicTo?: number } - /** Leave the node alone — visible raw source. */ - | { readonly paint: "none" }; - -export interface RuleMeta { - readonly intent: "render-raw" | "structural"; - readonly why: string; -} - -/** How one node name renders. `meta` tags the deliberate do-nothing rules so - * the coverage test can insist their reason is documented. */ -export type NodeRule = ((ctx: NodeContext) => Paint) & { readonly meta?: RuleMeta }; - -export type NodeRules = Readonly>; - -/* ---------------- Combinators — the common rules as one-liners ------------ */ - -const NONE: Paint = { paint: "none" }; - -/** Style the node's span with a class. */ -export function mark(className: string): NodeRule { - const paint: Paint = { paint: "mark", className }; - return () => paint; -} - -/** Stamp a line class on every line the node spans. */ -export function line(className: string): NodeRule { - const paint: Paint = { paint: "lineClass", className, span: "all" }; - return () => paint; -} - -/** Stamp a line class on the node's first line only (headings). */ -export function headingLine(className: string): NodeRule { - const paint: Paint = { paint: "lineClass", className, span: "first" }; - return () => paint; -} - -/** Hide the node (marker chrome), atomically. */ -export function hideAlways(): NodeRule { - const paint: Paint = { paint: "hide" }; - return () => paint; -} - -/** Deliberately unstyled-for-now, visible raw — `why` documents the intent. */ -export function raw(why: string): NodeRule { - return Object.assign(() => NONE, { meta: { intent: "render-raw", why } as RuleMeta }); -} - -/** A parser grouping construct, never directly visible — `why` says which. */ -export function structural(why: string): NodeRule { - return Object.assign(() => NONE, { meta: { intent: "structural", why } as RuleMeta }); -} - -export const none: Paint = NONE; - -/* ---------------- Extension seam ----------------------------------------- */ - -/** - * Rules contributed by extensions, merged over the base table (an extension - * may also deliberately override a base rule — last provider wins). The - * painter reads THIS, never the base table directly. - */ -export const nodeRulesFacet = Facet.define({ - combine(values) { - return Object.assign({}, ...values); - }, -}); diff --git a/packages/rich-editor/src/codemirror/core/parseToEnd.browser.test.ts b/packages/rich-editor/src/codemirror/core/parseToEnd.browser.test.ts deleted file mode 100644 index 4e5fc6a..0000000 --- a/packages/rich-editor/src/codemirror/core/parseToEnd.browser.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * The editor invariant, proven in the engine we ship on: with {@link parseToEnd} - * installed, the syntax tree covers the whole document shortly after opening — - * including documents past the ~100k point where CodeMirror's own background - * parse stops for good. - */ - -import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; -import { syntaxTree } from "@codemirror/language"; -import { EditorSelection, EditorState, type Extension } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it } from "vitest"; - -import { parseToEnd } from "./parseToEnd"; - -const views: EditorView[] = []; - -afterEach(() => { - for (const view of views.splice(0)) { - view.destroy(); - view.dom.parentElement?.remove(); - } -}); - -function openEditor(doc: string, extra: Extension[]): EditorView { - const parent = document.createElement("div"); - document.body.appendChild(parent); - const view = new EditorView({ - parent, - state: EditorState.create({ - doc, - selection: EditorSelection.cursor(0), - extensions: [markdown({ base: markdownLanguage }), ...extra], - }), - }); - views.push(view); - return view; -} - -/** Big enough that the tail sits past the background parse's permanent stop. */ -function hugeDoc(): string { - const filler = Array.from({ length: 8000 }, (_, i) => `paragraph line ${i}`).join("\n\n"); - return `${filler}\n\n\`\`\`\ncode\n\`\`\``; -} - -async function waitFor(check: () => boolean, ms: number): Promise { - const start = performance.now(); - while (performance.now() - start < ms) { - if (check()) return true; - await new Promise((r) => setTimeout(r, 50)); - } - return check(); -} - -describe("parseToEnd", () => { - it("covers a document the background parse alone never finishes", async () => { - const doc = hugeDoc(); - const view = openEditor(doc, [parseToEnd]); - const covered = await waitFor(() => syntaxTree(view.state).length >= doc.length, 10_000); - expect(covered).toBe(true); - }, 30_000); - - it("re-covers after an edit", async () => { - const doc = hugeDoc(); - const view = openEditor(doc, [parseToEnd]); - await waitFor(() => syntaxTree(view.state).length >= doc.length, 10_000); - - view.dispatch({ changes: { from: 0, insert: "# heading\n\n" } }); - const covered = await waitFor( - () => syntaxTree(view.state).length >= view.state.doc.length, - 10_000, - ); - expect(covered).toBe(true); - }, 30_000); - - it("control: without it, the background parse stops short — the premise", async () => { - // If this ever fails, CodeMirror now covers large documents on its own and - // the extension is obsolete — remove it rather than keep a no-op. - const doc = hugeDoc(); - const view = openEditor(doc, []); - const covered = await waitFor(() => syntaxTree(view.state).length >= doc.length, 4_000); - expect(covered).toBe(false); - }, 30_000); -}); diff --git a/packages/rich-editor/src/codemirror/core/parseToEnd.test.ts b/packages/rich-editor/src/codemirror/core/parseToEnd.test.ts deleted file mode 100644 index 9f7711d..0000000 --- a/packages/rich-editor/src/codemirror/core/parseToEnd.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -// @vitest-environment jsdom -import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; -import { syntaxTree } from "@codemirror/language"; -import { EditorState, type Extension } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it } from "vitest"; - -import { parseToEnd } from "./parseToEnd"; - -const views: EditorView[] = []; - -afterEach(() => { - for (const view of views.splice(0)) { - view.destroy(); - view.dom.parentElement?.remove(); - } -}); - -/** A raw view, deliberately not the harness: the harness pre-parses to the end - * of the document, which is the invariant this extension exists to establish. */ -function openEditor(doc: string, extra: Extension[]): EditorView { - const parent = document.createElement("div"); - document.body.appendChild(parent); - const view = new EditorView({ - parent, - state: EditorState.create({ - doc, - extensions: [markdown({ base: markdownLanguage }), ...extra], - }), - }); - views.push(view); - return view; -} - -/** Long enough that the opening frame's parse stops well short of the end. */ -function longDoc(): string { - return Array.from({ length: 800 }, (_, i) => `paragraph line ${i}`).join("\n\n"); -} - -async function waitFor(check: () => boolean, ms: number): Promise { - const start = Date.now(); - while (Date.now() - start < ms) { - if (check()) return true; - await new Promise((r) => setTimeout(r, 25)); - } - return check(); -} - -describe("parseToEnd", () => { - it("drives the installed tree to the end of the document", async () => { - const doc = longDoc(); - const view = openEditor(doc, [parseToEnd]); - expect(syntaxTree(view.state).length).toBeLessThan(doc.length); // the premise - const covered = await waitFor(() => syntaxTree(view.state).length >= doc.length, 5_000); - expect(covered).toBe(true); - }, 15_000); - - it("re-arms on a document change", async () => { - const view = openEditor(longDoc(), [parseToEnd]); - await waitFor(() => syntaxTree(view.state).length >= view.state.doc.length, 5_000); - - view.dispatch({ - changes: { from: view.state.doc.length, insert: `\n\n${longDoc()}` }, - }); - const covered = await waitFor( - () => syntaxTree(view.state).length >= view.state.doc.length, - 5_000, - ); - expect(covered).toBe(true); - }, 15_000); - - it("does not poll a view that has no language", async () => { - const view = openEditor(longDoc(), []); - const bare = new EditorView({ - parent: view.dom.parentElement!, - state: EditorState.create({ doc: "plain text", extensions: [parseToEnd] }), - }); - views.push(bare); - // One gap is enough for the first work() call to run and decline. - await new Promise((r) => setTimeout(r, 200)); - expect(syntaxTree(bare.state).length).toBe(0); - }); -}); diff --git a/packages/rich-editor/src/codemirror/core/parseToEnd.ts b/packages/rich-editor/src/codemirror/core/parseToEnd.ts deleted file mode 100644 index 898b099..0000000 --- a/packages/rich-editor/src/codemirror/core/parseToEnd.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { forceParsing, language, syntaxTree } from "@codemirror/language"; -import type { Extension } from "@codemirror/state"; -import { ViewPlugin, type EditorView, type ViewUpdate } from "@codemirror/view"; - -/** Work per slice. Small enough to never register as jank between frames. */ -const SLICE_MS = 10; - -/** Gap between slices, leaving the main thread mostly idle while the tail of a - * large document parses. Markdown covers ~240k characters in ~43ms of work, so - * even a very large note finishes within a few hundred ms of opening. */ -const GAP_MS = 25; - -/** - * Drives the parse to the end of the document, establishing the invariant the - * rest of the editor is written against: **the installed syntax tree covers - * the whole document**, not just what the viewport happened to render. - * - * Without it, CodeMirror's own background work stops 100,000 characters past - * the viewport (measured: the tree plateaus at 100,739 for a 167k-character - * document and a 341k one alike, and never advances again). Everything - * downstream of the tree is then silently wrong for the rest of the document: - * table and mermaid widgets never materialize, code-language affordances never - * appear, and structural commands are left to re-parse at the caret - * ({@link treeAt} — still wanted, since a keystroke can land before this - * finishes). - * - * Scheduled with `setTimeout`, not `requestIdleCallback`: WebKit stops - * delivering idle callbacks to an unfocused window, and a note opened while - * the app is in the background must still be parsed when the user comes back - * to it. - */ -export const parseToEnd: Extension = ViewPlugin.fromClass( - class { - private timer: number | null = null; - - constructor(private readonly view: EditorView) { - this.schedule(); - } - - update(update: ViewUpdate) { - if (update.docChanged) this.schedule(); - } - - destroy() { - if (this.timer !== null) window.clearTimeout(this.timer); - } - - private schedule() { - if (this.timer !== null) return; - this.timer = window.setTimeout(this.work, GAP_MS); - } - - private readonly work = () => { - this.timer = null; - const { state } = this.view; - // Without a language nothing will ever parse; rescheduling would poll - // forever for a tree that never comes. - if (!state.facet(language)) return; - if (syntaxTree(state).length >= state.doc.length) return; - forceParsing(this.view, state.doc.length, SLICE_MS); - // Reschedule until covered, not until progress: a slice can spend its - // whole budget without committing a longer tree (measured: the resume - // from CodeMirror's own stop point commits nothing on the first slice, - // then covers the rest of the document on the next). - this.schedule(); - }; - }, -); diff --git a/packages/rich-editor/src/codemirror/core/plugin.test.ts b/packages/rich-editor/src/codemirror/core/plugin.test.ts deleted file mode 100644 index a2fb05f..0000000 --- a/packages/rich-editor/src/codemirror/core/plugin.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -// @vitest-environment jsdom -import type { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it } from "vitest"; - -import { destroyEditors, makeEditor } from "./editorTestHarness"; -import { mark, nodeRulesFacet } from "./paint"; -import { markdownDecorationsPlugin } from "./plugin"; - -/** The plugin's atomic (= hidden) source ranges as [from, to] pairs. */ -function atomicRanges(view: EditorView): Array<[number, number]> { - const set = view.plugin(markdownDecorationsPlugin)?.atomic; - const out: Array<[number, number]> = []; - set?.between(0, view.state.doc.length, (from, to) => { - out.push([from, to]); - }); - return out; -} - -describe("markdownDecorationsPlugin — hides + atomicizes syntax markers", () => { - afterEach(destroyEditors); - - it("makes the bold ** markers atomic when the caret is outside the construct", () => { - const doc = "x **b** y"; - const view = makeEditor(doc, 0); // caret far from the bold - const ranges = atomicRanges(view); - const open = doc.indexOf("**b**"); - expect(ranges).toContainEqual([open, open + 2]); // opening ** - expect(ranges).toContainEqual([doc.indexOf("b") + 1, doc.indexOf("b") + 3]); // closing ** - // The visible "b" itself is never atomic. - const b = doc.indexOf("b"); - expect(ranges.some(([f, t]) => f <= b && b < t)).toBe(false); - }); - - it("hides the heading marker (and its space) when the caret is on another line", () => { - const doc = "# Title\nbody"; - const view = makeEditor(doc, doc.indexOf("body")); - expect(atomicRanges(view)).toContainEqual([0, 2]); // "# " - }); - - it("keeps the ** markers hidden/atomic even with the caret inside (hide-always)", () => { - // EmphasisMark is `hide-always` in the registry: this editor never reveals - // raw `**` for editing, so the cursor/delete normalizers can always treat - // the markers as atomic regardless of caret position. - const doc = "x **b** y"; - const view = makeEditor(doc, doc.indexOf("b")); // caret inside the bold - const open = doc.indexOf("**b**"); - expect(atomicRanges(view)).toContainEqual([open, open + 2]); - }); - - it("renders an ordered-list mark as its number and a bullet mark as a •", () => { - const ordered = makeEditor("1. first\n2. second", 0); - const numbers = [...ordered.dom.querySelectorAll(".cm-ordered-marker")].map((e) => e.textContent); - expect(numbers).toEqual(["1.", "2."]); - - const bullet = makeEditor("- item", 0); - expect(bullet.dom.querySelector(".cm-bullet-widget")?.textContent).toBe("•"); - expect(bullet.dom.querySelector(".cm-ordered-marker")).toBeNull(); - }); - - it("hides the leading backslash of an escape, keeping the escaped char (\\' → ')", () => { - const doc = "year\\'s"; // a backslash-escaped apostrophe, as some models emit - const view = makeEditor(doc, 0); - const slash = doc.indexOf("\\"); // the "\" before the apostrophe - expect(atomicRanges(view)).toContainEqual([slash, slash + 1]); - // The escaped "'" itself stays visible (never atomic). - const apos = doc.indexOf("'"); - expect(atomicRanges(view).some(([f, t]) => f <= apos && apos < t)).toBe(false); - }); -}); - -describe("nodeRulesFacet — extensions contribute rules without touching core", () => { - afterEach(destroyEditors); - - it("an extension-provided rule overrides the base rule for that node", () => { - // Emphasis is a base `mark("cm-emphasis")`; an extension re-rules it. - const view = makeEditor("*i*", 0, [ - nodeRulesFacet.of({ Emphasis: mark("cm-custom-emphasis") }), - ]); - expect(view.contentDOM.querySelector(".cm-custom-emphasis")).not.toBeNull(); - expect(view.contentDOM.querySelector(".cm-emphasis")).toBeNull(); - }); - - it("base rules keep painting when no extension contributes", () => { - const view = makeEditor("*i*", 0); - expect(view.contentDOM.querySelector(".cm-emphasis")).not.toBeNull(); - }); -}); diff --git a/packages/rich-editor/src/codemirror/core/plugin.ts b/packages/rich-editor/src/codemirror/core/plugin.ts deleted file mode 100644 index 21805c6..0000000 --- a/packages/rich-editor/src/codemirror/core/plugin.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Markdown decoration plugin — the PAINTER. Walks the Lezer tree against the - * visible viewport, asks each node's {@link NodeRule} how to render, and - * applies the returned {@link Paint}. - * - * The painter contains *no* construct knowledge: rules live in the base - * table (`NODE_RULES`) merged with any extension-contributed rules - * (`nodeRulesFacet`). The one switch here is over `Paint` — CodeMirror's - * closed set of mechanisms — so adding a construct, widget, contextual - * override, or whole extension never edits this file. - * - * Perf shape (unchanged from the spike): - * * Walks `view.visibleRanges` against `syntaxTree(view.state)` — - * the work is proportional to the viewport, not the document. - * * Rebuilt on doc change or viewport change. - * - * Decoration ordering (CM6 requires `from`-then-`startSide` ascending): - * * Line decorations collected in one bucket, mark / replace in - * another. The final `Decoration.set` lets CM6 sort defensively, - * costing ~µs on the typical viewport — cheaper than risking the - * "decorations out of order" runtime error. - */ - -import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; -import { RangeSetBuilder, type Range } from "@codemirror/state"; -import { - Decoration, - EditorView, - type DecorationSet, - ViewPlugin, - type PluginValue, - type ViewUpdate, -} from "@codemirror/view"; - -import { nodeRulesFacet, type NodeLike, type NodeRules } from "./paint"; -import { NODE_RULES } from "./registry"; - -/* ---------------- Decoration instances ----------------- */ -// -// Cached per `className` so we don't allocate a Decoration per node -// per build. CM6 dedups internally, but skipping the call is cheaper. - -const lineDecoCache = new Map(); -const markDecoCache = new Map(); -const HIDE_MARKER = Decoration.replace({}); - -function lineDeco(className: string): Decoration { - let d = lineDecoCache.get(className); - if (!d) { - d = Decoration.line({ class: className }); - lineDecoCache.set(className, d); - } - return d; -} - -function markDeco(className: string): Decoration { - let d = markDecoCache.get(className); - if (!d) { - d = Decoration.mark({ class: className }); - markDecoCache.set(className, d); - } - return d; -} - -/* ---------------- Effective rules ---------------- */ - -// Base + extension rules, merged once per distinct facet value (the facet -// result is referentially stable until providers change). -const mergedRulesCache = new WeakMap(); - -function effectiveRules(view: EditorView): NodeRules { - const extra = view.state.facet(nodeRulesFacet); - if (Object.keys(extra).length === 0) return NODE_RULES; - let merged = mergedRulesCache.get(extra); - if (!merged) { - merged = Object.assign({}, NODE_RULES, extra); - mergedRulesCache.set(extra, merged); - } - return merged; -} - -/* ---------------- The painter ---------------- */ - -interface BuildResult { - decorations: DecorationSet; - /** - * Every hidden/replaced range, surfaced as `EditorView.atomicRanges` so - * cursor motion (arrow keys, drag-select, double-click) treats hidden - * markup as a single atom — the user moves "around" hidden `**` instead - * of getting stranded between two invisible characters. - */ - atomic: DecorationSet; -} - -/** - * Block-level markers (`# ` heading, `> ` quote, `- ` list item) are - * followed by exactly one separator space; we hide that space along - * with the marker so the rendered line starts at the first content - * char. Inline markers (`*`, backticks) don't have this — only the - * line-leading case applies. - */ -function expandTrailingSpace( - view: EditorView, - range: { from: number; to: number }, -): number { - const line = view.state.doc.lineAt(range.from); - if (range.from !== line.from) return range.to; - const charAfter = view.state.doc.sliceString(range.to, range.to + 1); - return charAfter === " " ? range.to + 1 : range.to; -} - -function buildDecorations(view: EditorView): BuildResult { - const rules = effectiveRules(view); - const lineDecs: Range[] = []; - const markDecs: Range[] = []; - const atomicBuilder = new RangeSetBuilder(); - // Force-parse through the viewport so a list/task marker stays rendered while - // typing: the incremental tree can lag for the just-edited line on a large - // doc, briefly dropping the widget back to raw source (#37). Bounded to the - // viewport + a timeout, so a large note doesn't pay a full re-parse per key. - const tree = ensureSyntaxTree(view.state, view.viewport.to, 100) ?? syntaxTree(view.state); - - // Stamp a Decoration.line on every line overlapping the given - // range. CM6 requires `Decoration.line` to be anchored at a line - // start; this helper does the line-iteration once for callers. - const stampLineRange = (deco: Decoration, from: number, to: number) => { - let pos = from; - while (pos <= to) { - const line = view.state.doc.lineAt(pos); - lineDecs.push(deco.range(line.from)); - if (line.to >= to) break; - pos = line.to + 1; - } - }; - - for (const { from, to } of view.visibleRanges) { - tree.iterate({ - from, - to, - enter: (node) => { - const rule = rules[node.name]; - if (!rule) { - // Unknown node — the coverage test prevents this for the base - // grammar; an extension grammar's node without a contributed rule - // lands here. Skip silently rather than throw: a missing rule - // must not break editing. - return; - } - // `node.node` materializes a SyntaxNode (lezer's documented-expensive - // path); most rules are constant combinators that never read it, so - // the context exposes it — and the parent walk — through lazy getters - // that only pay when a contextual rule actually asks. - const paint = rule({ - name: node.name, - from: node.from, - to: node.to, - get parentName() { - return node.node.parent?.name; - }, - get node() { - return node.node as unknown as NodeLike; - }, - state: view.state, - }); - - switch (paint.paint) { - case "lineClass": { - if (paint.span === "first") { - const line = view.state.doc.lineAt(node.from); - lineDecs.push(lineDeco(paint.className).range(line.from)); - } else { - stampLineRange(lineDeco(paint.className), node.from, node.to); - } - return; - } - case "mark": { - markDecs.push(markDeco(paint.className).range(node.from, node.to)); - return; - } - case "hide": { - const range = paint.range ?? node; - const hideEnd = - paint.expandSpace === false ? range.to : expandTrailingSpace(view, range); - markDecs.push(HIDE_MARKER.range(range.from, hideEnd)); - atomicBuilder.add( - range.from, - Math.max(hideEnd, paint.atomicTo ?? hideEnd), - HIDE_MARKER, - ); - return; - } - case "widget": { - const hideEnd = expandTrailingSpace(view, node); - markDecs.push(paint.deco.range(node.from, hideEnd)); - atomicBuilder.add( - node.from, - Math.max(hideEnd, paint.atomicTo ?? hideEnd), - paint.deco, - ); - return; - } - case "none": - return; - } - }, - }); - } - - // Line decorations before mark/replace at the same point — this is - // the canonical CM6 ordering. Concat-then-sort is one O(n log n) - // for the whole viewport, dwarfed by the parse cost. - const all: Range[] = lineDecs.concat(markDecs); - return { - decorations: Decoration.set(all, /* sort */ true), - atomic: atomicBuilder.finish(), - }; -} - -export const markdownDecorationsPlugin = ViewPlugin.fromClass( - class implements PluginValue { - decorations: DecorationSet; - atomic: DecorationSet; - constructor(view: EditorView) { - const built = buildDecorations(view); - this.decorations = built.decorations; - this.atomic = built.atomic; - } - update(update: ViewUpdate) { - // No decoration depends on the cursor (markers never reveal on - // proximity), so caret-only transactions skip the whole build path. - if (update.docChanged || update.viewportChanged) { - const built = buildDecorations(update.view); - this.decorations = built.decorations; - this.atomic = built.atomic; - } - } - }, - { - decorations: (v) => v.decorations, - provide: (plugin) => - EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomic ?? Decoration.none), - }, -); diff --git a/packages/rich-editor/src/codemirror/core/registry.test.ts b/packages/rich-editor/src/codemirror/core/registry.test.ts deleted file mode 100644 index abcf5a7..0000000 --- a/packages/rich-editor/src/codemirror/core/registry.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Coverage gate for the decoration registry. - * - * Asserts that every Lezer markdown node Compose's editor will - * encounter at runtime has an explicit entry in - * `NODE_RULES`. "Explicit" includes `render-raw` - * and `structural` — the gate's job is not to demand styling for - * everything, just to make every choice conscious. - * - * We read the live parser's node set rather than maintaining a - * hand-typed canonical list, so the day someone bumps - * `@lezer/markdown` and it introduces (say) `MathBlock` or - * `Footnote`, this test trips before that node ships unstyled. - * - * Two languages because `markdownLanguage` extends `commonmarkLanguage` - * with GFM types (Table / Strikethrough / etc) — the editor wires the - * extended one, so the union is what the user will actually see. - */ - -import { commonmarkLanguage, markdownLanguage } from "@codemirror/lang-markdown"; -import { describe, expect, it } from "vitest"; - -import { NODE_RULES } from "./registry"; - -// `@codemirror/language`'s `Language.parser` is typed as the abstract -// `Parser` from `@lezer/common`, which doesn't surface `nodeSet`. The -// concrete `MarkdownParser` we get at runtime does. Narrow the public -// type at the boundary rather than importing `MarkdownParser` (which -// would pull `@lezer/markdown` into our direct deps just to satisfy -// the type checker for one test). -interface ParserWithNodeSet { - readonly nodeSet: { readonly types: readonly { readonly name: string }[] }; -} - -function collectNodeNames(): Set { - const names = new Set(); - for (const lang of [commonmarkLanguage, markdownLanguage]) { - const parser = lang.parser as unknown as ParserWithNodeSet; - for (const type of parser.nodeSet.types) { - // Lezer reserves an empty-named type at index 0 for the - // anonymous root; ignore it. Same for any other anonymous - // helper types — they're never named in tree iteration. - if (type.name) names.add(type.name); - } - } - return names; -} - -describe("markdown decoration registry", () => { - it("covers every node Lezer's CommonMark + GFM parsers emit", () => { - const live = collectNodeNames(); - const missing = [...live].filter((name) => !(name in NODE_RULES)).sort(); - expect( - missing, - // Custom message so the diff actually tells the reader what to do. - `Add NODE_RULES entries (mark()/line()/hideAlways()/raw(why)/… ) for: ${missing.join(", ")}`, - ).toEqual([]); - }); - - it("never carries dead entries the live parser no longer emits", () => { - const live = collectNodeNames(); - const extra = Object.keys(NODE_RULES) - .filter((name) => !live.has(name)) - .sort(); - expect( - extra, - `Registry has entries for nodes Lezer no longer emits (probably a stale handcoded name): ${extra.join(", ")}`, - ).toEqual([]); - }); - - it("requires every raw()/structural() rule to carry a documented why", () => { - const offenders: string[] = []; - for (const [name, rule] of Object.entries(NODE_RULES)) { - if (rule.meta && (!rule.meta.why || rule.meta.why.trim() === "")) offenders.push(name); - } - expect( - offenders, - `These do-nothing rules omit \`why\` — every "we don't decorate this" decision needs a documented reason: ${offenders.join(", ")}`, - ).toEqual([]); - }); -}); diff --git a/packages/rich-editor/src/codemirror/core/registry.ts b/packages/rich-editor/src/codemirror/core/registry.ts deleted file mode 100644 index 2d1fba3..0000000 --- a/packages/rich-editor/src/codemirror/core/registry.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Canonical table: every Lezer markdown node name → its {@link NodeRule} — - * ONE place answering "how does this construct render". The Lezer parser - * handles CommonMark + GFM spec compliance; this file is the visual contract. - * - * Why a table of rules (functions), not data: - * - * 1. **Completeness is auditable.** `registry.test.ts` reads the live - * parser's node set and fails when any node Lezer emits has no entry - * here — a lezer-markdown bump that adds a node trips the gate before - * it ships unstyled. Only the KEYS matter for that, so entries are free - * to be behavior. - * 2. **Context is first-class.** A node name can mean different things in - * different parents (a URL inside `[text](url)` is chrome; a bare - * pasted URL is content — hiding it made an invisible dead zone). - * Every rule receives context; contextual entries are ordinary inline - * functions, readable at the point of definition. - * 3. **Adding a construct touches one place.** A styled span is a - * one-liner via the combinators; a widget construct exports its rule - * from its own module and is listed here; an EXTENSION with its own - * grammar ships rules via `nodeRulesFacet` and never touches this file. - * 4. **Deliberate non-styling stays documented.** `raw(why)` / - * `structural(why)` tag their rules; the coverage test insists the - * `why` is present. - * - * The rules speak {@link Paint} — CodeMirror's own mechanisms (line class, - * span mark, hide, widget, nothing). That union is CLOSED: constructs grow, - * ways to paint don't, so the single switch over Paint lives in the painter - * (plugin.ts) and no new construct ever edits it. - */ - -import { horizontalRuleRule } from "./hrWidget"; -import { htmlBlockRule, htmlInlineRule } from "../html/htmlWidget"; -import { imageRule } from "../image/imageWidget"; -import { listMarkRule } from "../list/bulletWidget"; -import { - headingLine, - hideAlways, - line, - mark, - raw, - structural, - type NodeContext, - type NodeRules, -} from "./paint"; -import { taskMarkerRule } from "../list/taskCheckboxWidget"; - -/** Does this URL's parent Link render any visible label text? The label is - * everything between the opening `[` and closing `]` marks; whitespace-only - * counts as invisible (a lone space is a dead zone in practice). */ -function linkHasVisibleLabel(ctx: NodeContext): boolean { - const link = ctx.node.parent; - if (!link) return false; - let bracketOpen: { readonly to: number } | null = null; - let bracketClose: { readonly from: number } | null = null; - for (let child = link.firstChild; child; child = child.nextSibling) { - if (child.name !== "LinkMark") continue; - const mark = ctx.state.sliceDoc(child.from, child.to); - if (mark === "[" && !bracketOpen) bracketOpen = child; - else if (mark === "]" && !bracketClose) bracketClose = child; - } - if (!bracketOpen || !bracketClose || bracketClose.from <= bracketOpen.to) return false; - return ctx.state.sliceDoc(bracketOpen.to, bracketClose.from).trim() !== ""; -} - -export const NODE_RULES: NodeRules = { - // ----- Structural wrappers (never directly styled) ----- - Document: structural("top-level wrapper"), - Paragraph: structural("body font is the default"), - - // ----- ATX headings (`# H1` … `###### H6`) ----- - ATXHeading1: headingLine("cm-heading cm-heading--1"), - ATXHeading2: headingLine("cm-heading cm-heading--2"), - ATXHeading3: headingLine("cm-heading cm-heading--3"), - ATXHeading4: headingLine("cm-heading cm-heading--4"), - ATXHeading5: headingLine("cm-heading cm-heading--5"), - ATXHeading6: headingLine("cm-heading cm-heading--6"), - - // ----- Setext headings (`=====` / `-----` under text) ----- - SetextHeading1: raw("Phase 2: same line scaling as ATXHeading1"), - SetextHeading2: raw("Phase 2: same line scaling as ATXHeading2"), - - // ----- Block-level constructs ----- - Blockquote: line("cm-blockquote"), - BulletList: structural("ListItem children carry the styling"), - OrderedList: structural("ListItem children carry the styling"), - ListItem: line("cm-list-line"), - FencedCode: line("cm-fenced-code"), - CodeBlock: raw("Phase 2: indented code-block, same style as FencedCode"), - HorizontalRule: horizontalRuleRule, // `---` → styled `
` widget - HTMLBlock: htmlBlockRule, - LinkReference: raw("Phase 2: reference-style links resolve in click handler"), - - // ----- Inline styling ----- - Emphasis: mark("cm-emphasis"), - StrongEmphasis: mark("cm-strong"), - InlineCode: mark("cm-inline-code"), - Link: mark("cm-link"), - Image: imageRule, // `![alt](src)` → inline `` widget - - // ----- Inline literal sub-nodes (rendered inside their parent) ----- - // Inside a Link the URL is chrome ONLY when a visible label exists — for - // `[](url)` / `[ ](url)` the URL is the link's entire visible content, and - // hiding it (with the marks already hidden) left an invisible dead zone. - // Bare GFM autolinks and autolinks emit the SAME node name, and - // there the URL IS the content — same class of bug when hidden. - URL: (ctx) => { - if (ctx.parentName !== "Link") return { paint: "mark", className: "cm-link" }; - return linkHasVisibleLabel(ctx) - ? { paint: "hide" } - : { paint: "mark", className: "cm-link" }; - }, - LinkLabel: raw("visible inside Link; parent mark styles it"), - LinkTitle: hideAlways(), // `[text](URL "title")` — title never visible - CodeText: raw("interior of FencedCode/InlineCode; parent decoration covers it"), - CodeInfo: mark("cm-code-info"), // language tag on the opener row — visible so typing there is never invisible (§12.4) - HardBreak: structural("trailing two-spaces or backslash, no visible glyph"), - Comment: raw("Phase 2: dim inline HTML comments"), - CommentBlock: raw("Phase 2: dim block HTML comments"), - ProcessingInstruction: raw("Phase 2: dim like HTML comments"), - ProcessingInstructionBlock: raw("Phase 2: dim like HTML comments"), - Entity: raw("HTML entities like & render as-is, by design"), - // Backslash-escape: hide only the leading `\`, so `\'` renders as `'` per - // CommonMark rather than showing a literal backslash. - Escape: (ctx) => ({ - paint: "hide", - range: { from: ctx.from, to: ctx.from + 1 }, - expandSpace: false, - }), - HTMLTag: htmlInlineRule, - - // ----- Syntax markers (hidden always — non-technical UX, distinct from Obsidian's Live Preview) ----- - // `#` marks on ATX headings hide; a SETEXT heading's underline is that - // heading's only marker on its own line — hiding it left an invisible, - // unclickable dead line. Visible until setext gets real styling. - HeaderMark: (ctx) => - ctx.parentName?.startsWith("SetextHeading") ? { paint: "none" } : { paint: "hide" }, - EmphasisMark: hideAlways(), // `*` / `_` - CodeMark: hideAlways(), // backticks for inline / fence pairs for blocks - LinkMark: hideAlways(), // `[`/`]`/`(`/`)` - QuoteMark: hideAlways(), // `>` - ListMark: listMarkRule, // `-`/`1.` → bullet / number / nothing beside a checkbox - TaskMarker: taskMarkerRule, // `[ ]` / `[x]` → real checkbox - - // ----- GFM extensions (enabled via `markdownLanguage`) ----- - Strikethrough: mark("cm-strikethrough"), - // `~~` joins the hidden-marker system like EmphasisMark — the flanking - // guard and delete normalizer already assume these semantics; leaving the - // marks visible made deletion eat single tildes (interaction-spec §8.1). - StrikethroughMark: hideAlways(), - Subscript: raw("Phase 2: vertical-align: sub"), - SubscriptMark: raw("Phase 2: hide `~` off-parent"), - Superscript: raw("Phase 2: vertical-align: super"), - SuperscriptMark: raw("Phase 2: hide `^` off-parent"), - Emoji: raw("Phase 2: replace `:smile:` with the emoji glyph"), - Autolink: raw("wrapper of a visible URL child; Phase 2 wires click-to-open"), - Task: raw("Phase 2: render task list items with a real checkbox"), - - // GFM tables — handled by a dedicated StateField that emits the block - // widget on Table nodes (CM6 forbids multi-line replace from a ViewPlugin, - // so this rules-driven plugin can't do that shape; see tableField.ts). - Table: structural("tableField StateField owns Table rendering"), - TableHeader: structural("covered by Table widget"), - TableRow: structural("covered by Table widget"), - TableCell: structural("covered by Table widget"), - TableDelimiter: structural("covered by Table widget"), -}; diff --git a/packages/rich-editor/src/codemirror/core/renderedOutput.test.ts b/packages/rich-editor/src/codemirror/core/renderedOutput.test.ts deleted file mode 100644 index f1452c1..0000000 --- a/packages/rich-editor/src/codemirror/core/renderedOutput.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, describe, expect, it } from "vitest"; - -import { destroyEditors, makeFullEditor } from "./editorTestHarness"; - -/** - * What the USER SEES — the rendered output, not the markdown source. - * - * The block-command / source-level tests assert what's written to the document; - * this suite asserts what's drawn for it, through the *full* editor extension - * set (wikilinks, highlight, footnotes, math, tables — not just the base - * decorations). That layer was previously untested, which is how a numbered - * list rendering as bullets and a task item drawing both a bullet and a checkbox - * shipped unnoticed. - */ - -/** The visible text of each rendered line (hidden markers gone, widgets in). */ -function seen(doc: string): string[] { - const view = makeFullEditor(doc, 0); - return [...view.contentDOM.querySelectorAll(".cm-line")].map((line) => line.textContent ?? ""); -} - -describe("rendered output — what the user sees", () => { - afterEach(destroyEditors); - - it("hides heading / emphasis / code markers, leaving the content", () => { - expect(seen("# Title")).toEqual(["Title"]); - expect(seen("**b** *i* `c`")).toEqual(["b i c"]); - }); - - it("a bare pasted URL stays VISIBLE, styled as a link", () => { - // Field report: pasted URLs vanished (the registry's URL hide-always was - // written for [text](url), but bare autolinks emit the same node) — - // leaving an invisible, unclickable dead zone the user pasted into - // repeatedly because nothing appeared. - expect(seen("open ai build day: https://openai.devpost.com/x")).toEqual([ - "open ai build day: https://openai.devpost.com/x", - ]); - const view = makeFullEditor("see https://a.b/c now", 0); - const link = view.contentDOM.querySelector(".cm-link"); - expect(link?.textContent).toBe("https://a.b/c"); - }); - - it("an autolink shows its URL (brackets hidden)", () => { - expect(seen("go now")).toEqual(["go https://a.b/c now"]); - }); - - it("a [label](url) link still shows ONLY the label", () => { - expect(seen("see [docs](https://a.b/c) now")).toEqual(["see docs now"]); - }); - - it("an EMPTY-label link shows its URL — never an invisible hole", () => { - // Same dead-zone class as the bare-URL report, one sibling over: with the - // label and marks hidden, the URL was the link's only visible content. - expect(seen("a [](https://x.dev/page) b")).toEqual(["a https://x.dev/page b"]); - expect(seen("a [ ](https://y.dev/q) b")).toEqual(["a https://y.dev/q b"]); - }); - - it("the caret can sit inside a bare URL (no atomic dead zone)", () => { - const doc = "x https://a.b/c y"; - const view = makeFullEditor(doc, 0); - const inside = doc.indexOf("a.b") + 1; - view.dispatch({ selection: { anchor: inside } }); - expect(view.state.selection.main.head).toBe(inside); - }); - - // ── INVARIANT: nothing the user typed renders invisibly ──────────────────── - // The registry hides construct CHROME; it must never hide CONTENT. Each row - // plants a sentinel in a position that has bitten (or could): the sentinel - // must appear in the rendered text. Bare URLs (#157), setext underlines, and - // angle-bracket placeholders all failed this before their fixes — new - // constructs add a row here. - describe("no user text becomes invisible", () => { - const CASES: Array<[name: string, doc: string, sentinel: string]> = [ - ["bare pasted URL", "day: https://x.dev/SENTINEL9 end", "https://x.dev/SENTINEL9"], - ["angle autolink", "go now", "https://x.dev/SENTINEL9"], - ["email autolink", "mail now", "sentinel9@x.dev"], - ["setext H1 underline", "Title\n===", "==="], - ["setext H2 underline", "Title\n---", "---"], - ["angle-bracket placeholder", "Dear , hi", ""], - ["stray closing tag", "a
c", "
"], - ["stripped script tag", "a c", "SENTINEL9"], - ["reference link label", "see [SENTINEL9][1] end\n\n[1]: https://x.dev", "SENTINEL9"], - ["html comment", "a c", "SENTINEL9"], - ["entity", "a &SENTINEL9 c", "SENTINEL9"], - // Code is literal: an inline construct's syntax inside a code context - // must render verbatim, never as its rich form (export parity, #167). - ["wikilink in a fenced code block", "```bash\nnpm i [[SENTINEL9]]\n```", "[[SENTINEL9]]"], - ["wikilink in inline code", "run `use [[SENTINEL9]] here` now", "[[SENTINEL9]]"], - ["highlight marks in a fenced code block", "```\na ==SENTINEL9== b\n```", "==SENTINEL9=="], - ["footnote ref in a fenced code block", "```\nsee [^SENTINEL9] end\n```", "[^SENTINEL9]"], - ["inline math in inline code", "a `costs $SENTINEL9$ x` b", "$SENTINEL9$"], - ["math block marks in a fenced code block", "```\n$$\nSENTINEL9\n$$\n```", "$$"], - ]; - for (const [name, doc, sentinel] of CASES) { - it(name, () => { - expect(seen(doc).join("\n")).toContain(sentinel); - }); - } - }); - - it("draws a bullet as • and an ordered item as its number — never a bullet", () => { - expect(seen("- item")).toEqual(["•item"]); - expect(seen("1. first\n2. second")).toEqual(["1.first", "2.second"]); - // The regression behind #22: an ordered mark must not render as a bullet. - expect(seen("3. lonely")).toEqual(["3.lonely"]); - }); - - it("renumbers an ordered list on display (CommonMark), ignoring source digits", () => { - expect(seen("1. a\n1. b\n1. c")).toEqual(["1.a", "2.b", "3.c"]); - // Starts at the first item's number, then sequential. - expect(seen("3. a\n9. b")).toEqual(["3.a", "4.b"]); - }); - - it("re-numbers live when an item is removed", () => { - const view = makeFullEditor("1. a\n1. b\n1. c", 0); - const numbers = () => - [...view.contentDOM.querySelectorAll(".cm-ordered-marker")].map((e) => e.textContent); - expect(numbers()).toEqual(["1.", "2.", "3."]); - view.dispatch({ changes: { from: 0, to: 5, insert: "" } }); // remove the first item - expect(numbers()).toEqual(["1.", "2."]); - }); - - it("re-numbers live when an item is inserted", () => { - const view = makeFullEditor("1. a\n1. b", 0); - const numbers = () => - [...view.contentDOM.querySelectorAll(".cm-ordered-marker")].map((e) => e.textContent); - expect(numbers()).toEqual(["1.", "2."]); - view.dispatch({ changes: { from: 5, insert: "1. mid\n" } }); // insert a new second item - expect(numbers()).toEqual(["1.", "2.", "3."]); // the former second item renumbers to 3 - }); - - it("keeps the number marker rendered while typing into an item", () => { - // The contract behind #37: editing an item's text must not drop its marker - // back to raw `1.` source. (The shipped flicker was an incomplete-parse race - // the editor now guards against by force-parsing the viewport.) - const view = makeFullEditor("1. a\n2. b", 0); - const numbers = () => - [...view.contentDOM.querySelectorAll(".cm-ordered-marker")].map((e) => e.textContent); - expect(numbers()).toEqual(["1.", "2."]); - view.dispatch({ changes: { from: view.state.doc.length, insert: "more" } }); - expect(numbers()).toEqual(["1.", "2."]); - }); - - it("renders a nested ordered sublist starting at 1", () => { - // The result of indenting two items under a parent (#40): the sublist is a - // separate ordered list, so it renumbers from 1 by position. - const view = makeFullEditor("1. parent\n 1. child\n 1. second", 0); - const numbers = [...view.contentDOM.querySelectorAll(".cm-ordered-marker")].map( - (e) => e.textContent, - ); - expect(numbers).toEqual(["1.", "1.", "2."]); - }); - - it("draws a task item as a checkbox, with no bullet beside it", () => { - const content = makeFullEditor("- [ ] todo", 0).contentDOM; - expect(content.querySelector(".cm-task-checkbox")).not.toBeNull(); - expect(content.querySelector(".cm-bullet-widget")).toBeNull(); - }); - - it("hides wikilink brackets, showing the name (or the alias)", () => { - expect(seen("[[Note Name]]")).toEqual(["Note Name"]); - expect(seen("[[target|Alias]]")).toEqual(["Alias"]); - }); - - it("keeps [[…]] literal inside a fenced code block", () => { - expect(seen("```bash\nnpm i [[not-a-link]]\n```")).toEqual([ - "bash", - "npm i [[not-a-link]]", - "", - ]); - }); - - it("hides markdown-link and highlight markers, leaving the visible text", () => { - expect(seen("[text](http://example.com)")).toEqual(["text"]); - expect(seen("==marked==")).toEqual(["marked"]); - }); - - it("tags a blockquote line so it can be styled", () => { - const line = makeFullEditor("> quoted", 0).contentDOM.querySelector(".cm-line"); - expect(line?.className).toContain("cm-blockquote"); - }); -}); diff --git a/packages/rich-editor/src/codemirror/core/treeAt.browser.test.ts b/packages/rich-editor/src/codemirror/core/treeAt.browser.test.ts deleted file mode 100644 index d6e446e..0000000 --- a/packages/rich-editor/src/codemirror/core/treeAt.browser.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Real-WebKit proof that a viewport-limited tree is a production condition, not - * a jsdom artifact — the reason {@link treeAt} exists. - * - * Measured in this engine on a 7,902-character document (editor 11,404px tall, - * real layout): on the frame the view opens, the parser has covered 3,009 - * characters and the viewport is 0–331. CodeMirror's background parse then - * walks past the viewport during idle and covers the rest within ~1.5s. - * - * So the wrong answer is a race, not a permanent state, which is exactly why it - * survives hand-testing: by the time a human has clicked into the document it - * has healed. These cases run the command on the opening frame, where a - * `syntaxTree` read still answers "no fence here" and the guard lets a delete - * cross a boundary it should have walled. - * - * The jsdom suites pin the same moment permanently — no layout means no viewport - * growth and no idle parse work — which is what made them flaky before the fix. - */ - -import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; -import { syntaxTree } from "@codemirror/language"; -import { EditorSelection, EditorState } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it } from "vitest"; - -import { markdownDecorationsPlugin } from "./plugin"; -import { tightListContinuation } from "../list/listContinuation"; -import { visibleBackspace } from "../interaction/deleteNormalizer"; - -const views: EditorView[] = []; - -afterEach(() => { - for (const view of views.splice(0)) { - view.destroy(); - view.dom.parentElement?.remove(); - } -}); - -/** - * An editor built the way the app builds one — no pre-parse. The harness's - * `makeEditor` drives the parse to the end of the document first, which is the - * one thing that would hide what these cases are about. - */ -function openEditor(doc: string, caret: number): EditorView { - const parent = document.createElement("div"); - document.body.appendChild(parent); - const view = new EditorView({ - parent, - state: EditorState.create({ - doc, - selection: EditorSelection.cursor(caret), - extensions: [markdown({ base: markdownLanguage }), markdownDecorationsPlugin], - }), - }); - views.push(view); - return view; -} - -/** Paragraphs enough to put `tail` past the opening frame's parsed region. */ -function belowTheFold(tail: string) { - const filler = Array.from({ length: 400 }, (_, i) => `paragraph line ${i}`).join("\n\n"); - return `${filler}\n\n${tail}`; -} - -/** - * The premise, asserted rather than assumed: on this frame the parser really - * has stopped short of the caret. A failure here means CodeMirror now covers - * the document up front and these cases have stopped testing anything — the - * test needs revisiting, not the editor. - */ -function expectParseStopsShortOf(view: EditorView, caret: number) { - expect(syntaxTree(view.state).length).toBeLessThan(caret); -} - -/** Wait until the background parse stops advancing, whatever it reached. */ -async function settle(view: EditorView): Promise { - let last = -1; - let same = 0; - while (same < 3) { - await new Promise((r) => setTimeout(r, 100)); - const len = syntaxTree(view.state).length; - same = len === last ? same + 1 : 0; - last = len; - } - return last; -} - -describe("structural lookups on the frame the document opens", () => { - it("walls a fence that the parser has not reached yet", () => { - const doc = belowTheFold("```\ncode\n```"); - const caret = doc.lastIndexOf("code"); - const view = openEditor(doc, caret); - - expectParseStopsShortOf(view, caret); - expect(visibleBackspace(view)).toBe(true); - expect(view.state.doc.toString()).toBe(doc); - }); - - it("continues a list the parser has not reached yet", () => { - const doc = belowTheFold("- item"); - const caret = doc.length; - const view = openEditor(doc, caret); - - expectParseStopsShortOf(view, caret); - expect(tightListContinuation(view)).toBe(true); - expect(view.state.doc.toString()).toBe(`${doc}\n- `); - }); -}); - -/** - * The other regime, where the wrong answer is not a race and waiting does not - * help. CodeMirror's idle parse stops 100,000 characters past the viewport: - * measured, the tree plateaus at 100,739 for a 167k document and for a 341k - * one alike, and never advances again. - * - * Reaching it takes a document over ~100k *and* a caret that far from what is - * rendered — a restored caret on open, a search hit, an outline jump. Not - * typing, which scrolls the caret into view first. Narrow, but permanent, and - * this case cannot flake: its premise is that the parse has already given up. - */ -describe("structural lookups past the background parse's reach", () => { - it("walls a fence no amount of idle time will reach", async () => { - const filler = Array.from({ length: 8000 }, (_, i) => `paragraph line ${i}`).join("\n\n"); - const doc = `${filler}\n\n\`\`\`\ncode\n\`\`\``; - const caret = doc.lastIndexOf("code"); - const view = openEditor(doc, caret); - - const reached = await settle(view); - expect(reached).toBeLessThan(caret); - - expect(visibleBackspace(view)).toBe(true); - expect(view.state.doc.toString()).toBe(doc); - }, 60_000); -}); diff --git a/packages/rich-editor/src/codemirror/core/treeAt.ts b/packages/rich-editor/src/codemirror/core/treeAt.ts deleted file mode 100644 index 755786a..0000000 --- a/packages/rich-editor/src/codemirror/core/treeAt.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; -import type { EditorState } from "@codemirror/state"; - -/** - * How long a structural lookup may spend parsing. It runs inside a keystroke, - * so this is a budget rather than a guarantee: past it we answer from whatever - * has been parsed, exactly as before. - */ -const PARSE_BUDGET_MS = 50; - -/** - * A syntax tree that reaches `pos`. - * - * `syntaxTree` returns only what the **viewport** has driven the parser - * through, so `resolveInner` past that point reports the position as bare - * `Document`. Every command that asks "am I inside a list / a fence / a table?" - * then gets "no" and takes the plain-text path: a wrong answer, not a slow one. - * - * Measured in WebKit, the engine we ship on, with real layout — two regimes. - * - * On the frame a document opens the parser has covered a few thousand - * characters: 3,009 of 7,902, viewport 0–331, for an editor 11,404px tall. The - * idle parse then catches up within about half a second, so near the viewport - * this is a race — and it heals before a hand-test can see it. - * - * Further out it never heals. The idle parse stops 100,000 characters past the - * viewport: the tree plateaus at 100,739 for a 167k-character document and a - * 341k one alike, and stays there. `ensureSyntaxTree` resumes from that plateau - * and finishes the 341k document in 43ms — inside the budget above. - * - * Only for questions about a *position*. Decoration plugins iterate - * `view.visibleRanges` and should keep using `syntaxTree` directly: there, - * being limited to the viewport is the point. - */ -/// The tree type, named without importing `@lezer/common` — it is a transitive -/// dependency, and a declaration referring to it by pnpm path is not portable -/// for anyone consuming the built package. -type Tree = ReturnType; - -export function treeAt(state: EditorState, pos: number): Tree { - return ensureSyntaxTree(state, pos + 1, PARSE_BUDGET_MS) ?? syntaxTree(state); -} diff --git a/packages/rich-editor/src/codemirror/extensions/footnoteExtension.ts b/packages/rich-editor/src/codemirror/extensions/footnoteExtension.ts deleted file mode 100644 index 333c8e9..0000000 --- a/packages/rich-editor/src/codemirror/extensions/footnoteExtension.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { footnotePlugin } from "../footnote"; - -import { type MarkdownExtension } from "./types"; - -export const footnoteExtension: MarkdownExtension = { - name: "@compose/footnote", - version: "0.1.0", - description: "Renders `[^id]` references and `[^id]:` definitions with tooltip jump.", - extensions: [footnotePlugin], -}; diff --git a/packages/rich-editor/src/codemirror/extensions/highlightExtension.ts b/packages/rich-editor/src/codemirror/extensions/highlightExtension.ts deleted file mode 100644 index 46f4cd8..0000000 --- a/packages/rich-editor/src/codemirror/extensions/highlightExtension.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { highlightPlugin } from "../highlight"; - -import { type MarkdownExtension } from "./types"; - -export const highlightExtension: MarkdownExtension = { - name: "@compose/highlight", - version: "0.1.0", - description: "Renders `==text==` with a yellow highlight background.", - extensions: [highlightPlugin], -}; diff --git a/packages/rich-editor/src/codemirror/extensions/index.ts b/packages/rich-editor/src/codemirror/extensions/index.ts deleted file mode 100644 index e7cd4a0..0000000 --- a/packages/rich-editor/src/codemirror/extensions/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { type MarkdownExtension, type ToolbarContribution, type CaretContextSnapshot } from "./types"; -export { composeExtensions, type ComposedExtension } from "./loader"; -export { highlightExtension } from "./highlightExtension"; -export { footnoteExtension } from "./footnoteExtension"; -export { mathExtension } from "./mathExtension"; -export { mermaidExtension } from "./mermaidExtension"; -export { tableExtension } from "./tableExtension"; -export { wikilinkExtension } from "./wikilinkExtension"; diff --git a/packages/rich-editor/src/codemirror/extensions/loader.ts b/packages/rich-editor/src/codemirror/extensions/loader.ts deleted file mode 100644 index bc3efcd..0000000 --- a/packages/rich-editor/src/codemirror/extensions/loader.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { type Extension } from "@codemirror/state"; -import { keymap as cmKeymap } from "@codemirror/view"; - -import { nodeRulesFacet } from "../core"; -import { type MarkdownExtension, type ToolbarContribution } from "./types"; - -export interface ComposedExtension { - extensions: Extension[]; - toolbar: ToolbarContribution[]; -} - -export function composeExtensions(modules: readonly MarkdownExtension[]): ComposedExtension { - const extensions: Extension[] = []; - const toolbar: ToolbarContribution[] = []; - const allKeyBindings: import("@codemirror/view").KeyBinding[] = []; - const seenRuleNames = new Set(); - - for (const mod of modules) { - if (mod.extensions?.length) extensions.push(...mod.extensions); - if (mod.keymap?.length) allKeyBindings.push(...mod.keymap); - if (mod.toolbar?.length) toolbar.push(...mod.toolbar); - if (mod.rules) { - // An extension's node rules feed the decoration painter through the - // facet — introducing (or deliberately overriding) a construct never - // touches the base table. - extensions.push(nodeRulesFacet.of(mod.rules)); - for (const name of Object.keys(mod.rules)) { - if (seenRuleNames.has(name)) { - // eslint-disable-next-line no-console - console.warn( - `MarkdownExtension "${mod.name}" overrides node rule "${name}" already provided by another extension.`, - ); - } - seenRuleNames.add(name); - } - } - } - - if (allKeyBindings.length) extensions.push(cmKeymap.of(allKeyBindings)); - - return { extensions, toolbar }; -} diff --git a/packages/rich-editor/src/codemirror/extensions/mathExtension.ts b/packages/rich-editor/src/codemirror/extensions/mathExtension.ts deleted file mode 100644 index b7756f3..0000000 --- a/packages/rich-editor/src/codemirror/extensions/mathExtension.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { mathPlugin } from "../math"; - -import { type MarkdownExtension } from "./types"; - -export const mathExtension: MarkdownExtension = { - name: "@compose/math", - version: "0.1.0", - description: "Renders `$x$` inline and `$$x$$` block math via KaTeX.", - extensions: [mathPlugin], -}; diff --git a/packages/rich-editor/src/codemirror/extensions/mermaidExtension.ts b/packages/rich-editor/src/codemirror/extensions/mermaidExtension.ts deleted file mode 100644 index c225dce..0000000 --- a/packages/rich-editor/src/codemirror/extensions/mermaidExtension.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { mermaidField } from "../mermaid"; - -import { type MarkdownExtension } from "./types"; - -export const mermaidExtension: MarkdownExtension = { - name: "@compose/mermaid", - version: "0.1.0", - description: - "Renders closed ```mermaid fences as diagrams; click a diagram to edit its source.", - extensions: [mermaidField], -}; diff --git a/packages/rich-editor/src/codemirror/extensions/tableExtension.ts b/packages/rich-editor/src/codemirror/extensions/tableExtension.ts deleted file mode 100644 index 6e62ed1..0000000 --- a/packages/rich-editor/src/codemirror/extensions/tableExtension.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { EditorView } from "@codemirror/view"; - -import { armedTableField, tableArmedHighlight, tableField } from "../table"; -import { InlineCellSurface } from "../tablev2/inlineCellSurface"; -import { tableV2HoverControls } from "../tablev2/tableV2HoverControls"; -import { tableV2Interaction } from "../tablev2/tableV2Interaction"; -import { tableV2Sync } from "../tablev2/tableV2Sync"; - -import { type MarkdownExtension } from "./types"; - -/** - * A FACTORY, not a const: each composition gets its own editing surface (the - * one-active-edit state), so two mounted editors can never share a cell edit. - */ -export function tableExtension(): MarkdownExtension { - const surface = new InlineCellSurface(); - return { - name: "@compose/table", - version: "0.2.0", - description: - "Renders GFM tables as a grid widget; cells edit natively in place (ADR 0001).", - extensions: [ - tableField, - // Treat each table as one atomic block: the caret, clicks, and selection - // skip over it instead of landing on its hidden `| … |` source. Without - // this, arrows/clicks resolve to hidden offsets and Backspace edits a - // hidden pipe — corrupting the grid (and eating the blank-line separator). - EditorView.atomicRanges.of((view) => view.state.field(tableField)), - // Two-step delete: the field records which table the next press deletes; - // the plugin outlines it and draws the "armed" edge line. - armedTableField, - tableArmedHighlight, - // Keep the surface's table anchor + active edit alive across doc changes. - tableV2Sync(surface), - // Click-to-edit at the clicked character, bridge keys (Tab/arrows/ - // Backspace/Delete/Enter), whole-cell drag selection + TSV copy, the - // structure menu, and table entry/exit from the main document. - tableV2Interaction(surface), - // Hover "+" quick inserters (row below / column right). - tableV2HoverControls(), - ], - }; -} diff --git a/packages/rich-editor/src/codemirror/extensions/types.ts b/packages/rich-editor/src/codemirror/extensions/types.ts deleted file mode 100644 index 5c9f964..0000000 --- a/packages/rich-editor/src/codemirror/extensions/types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { type Extension } from "@codemirror/state"; -import { type KeyBinding } from "@codemirror/view"; -import { type ReactNode } from "react"; - -import { type NodeRules } from "../core"; - -export interface ToolbarContribution { - readonly id: string; - readonly group: "heading" | "format" | "block" | "insert" | string; - readonly label: string; - readonly icon: ReactNode; - readonly shortcut?: string; - readonly isActive?: (caretContext: CaretContextSnapshot) => boolean; - readonly run: (view: import("@codemirror/view").EditorView) => void; -} - -export interface CaretContextSnapshot { - readonly bold: boolean; - readonly italic: boolean; - readonly code: boolean; - readonly link: boolean; - readonly heading: 1 | 2 | 3 | 4 | 5 | 6 | 0; - readonly bulletList: boolean; - readonly orderedList: boolean; - readonly blockquote: boolean; -} - -export interface MarkdownExtension { - readonly name: string; - readonly version: string; - readonly description?: string; - - /** Node rules for constructs this extension's grammar introduces (or - * deliberately overrides) — merged into the decoration painter via - * `nodeRulesFacet`, so an extension never edits the base table. */ - readonly rules?: NodeRules; - readonly extensions?: Extension[]; - readonly keymap?: KeyBinding[]; - readonly toolbar?: ToolbarContribution[]; -} diff --git a/packages/rich-editor/src/codemirror/extensions/wikilinkExtension.ts b/packages/rich-editor/src/codemirror/extensions/wikilinkExtension.ts deleted file mode 100644 index fca2b98..0000000 --- a/packages/rich-editor/src/codemirror/extensions/wikilinkExtension.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { wikilinkPlugin } from "../wikilink"; - -import { type MarkdownExtension } from "./types"; - -export const wikilinkExtension: MarkdownExtension = { - name: "@compose/wikilink", - version: "0.1.0", - description: "Renders `[[target]]` / `[[target|alias]]` as clickable links.", - extensions: [wikilinkPlugin], -}; diff --git a/packages/rich-editor/src/codemirror/features/blockCommandSteps.ts b/packages/rich-editor/src/codemirror/features/blockCommandSteps.ts deleted file mode 100644 index c12da04..0000000 --- a/packages/rich-editor/src/codemirror/features/blockCommandSteps.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Shared Given/When/Then steps for the block-command feature files. - * - * The whole block-command surface has one regular shape — a document with a - * caret (or selection) goes in, a command runs, a document with a caret comes - * out — so these steps drive every scenario across the `.feature` files. They're - * registered once as a shared pool via {@link defineBlockSteps}; each scenario - * draws the steps it needs. - * - * Marker convention in the doc strings: `‸` marks the caret; a second `‸` turns - * it into a selection spanning the two marks. (`|` would collide with table - * syntax, so it's deliberately not used.) - */ - -import { EditorSelection } from "@codemirror/state"; -import { type EditorView } from "@codemirror/view"; -import { defineSteps } from "@amiceli/vitest-cucumber"; -import { expect } from "vitest"; - -import { blockCommands } from "../format"; -import { indentListItem, outdentListItem } from "../list"; -import { makeEditor, text } from "../core/editorTestHarness"; - -const CARET = "‸"; - -/** Zero-width space the runner prefixes onto doc-string content lines to hide - * literal markdown from the cucumber parser; stripped back out here. */ -export const ZWSP = String.fromCharCode(0x200b); - -const COMMANDS: Record boolean> = { - "toggle heading 1": blockCommands.toggleHeading1, - "toggle heading 2": blockCommands.toggleHeading2, - "toggle heading 3": blockCommands.toggleHeading3, - "toggle a bullet list": blockCommands.toggleBulletList, - "toggle an ordered list": blockCommands.toggleOrderedList, - "toggle a task list": blockCommands.toggleTaskList, - "toggle a blockquote": blockCommands.toggleBlockquote, - "toggle a code block": blockCommands.toggleCodeBlock, - "insert a table": blockCommands.insertTable, - "indent the list item": indentListItem, - "outdent the list item": outdentListItem, -}; - -// A Gherkin doc string reaches us with the runner's zero-width guards and the -// parser's own per-line trim; drop the guards and any trailing blank line so the -// literal markdown is what the scenario asserts against. -function cleanDoc(block: string): string { - return block - .split(ZWSP) - .join("") - .replace(/\n+$/, ""); -} - -// `‸` → caret; a second `‸` → a selection from the first mark to the second. -function parseMarked(marked: string): { doc: string; anchor: number; head: number } { - const first = marked.indexOf(CARET); - if (first < 0) return { doc: marked, anchor: 0, head: 0 }; - const afterFirst = marked.slice(0, first) + marked.slice(first + CARET.length); - const second = afterFirst.indexOf(CARET); - if (second < 0) return { doc: afterFirst, anchor: first, head: first }; - const doc = afterFirst.slice(0, second) + afterFirst.slice(second + CARET.length); - return { doc, anchor: first, head: second }; -} - -// Re-insert the marker(s) at the current selection, so a `Then` doc string can -// assert the resulting caret/selection as readably as it set the starting one. -function renderMarked(view: EditorView): string { - const { anchor, head } = view.state.selection.main; - const body = text(view); - if (anchor === head) return body.slice(0, head) + CARET + body.slice(head); - const [from, to] = anchor < head ? [anchor, head] : [head, anchor]; - return body.slice(0, from) + CARET + body.slice(from, to) + CARET + body.slice(to); -} - -// Register the shared step pool. Scenarios run sequentially within the file, so -// a single live editor is enough to thread Given → When → Then. -export function defineBlockSteps(): void { - let view: EditorView; - - defineSteps(({ Given, When, Then, And }) => { - Given("the document:", (_ctx: unknown, doc: string) => { - const { doc: source, anchor, head } = parseMarked(cleanDoc(doc)); - view = makeEditor(source, head); - if (anchor !== head) view.dispatch({ selection: EditorSelection.range(anchor, head) }); - }); - - const run = (_ctx: unknown, phrase: string) => { - const command = COMMANDS[phrase]; - if (!command) throw new Error(`no editor command bound for "${phrase}"`); - command(view); - }; - When("I {string}", run); - And("I {string}", run); - - // A `Then` doc string opts into a caret assertion by including `‸`; without - // one it asserts the document text alone. - Then("the document is:", (_ctx: unknown, expected: string) => { - const want = cleanDoc(expected); - expect(want.includes(CARET) ? renderMarked(view) : text(view)).toBe(want); - }); - }); -} - -// The cucumber parser treats a `#`-leading line as a comment and a `"""`/``` line -// as a doc-string delimiter — even *inside* a doc string — and it strips each -// content line's leading whitespace, which would also flatten the *relative* -// indent that list-nesting scenarios assert. So for every doc-string content -// line: strip the doc string's own base indent (the opening `"""` column), then -// prefix a zero-width space at column 0. The ZWSP is now the first character, so -// the parser strips nothing (relative indent survives) and reads no `#`/`"""`/``` -// as syntax; the steps drop the ZWSP back out. Source `.feature` files stay clean. -export function protectMarkdown(raw: string): string { - let insideDocString = false; - let baseIndent = 0; - return raw - .split("\n") - .map((line) => { - const trimmed = line.trimStart(); - if (trimmed.startsWith('"""')) { - if (!insideDocString) baseIndent = line.length - trimmed.length; - insideDocString = !insideDocString; - return line; - } - if (!insideDocString) return line; - const dedented = line.startsWith(" ".repeat(baseIndent)) - ? line.slice(baseIndent) - : trimmed; - return ZWSP + dedented; - }) - .join("\n"); -} diff --git a/packages/rich-editor/src/codemirror/features/blockCommands.feature.test.ts b/packages/rich-editor/src/codemirror/features/blockCommands.feature.test.ts deleted file mode 100644 index e3d858e..0000000 --- a/packages/rich-editor/src/codemirror/features/blockCommands.feature.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -// @vitest-environment jsdom -/// -import { describeFeature, loadFeatureFromText } from "@amiceli/vitest-cucumber"; -import { afterEach } from "vitest"; - -import { destroyEditors } from "../core/editorTestHarness"; -import { defineBlockSteps, protectMarkdown } from "./blockCommandSteps"; - -defineBlockSteps(); -afterEach(destroyEditors); - - -// Vite resolves and inlines each sibling `.feature` file's text at transform time -// (robust under vitest, where runtime paths are virtual). Each scenario draws -// from the shared step pool, so a new behaviour is just a scenario in a -// `.feature` file — no test code to touch. -const featureFiles = import.meta.glob("./*.feature", { - query: "?raw", - import: "default", - eager: true, -}) as Record; - -for (const content of Object.values(featureFiles)) { - const feature = loadFeatureFromText(protectMarkdown(content)); - describeFeature(feature, ({ Scenario }) => { - for (const scenario of feature.scenarii) { - Scenario(scenario.description, () => {}); - } - }); -} diff --git a/packages/rich-editor/src/codemirror/features/blockquote.feature b/packages/rich-editor/src/codemirror/features/blockquote.feature deleted file mode 100644 index 735bb0c..0000000 --- a/packages/rich-editor/src/codemirror/features/blockquote.feature +++ /dev/null @@ -1,51 +0,0 @@ -Feature: Block commands — blockquote - - A blockquote is a container, not a line type: it toggles the `>` marker on the - current line (or every line of a selection) and composes with whatever the line - already is — quoting a heading keeps the heading. - - Scenario: Quote a line - Given the document: - """ - ‸To be or not to be - """ - When I "toggle a blockquote" - Then the document is: - """ - > To be or not to be - """ - - Scenario: Unquote a line - Given the document: - """ - > ‸To be or not to be - """ - When I "toggle a blockquote" - Then the document is: - """ - To be or not to be - """ - - Scenario: Quote every line of a multi-line selection - Given the document: - """ - ‸first - second‸ - """ - When I "toggle a blockquote" - Then the document is: - """ - > first - > second - """ - - Scenario: Quoting composes with the line's existing type - Given the document: - """ - ## ‸Section - """ - When I "toggle a blockquote" - Then the document is: - """ - > ## ‸Section - """ diff --git a/packages/rich-editor/src/codemirror/features/browser/table-editing.feature b/packages/rich-editor/src/codemirror/features/browser/table-editing.feature deleted file mode 100644 index ddfd5dd..0000000 --- a/packages/rich-editor/src/codemirror/features/browser/table-editing.feature +++ /dev/null @@ -1,189 +0,0 @@ -Feature: Table editing — the full interaction surface (ADR 0001) - - The redesign's contract, wired to step definitions in - tablev2/tableEditingSteps.ts and run IN REAL WEBKIT by - tablev2/tableEditing.feature.browser.test.ts (this directory is outside the - jsdom runner's ./*.feature glob). @pure scenarios exercise the bridge/model - math directly; @browser scenarios drive the real surface with real events. - - Background: - Given a document with prose around this table: - """ - | Name | Role | - | --- | --- | - | Ada | Engineer | - | Lin | Designer | - """ - - # ── 1–2. The document around the table ──────────────────────────────── - - @browser - Scenario: Arrow down from the line above enters the first cell - Given the caret is on the line directly above the table - When I press "ArrowDown" - Then the caret is inside the "Name" cell - - @browser - Scenario: Arrow down from the last row exits below the table - Given the caret is in the "Lin" cell - When I press "ArrowDown" - Then the caret is in the main document directly below the table - - @browser - Scenario: Arrow up from the header exits above the table - Given the caret is in the "Name" cell - When I press "ArrowUp" - Then the caret is in the main document directly above the table - - # ── 3. Click goes exactly where clicked ─────────────────────────────── - - @browser - Scenario: Clicking mid-word in a cell places the caret at that character - When I click between "A" and "d" in the "Ada" cell - Then the caret is in the "Ada" cell at offset 1 - And typing "x" produces "Axda" in that cell - - @browser - Scenario: Clicking an empty cell shows a visible caret in that cell - Given the table has an empty row - When I click the empty row's first cell - Then that cell has focus and a visible caret - And the main editor selection did not move - - # ── 4. Arrows inside a cell vs at its edges ─────────────────────────── - - @browser - Scenario: Arrows move within the cell text first - Given the caret is in the "Ada" cell at offset 1 - When I press "ArrowRight" - Then the caret is in the "Ada" cell at offset 2 - - @pure - Scenario: Arrow right at the cell's last offset targets the next cell - Given the bridge state is row 1, column 0, offset 3, length 3 - When the bridge receives "ArrowRight" - Then the bridge targets row 1, column 1 at the start - - @pure - Scenario: Arrow left at offset 0 targets the previous cell's end - Given the bridge state is row 1, column 1, offset 0, length 8 - When the bridge receives "ArrowLeft" - Then the bridge targets row 1, column 0 at the end - - @browser - Scenario: Tab and Shift-Tab step through cells - Given the caret is in the "Ada" cell - When I press "Tab" - Then the caret is inside the "Engineer" cell - When I press "Shift-Tab" - Then the caret is inside the "Ada" cell - - # ── 5. The mouse cannot fall out of the table ───────────────────────── - - @browser - Scenario: Clicking cells never moves the main caret - Given the main caret is parked in the prose above the table - When I click each body cell of the table in turn - Then the main editor selection never changes - And the drawn caret stays hidden while a cell is edited - - # ── 6–10. Structure commands ────────────────────────────────────────── - - @pure - Scenario: Add a row below the current row - When the "add row below" command runs from the "Ada" cell - Then the table has 3 body rows - And every cell of body row 2 is empty - - @pure - Scenario: Add a column after the current column - When the "add column after" command runs from the "Name" cell - Then every row has 3 cells - And the delimiter row gains one "---" cell - - @pure - Scenario: Delete the current row - When the "delete row" command runs from the "Ada" cell - Then the table has 1 body row and it contains "Lin" - - @pure - Scenario: Delete the current column - When the "delete column" command runs from the "Role" cell - Then every row has 1 cell - And no cell contains "Engineer" - - @pure - Scenario: Delete table removes exactly the table's source range - When the "delete table" command runs from the "Ada" cell - Then the table's source lines are gone - And the surrounding prose is untouched - - # ── 11–13. Visual selection of row / column / table ─────────────────── - - @browser - Scenario: Drag across cells selects whole cells, not ragged text - When I press the mouse in the "Ada" cell and release in the "Designer" cell - Then cells 1,0 through 2,1 render as selected - And the native text selection is empty - - @browser - Scenario: Copying a cell selection yields TSV - Given cells 1,0 through 2,1 are selected - When I copy - Then the clipboard contains the selected cells as TSV - - @pure - Scenario: Select-column resolves to every cell in that column - When column 1 of a 3-row grid is selected - Then the selection set is cells 0,1 and 1,1 and 2,1 - - @browser - Scenario: The menu's Select column paints the whole column - When I right-click the "Ada" cell - And I choose "Select column" from the table menu - Then 3 cells render as selected - - # ── 14. Backspace and Delete ────────────────────────────────────────── - - @browser - Scenario: Backspace mid-cell deletes one character in the cell - Given the caret is in the "Ada" cell at offset 2 - When I press "Backspace" - Then the "Ada" cell now contains "Aa" - And the table structure is unchanged - - @pure - Scenario: Backspace at offset 0 navigates, never merges - Given the bridge state is row 1, column 1, offset 0, length 8 - When the bridge receives "Backspace" - Then the bridge targets row 1, column 0 at the end - - # ── 15. Context menu ────────────────────────────────────────────────── - - @browser - Scenario: Right-click opens the structure menu targeting that cell - When I right-click the "Lin" cell - Then the table menu opens - And choosing "Insert row below" inserts a row after the "Lin" row - - # ── Cross-cutting: the spike gate conditions (ADR §Editing surface) ─── - - @browser - Scenario: A widget update mid-edit never loses the active cell edit - Given I am typing "Q" at the end of the "Ada" cell - When an unrelated document change forces the table widget to update - Then my in-progress text and caret survive - And further typing still lands at the caret - - @browser - Scenario: An external edit to another cell merges cleanly - Given I am typing "2" at the end of the "Ada" cell - When the document changes "Designer" to "Writer" externally - Then committing my edit produces a table containing both "Ada2" and "Writer" - - @browser - Scenario: Undo after a cell edit is one step and CM-owned - Given I typed "X" into the "Ada" cell and committed - When I press "Mod-z" - Then the table source shows "Ada" again - And redo restores the committed edit diff --git a/packages/rich-editor/src/codemirror/features/code-block.feature b/packages/rich-editor/src/codemirror/features/code-block.feature deleted file mode 100644 index 0f17cc5..0000000 --- a/packages/rich-editor/src/codemirror/features/code-block.feature +++ /dev/null @@ -1,30 +0,0 @@ -Feature: Block commands — code block - - Toggling a code block wraps the current line (or selection) in a ``` fence and - drops the caret inside, and unwraps it when the fences are already there. - - Scenario: Wrap a line in a fenced code block - Given the document: - """ - const x = 1‸ - """ - When I "toggle a code block" - Then the document is: - """ - ``` - ‸const x = 1 - ``` - """ - - Scenario: Unwrap an existing fenced code block - Given the document: - """ - ``` - const x = 1‸ - ``` - """ - When I "toggle a code block" - Then the document is: - """ - const x = 1 - """ diff --git a/packages/rich-editor/src/codemirror/features/headings.feature b/packages/rich-editor/src/codemirror/features/headings.feature deleted file mode 100644 index 580601e..0000000 --- a/packages/rich-editor/src/codemirror/features/headings.feature +++ /dev/null @@ -1,86 +0,0 @@ -Feature: Block commands — headings - - A heading is a line type. Applying it replaces whatever the line was — a plain - line, a list item, or another heading level — and applying the same level - again turns it back into a plain line. The caret stays with the text, never - stranded before the marker. - - Scenario: Promote a plain line to a heading - Given the document: - """ - ‸My title - """ - When I "toggle heading 2" - Then the document is: - """ - ## ‸My title - """ - - Scenario: Toggle the same heading level back off - Given the document: - """ - ## ‸My title - """ - When I "toggle heading 2" - Then the document is: - """ - ‸My title - """ - - Scenario: Swap one heading level for another in place - Given the document: - """ - # ‸My title - """ - When I "toggle heading 2" - Then the document is: - """ - ## ‸My title - """ - - Scenario: A heading replaces a bullet marker rather than stacking on it - Given the document: - """ - - ‸Buy milk - """ - When I "toggle heading 2" - Then the document is: - """ - ## ‸Buy milk - """ - - Scenario: A heading replaces an ordered marker too - Given the document: - """ - 1. ‸Buy milk - """ - When I "toggle heading 2" - Then the document is: - """ - ## ‸Buy milk - """ - - Scenario: Apply a heading to every line of a multi-line selection - Given the document: - """ - ‸first - second‸ - """ - When I "toggle heading 3" - Then the document is: - """ - ### first - ### second - """ - - Scenario: Heading 1 then 3 keeps swapping the level, never stacking markers - Given the document: - """ - ‸note - """ - When I "toggle heading 1" - And I "toggle heading 3" - Then the document is: - """ - ### note - """ diff --git a/packages/rich-editor/src/codemirror/features/indent.feature b/packages/rich-editor/src/codemirror/features/indent.feature deleted file mode 100644 index 4b86c6c..0000000 --- a/packages/rich-editor/src/codemirror/features/indent.feature +++ /dev/null @@ -1,68 +0,0 @@ -Feature: Block commands — list indent / outdent - - Tab nests a list item one level under its preceding sibling — by the parent - marker's width, so the result parses as a CommonMark sublist — and Shift-Tab - promotes it back. A freshly nested ordered item restarts at 1. Tab on the - first item of a level (nothing to nest under) and Shift-Tab at the top level - leave the document unchanged. - - Scenario: Tab nests an ordered item under its sibling, restarting at 1 - Given the document: - """ - 1. first - 2. ‸second - """ - When I "indent the list item" - Then the document is: - """ - 1. first - 1. second - """ - - Scenario: Tab nests a bullet under its sibling by two columns - Given the document: - """ - - first - - ‸second - """ - When I "indent the list item" - Then the document is: - """ - - first - - second - """ - - Scenario: Tab on the first item does nothing — nothing to nest under - Given the document: - """ - 1. ‸only - """ - When I "indent the list item" - Then the document is: - """ - 1. only - """ - - Scenario: Shift-Tab outdents a nested item back to the top level - Given the document: - """ - - first - - ‸second - """ - When I "outdent the list item" - Then the document is: - """ - - first - - second - """ - - Scenario: Shift-Tab at the top level does nothing - Given the document: - """ - - ‸item - """ - When I "outdent the list item" - Then the document is: - """ - - item - """ diff --git a/packages/rich-editor/src/codemirror/features/lists.feature b/packages/rich-editor/src/codemirror/features/lists.feature deleted file mode 100644 index 4f853a4..0000000 --- a/packages/rich-editor/src/codemirror/features/lists.feature +++ /dev/null @@ -1,146 +0,0 @@ -Feature: Block commands — lists - - Bullet and ordered lists toggle on the current line, convert between each - other in place, and apply to every line of a multi-line selection. - - Scenario: Turn a plain line into a bullet - Given the document: - """ - ‸Buy milk - """ - When I "toggle a bullet list" - Then the document is: - """ - - Buy milk - """ - - Scenario: Toggle a bullet back off - Given the document: - """ - - ‸Buy milk - """ - When I "toggle a bullet list" - Then the document is: - """ - Buy milk - """ - - Scenario: Convert an ordered item to a bullet - Given the document: - """ - 1. ‸Buy milk - """ - When I "toggle a bullet list" - Then the document is: - """ - - Buy milk - """ - - Scenario: Turn a plain line into an ordered item - Given the document: - """ - ‸Buy milk - """ - When I "toggle an ordered list" - Then the document is: - """ - 1. Buy milk - """ - - Scenario: Convert a bullet to an ordered item - Given the document: - """ - - ‸Buy milk - """ - When I "toggle an ordered list" - Then the document is: - """ - 1. Buy milk - """ - - Scenario: Number every line of a multi-line selection - Given the document: - """ - ‸first - second‸ - """ - When I "toggle an ordered list" - Then the document is: - """ - 1. first - 2. second - """ - - Scenario: Bullet every line of a multi-line selection - Given the document: - """ - ‸first - second‸ - """ - When I "toggle a bullet list" - Then the document is: - """ - - first - - second - """ - - Scenario: Bulleting a partly-bulleted selection makes every line a bullet - Given the document: - """ - ‸- a - b‸ - """ - When I "toggle a bullet list" - Then the document is: - """ - - a - - b - """ - - Scenario: A list toggles off only when every line is already a bullet - Given the document: - """ - ‸- a - - b‸ - """ - When I "toggle a bullet list" - Then the document is: - """ - a - b - """ - - Scenario: Numbering a mixed selection renumbers it sequentially from 1 - Given the document: - """ - ‸3. a - b‸ - """ - When I "toggle an ordered list" - Then the document is: - """ - 1. a - 2. b - """ - - Scenario: A bullet replaces a heading marker rather than stacking on it - Given the document: - """ - ## ‸Section - """ - When I "toggle a bullet list" - Then the document is: - """ - - ‸Section - """ - - Scenario: An ordered item replaces a heading marker too - Given the document: - """ - ## ‸Section - """ - When I "toggle an ordered list" - Then the document is: - """ - 1. ‸Section - """ diff --git a/packages/rich-editor/src/codemirror/features/table.feature b/packages/rich-editor/src/codemirror/features/table.feature deleted file mode 100644 index dab02bf..0000000 --- a/packages/rich-editor/src/codemirror/features/table.feature +++ /dev/null @@ -1,48 +0,0 @@ -Feature: Block commands — table - - Inserting a table drops a 2×2 GFM skeleton on its own block — in place on a - blank line, otherwise pushed below the current line with a separating blank - line — and selects the first header cell so you can name the column. - - Scenario: Insert a table on a blank line and select the first header - Given the document: - """ - ‸ - """ - When I "insert a table" - Then the document is: - """ - | ‸Header‸ | Header | - | --- | --- | - | Cell | Cell | - """ - - Scenario: Push the table below the current line with a blank separator - Given the document: - """ - Some prose‸ - """ - When I "insert a table" - Then the document is: - """ - Some prose - - | Header | Header | - | --- | --- | - | Cell | Cell | - """ - - Scenario: Reuse an existing blank line instead of adding another - Given the document: - """ - Some prose - ‸ - """ - When I "insert a table" - Then the document is: - """ - Some prose - | Header | Header | - | --- | --- | - | Cell | Cell | - """ diff --git a/packages/rich-editor/src/codemirror/features/tasks.feature b/packages/rich-editor/src/codemirror/features/tasks.feature deleted file mode 100644 index c620b2d..0000000 --- a/packages/rich-editor/src/codemirror/features/tasks.feature +++ /dev/null @@ -1,62 +0,0 @@ -Feature: Block commands — task list - - A task list toggles a `- [ ] ` checkbox on the current line (or every line of a - selection) — a line type alongside bullets and numbers, replacing any other - marker on the line. - - Scenario: Turn a plain line into a task - Given the document: - """ - ‸write tests - """ - When I "toggle a task list" - Then the document is: - """ - - [ ] write tests - """ - - Scenario: Toggle a task back off - Given the document: - """ - - [ ] ‸write tests - """ - When I "toggle a task list" - Then the document is: - """ - write tests - """ - - Scenario: A bullet becomes a task, gaining a checkbox - Given the document: - """ - - ‸buy milk - """ - When I "toggle a task list" - Then the document is: - """ - - [ ] buy milk - """ - - Scenario: A heading becomes a task (markers are mutually exclusive) - Given the document: - """ - ## ‸section - """ - When I "toggle a task list" - Then the document is: - """ - - [ ] section - """ - - Scenario: Make every line of a selection a task - Given the document: - """ - ‸first - second‸ - """ - When I "toggle a task list" - Then the document is: - """ - - [ ] first - - [ ] second - """ diff --git a/packages/rich-editor/src/codemirror/footnote/footnotePlugin.test.ts b/packages/rich-editor/src/codemirror/footnote/footnotePlugin.test.ts deleted file mode 100644 index 92fafb2..0000000 --- a/packages/rich-editor/src/codemirror/footnote/footnotePlugin.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -// @vitest-environment jsdom -import type { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it } from "vitest"; - -import { destroyEditors, makeEditor } from "../core/editorTestHarness"; -import { footnotePlugin } from "./footnotePlugin"; - -function atomic(view: EditorView): Array<[number, number]> { - const out: Array<[number, number]> = []; - view - .plugin(footnotePlugin) - ?.atomic.between(0, view.state.doc.length, (from, to) => { - out.push([from, to]); - }); - return out; -} - -describe("footnotePlugin — [^id] references", () => { - afterEach(destroyEditors); - - it("hides the [^ and ] brackets of a reference, leaving the label", () => { - const doc = "see [^1] here"; - const view = makeEditor(doc, 0, [footnotePlugin]); - const ref = doc.indexOf("[^1]"); // 4 - const ranges = atomic(view); - expect(ranges).toContainEqual([ref, ref + 2]); // "[^" - expect(ranges).toContainEqual([ref + 3, ref + 4]); // "]" - }); - - it("does not treat a definition line ([^1]: …) as a hidden reference", () => { - const view = makeEditor("[^1]: the note", 0, [footnotePlugin]); - expect(atomic(view)).toEqual([]); - }); - - it("leaves a [^ref] literal inside code (fenced block and inline span)", () => { - expect(atomic(makeEditor("```\nsee [^1] here\n```", 0, [footnotePlugin]))).toEqual([]); - expect(atomic(makeEditor("x `see [^1] here` y", 0, [footnotePlugin]))).toEqual([]); - }); - - it("does not style a definition-shaped line inside a fenced code block", () => { - const view = makeEditor("```\n[^1]: the note\n```", 0, [footnotePlugin]); - expect(view.contentDOM.querySelector(".cm-footnote-def")).toBeNull(); - }); -}); diff --git a/packages/rich-editor/src/codemirror/footnote/footnotePlugin.ts b/packages/rich-editor/src/codemirror/footnote/footnotePlugin.ts deleted file mode 100644 index 0c5ef8a..0000000 --- a/packages/rich-editor/src/codemirror/footnote/footnotePlugin.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { type Range } from "@codemirror/state"; -import { - Decoration, - type DecorationSet, - EditorView, - ViewPlugin, - type PluginValue, - type ViewUpdate, -} from "@codemirror/view"; - -import { inCode, viewportTree } from "../core/codeContext"; - -const FOOTNOTE_REF_RE = /(?[] = []; - const markDecs: Range[] = []; - const atomic: Range[] = []; - const tree = viewportTree(view); - - for (const { from, to } of view.visibleRanges) { - let lineNum = view.state.doc.lineAt(from).number; - const lastLineNum = view.state.doc.lineAt(to).number; - while (lineNum <= lastLineNum) { - const line = view.state.doc.line(lineNum); - - if (FOOTNOTE_DEF_LINE_RE.test(line.text) && !inCode(tree, line.from)) { - lineDecs.push(defLineDeco.range(line.from)); - } else { - FOOTNOTE_REF_RE.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = FOOTNOTE_REF_RE.exec(line.text)) !== null) { - const matchStart = line.from + m.index; - const matchEnd = matchStart + m[0].length; - if (inCode(tree, matchStart) || inCode(tree, matchEnd - 1)) continue; - const labelStart = matchStart + 2; - const labelEnd = matchEnd - 1; - if (labelEnd <= labelStart) continue; - - markDecs.push(refHide.range(matchStart, labelStart)); - atomic.push(refHide.range(matchStart, labelStart)); - markDecs.push(refLabelMark.range(labelStart, labelEnd)); - markDecs.push(refHide.range(labelEnd, matchEnd)); - atomic.push(refHide.range(labelEnd, matchEnd)); - } - } - - lineNum += 1; - } - } - - return { - decorations: Decoration.set(lineDecs.concat(markDecs), true), - atomic: Decoration.set(atomic, true), - }; -} - -export const footnotePlugin = ViewPlugin.fromClass( - class implements PluginValue { - decorations: DecorationSet; - atomic: DecorationSet; - constructor(view: EditorView) { - const built = buildDecorations(view); - this.decorations = built.decorations; - this.atomic = built.atomic; - } - update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { - const built = buildDecorations(update.view); - this.decorations = built.decorations; - this.atomic = built.atomic; - } - } - }, - { - decorations: (v) => v.decorations, - provide: (plugin) => - EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomic ?? Decoration.none), - }, -); diff --git a/packages/rich-editor/src/codemirror/footnote/index.ts b/packages/rich-editor/src/codemirror/footnote/index.ts deleted file mode 100644 index 9e1f10b..0000000 --- a/packages/rich-editor/src/codemirror/footnote/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Footnotes: `[^ref]` references and definition blocks. */ -export * from "./footnotePlugin"; diff --git a/packages/rich-editor/src/codemirror/format/blockCommands.test.ts b/packages/rich-editor/src/codemirror/format/blockCommands.test.ts deleted file mode 100644 index bcc0a93..0000000 --- a/packages/rich-editor/src/codemirror/format/blockCommands.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -// @vitest-environment jsdom -import { EditorSelection } from "@codemirror/state"; -import type { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it } from "vitest"; - -import { blockCommands } from "./blockCommands"; -import { destroyEditors, makeEditor, text } from "../core/editorTestHarness"; - -/** Editor over `doc` with the caret ON the line containing `marker` (at its - * first character). */ -function editorAtLine(doc: string, marker: string): EditorView { - const pos = doc.indexOf(marker); - if (pos < 0) throw new Error(`marker ${JSON.stringify(marker)} not in doc`); - return makeEditor(doc, pos); -} - -function selectAll(view: EditorView): void { - view.dispatch({ selection: EditorSelection.range(0, view.state.doc.length) }); -} - -describe("blockCommands — code is off-limits (#61)", () => { - afterEach(destroyEditors); - - it("bullet toggle inside a fenced block leaves the code alone", () => { - // `- item` inside a fence is code, not a list — a regex can't tell. - const view = editorAtLine("```\n- item\n```", "- item"); - blockCommands.toggleBulletList(view); - expect(text(view)).toBe("```\n- item\n```"); - }); - - it("heading toggle inside a fenced block leaves the code alone", () => { - const view = editorAtLine("```\ncode line\n```", "code line"); - blockCommands.toggleHeading2(view); - expect(text(view)).toBe("```\ncode line\n```"); - }); - - it("task toggle on the fence line itself is a no-op", () => { - const view = editorAtLine("```\ncode\n```", "```"); - blockCommands.toggleTaskList(view); - expect(text(view)).toBe("```\ncode\n```"); - }); - - it("blockquote toggle inside a fenced block leaves the code alone", () => { - const view = editorAtLine("```\ncode line\n```", "code line"); - blockCommands.toggleBlockquote(view); - expect(text(view)).toBe("```\ncode line\n```"); - }); - - it("a selection spanning prose and a fence only formats the prose", () => { - const view = makeEditor("intro\n```\ncode\n```\noutro", 0); - selectAll(view); - blockCommands.toggleBulletList(view); - expect(text(view)).toBe("- intro\n```\ncode\n```\n- outro"); - }); - - it("top-level indented code is protected too", () => { - const view = editorAtLine("para\n\n - looks like a bullet", "- looks"); - blockCommands.toggleBulletList(view); - expect(text(view)).toBe("para\n\n - looks like a bullet"); - }); -}); - -describe("blockCommands — nested list items (#61)", () => { - afterEach(destroyEditors); - - it("bullet toggle strips a nested item's marker in place", () => { - // The nested marker isn't at column 0; editing must land where the parser - // saw it, not prepend a second marker. - const view = editorAtLine("- a\n - b", "- b"); - blockCommands.toggleBulletList(view); - expect(text(view)).toBe("- a\n b"); - }); - - it("bullet toggle strips a whole nested selection", () => { - const view = makeEditor("- a\n - b", 0); - selectAll(view); - blockCommands.toggleBulletList(view); - expect(text(view)).toBe("a\n b"); - }); - - it("ordered toggle strips a nested ordered item in place", () => { - const view = editorAtLine("1. a\n 1. b", "1. b"); - blockCommands.toggleOrderedList(view); - expect(text(view)).toBe("1. a\n b"); - }); - - it("a list-item continuation line becomes a nested bullet, keeping its indent", () => { - const view = editorAtLine("- a\n b", "b"); - blockCommands.toggleBulletList(view); - expect(text(view)).toBe("- a\n - b"); - }); - - it("four-space content under a list item is a nested item, not indented code", () => { - const view = editorAtLine("- a\n - b", "- b"); - blockCommands.toggleBulletList(view); - expect(text(view)).toBe("- a\n b"); - }); -}); - -describe("blockCommands — quoted lines compose (#61)", () => { - afterEach(destroyEditors); - - it("bullet toggle sees a list item through the quote marker", () => { - const view = editorAtLine("> - item", "> - item"); - blockCommands.toggleBulletList(view); - expect(text(view)).toBe("> item"); - }); - - it("heading toggle lands after the quote marker", () => { - const view = editorAtLine("> thought", "> thought"); - blockCommands.toggleHeading2(view); - expect(text(view)).toBe("> ## thought"); - }); - - it("blockquote toggle strips one level from a nested quote", () => { - const view = editorAtLine("> > deep", "> > deep"); - blockCommands.toggleBlockquote(view); - expect(text(view)).toBe("> deep"); - }); - - it("blockquote toggle round-trips plain text", () => { - const view = makeEditor("plain", 0); - blockCommands.toggleBlockquote(view); - expect(text(view)).toBe("> plain"); - }); -}); - -describe("blockCommands — marker swaps land on the parsed marker", () => { - afterEach(destroyEditors); - - it("heading toggle on the same level removes it", () => { - const view = makeEditor("## title", 0); - blockCommands.toggleHeading2(view); - expect(text(view)).toBe("title"); - }); - - it("heading toggle on another level swaps the marker", () => { - const view = makeEditor("## title", 0); - blockCommands.toggleHeading1(view); - expect(text(view)).toBe("# title"); - }); - - it("heading toggle replaces a whole task marker, checkbox included", () => { - const view = makeEditor("- [ ] task", 0); - blockCommands.toggleHeading2(view); - expect(text(view)).toBe("## task"); - }); - - it("bullet toggle converts an ordered item", () => { - const view = makeEditor("1. x", 0); - blockCommands.toggleBulletList(view); - expect(text(view)).toBe("- x"); - }); - - it("bullet toggle converts a heading", () => { - const view = makeEditor("## x", 0); - blockCommands.toggleBulletList(view); - expect(text(view)).toBe("- x"); - }); - - it("ordered toggle renumbers a mixed selection from 1", () => { - const view = makeEditor("text\n- b\n1. c", 0); - selectAll(view); - blockCommands.toggleOrderedList(view); - expect(text(view)).toBe("1. text\n2. b\n3. c"); - }); - - it("a non-1 pseudo-marker on a continuation line is content, not a marker", () => { - // CommonMark: an ordered list interrupts a paragraph only when it starts - // at 1, so `5. c` here is lazy-continuation PROSE of the bullet above — - // the toggle numbers the line and keeps its text verbatim. - const view = makeEditor("text\n- b\n5. c", 0); - selectAll(view); - blockCommands.toggleOrderedList(view); - expect(text(view)).toBe("1. text\n2. b\n3. 5. c"); - }); - - it("ordered toggle strips a fully ordered selection", () => { - const view = makeEditor("1. a\n2. b", 0); - selectAll(view); - blockCommands.toggleOrderedList(view); - expect(text(view)).toBe("a\nb"); - }); - - it("task toggle adds a checkbox to a plain bullet", () => { - const view = makeEditor("- x", 0); - blockCommands.toggleTaskList(view); - expect(text(view)).toBe("- [ ] x"); - }); - - it("task toggle strips a checked task entirely", () => { - const view = makeEditor("- [x] done", 0); - blockCommands.toggleTaskList(view); - expect(text(view)).toBe("done"); - }); -}); - -describe("blockCommands — code fence wrapping", () => { - afterEach(destroyEditors); - - it("wraps the selected lines in a ``` fence", () => { - const view = makeEditor("plain text", 0); - view.dispatch({ selection: EditorSelection.range(0, 10) }); - blockCommands.toggleCodeBlock(view); - expect(text(view)).toBe("```\nplain text\n```"); - }); - - it("lengthens the fence past any backtick run in the content", () => { - // Wrapping this with ``` would close the fence at the inner ``` and spill - // the rest as prose — the fence must be one backtick longer. - const view = makeEditor("docs say\n```\nnested\n```", 0); - view.dispatch({ selection: EditorSelection.range(0, view.state.doc.length) }); - blockCommands.toggleCodeBlock(view); - expect(text(view)).toBe("````\ndocs say\n```\nnested\n```\n````"); - }); -}); diff --git a/packages/rich-editor/src/codemirror/format/blockCommands.ts b/packages/rich-editor/src/codemirror/format/blockCommands.ts deleted file mode 100644 index e864c7a..0000000 --- a/packages/rich-editor/src/codemirror/format/blockCommands.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Block-level commands — heading levels, bullet/ordered list, blockquote. - * - * Each command operates on the current line at the caret. Toggle - * semantics: applying H2 to a line that's already H2 removes the - * heading; applying H2 to a line that's H1 swaps the marker. - * - * These commands edit the **markdown source** at the line start. - * Lezer reparses, decorations recompute, the heading / list / quote - * appears rendered. No special rendering pipeline — the markers go - * through the same hide-always treatment as user-typed markdown. - * - * Selection-aware: if a non-empty selection spans multiple lines, - * the operation applies to every line. (Useful for bullet/quote.) - */ - -import { EditorSelection, type ChangeSpec, type Line, Prec } from "@codemirror/state"; -import { type Command, EditorView, keymap } from "@codemirror/view"; - -import { lineStructure, type LineStructure } from "../core/lineStructure"; - -function eachLineInSelection(view: EditorView): Line[] { - const lines: Line[] = []; - const seen = new Set(); - for (const range of view.state.selection.ranges) { - let pos = range.from; - while (pos <= range.to) { - const line = view.state.doc.lineAt(pos); - if (!seen.has(line.number)) { - seen.add(line.number); - lines.push(line); - } - if (line.to >= range.to) break; - pos = line.to + 1; - } - } - return lines; -} - -function dispatchLineChanges(view: EditorView, changes: ChangeSpec[], userEvent: string): boolean { - if (changes.length === 0) return true; - const changeSet = view.state.changes(changes); - // Map the selection with assoc +1 so the caret rides forward over a prepended - // marker, staying with the user's text rather than stranding before it. - view.dispatch({ changes: changeSet, selection: view.state.selection.map(changeSet, 1), userEvent }); - return true; -} - -// Heading, bullet, and ordered are mutually-exclusive line types: setting one -// replaces any other already on the line (so `- x` → heading is `## x`, not -// `## - x`). Blockquote and code are containers — they compose — so they're not -// part of this set. Line classification comes from the SYNTAX TREE -// (lineStructure, #61): a `- x` inside a code fence is code, not a bullet, and -// a nested ` - x` is a bullet even though its marker isn't at column 0. - -/** The selected lines the structure commands may touch, each with what the - * grammar says it is. Code lines are excluded wholesale — a formatting toolbar - * must never rewrite code. */ -function structuredLines(view: EditorView): { line: Line; info: LineStructure }[] { - return eachLineInSelection(view) - .map((line) => ({ line, info: lineStructure(view.state, line) })) - .filter(({ info }) => !info.inCode); -} - -/** Replace this line's current type marker (heading or list, whichever the - * tree found) with `marker`, or insert `marker` at the content start — so - * nested/indented lines keep their indentation. */ -function swapLineMarker({ info }: { info: LineStructure }, marker: string): ChangeSpec { - const existing = info.heading ?? info.list; - return existing - ? { from: existing.markFrom, to: existing.markTo, insert: marker } - : { from: info.contentFrom, insert: marker }; -} - -/* -------- Heading -------- */ - -function makeToggleHeading(level: 1 | 2 | 3 | 4 | 5 | 6): Command { - const marker = `${"#".repeat(level)} `; - return (view) => { - const changes: ChangeSpec[] = []; - for (const entry of structuredLines(view)) { - const { info } = entry; - if (info.heading && info.heading.level === level) { - // Already this level → back to a paragraph. - changes.push({ from: info.heading.markFrom, to: info.heading.markTo, insert: "" }); - } else { - changes.push(swapLineMarker(entry, marker)); - } - } - return dispatchLineChanges(view, changes, "input.format.heading"); - }; -} - -/* -------- Bullet list -------- */ - -// All-or-nothing toggle: strip the list only when *every* selected line is -// already a bullet, otherwise make them all bullets (converting ordered items -// and leaving existing bullets be). A per-line toggle would make a mixed -// selection more inconsistent — un-bulleting some lines while bulleting others. -const toggleBulletList: Command = (view) => { - const entries = structuredLines(view); - const allBullets = - entries.length > 0 && entries.every(({ info }) => info.list?.kind === "bullet"); - const changes: ChangeSpec[] = []; - for (const entry of entries) { - const { info } = entry; - if (allBullets) { - changes.push({ from: info.list!.markFrom, to: info.list!.markTo, insert: "" }); - } else if (info.list?.kind !== "bullet") { - changes.push(swapLineMarker(entry, "- ")); - } - } - return dispatchLineChanges(view, changes, "input.format.list"); -}; - -/* -------- Ordered list -------- */ - -// All-or-nothing toggle (see bullets). When making the selection ordered, every -// line is (re)numbered from 1 so a mixed or mis-numbered selection comes out -// sequential. -const toggleOrderedList: Command = (view) => { - const entries = structuredLines(view); - const allOrdered = - entries.length > 0 && entries.every(({ info }) => info.list?.kind === "ordered"); - const changes: ChangeSpec[] = []; - let counter = 0; - for (const entry of entries) { - const { info } = entry; - if (allOrdered) { - changes.push({ from: info.list!.markFrom, to: info.list!.markTo, insert: "" }); - } else { - counter += 1; - changes.push(swapLineMarker(entry, `${counter}. `)); - } - } - return dispatchLineChanges(view, changes, "input.format.list"); -}; - -/* -------- Task list -------- */ - -// All-or-nothing toggle: strip the checkbox prefix only when every selected line -// is already a task, otherwise make them all tasks (replacing any other line -// marker; a plain line or other list item gains a `- [ ] `). -const toggleTaskList: Command = (view) => { - const entries = structuredLines(view); - const allTasks = entries.length > 0 && entries.every(({ info }) => info.list?.task); - const changes: ChangeSpec[] = []; - for (const entry of entries) { - const { info } = entry; - if (allTasks) { - changes.push({ from: info.list!.markFrom, to: info.list!.markTo, insert: "" }); - } else if (!info.list?.task) { - changes.push(swapLineMarker(entry, "- [ ] ")); - } - } - return dispatchLineChanges(view, changes, "input.format.task"); -}; - -/* -------- Blockquote -------- */ - -// Per-line (not all-or-nothing): quotes are containers, so each line -// independently gains a level or sheds its outermost one. -const toggleBlockquote: Command = (view) => { - const changes: ChangeSpec[] = []; - for (const { info } of structuredLines(view)) { - changes.push( - info.quote - ? { from: info.quote.markFrom, to: info.quote.markTo, insert: "" } - : { from: info.contentFrom, insert: "> " }, - ); - } - return dispatchLineChanges(view, changes, "input.format.blockquote"); -}; - -/* -------- Code block (toggle fenced block around selection) -------- */ - -const toggleCodeBlock: Command = (view) => { - const sel = view.state.selection.main; - // Toggle a fence around the current/selection lines. - const firstLine = view.state.doc.lineAt(sel.from); - const lastLine = view.state.doc.lineAt(sel.to); - // Detect if both fences already exist (line before firstLine starts - // with ``` and line after lastLine starts with ```). - const above = firstLine.number > 1 ? view.state.doc.line(firstLine.number - 1) : null; - const below = lastLine.number < view.state.doc.lines ? view.state.doc.line(lastLine.number + 1) : null; - if (above && /^```/.test(above.text) && below && /^```/.test(below.text)) { - // Strip both fences - view.dispatch({ - changes: [ - { from: above.from, to: above.to + 1, insert: "" }, - { from: below.from - 1, to: below.to, insert: "" }, - ], - userEvent: "input.format.code-block", - }); - return true; - } - // Wrap selection in fences. The fence must be LONGER than any backtick run - // inside the wrapped content — wrapping text that itself contains ``` with a - // ``` fence would close the block early and spill the rest as prose. - const inner = view.state.sliceDoc(firstLine.from, lastLine.to); - const longestRun = (inner.match(/`+/g) ?? []).reduce((max, run) => Math.max(max, run.length), 0); - const fence = "`".repeat(Math.max(3, longestRun + 1)); - view.dispatch({ - changes: [ - { from: firstLine.from, insert: `${fence}\n` }, - { from: lastLine.to, insert: `\n${fence}` }, - ], - selection: EditorSelection.cursor(firstLine.from + fence.length + 1), - userEvent: "input.format.code-block", - }); - return true; -}; - -/* -------- Insert table (GFM 2×2 skeleton) -------- */ - -const TABLE_SKELETON = "| Header | Header |\n| --- | --- |\n| Cell | Cell |"; -// Offset from the table's start to the first header cell's content, so the -// caret lands ready to type the first column header ("| " → 2 chars in). -const FIRST_CELL_OFFSET = 2; - -// GFM requires a blank line before a table for it to parse as one. Insert the -// skeleton on its own block: in place when the caret sits on a blank line, -// otherwise pushed below the current line with a separating blank line. -const insertTable: Command = (view) => { - const sel = view.state.selection.main; - const line = view.state.doc.lineAt(sel.from); - const onBlankLine = line.text.trim().length === 0; - - const prefix = onBlankLine ? "" : "\n\n"; - const from = onBlankLine ? line.from : line.to; - const to = line.to; - const caret = from + prefix.length + FIRST_CELL_OFFSET; - - view.dispatch({ - changes: { from, to, insert: `${prefix}${TABLE_SKELETON}` }, - selection: EditorSelection.range(caret, caret + "Header".length), - userEvent: "input.format.table", - }); - return true; -}; - -export const blockCommands = { - toggleHeading1: makeToggleHeading(1), - toggleHeading2: makeToggleHeading(2), - toggleHeading3: makeToggleHeading(3), - toggleBulletList, - toggleOrderedList, - toggleTaskList, - toggleBlockquote, - toggleCodeBlock, - insertTable, -}; - -export const blockCommandsKeymap = Prec.high( - keymap.of([ - { key: "Mod-1", run: blockCommands.toggleHeading1 }, - { key: "Mod-2", run: blockCommands.toggleHeading2 }, - { key: "Mod-3", run: blockCommands.toggleHeading3 }, - { key: "Mod-Shift-7", run: blockCommands.toggleOrderedList }, - { key: "Mod-Shift-8", run: blockCommands.toggleBulletList }, - { key: "Mod-Shift-9", run: blockCommands.toggleBlockquote }, - ]), -); diff --git a/packages/rich-editor/src/codemirror/format/formatCommands.test.ts b/packages/rich-editor/src/codemirror/format/formatCommands.test.ts deleted file mode 100644 index cf93fd0..0000000 --- a/packages/rich-editor/src/codemirror/format/formatCommands.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -// @vitest-environment jsdom -import { EditorSelection } from "@codemirror/state"; -import { afterEach, describe, expect, it } from "vitest"; - -import { destroyEditors, makeEditor, text } from "../core/editorTestHarness"; -import { formatCommands } from "./formatCommands"; - -function select(view: ReturnType, from: number, to: number): void { - view.dispatch({ selection: EditorSelection.range(from, to) }); -} - -describe("formatCommands — toggle bold / italic / code", () => { - afterEach(destroyEditors); - - it("wraps a selection in ** and re-selects the content", () => { - const view = makeEditor("hello", 0); - select(view, 0, 5); - formatCommands.toggleBold(view); - expect(text(view)).toBe("**hello**"); - expect([view.state.selection.main.from, view.state.selection.main.to]).toEqual([2, 7]); - }); - - it("unwraps when the caret is already inside bold (toggle off)", () => { - const view = makeEditor("**hello**", 4); // caret inside "hello" - formatCommands.toggleBold(view); - expect(text(view)).toBe("hello"); - }); - - it("inserts empty ** and puts the caret between them on a collapsed selection", () => { - const view = makeEditor("ab", 1); - formatCommands.toggleBold(view); - expect(text(view)).toBe("a****b"); - expect(view.state.selection.main.head).toBe(3); // between the markers - }); - - it("italic uses single * and inline code uses backticks", () => { - const italic = makeEditor("x", 0); - select(italic, 0, 1); - formatCommands.toggleItalic(italic); - expect(text(italic)).toBe("*x*"); - - const code = makeEditor("x", 0); - select(code, 0, 1); - formatCommands.toggleInlineCode(code); - expect(text(code)).toBe("`x`"); - }); -}); - -describe("formatCommands — flanking-safe wrapping (CommonMark)", () => { - afterEach(destroyEditors); - - it("keeps a trailing space outside the markers — `**compose **` would be literal text", () => { - const view = makeEditor("compose next", 0); - select(view, 0, 8); // "compose " incl. the trailing space - formatCommands.toggleBold(view); - expect(text(view)).toBe("**compose** next"); - // The re-selection covers just the wrapped core. - expect([view.state.selection.main.from, view.state.selection.main.to]).toEqual([2, 9]); - }); - - it("keeps a leading space outside the markers too", () => { - const view = makeEditor("say compose", 0); - select(view, 3, 11); // " compose" incl. the leading space - formatCommands.toggleItalic(view); - expect(text(view)).toBe("say *compose*"); - }); - - it("does nothing for an all-whitespace selection", () => { - const view = makeEditor("a b", 0); - select(view, 1, 4); - formatCommands.toggleBold(view); - expect(text(view)).toBe("a b"); - }); - - it("code spans wrap verbatim — backticks have no flanking rule", () => { - const view = makeEditor("run cmd", 0); - select(view, 4, 7); - // Include no spaces here, but prove the code path is untrimmed by selecting - // with an edge space and expecting it INSIDE the backticks. - const spaced = makeEditor("run cmd ", 0); - select(spaced, 4, 8); // "cmd " incl. trailing space - formatCommands.toggleInlineCode(spaced); - expect(text(spaced)).toBe("run `cmd `"); - void view; - }); -}); - -describe("formatCommands — code spans with backtick content (CommonMark fences)", () => { - afterEach(destroyEditors); - - it("sizes the fence past the longest inner backtick run", () => { - const view = makeEditor("use a`b here", 0); - select(view, 4, 7); // a`b - formatCommands.toggleInlineCode(view); - // `a`b` would be broken source — the fence must be `` … ``. - expect(text(view)).toBe("use ``a`b`` here"); - }); - - it("pads content that starts or ends with a backtick", () => { - const view = makeEditor("x `cmd x", 0); - select(view, 2, 6); // `cmd - formatCommands.toggleInlineCode(view); - expect(text(view)).toBe("x `` `cmd `` x"); - }); - - it("unwraps a long-fenced padded span back to the original text", () => { - const view = makeEditor("`` `cmd `` done", 3); - formatCommands.toggleInlineCode(view); - expect(text(view)).toBe("`cmd done"); - }); -}); - - -describe("below the fold — positions the viewport-driven parse never reached", () => { - afterEach(destroyEditors); - - /** Enough prose that the opening frame's parse stops well short of `tail`. */ - function farDown(tail: string): string { - const filler = Array.from({ length: 400 }, (_, i) => `paragraph line ${i}`).join("\n\n"); - return `${filler}\n\n${tail}`; - } - - it("toggles off bold the parser has not reached yet", () => { - const doc = farDown("**hello**"); - const view = makeEditor(doc, doc.length - 4); // inside "hello" - formatCommands.toggleBold(view); - expect(text(view)).toBe(farDown("hello")); - }); -}); diff --git a/packages/rich-editor/src/codemirror/format/formatCommands.ts b/packages/rich-editor/src/codemirror/format/formatCommands.ts deleted file mode 100644 index daf2df4..0000000 --- a/packages/rich-editor/src/codemirror/format/formatCommands.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Formatting commands — spec section 9. - * - * Cmd/Ctrl + B → toggle bold (`**…**`) - * Cmd/Ctrl + I → toggle italic (`*…*`) - * Cmd/Ctrl + E → toggle inline code (`` `…` ``) - * - * Toggle, not insert: pressing Cmd+B with the caret already inside a - * `StrongEmphasis` removes the bold, mirroring Tiptap / Pages / Word. - * - * Cmd+K (link popover) is Phase 2.8 — it needs a UI affordance and - * is intentionally not bound here. - * - * These commands work uniformly on collapsed selections (where they - * just insert the markers and place the caret between them) and on - * non-collapsed selections (where they wrap the visible content). - */ - -import { EditorSelection, Prec } from "@codemirror/state"; -import { type Command, EditorView, keymap } from "@codemirror/view"; -import { treeAt } from "../core/treeAt"; - -type SyntaxNodeLike = { - readonly name: string; - readonly from: number; - readonly to: number; - readonly parent: SyntaxNodeLike | null; -}; - -interface FormatSpec { - readonly markName: string; // Lezer node name for the wrapping construct - readonly opener: string; - readonly closer: string; - /** CommonMark's flanking rules make emphasis delimiters literal when the - * opener is followed — or the closer preceded — by whitespace: `**compose **` - * is plain text, not bold. Specs with this flag wrap only the selection's - * non-whitespace core, leaving edge spaces outside the markers (Word / - * Pages / Obsidian behavior). Code spans have no flanking rule and may - * meaningfully contain edge spaces, so they wrap verbatim. */ - readonly keepEdgeWhitespaceOutside: boolean; -} - -const FORMAT_BOLD: FormatSpec = { - markName: "StrongEmphasis", - opener: "**", - closer: "**", - keepEdgeWhitespaceOutside: true, -}; -const FORMAT_ITALIC: FormatSpec = { - markName: "Emphasis", - opener: "*", - closer: "*", - keepEdgeWhitespaceOutside: true, -}; -const FORMAT_CODE: FormatSpec = { - markName: "InlineCode", - opener: "`", - closer: "`", - keepEdgeWhitespaceOutside: false, -}; - -function nearestAncestor( - view: EditorView, - pos: number, - name: string, -): { from: number; to: number } | null { - let node: SyntaxNodeLike | null = treeAt(view.state, pos).resolveInner( - pos, - 1, - ) as unknown as SyntaxNodeLike; - while (node) { - if (node.name === name) return { from: node.from, to: node.to }; - node = node.parent; - } - return null; -} - -/** - * Delimiters + content for a code span, per CommonMark: the backtick fence - * must be LONGER than any backtick run inside the content (`` `a`b` `` is - * broken source), and content that starts or ends with a backtick needs one - * space of padding — the renderer strips exactly one space from each side. - */ -function codeSpanWrap(core: string): { fence: string; content: string } { - const runs = core.match(/`+/g) ?? []; - const longest = runs.reduce((max, run) => Math.max(max, run.length), 0); - const fence = "`".repeat(longest + 1); - const pad = core.startsWith("`") || core.endsWith("`") ? " " : ""; - return { fence, content: pad + core + pad }; -} - -/** Undo {@link codeSpanWrap}: measure the actual fence off the source (any - * run length), then strip the one-space padding pair the grammar treats as - * invisible. Null when the text isn't a well-formed span. */ -function codeSpanUnwrap(text: string): string | null { - const fence = /^`+/.exec(text)?.[0]; - if (!fence || !text.endsWith(fence) || text.length < fence.length * 2) return null; - let content = text.slice(fence.length, text.length - fence.length); - if (content.startsWith(" ") && content.endsWith(" ") && content.trim() !== "") { - content = content.slice(1, -1); - } - return content; -} - -function makeToggleCommand(spec: FormatSpec): Command { - return (view) => { - const sel = view.state.selection.main; - - // Case 1: caret/selection sits inside an existing instance of this - // construct → strip the markers (toggle off). - const existing = nearestAncestor(view, sel.head, spec.markName); - if (existing) { - const { from, to } = existing; - const text = view.state.sliceDoc(from, to); - if (spec.markName === "InlineCode") { - // Code spans carry variable-length fences + optional padding — undo - // them off the actual source, not the spec's one-backtick default. - const content = codeSpanUnwrap(text); - if (content !== null) { - view.dispatch({ - changes: { from, to, insert: content }, - selection: sel.empty - ? EditorSelection.cursor( - Math.min(Math.max(from, sel.head - 1), from + content.length), - ) - : EditorSelection.range(from, from + content.length), - userEvent: "input.format.unwrap", - }); - return true; - } - } - if (text.startsWith(spec.opener) && text.endsWith(spec.closer)) { - const content = text.slice(spec.opener.length, text.length - spec.closer.length); - const newSelFrom = from; - const newSelTo = from + content.length; - view.dispatch({ - changes: { from, to, insert: content }, - selection: sel.empty - ? EditorSelection.cursor(Math.max(newSelFrom, sel.head - spec.opener.length)) - : EditorSelection.range(newSelFrom, newSelTo), - userEvent: "input.format.unwrap", - }); - return true; - } - } - - // Case 2: wrap. Collapsed selection inserts markers + places caret - // between them; ranged selection wraps the content. - if (sel.empty) { - const insert = spec.opener + spec.closer; - view.dispatch({ - changes: { from: sel.head, insert }, - selection: EditorSelection.cursor(sel.head + spec.opener.length), - userEvent: "input.format.wrap", - }); - return true; - } - const content = view.state.sliceDoc(sel.from, sel.to); - // Shrink the wrap to the selection's non-whitespace core where the grammar - // demands it — wrapping edge spaces would emit markdown the parser is - // REQUIRED to treat as literal text (see keepEdgeWhitespaceOutside). - const leading = spec.keepEdgeWhitespaceOutside - ? content.length - content.trimStart().length - : 0; - const trailing = spec.keepEdgeWhitespaceOutside - ? content.length - content.trimEnd().length - : 0; - const core = content.slice(leading, content.length - trailing); - if (!core) { - // Nothing but whitespace selected — no formatting to apply. - return true; - } - const wrapFrom = sel.from + leading; - const wrapTo = sel.to - trailing; - // Code spans size their fence to the content (backticks inside need a - // longer fence) — the other constructs use the spec's fixed markers. - const { opener, inner } = - spec.markName === "InlineCode" - ? (() => { - const { fence, content: padded } = codeSpanWrap(core); - return { opener: fence, inner: padded }; - })() - : { opener: spec.opener, inner: core }; - const wrapped = opener + inner + opener; - view.dispatch({ - changes: { from: wrapFrom, to: wrapTo, insert: wrapped }, - selection: EditorSelection.range( - wrapFrom + opener.length, - wrapFrom + opener.length + inner.length, - ), - userEvent: "input.format.wrap", - }); - return true; - }; -} - -export const formatCommands = { - toggleBold: makeToggleCommand(FORMAT_BOLD), - toggleItalic: makeToggleCommand(FORMAT_ITALIC), - toggleInlineCode: makeToggleCommand(FORMAT_CODE), -}; - -export const formatCommandsKeymap = Prec.high( - keymap.of([ - { key: "Mod-b", run: formatCommands.toggleBold }, - { key: "Mod-i", run: formatCommands.toggleItalic }, - { key: "Mod-e", run: formatCommands.toggleInlineCode }, - ]), -); diff --git a/packages/rich-editor/src/codemirror/format/index.ts b/packages/rich-editor/src/codemirror/format/index.ts deleted file mode 100644 index 230cf2e..0000000 --- a/packages/rich-editor/src/codemirror/format/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Formatting commands behind the toolbar and keymaps: inline marks - * (bold/italic/strikethrough/code) and block shapes (headings, quotes, - * lists, fences). - */ -export * from "./formatCommands"; -export * from "./blockCommands"; diff --git a/packages/rich-editor/src/codemirror/highlight/highlightPlugin.test.ts b/packages/rich-editor/src/codemirror/highlight/highlightPlugin.test.ts deleted file mode 100644 index f6b8400..0000000 --- a/packages/rich-editor/src/codemirror/highlight/highlightPlugin.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// @vitest-environment jsdom -import type { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it } from "vitest"; - -import { destroyEditors, makeEditor } from "../core/editorTestHarness"; -import { highlightPlugin } from "./highlightPlugin"; - -function atomic(view: EditorView): Array<[number, number]> { - const out: Array<[number, number]> = []; - view - .plugin(highlightPlugin) - ?.atomic.between(0, view.state.doc.length, (from, to) => { - out.push([from, to]); - }); - return out; -} - -describe("highlightPlugin — ==text== highlight", () => { - afterEach(destroyEditors); - - it("hides the == markers (atomic) around the highlighted text", () => { - const view = makeEditor("==hi==", 0, [highlightPlugin]); - const ranges = atomic(view); - expect(ranges).toContainEqual([0, 2]); // opening == - expect(ranges).toContainEqual([4, 6]); // closing == - }); - - it("does nothing for plain text with no == markers", () => { - const view = makeEditor("plain text", 0, [highlightPlugin]); - expect(atomic(view)).toEqual([]); - }); - - it("leaves == markers literal inside a fenced code block", () => { - const view = makeEditor("```\na ==hi== b\n```", 0, [highlightPlugin]); - expect(atomic(view)).toEqual([]); - }); - - it("leaves == markers literal inside an inline code span", () => { - const view = makeEditor("x `a ==hi== b` y", 0, [highlightPlugin]); - expect(atomic(view)).toEqual([]); - }); -}); diff --git a/packages/rich-editor/src/codemirror/highlight/highlightPlugin.ts b/packages/rich-editor/src/codemirror/highlight/highlightPlugin.ts deleted file mode 100644 index 5d88f02..0000000 --- a/packages/rich-editor/src/codemirror/highlight/highlightPlugin.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { type Range } from "@codemirror/state"; -import { - Decoration, - type DecorationSet, - EditorView, - ViewPlugin, - type PluginValue, - type ViewUpdate, -} from "@codemirror/view"; - -import { inCode, viewportTree } from "../core/codeContext"; - -const HIGHLIGHT_RE = /==([^=\n]+?)==/g; -const HIDE = Decoration.replace({}); -const highlightMark = Decoration.mark({ class: "cm-highlight" }); - -function buildDecorations(view: EditorView): { decorations: DecorationSet; atomic: DecorationSet } { - const marks: Range[] = []; - const atomic: Range[] = []; - const tree = viewportTree(view); - - for (const { from, to } of view.visibleRanges) { - const text = view.state.sliceDoc(from, to); - HIGHLIGHT_RE.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = HIGHLIGHT_RE.exec(text)) !== null) { - const matchStart = from + match.index; - const matchEnd = matchStart + match[0].length; - if (inCode(tree, matchStart) || inCode(tree, matchEnd - 2)) continue; - const innerStart = matchStart + 2; - const innerEnd = matchEnd - 2; - if (innerEnd <= innerStart) continue; - - marks.push(HIDE.range(matchStart, innerStart)); - atomic.push(HIDE.range(matchStart, innerStart)); - marks.push(highlightMark.range(innerStart, innerEnd)); - marks.push(HIDE.range(innerEnd, matchEnd)); - atomic.push(HIDE.range(innerEnd, matchEnd)); - } - } - - return { - decorations: Decoration.set(marks, true), - atomic: Decoration.set(atomic, true), - }; -} - -export const highlightPlugin = ViewPlugin.fromClass( - class implements PluginValue { - decorations: DecorationSet; - atomic: DecorationSet; - constructor(view: EditorView) { - const built = buildDecorations(view); - this.decorations = built.decorations; - this.atomic = built.atomic; - } - update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { - const built = buildDecorations(update.view); - this.decorations = built.decorations; - this.atomic = built.atomic; - } - } - }, - { - decorations: (v) => v.decorations, - provide: (plugin) => - EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomic ?? Decoration.none), - }, -); diff --git a/packages/rich-editor/src/codemirror/highlight/index.ts b/packages/rich-editor/src/codemirror/highlight/index.ts deleted file mode 100644 index 1a08b37..0000000 --- a/packages/rich-editor/src/codemirror/highlight/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Highlight marks: `==text==` rendered as a highlighted span. */ -export * from "./highlightPlugin"; diff --git a/packages/rich-editor/src/codemirror/html/htmlWidget.test.ts b/packages/rich-editor/src/codemirror/html/htmlWidget.test.ts deleted file mode 100644 index 2ea35eb..0000000 --- a/packages/rich-editor/src/codemirror/html/htmlWidget.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, describe, expect, it } from "vitest"; - -import { destroyEditors, makeEditor } from "../core/editorTestHarness"; -import { HtmlWidget } from "./htmlWidget"; - -describe("HtmlWidget — sanitized inline HTML rendering", () => { - afterEach(destroyEditors); - - it("renders safe markup as real DOM", () => { - const view = makeEditor("x", 0); - const dom = new HtmlWidget("bold it", false).toDOM(view); - expect(dom.querySelector("b")?.textContent).toBe("bold"); - expect(dom.querySelector("em")?.textContent).toBe("it"); - }); - - it("strips ", false).toDOM(view); - expect(dom.querySelector("script")).toBeNull(); - expect(dom.querySelector("b")?.textContent).toBe("ok"); - }); - - it("strips inline event handlers", () => { - const view = makeEditor("x", 0); - const dom = new HtmlWidget('', false).toDOM(view); - expect(dom.querySelector("img")?.getAttribute("onerror")).toBeNull(); - }); - - it("uses a
.cm-html-block for block, .cm-html-inline for inline", () => { - const view = makeEditor("x", 0); - const block = new HtmlWidget("

hi

", true).toDOM(view); - const inline = new HtmlWidget("hi", false).toDOM(view); - expect(block.tagName).toBe("DIV"); - expect(block.className).toBe("cm-html-block"); - expect(inline.tagName).toBe("SPAN"); - expect(inline.className).toBe("cm-html-inline"); - }); -}); diff --git a/packages/rich-editor/src/codemirror/html/htmlWidget.ts b/packages/rich-editor/src/codemirror/html/htmlWidget.ts deleted file mode 100644 index da13ee6..0000000 --- a/packages/rich-editor/src/codemirror/html/htmlWidget.ts +++ /dev/null @@ -1,91 +0,0 @@ -import DOMPurify from "dompurify"; - -import { type NodeRule, type Paint, none } from "../core/paint"; -import { Decoration, EditorView, WidgetType } from "@codemirror/view"; - -const SANITIZE_CONFIG = { - FORBID_TAGS: ["script", "style", "iframe", "object", "embed", "form", "input", "button"] as string[], - FORBID_ATTR: ["on*"] as string[], - ALLOWED_URI_REGEXP: - /^(?:(?:https?|mailto|tel|data:image\/(?:png|jpeg|gif|webp|svg\+xml)):)/i, - RETURN_TRUSTED_TYPE: false, -}; - -/** - * Would this HTML draw anything the user can SEE once sanitized? A stripped - * or unknown tag (``, `
`, `