Sfoglia il codice sorgente

fix(editor): heading, auto-close, HR, and insertion zone improvements

- Prevent tag palette from opening on bare `#` (heading attempt)
- Normalize non-breaking spaces (U+00A0) to regular spaces on input
- Treat non-breaking space as whitespace in tag parser boundaries
- Clamp heading marker ranges to node bounds for edge cases
- Reject whitespace-only content in tag decoration rule
- Add skip-over for toggle delimiters (`**`, `~~`, `^^`) โ€” type past
  closing pairs one character at a time
- Only auto-close toggle delimiters when preceded by whitespace or
  start-of-text (prevents mid-word and post-pair activation)
- Add `^^` auto-close rule for highlight syntax
- Add horizontal rule (`---`) rendering with decoration-only approach
- Reject even-length `*`-based HRs (e.g. `****`) to avoid bold conflict
- Add CodeMirror Shift+Enter to insert paragraph before/after code block
- Add ArrowUp/Down/Escape navigation to insertion zones
- Remove unused date feature (field, CSS, demo page)
Zander Hawke 4 giorni fa
parent
commit
62079655a0

+ 2 - 8
apps/dev/src/pages/properties.vue

@@ -4,7 +4,7 @@ import LiveExample from "~/components/LiveExample.vue"
 
 <template>
   <UPage>
-    <UPageHeader title="Properties & Dates" description="Key-value metadata and date parsing." />
+    <UPageHeader title="Properties" description="Key-value metadata." />
 
     <UPageBody>
       <div class="space-y-8">
@@ -14,16 +14,10 @@ import LiveExample from "~/components/LiveExample.vue"
           :content="`This is a document with metadata.\nauthor:: John Doe\nstatus:: published`"
         />
 
-        <LiveExample
-          title="Dates"
-          description="ISO dates are detected and styled."
-          :content="`๐Ÿ“… 2026-06-14 is a recognized date\n๐Ÿ“… 2025-12-25 Christmas`"
-        />
-
         <LiveExample
           title="Mixed Properties"
           description="Properties work alongside other syntax."
-          :content="`## Meeting Notes\ntags:: #development #planning\ndate:: 2026-06-14\nTODO Review the agenda`"
+          :content="`## Meeting Notes\ntags:: #development #planning\nTODO Review the agenda`"
         />
       </div>
     </UPageBody>

+ 1 - 1
packages/editor/README.md

@@ -176,7 +176,7 @@ Formatting is purely decorative โ€” markdown syntax is stored as plain text in P
 | Fenced Code Block | `` ```python `` / `~~~js` | CM6 node view |
 | Highlight | `^^text^^` | `md-highlight` |
 
-> **Priority flags** (`[#A]`, `[#B]`, `[#C]`) and **date emoji** (`๐Ÿ“… YYYY-MM-DD`) were removed from the parser โ€” they render as plain text. Styling classes remain for backward compatibility.
+> **Priority flags** (`[#A]`, `[#B]`, `[#C]`) were removed from the parser โ€” they render as plain text. Styling classes remain for backward compatibility.
 
 ### Pattern Detection
 

+ 10 - 9
packages/editor/src/assets/style.css

@@ -133,6 +133,16 @@
   @apply text-(--ui-text-muted) opacity-40 font-mono text-[0.7em] mr-2 align-middle select-none;
 }
 
