Răsfoiți Sursa

feat(editor): add error boundaries for KaTeX, parser failures, and mount crashes

- Wrap renderKatexSync in try/catch for both inline math widgets and
  table cell rendering — shows raw source with md-math-error styling
  (red, wavy underline) on KaTeX failure instead of silently breaking
- Wrap parseBlockTokens in try/catch — on parser crash, return empty
  cache so block renders as raw text; logs warning via createLogger
- Wrap EditorView mount in try/catch in EditorBlock.vue — on failure,
  render fallback <textarea> with block content; emit error event
- Add error event to EditorBlock and Editor APIs (bubbles to parent)
- Add md-math-error CSS class and aria-errormessage on error spans
- Document error event in README and add JSDoc on all error boundaries
Zander Hawke 3 zile în urmă
părinte
comite
58c8257fa5

+ 2 - 0
packages/editor/README.md

@@ -65,6 +65,7 @@ app.use(EnesisEditor)
 | `focus` | `{ view, handlers }` | Block received focus |
 | `blur` | — | Block lost focus |
 | `selection-change` | `{ from, to, empty }` | Selection changed |
+| `error` | `{ code, message?, blockId? }` | Mount failure, KaTeX parse error, or parser crash |
 
 ### Exposed methods
 
@@ -120,6 +121,7 @@ The editor forwards a `#toolbar` slot with bindings from the focused block:
 | `focus` | `{ view, handlers }` | Any block received focus. |
 | `blur` | — | All blocks lost focus. |
 | `selection-change` | `{ from, to, empty }` | Selection changed in the active block. |
+| `error` | `{ code, message?, blockId? }` | Mount failure, KaTeX parse error, or parser crash from any block. |
 
 ### Exposed methods
 

+ 6 - 0
packages/editor/src/assets/style.css

@@ -448,6 +448,12 @@
   @apply bg-(--ui-bg-elevated) rounded px-0.5 text-(--ui-text);
 }
 
+.md-math-error {
+  @apply font-mono text-xs text-(--ui-error) bg-(--ui-error)/10 px-1.5 py-0.5 rounded;
+  text-decoration: wavy underline;
+  text-underline-offset: 2px;
+}
+
 .md-math-hidden {
   display: none !important;
 }

+ 2 - 0
packages/editor/src/components/Editor.vue

@@ -48,6 +48,7 @@ const emit = defineEmits<{
   focus: [payload: { view: EditorView; handlers: FormattingHandlers }]
   blur: []
   "selection-change": [payload: { from: number; to: number; empty: boolean }]
+  error: [payload: { code: string; message?: string; blockId?: string }]
 }>()
 
 const props = withDefaults(
@@ -674,6 +675,7 @@ defineExpose({ undo, redo, canUndo, canRedo })
           @pattern-update="onPatternUpdate"
           @pattern-close="onPatternClose"
           @content-change-op="onBlockContentChangeOp"
+          @error="emit('error', $event)"
         />
       </div>
     </template>

+ 104 - 82
packages/editor/src/components/EditorBlock.vue

@@ -120,6 +120,7 @@ const emit = defineEmits<{
     newContent: string
     timestamp: number
   }]
+  error: [payload: { code: string; message?: string; blockId?: string }]
 }>()
 
 const log = createLogger("Block", props.debug)
