Procházet zdrojové kódy

feat: unify block marker visibility with inline decorator hide/reveal system

- Switch heading # , task TODO, blockquote >, and callout > [!NOTE] markers from type: "content" to type: "hidden" in block-rules.ts
- Fix buildTokenDecorations to preserve explicit className when applying md-decorator-hidden — block markers keep their own styling (colors, fonts, sizes) while gaining the squeeze animation
- Share layout and transition properties across all marker classes via a group selector in CSS
- Add padding: 0 !important to .md-decorator-hidden so markers with padding (like TODO) don't leave stubs when compressed
- Add GNU sed note to AGENTS.md
Zander Hawke před 1 týdnem
rodič
revize
dd752a42d1

+ 6 - 0
AGENTS.md

@@ -305,6 +305,12 @@ In `Block.vue`, pass rules to `autoClosePlugin([...])` in the EditorState's `plu
 
 ---
 
+## Environment
+
+> **GNU sed, not BSD sed.** This is macOS but `sed` refers to GNU sed (installed via Homebrew). Use `sed -i` without an empty-string argument for in-place edits, e.g. `sed -i 's/foo/bar/' file`. Do not use `sed -i ''` (that's BSD sed syntax).
+
+---
+
 ## Critical Constraints
 
 > **Read-only packed files.** Aggregated repo views from tools like Repomix or `workspace-pack` are transport-only. Never write output targeting packed container files. Always emit changes to original source paths under `apps/` or `packages/`.

+ 0 - 127
apps/dev/src/components/Editor.vue

@@ -1,127 +0,0 @@
-<script setup lang="ts">
-import { Block } from "@enesis/editor"
-import { ref } from "vue"
-
-const content = ref(`# Welcome
-This is a **block editor** with markdown decorations.
-
-> [!NOTE] This is a callout block with important information.
-> This is a continuation of the callout -^
-
-[[Page Name]] creates a page reference chip, while ((block-id)) creates a block reference.
-
-#tag creates a tag chip, and you can use links like [this link](https://example.com).
-
-📅 2026-05-20 is a date
-
-\`\`\`python
-def hello():
-    print("Hello from CM6!")
-    for i in range(3):
-        print(f"Line {i}")
-\`\`\`
-
-property:: This is a property line
-another:: [[Page reference]] in a property
-`)
-
-const task1 = ref(`# Test callout
-> [!NOTE] First line of the callout
-> second line continuation
-`)
-const task2 = ref("DOING this is another task")
-const task3 = ref("DONE a completed task [#A]")
-
-function log(event: string, payload?: unknown) {
-  console.log(`[Block] ${event}`, payload)
-
-  if (event === "blur") {
-    console.log(content.value)
-  }
-}
-</script>
-
-<template>
-  <div class="p-8 max-w-2xl mx-auto">
-    <Block
-      v-model:content="content"
-      :focused="true"
-      cursor-position="end"
-      marker-mode="always-visible"
-      @change="log('change', $event)"
-      @split="log('split', $event)"
-      @merge-previous="log('merge-previous')"
-      @delete-if-empty="log('delete-if-empty')"
-      @indent="log('indent')"
-      @outdent="log('outdent')"
-      @arrow-up-from-start="log('arrow-up-from-start')"
-      @arrow-down-from-end="log('arrow-down-from-end')"
-      @pattern-open="log('pattern-open', $event)"
-      @pattern-update="log('pattern-update', $event)"
-      @pattern-close="log('pattern-close', $event)"
-      @focus="log('focus', $event)"
-      @blur="log('blur')"
-      @selection-change="log('selection-change', $event)"
-    />
-
-    <Block
-      v-model:content="task1"
-      :focused="true"
-      cursor-position="end"
-      @change="log('change', $event)"
-      @split="log('split', $event)"
-      @merge-previous="log('merge-previous')"
-      @delete-if-empty="log('delete-if-empty')"
-      @indent="log('indent')"
-      @outdent="log('outdent')"
-      @arrow-up-from-start="log('arrow-up-from-start')"
-      @arrow-down-from-end="log('arrow-down-from-end')"
-      @pattern-open="log('pattern-open', $event)"
-      @pattern-update="log('pattern-update', $event)"
-      @pattern-close="log('pattern-close', $event)"
-      @focus="log('focus', $event)"
-      @blur="log('blur')"
-      @selection-change="log('selection-change', $event)"
-    />
-
-    <Block
-      v-model:content="task2"
-      :focused="true"
-      cursor-position="end"
-      @change="log('change', $event)"
-      @split="log('split', $event)"
-      @merge-previous="log('merge-previous')"
-      @delete-if-empty="log('delete-if-empty')"
-      @indent="log('indent')"
-      @outdent="log('outdent')"
-      @arrow-up-from-start="log('arrow-up-from-start')"
-      @arrow-down-from-end="log('arrow-down-from-end')"
-      @pattern-open="log('pattern-open', $event)"
-      @pattern-update="log('pattern-update', $event)"
-      @pattern-close="log('pattern-close', $event)"
-      @focus="log('focus', $event)"
-      @blur="log('blur')"
-      @selection-change="log('selection-change', $event)"
-    />
-
-    <Block
-      v-model:content="task3"
-      :focused="true"
-      cursor-position="end"
-      @change="log('change', $event)"
-      @split="log('split', $event)"
-      @merge-previous="log('merge-previous')"
-      @delete-if-empty="log('delete-if-empty')"
-      @indent="log('indent')"
-      @outdent="log('outdent')"
-      @arrow-up-from-start="log('arrow-up-from-start')"
-      @arrow-down-from-end="log('arrow-down-from-end')"
-      @pattern-open="log('pattern-open', $event)"
-      @pattern-update="log('pattern-update', $event)"
-      @pattern-close="log('pattern-close', $event)"
-      @focus="log('focus', $event)"
-      @blur="log('blur')"
-      @selection-change="log('selection-change', $event)"
-    />
-  </div>
-</template>

+ 125 - 1
apps/dev/src/pages/EditorView.vue

@@ -1,3 +1,127 @@
+<script setup lang="ts">
+import { Block } from "@enesis/editor"
+import { ref } from "vue"
+
+const content = ref(`# Welcome
+This is a **block editor** with markdown decorations.
+
+> [!NOTE] This is a callout block with important information.
+> This is a continuation of the callout -^
+
+[[Page Name]] creates a page reference chip, while ((block-id)) creates a block reference.
+
+#tag creates a tag chip, and you can use links like [this link](https://example.com).
+
+📅 2026-05-20 is a date
+
+\`\`\`python
+def hello():
+    print("Hello from CM6!")
+    for i in range(3):
+        print(f"Line {i}")
+\`\`\`
+
+property:: This is a property line
+another:: [[Page reference]] in a property
+`)
+
+const task1 = ref(`# Test callout
+> [!NOTE] First line of the callout
+> second line continuation
+`)
+const task2 = ref("DOING this is another task")
+const task3 = ref("DONE a completed task [#A]")
+
+function log(event: string, payload?: unknown) {
+  console.log(`[Block] ${event}`, payload)
+
+  if (event === "blur") {
+    console.log(content.value)
+  }
+}
+</script>
+
 <template>
-  <Editor />
+  <div class="p-8 max-w-2xl mx-auto">
+    <Block
+      v-model:content="content"
+      :focused="true"
+      cursor-position="end"
+      marker-mode="always-visible"
+      @change="log('change', $event)"
+      @split="log('split', $event)"
+      @merge-previous="log('merge-previous')"
+      @delete-if-empty="log('delete-if-empty')"
+      @indent="log('indent')"
+      @outdent="log('outdent')"
+      @arrow-up-from-start="log('arrow-up-from-start')"
+      @arrow-down-from-end="log('arrow-down-from-end')"
+      @pattern-open="log('pattern-open', $event)"
+      @pattern-update="log('pattern-update', $event)"
+      @pattern-close="log('pattern-close', $event)"
+      @focus="log('focus', $event)"
+      @blur="log('blur')"
+      @selection-change="log('selection-change', $event)"
+    />
+
+    <Block
+      v-model:content="task1"
+      :focused="true"
+      cursor-position="end"
+      @change="log('change', $event)"
+      @split="log('split', $event)"
+      @merge-previous="log('merge-previous')"
+      @delete-if-empty="log('delete-if-empty')"
+      @indent="log('indent')"
+      @outdent="log('outdent')"
+      @arrow-up-from-start="log('arrow-up-from-start')"
+      @arrow-down-from-end="log('arrow-down-from-end')"
+      @pattern-open="log('pattern-open', $event)"
+      @pattern-update="log('pattern-update', $event)"
+      @pattern-close="log('pattern-close', $event)"
+      @focus="log('focus', $event)"
+      @blur="log('blur')"
+      @selection-change="log('selection-change', $event)"
+    />
+
+    <Block
+      v-model:content="task2"
+      :focused="true"
+      cursor-position="end"
+      @change="log('change', $event)"
+      @split="log('split', $event)"
+      @merge-previous="log('merge-previous')"
+      @delete-if-empty="log('delete-if-empty')"
+      @indent="log('indent')"
+      @outdent="log('outdent')"
+      @arrow-up-from-start="log('arrow-up-from-start')"
+      @arrow-down-from-end="log('arrow-down-from-end')"
+      @pattern-open="log('pattern-open', $event)"
+      @pattern-update="log('pattern-update', $event)"
+      @pattern-close="log('pattern-close', $event)"
+      @focus="log('focus', $event)"
+      @blur="log('blur')"
+      @selection-change="log('selection-change', $event)"
+    />
+
+    <Block
+      v-model:content="task3"
+      :focused="true"
+      cursor-position="end"
+      @change="log('change', $event)"
+      @split="log('split', $event)"
+      @merge-previous="log('merge-previous')"
+      @delete-if-empty="log('delete-if-empty')"
+      @indent="log('indent')"
+      @outdent="log('outdent')"
+      @arrow-up-from-start="log('arrow-up-from-start')"
+      @arrow-down-from-end="log('arrow-down-from-end')"
+      @pattern-open="log('pattern-open', $event)"
+      @pattern-update="log('pattern-update', $event)"
+      @pattern-close="log('pattern-close', $event)"
+      @focus="log('focus', $event)"
+      @blur="log('blur')"
+      @selection-change="log('selection-change', $event)"
+    />
+  </div>
 </template>

+ 9 - 9
apps/dev/src/pages/MathTestView.vue

@@ -57,11 +57,11 @@ function log(event: string, payload?: unknown) {
   <div class="p-8 max-w-2xl mx-auto space-y-6">
     <h1 class="text-2xl font-bold">Math Feature Tests</h1>
 
-    <section class="rounded-lg border border-(--ui-border) p-4 space-y-2">
+    <section class="rounded-lg border border-default p-4 space-y-2">
       <h2 class="text-lg font-semibold">Inline Math</h2>
-      <p class="text-sm text-(--ui-text-muted)">
+      <p class="text-sm text-muted">
         Type <code class="text-xs">$...$</code> for inline math. Use
-        <kbd class="px-1.5 py-0.5 rounded bg-(--ui-bg-elevated) border border-(--ui-border) text-xs">Mod+M</kbd>
+        <kbd class="px-1.5 py-0.5 rounded bg-elevated border border-default text-xs">Mod+M</kbd>
         to toggle inline math on selection.
       </p>
       <Block
@@ -76,12 +76,12 @@ function log(event: string, payload?: unknown) {
       />
     </section>
 
-    <section class="rounded-lg border border-(--ui-border) p-4 space-y-2">
+    <section class="rounded-lg border border-default p-4 space-y-2">
       <h2 class="text-lg font-semibold">Block Math</h2>
-      <p class="text-sm text-(--ui-text-muted)">
+      <p class="text-sm text-muted">
         Type <code class="text-xs">$$</code> at the start of an empty paragraph for a
         display math block. Use
-        <kbd class="px-1.5 py-0.5 rounded bg-(--ui-bg-elevated) border border-(--ui-border) text-xs">Mod+Shift+M</kbd>
+        <kbd class="px-1.5 py-0.5 rounded bg-elevated border border-default text-xs">Mod+Shift+M</kbd>
         to insert one.
       </p>
       <Block
@@ -96,16 +96,16 @@ function log(event: string, payload?: unknown) {
       />
     </section>
 
-    <section class="rounded-lg border border-(--ui-border) p-4 space-y-2">
+    <section class="rounded-lg border border-default p-4 space-y-2">
       <h2 class="text-lg font-semibold">Mixed Syntax</h2>
-      <p class="text-sm text-(--ui-text-muted)">
+      <p class="text-sm text-muted">
         Math combined with headings, bold, callouts, page refs, tasks, and properties.
       </p>
       <Block
         v-model:content="mixed"
         :focused="true"
         cursor-position="end"
-        marker-mode="always-visible"
+        marker-mode="live-preview"
         @change="log('change', $event)"
         @focus="log('focus', $event)"
         @blur="log('blur')"

+ 16 - 11
packages/editor/src/assets/style.css

@@ -13,21 +13,16 @@
 
 /* ── Decorators (Markdown syntax) ────────────────────────────────── */
 
-.md-decorator {
-  /* 1. Establish an inline-flex container that can smoothly compress horizontally */
+/* All marker classes — both inline decorators and block markers —
+ * share the same layout and transition properties for smooth hide/reveal. */
+.md-decorator,
+.md-heading-marker,
+.md-task-marker,
+.md-marker {
   display: inline-flex;
   align-items: center;
-  justify-content: center;
   white-space: nowrap;
   vertical-align: baseline;
-
-  /* 2. Setup font constraints */
-  font-family: var(--ui-font-mono, ui-monospace, "SF Mono", monospace);
-  @apply text-(--ui-text-muted) text-[0.85em] select-none;
-
-  /* 3. Transition layout boundaries and opacity together */
-  opacity: 0.4;
-  letter-spacing: normal;
   transition:
     max-width 300ms cubic-bezier(0.4, 0, 0.2, 1),
     opacity 250ms ease-in-out,
@@ -35,11 +30,21 @@
     margin 300ms cubic-bezier(0.4, 0, 0.2, 1);
 }
 
+.md-decorator {
+  /* Inline decorators (delimiters like **, *, `, ~~, [[, ((, ^^) */
+  font-family: var(--ui-font-mono, ui-monospace, "SF Mono", monospace);
+  @apply text-(--ui-text-muted) text-[0.85em] select-none;
+  opacity: 0.4;
+  letter-spacing: normal;
+}
+
 /* Compressed / Squeezed state when blurred in live-preview mode */
 .md-decorator-hidden {
   max-width: 0px !important;
   opacity: 0 !important;
   letter-spacing: -0.5em !important;
+  padding-left: 0 !important;
+  padding-right: 0 !important;
   margin-left: 0px !important;
   margin-right: 0px !important;
   overflow: hidden;

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

@@ -291,14 +291,13 @@ function buildTokenDecorations(
 
     // Apply visibility rules based on marker mode
     if (isHiddenDecorator) {
+      const baseClass = range.className ?? "md-decorator"
       if (markerMode === "always-visible") {
         // Always show markers
-        rangeClass = "md-decorator"
+        rangeClass = baseClass
       } else if (markerMode === "live-preview") {
         // Live preview: reveal markers when focused
-        rangeClass = isFocused
-          ? "md-decorator"
-          : "md-decorator md-decorator-hidden"
+        rangeClass = isFocused ? baseClass : `${baseClass} md-decorator-hidden`
       }
     }
 

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

@@ -313,7 +313,7 @@ describe("block decorations", () => {
     expect(result.blocks[0].metadata?.level).toBe(1)
     expect(result.blocks[0].decorationRanges).toHaveLength(2)
     expect(result.blocks[0].decorationRanges[0]).toEqual({
-      type: "content",
+      type: "hidden",
       from: 0,
       to: 1,
       className: "md-heading-marker",
@@ -333,7 +333,7 @@ describe("block decorations", () => {
     expect(result.blocks[0].metadata?.level).toBe(3)
     expect(result.blocks[0].decorationRanges).toHaveLength(2)
     expect(result.blocks[0].decorationRanges[0]).toEqual({
-      type: "content",
+      type: "hidden",
       from: 0,
       to: 3,
       className: "md-heading-marker",
@@ -352,7 +352,7 @@ describe("block decorations", () => {
     expect(result.blocks[0].type).toBe("task")
     expect(result.blocks[0].decorationRanges).toHaveLength(2)
     expect(result.blocks[0].decorationRanges[0]).toEqual({
-      type: "content",
+      type: "hidden",
       from: 0,
       to: 4,
       className: "md-task-marker",
@@ -372,7 +372,7 @@ describe("block decorations", () => {
     expect(result.blocks[0].type).toBe("task")
     expect(result.blocks[0].decorationRanges).toHaveLength(2)
     expect(result.blocks[0].decorationRanges[0]).toEqual({
-      type: "content",
+      type: "hidden",
       from: 0,
       to: 4,
       className: "md-task-marker",
@@ -434,7 +434,7 @@ describe("leading whitespace handling", () => {
     const result = parseMarkdown("  # Title")
     expect(result.blocks).toHaveLength(1)
     expect(result.blocks[0].decorationRanges[0]).toEqual({
-      type: "content",
+      type: "hidden",
       from: 2,
       to: 3,
       className: "md-heading-marker",
@@ -451,7 +451,7 @@ describe("leading whitespace handling", () => {
     const result = parseMarkdown("  TODO buy milk")
     expect(result.blocks).toHaveLength(1)
     expect(result.blocks[0].decorationRanges[0]).toEqual({
-      type: "content",
+      type: "hidden",
       from: 2,
       to: 6,
       className: "md-task-marker",
@@ -469,7 +469,7 @@ describe("leading whitespace handling", () => {
     expect(result.blocks).toHaveLength(1)
     expect(result.blocks[0].type).toBe("task")
     expect(result.blocks[0].decorationRanges[0]).toEqual({
-      type: "content",
+      type: "hidden",
       from: 2,
       to: 6,
       className: "md-task-marker",
@@ -486,7 +486,7 @@ describe("leading whitespace handling", () => {
     const result = parseMarkdown("  > Quote text")
     expect(result.blocks).toHaveLength(1)
     expect(result.blocks[0].decorationRanges[0]).toEqual({
-      type: "content",
+      type: "hidden",
       from: 2,
       to: 3,
       className: "md-marker",
@@ -497,7 +497,7 @@ describe("leading whitespace handling", () => {
     const result = parseMarkdown("  > [!NOTE] important")
     expect(result.blocks).toHaveLength(1)
     expect(result.blocks[0].decorationRanges[0]).toEqual({
-      type: "content",
+      type: "hidden",
       from: 2,
       to: 11,
       className: "md-marker",
@@ -510,7 +510,7 @@ describe("whitespace variations in task rules", () => {
     const result = parseMarkdown("TODO    buy milk")
     expect(result.blocks).toHaveLength(1)
     expect(result.blocks[0].decorationRanges[0]).toEqual({
-      type: "content",
+      type: "hidden",
       from: 0,
       to: 4,
       className: "md-task-marker",
@@ -536,7 +536,7 @@ describe("whitespace variations in task rules", () => {
       const result = parseMarkdown("# Title")
       expect(result.blocks).toHaveLength(1)
       expect(result.blocks[0].decorationRanges[0]).toEqual({
-        type: "content",
+        type: "hidden",
         from: 0,
         to: 1,
         className: "md-heading-marker",
@@ -553,7 +553,7 @@ describe("whitespace variations in task rules", () => {
       const result = parseMarkdown("TODO buy milk")
       expect(result.blocks).toHaveLength(1)
       expect(result.blocks[0].decorationRanges[0]).toEqual({
-        type: "content",
+        type: "hidden",
         from: 0,
         to: 4,
         className: "md-task-marker",
@@ -676,7 +676,7 @@ describe("wrapperAttrs output", () => {
     expect(result.blocks[0].type).toBe("blockquote")
     expect(result.blocks[0].decorationRanges).toHaveLength(1)
     expect(result.blocks[0].decorationRanges[0]).toEqual({
-      type: "content",
+      type: "hidden",
       from: 0,
       to: 1,
       className: "md-marker",

+ 1 - 1
packages/editor/src/lib/__tests__/paragraph-parsing.test.ts

@@ -8,7 +8,7 @@ describe("paragraph-based parsing", () => {
     expect(result.blocks[0].type).toBe("blockquote")
     expect(result.blocks[0].decorationRanges).toHaveLength(1)
     expect(result.blocks[0].decorationRanges[0]).toEqual({
-      type: "content",
+      type: "hidden",
       from: 0,
       to: 1,
       className: "md-marker",

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

@@ -76,7 +76,7 @@ function createPlainBlockquoteDecoration(
       const markerStart = offset + leadingOffset + gtPos
       const markerEnd = markerStart + 1 // just the ">"
       decorationRanges.push({
-        type: "content",
+        type: "hidden",
         from: markerStart,
         to: markerEnd,
         className: "md-marker",
@@ -122,7 +122,7 @@ function createCalloutDecoration(
     },
     decorationRanges: [
       {
-        type: "content",
+        type: "hidden",
         from: markerStart,
         to: markerEnd,
         className: "md-marker",
@@ -162,7 +162,7 @@ export function createHeadingRule(): BlockRule {
         wrapperAttrs: { "data-heading-level": String(level) },
         decorationRanges: [
           {
-            type: "content",
+            type: "hidden",
             from: node.from,
             to: node.from + level,
             className: "md-heading-marker",
@@ -203,7 +203,7 @@ export function createTaskRule(): BlockRule {
         wrapperAttrs: { "data-task-state": taskState },
         decorationRanges: [
           {
-            type: "content",
+            type: "hidden",
             from: markerNode.from,
             to: markerNode.to,
             className: "md-task-marker",