+/* โ”€โ”€ Horizontal rules โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
+
+.md-hr {
+  @apply flex items-center justify-center my-6 before:flex-1 before:border-t before:border-(--ui-border-accented) before:border-dashed after:flex-1 after:border-t after:border-(--ui-border-accented) after:border-dashed;
+}
+
+.md-hr-marker {
+  @apply px-3 font-mono text-xs font-medium tracking-[0.4em] select-none text-(--ui-text-muted);
+}
+
 /* โ”€โ”€ Tasks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
 
 .md-task-marker {
@@ -207,15 +217,6 @@
   @apply bg-(--ui-primary)/8 text-(--ui-primary);
 }
 
-/* โ”€โ”€ Date โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
-
-.md-date-marker {
-  @apply opacity-60 mr-1.5 select-none;
-}
-.md-date-content {
-  @apply text-(--ui-text-muted) font-mono text-[0.9em] bg-(--ui-bg-elevated) px-1.5 py-px rounded-md border border-(--ui-border);
-}
-
 /* โ”€โ”€ Code Blocks (CM6 Node View) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
 
 .cm-code-block {

+ 24 - 0
packages/editor/src/components/Block.vue

@@ -35,6 +35,7 @@ import {
   dollarSignRule,
   doubleAsteriskRule,
   doubleTildeRule,
+  doubleCaretRule,
   singleUnderscoreRule,
   tripleBacktickRule,
 } from "@/lib/auto-close-plugin"
@@ -243,6 +244,7 @@ onMounted(() => {
         dollarSignRule,
         doubleAsteriskRule,
         doubleTildeRule,
+        doubleCaretRule,
         singleUnderscoreRule,
         createPairRule("{", "}"),
         createPairRule('"', '"', { context: /^$|[\s([{:>]/ }),
@@ -364,6 +366,28 @@ onMounted(() => {
       }
     },
 
+    requestInsertParagraphAround(direction, nodePos) {
+      log.info("requestInsertParagraphAround", { direction, nodePos })
+      const currentView = view
+      if (!currentView) return
+      const node = currentView.state.doc.nodeAt(nodePos)
+      if (!node) return
+      const tr = currentView.state.tr
+      const para = currentView.state.schema.nodes.paragraph!.create()
+      if (direction === "before") {
+        tr.insert(nodePos, para)
+        const $pos = tr.doc.resolve(nodePos + 1)
+        tr.setSelection(TextSelection.near($pos))
+      } else {
+        const afterPos = nodePos + node.nodeSize
+        tr.insert(afterPos, para)
+        const $pos = tr.doc.resolve(afterPos + 1)
+        tr.setSelection(TextSelection.near($pos))
+      }
+      currentView.dispatch(tr)
+      currentView.focus()
+    },
+
     requestChangeLanguage(newLanguage, body, nodePos) {
       log.info("requestChangeLanguage", {
         newLanguage,

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

@@ -281,6 +281,18 @@ function onPatternClose(_payload: PatternClosePayload) {
   activeSession.value = null
 }
 
+function onZoneNavigateUp(zoneIndex: number) {
+  if (zoneIndex <= 0) return
+  const prev = blocks.value[zoneIndex - 1]
+  if (prev) registry.focus(prev.id, "end")
+}
+
+function onZoneNavigateDown(zoneIndex: number) {
+  if (zoneIndex >= blocks.value.length) return
+  const next = blocks.value[zoneIndex]
+  if (next) registry.focus(next.id, "start")
+}
+
 // โ”€โ”€ Block event handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 function onSplit(index: number, before: string, after: string) {
@@ -503,6 +515,8 @@ defineExpose({ undo, redo, canUndo, canRedo })
         @activate="(content) => onZoneActivate(i, content)"
         @focus="onZoneFocus(i)"
         @blur="onZoneBlur"
+        @navigate-up="onZoneNavigateUp(i)"
+        @navigate-down="onZoneNavigateDown(i)"
       />
       <div class="flex items-start">
         <div
@@ -546,6 +560,8 @@ defineExpose({ undo, redo, canUndo, canRedo })
       @activate="(content) => onZoneActivate(blocks.length, content)"
       @focus="onZoneFocus(blocks.length)"
       @blur="onZoneBlur"
+      @navigate-up="onZoneNavigateUp(blocks.length)"
+      @navigate-down="onZoneNavigateDown(blocks.length)"
     />
   </div>
 

+ 20 - 0
packages/editor/src/components/InsertionZone.vue

@@ -5,6 +5,8 @@ const emit = defineEmits<{
   activate: [content: string]
   focus: []
   blur: []
+  "navigate-up": []
+  "navigate-down": []
 }>()
 
 const hovered = ref(false)
@@ -12,6 +14,24 @@ const focused = ref(false)
 const rootEl = ref<HTMLElement | null>(null)
 
 function onKeydown(e: KeyboardEvent) {
+  if (e.key === "ArrowUp") {
+    e.preventDefault()
+    emit("navigate-up")
+    return
+  }
+
+  if (e.key === "ArrowDown") {
+    e.preventDefault()
+    emit("navigate-down")
+    return
+  }
+
+  if (e.key === "Escape") {
+    e.preventDefault()
+    rootEl.value?.blur()
+    return
+  }
+
   if (e.key === "Enter") {
     e.preventDefault()
     emit("activate", "")

+ 22 - 0
packages/editor/src/composables/useCodeBlockView.ts

@@ -28,6 +28,12 @@ export interface CodeBlockNavigationDelegate {
   /** User pressed ArrowUp at first line or ArrowDown at last line. */
   requestNavigateOut(direction: "up" | "down", nodePos: number): void
 