@@ -206,6 +207,7 @@ const formattingHandlers: FormattingHandlers = {
 
 const editorRef = useTemplateRef("editorRef")
 let view: EditorView | null = null
+const mountError = ref(false)
 
 const { plugin: markdownDecorationsPlugin } = createMarkdownDecorationsPlugin(
   props.markerMode ?? "live-preview",
@@ -543,94 +545,107 @@ onMounted(() => {
     },
   }
 
-  view = new EditorView(mountEl, {
-    state: editorState,
-    nodeViews: {
-      code_block: (node, view, getPos) =>
-        new CodeBlockView(
-          node,
-          view,
-          () => getPos() ?? 0,
-          delegate,
-          props.debug,
-        ),
-      math_block: (node, view, getPos) =>
-        new MathBlockView(node, view, () => getPos() ?? 0),
-      ...getRegisteredNodeViews(),
-    },
-    handleKeyDown,
-    handleTextInput(_view, _from, _to, text) {
-      if (text.indexOf("\u00a0") !== -1) {
-        _view.dispatch(
-          _view.state.tr.insertText(text.replace(/\u00a0/g, " "), _from, _to),
-        )
-        return true
-      }
-      return false
-    },
-    handlePaste: createPasteHandler((before, after) =>
-      emit("split", before, after),
-    ),
-    dispatchTransaction(transaction: Transaction) {
-      const currentView = view
-      if (!currentView) return
-      const prevContent = docToContent(currentView.state.doc)
-      const newState = currentView.state.apply(transaction)
-      currentView.updateState(newState)
-
-      if (transaction.docChanged && !transaction.getMeta("externalUpdate")) {
-        const newContent = docToContent(newState.doc)
-        log.debug("content change", {
-          length: newContent.length,
-          docChanged: true,
-        })
-        content.value = newContent
-        emit("change", newContent)
-        emit("content-change-op", {
-          blockId: props.id ?? "",
-          previousContent: prevContent,
-          newContent,
-          timestamp: Date.now(),
-        })
-      }
-
-      const { from, to, empty } = newState.selection
-      if (from !== lastSelectionFrom || to !== lastSelectionTo) {
-        lastSelectionFrom = from
-        lastSelectionTo = to
-        emit("selection-change", { from, to, empty })
-      }
-    },
-    handleDOMEvents: {
-      focus() {
-        if (view) {
-          view.dispatch(view.state.tr.setMeta("decorations:focus", true))
-          emit("focus", { view, handlers: formattingHandlers })
-          if (props.id && props.registry) {
-            props.registry.notifyFocused(props.id)
-          }
+  // Mount the ProseMirror EditorView. If this fails (e.g. invalid schema,
+  // missing DOM, CM6 crash during node view init), catch the error and
+  // fall back to a plain <textarea>.
+  try {
+    view = new EditorView(mountEl, {
+      state: editorState,
+      nodeViews: {
+        code_block: (node, view, getPos) =>
+          new CodeBlockView(
+            node,
+            view,
+            () => getPos() ?? 0,
+            delegate,
+            props.debug,
+          ),
+        math_block: (node, view, getPos) =>
+          new MathBlockView(node, view, () => getPos() ?? 0),
+        ...getRegisteredNodeViews(),
+      },
+      handleKeyDown,
+      handleTextInput(_view, _from, _to, text) {
+        if (text.indexOf("\u00a0") !== -1) {
+          _view.dispatch(
+            _view.state.tr.insertText(text.replace(/\u00a0/g, " "), _from, _to),
+          )
+          return true
         }
         return false
       },
-      blur() {
-        if (view) {
-          view.dispatch(view.state.tr.setMeta("decorations:focus", false))
+      handlePaste: createPasteHandler((before, after) =>
+        emit("split", before, after),
+      ),
+      dispatchTransaction(transaction: Transaction) {
+        const currentView = view
+        if (!currentView) return
+        const prevContent = docToContent(currentView.state.doc)
+        const newState = currentView.state.apply(transaction)
+        currentView.updateState(newState)
+
+        if (transaction.docChanged && !transaction.getMeta("externalUpdate")) {
+          const newContent = docToContent(newState.doc)
+          log.debug("content change", {
+            length: newContent.length,
+            docChanged: true,
+          })
+          content.value = newContent
+          emit("change", newContent)
+          emit("content-change-op", {
+            blockId: props.id ?? "",
+            previousContent: prevContent,
+            newContent,
+            timestamp: Date.now(),
+          })
+        }
+
+        const { from, to, empty } = newState.selection
+        if (from !== lastSelectionFrom || to !== lastSelectionTo) {
+          lastSelectionFrom = from
+          lastSelectionTo = to
+          emit("selection-change", { from, to, empty })
         }
-        emit("blur")
-        return false
       },
-    },
-  })
+      handleDOMEvents: {
+        focus() {
+          if (view) {
+            view.dispatch(view.state.tr.setMeta("decorations:focus", true))
+            emit("focus", { view, handlers: formattingHandlers })
+            if (props.id && props.registry) {
+              props.registry.notifyFocused(props.id)
+            }
+          }
+          return false
+        },
+        blur() {
+          if (view) {
+            view.dispatch(view.state.tr.setMeta("decorations:focus", false))
+          }
+          emit("blur")
+          return false
+        },
+      },
+    })
 
-  if (props.focused && view) {
-    view.dispatch(view.state.tr.setMeta("decorations:focus", true))
-    focusAt(props.cursorPosition)
-  }
+    if (props.focused && view) {
+      view.dispatch(view.state.tr.setMeta("decorations:focus", true))
+      focusAt(props.cursorPosition)
+    }
 
-  if (props.id && props.registry) {
-    props.registry.register(props.id, {
-      focus: focusAt,
-      element: mountEl ?? undefined,
+    if (props.id && props.registry) {
+      props.registry.register(props.id, {
+        focus: focusAt,
+        element: mountEl ?? undefined,
+      })
+    }
+  } catch (e) {
+    log.error("mount failed", { error: e, blockId: props.id })
+    mountError.value = true
+    emit("error", {
+      code: "editor-mount-failed",
+      message: e instanceof Error ? e.message : String(e),
+      blockId: props.id,
     })
   }
 })
@@ -708,7 +723,14 @@ defineExpose({
 
 <template>
   <div ref="menuWrapperRef" style="position: relative">
-    <div ref="editorRef" class="w-full min-h-7 text-base"></div>
+    <div v-if="mountError" class="w-full min-h-7 text-base">
+      <textarea
+        v-model="content"
+        class="w-full min-h-7 p-1 text-sm font-mono bg-transparent border border-(--ui-error)/50 rounded-md resize-none outline-none"
+        :aria-label="`Block editor (mount failed)`"
+      />
+    </div>
+    <div v-else ref="editorRef" class="w-full min-h-7 text-base"></div>
 
     <EditorSuggestionMenu
       ref="menuRef"

+ 32 - 4
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -22,6 +22,7 @@ import { renderKatexSync } from "@/lib/katex"
 import { createLogger } from "@/lib/logger"
 import {
   type DecorationRange,
+  type ParsedDecorations,
   MarkdownRuleEngine,
   parseMarkdown,
 } from "@/lib/markdown-parser"
@@ -69,6 +70,7 @@ interface BlockTokenCache {
 /**
  * Parse tokens for the combined text of a block. Returns cached structure.
  * Only runs when document content changes.
+ * On parse failure, logs a warning and returns an empty cache (raw text).
  */
 function parseBlockTokens(
   text: string,
@@ -76,7 +78,20 @@ function parseBlockTokens(
   engine: MarkdownRuleEngine,
   oldTree?: Tree,
 ): BlockTokenCache {
-  const result = parseMarkdown(text, engine, oldTree)
+  let result: ParsedDecorations & { tree: Tree }
+  try {
+    result = parseMarkdown(text, engine, oldTree)
+  } catch (e) {
+    log.warn("parseBlockTokens failed", { error: e, textLen: text.length })
+    return {
+      text,
+      boundaries,
+      tree: undefined,
+      content: [],
+      blocks: [],
+      properties: [],
+    }
+  }
   return {
     text,
     boundaries,
@@ -521,7 +536,15 @@ function applyInlineDecorations(
             (view) => {
               const widgetSpan = document.createElement("span")
               widgetSpan.className = "md-math-rendered"
-              renderKatexSync(source, widgetSpan, false)
+              // If KaTeX fails, show the raw source as an error decoration
+              // instead of letting the exception propagate.
+              try {
+                renderKatexSync(source, widgetSpan, false)
+              } catch {
+                widgetSpan.textContent = source
+                widgetSpan.className = "md-math-error"
+                widgetSpan.setAttribute("aria-errormessage", "math-parse-error")
+              }
               widgetSpan.addEventListener("mousedown", (e) => {
                 e.preventDefault()
                 e.stopPropagation()
@@ -792,8 +815,13 @@ function renderCellContent(text: string): string {
         }
         const mathSource = text.slice(contentRange.from, contentRange.to)
         const temp = document.createElement("span")
-        renderKatexSync(mathSource, temp, false)
-        opens[contentRange.from] = (opens[contentRange.from] ?? "") + temp.innerHTML
+        // If KaTeX fails in a table cell, fall back to escaped source.
+        try {
+          renderKatexSync(mathSource, temp, false)
+          opens[contentRange.from] = (opens[contentRange.from] ?? "") + temp.innerHTML
+        } catch {
+          opens[contentRange.from] = (opens[contentRange.from] ?? "") + `<span class="md-math-error">${escapeHtml(mathSource)}</span>`
+        }
       }
       continue
     }