+  /** User pressed Shift+Enter on first or last line of a code block. */
+  requestInsertParagraphAround(
+    direction: "before" | "after",
+    nodePos: number,
+  ): void
+
   /** User pressed Backspace on an empty code block. */
   requestRemoveBlock(nodePos: number): void
 
@@ -252,6 +258,22 @@ export class CodeBlockView implements NodeView {
           return false
         },
       },
+      {
+        key: "Shift-Enter",
+        run: (cm6View) => {
+          const sel = cm6View.state.selection.main
+          if (sel.from === 0 && sel.empty) {
+            this.delegate.requestInsertParagraphAround("before", this.getPos())
+            return true
+          }
+          const docLen = cm6View.state.doc.length
+          if (sel.from === docLen && sel.empty) {
+            this.delegate.requestInsertParagraphAround("after", this.getPos())
+            return true
+          }
+          return false
+        },
+      },
       {
         key: "Backspace",
         run: (cm6View) => {

+ 52 - 0
packages/editor/src/lib/__tests__/markdown-parser.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, it } from "vitest"
 import {
+  type BlockDecoration,
   type BlockRefDecoration,
   type InlineMathDecoration,
   type PageRefDecoration,
@@ -731,3 +732,54 @@ describe("InlineMath", () => {
     expect(result.content).toHaveLength(0)
   })
 })
+
+describe("horizontal-rule", () => {
+  it("parses --- as horizontal rule", () => {
+    const result = parseMarkdown("---")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("horizontal-rule")
+    expect(
+      (result.blocks[0] as BlockDecoration).nodeWrapper?.className,
+    ).toBe("md-hr")
+  })
+
+  it("parses *** as horizontal rule", () => {
+    const result = parseMarkdown("***")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("horizontal-rule")
+  })
+
+  it("hides --- content with hidden decoration range", () => {
+    const result = parseMarkdown("---")
+    const block = result.blocks[0] as BlockDecoration
+    expect(block.decorationRanges).toHaveLength(1)
+    expect(block.decorationRanges[0]).toEqual({
+      type: "hidden",
+      from: 0,
+      to: 3,
+      className: "md-hr-marker",
+    })
+  })
+
+  it("does not parse --- with trailing text as horizontal rule", () => {
+    const result = parseMarkdown("--- not a break")
+    expect(result.blocks).toHaveLength(0)
+  })
+
+  it("parses ---- as horizontal rule", () => {
+    const result = parseMarkdown("----")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("horizontal-rule")
+  })
+
+  it("does not parse **** as horizontal rule (bold conflict)", () => {
+    const result = parseMarkdown("****")
+    expect(result.blocks).toHaveLength(0)
+  })
+
+  it("parses ***** as horizontal rule (odd length)", () => {
+    const result = parseMarkdown("*****")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("horizontal-rule")
+  })
+})

+ 34 - 8
packages/editor/src/lib/auto-close-plugin.ts

@@ -173,6 +173,22 @@ export function createToggleRule(
       if (text !== lastChar) return false
       if (!isTextBlock(view.state, from)) return false
 
+      // Skip-over: jump past a matching closing delimiter character
+      // one at a time. Runs before the prefix/space checks so the
+      // cursor can advance through an existing closing pair.
+      if (from === to) {
+        const next = state.doc.textBetween(from, from + 1)
+        if (next === lastChar) {
+          log.debug("toggle skip-over", { delimiter, char: lastChar })
+          const tr = state.tr.setSelection(
+            TextSelection.create(state.doc, from + 1),
+          )
+          view.dispatch(tr)
+          view.focus()
+          return true
+        }
+      }
+
       // Check if previous chars match the delimiter prefix
       const prevChars = state.doc.textBetween(
         Math.max(0, from - prefix.length),
@@ -180,14 +196,14 @@ export function createToggleRule(
       )
       if (prevChars !== prefix) return false
 
-      // Word boundary check for the last character
-      if (options?.wordBoundary) {
-        const before = state.doc.textBetween(
-          Math.max(0, from - prefix.length - 1),
-          from - prefix.length,
-        )
-        if (before && /\w/.test(before)) return false
-      }
+      // Only auto-close when preceded by whitespace or start-of-text.
+      // This prevents auto-closing mid-word (e.g. "text**" should not
+      // become "text****") or right after a completed pair.
+      const before = state.doc.textBetween(
+        Math.max(0, from - prefix.length - 1),
+        from - prefix.length,
+      )
+      if (before && !/^\s$/.test(before)) return false
 
       // handleTextInput fires one character at a time, so by the time
       // both ** are typed any prior selection is already collapsed.
@@ -252,6 +268,9 @@ export const doubleAsteriskRule: AutoCloseRule = createToggleRule("**")
 /** Auto-close ~~ (double tilde โ†’ strikethrough) */
 export const doubleTildeRule: AutoCloseRule = createToggleRule("~~")
 
+/** Auto-close ^^ (double caret โ†’ highlight) */
+export const doubleCaretRule: AutoCloseRule = createToggleRule("^^")
+
 /**
  * Auto-close $ for inline math and $$ โ†’ math_block conversion.
  *
@@ -368,3 +387,10 @@ export const singleUnderscoreRule: AutoCloseRule = {
     return true
   },
 }
+
+/**
+ * Redirect spaces typed inside a toggle pair (e.g. inside **text**)
+ * to after the closing delimiter. If the next character is typed,
+ * both the space and the character move back into the pair.
+ */
+// (removed โ€” revisit later)

+ 43 - 1
packages/editor/src/lib/markdown-rules/block-rules.ts

@@ -220,8 +220,50 @@ export function createTaskRule(): BlockRule {
   }
 }
 
+// โ”€โ”€ Horizontal rule rule โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+export function createHorizontalRuleRule(): BlockRule {
+  return {
+    name: "horizontal-rule",
+    nodeTypes: ["HorizontalRule"],
+    match(node, ctx) {
+      if (node.type.name !== "HorizontalRule") return false
+      // Reject even-length *-based HRs (e.g. ****) โ€” they're likely
+      // bold toggle pairs, not thematic breaks.
+      const text = ctx.doc.slice(node.from, node.to)
+      if (text.length >= 4 && text[0] === "*" && text.split("").every((c) => c === "*")) {
+        return text.length % 2 !== 0
+      }
+      return true
+    },
+    run(node) {
+      return {
+        type: "horizontal-rule",
+        nodeWrapper: {
+          className: "md-hr",
+          attrs: { "data-md-type": "horizontal-rule" },
+        },
+        wrapperAttrs: { "data-md-type": "horizontal-rule" },
+        decorationRanges: [
+          {
+            type: "hidden",
+            from: node.from,
+            to: node.to,
+            className: "md-hr-marker",
+          },
+        ],
+      }
+    },
+  }
+}
+
 // โ”€โ”€ Default block rule set โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 export function createBlockRules(): BlockRule[] {
-  return [createBlockquoteRule(), createHeadingRule(), createTaskRule()]
+  return [
+    createBlockquoteRule(),
+    createHeadingRule(),
+    createTaskRule(),
+    createHorizontalRuleRule(),
+  ]
 }

+ 1 - 2
packages/editor/src/lib/markdown-rules/types.ts

@@ -85,13 +85,12 @@ export type PriorityLevel = "A" | "B" | "C"
 export type CalloutType = "NOTE" | "WARNING" | "TIP" | "DANGER" | "INFO"
 
 export interface BlockDecoration extends ParsedToken {
-  type: "heading" | "task" | "blockquote" | "callout"
+  type: "heading" | "task" | "blockquote" | "callout" | "horizontal-rule"
   metadata?: {
     level?: number
     taskState?: TaskState
     priority?: PriorityLevel
     calloutType?: CalloutType
-    date?: string
   }
 }