Quellcode durchsuchen

feat: add live-preview marker visibility modes with accordion CSS effect

- Add MarkerVisibilityMode type with 'live-preview' and 'always-visible' options
- Implement CSS accordion transition for markdown decorators (max-width + letter-spacing)
- Property keys always visible (not treated as decorators)
- Add markerMode prop to Block.vue with reactive mode switching
- Update tests for new live-preview behavior
Zander Hawke vor 1 Monat
Ursprung
Commit
e5d0c53b45

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

@@ -37,6 +37,7 @@ function log(event: string, payload?: unknown) {
       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')"

+ 87 - 39
packages/editor/src/assets/style.css

@@ -1,107 +1,148 @@
 @reference "tailwindcss";
 @reference "@nuxt/ui";
 
+/* ── Base Editor ─────────────────────────────────────────────────── */
+
 .ProseMirror {
-  @apply outline-none min-h-7 whitespace-pre-wrap break-words;
+  @apply outline-none min-h-7 whitespace-pre-wrap break-words text-(--ui-text-main) leading-relaxed;
 }
 
 .ProseMirror p {
   @apply m-0;
 }
 
+/* ── Decorators (Markdown syntax) ────────────────────────────────── */
+
 .md-decorator {
-  @apply sr-only;
+  /* 1. Establish an inline-flex container that can smoothly compress horizontally */
+  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. The secret sauce: Transition layout boundaries and opacity together */
+  /* max-width: 4ch; */
+  opacity: 0.4;
+  letter-spacing: normal;
+  transition:
+    max-width 300ms cubic-bezier(0.4, 0, 0.2, 1),
+    opacity 250ms ease-in-out,
+    letter-spacing 300ms ease-in-out,
+    margin 300ms cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+/* Compressed / Squeezed state when blurred in live-preview mode */
+.md-decorator-hidden {
+  max-width: 0px !important;
+  opacity: 0 !important;
+  letter-spacing: -0.5em !important;
+  margin-left: 0px !important;
+  margin-right: 0px !important;
+  overflow: hidden;
+  pointer-events: none;
 }
 
 /* ── Marks ───────────────────────────────────────────────────────── */
 
 .md-strong {
-  @apply font-bold;
+  @apply font-semibold text-(--ui-text-highlight);
 }
 .md-em {
   @apply italic;
 }
 .md-code {
-  @apply font-mono bg-(--ui-bg-elevated) px-1 py-0.5 rounded text-sm;
+  @apply font-mono bg-(--ui-bg-elevated) text-(--ui-text-highlight) px-1.5 py-0.5 rounded-md text-[0.85em] border border-(--ui-border) shadow-sm;
 }
 .md-strike {
-  @apply line-through;
+  @apply line-through text-(--ui-text-muted);
 }
 .md-highlight {
-  @apply bg-yellow-400/20 dark:bg-yellow-400/30 px-0.5 py-0.5 rounded;
+  @apply bg-yellow-400/20 dark:bg-yellow-400/30 px-1 py-0.5 rounded-sm;
 }
 .md-link {
-  @apply text-(--ui-primary) underline cursor-pointer;
+  @apply text-(--ui-primary) underline underline-offset-4 decoration-(--ui-primary)/30 hover:decoration-(--ui-primary) transition-all duration-200 cursor-pointer;
 }
 
-/* ── References ──────────────────────────────────────────────────── */
+/* ── Chips (Page Refs, Block Refs, Tags) ─────────────────────────── */
+
+/* Shared base to prevent line-height jumps when editing */
+.md-page-ref,
+.md-block-ref,
+.md-tag {
+  @apply inline-flex items-baseline px-1.5 py-px rounded-md border text-[0.9em] font-medium cursor-pointer transition-colors duration-200 align-baseline;
+}
 
 .md-page-ref {
-  @apply inline px-1.5 py-0.5 rounded-md bg-(--ui-primary)/10 text-(--ui-primary) border border-(--ui-primary)/20 text-sm font-medium cursor-pointer hover:bg-(--ui-primary)/15;
+  @apply bg-(--ui-primary)/10 text-(--ui-primary) border-(--ui-primary)/20 hover:bg-(--ui-primary)/20;
 }
 
 .md-block-ref {
-  @apply inline px-1.5 py-0.5 rounded-md bg-(--ui-purple)/10 text-(--ui-purple) border border-(--ui-purple)/20 text-sm font-medium cursor-pointer hover:bg-(--ui-purple)/15;
+  @apply bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20 hover:bg-purple-500/20;
 }
 
-/* ── Tags ────────────────────────────────────────────────────────── */
-
 .md-tag {
-  @apply inline px-1.5 py-0.5 rounded-md bg-(--ui-success)/10 text-(--ui-success)
-         border border-(--ui-success)/20 text-sm font-medium cursor-pointer
-         hover:bg-(--ui-success)/15;
+  @apply bg-(--ui-success)/10 text-(--ui-success) border-(--ui-success)/20 hover:bg-(--ui-success)/20;
 }
 
 /* ── Properties ──────────────────────────────────────────────────── */
 
 .md-property-key {
-  @apply text-(--ui-text-highlight) font-semibold;
+  @apply text-(--ui-text-muted) font-medium text-[0.85em] uppercase tracking-wider;
 }
 .md-property-delimiter {
-  @apply text-(--ui-text-muted) font-semibold;
+  @apply text-(--ui-border) mx-1.5 select-none;
 }
 .md-property-value {
-  @apply text-(--ui-text-muted);
+  @apply text-(--ui-text-main);
 }
 
 /* ── Headings ────────────────────────────────────────────────────── */
 
+.md-heading-content {
+  @apply text-(--ui-text-highlight) transition-colors duration-200;
+}
 .md-heading-content[data-heading-level="1"] {
-  @apply text-2xl font-bold;
+  @apply text-3xl font-bold tracking-tight mt-4 mb-2;
 }
 .md-heading-content[data-heading-level="2"] {
-  @apply text-xl font-semibold;
+  @apply text-2xl font-semibold tracking-tight mt-3 mb-1;
 }
 .md-heading-content[data-heading-level="3"] {
-  @apply text-lg font-medium;
+  @apply text-xl font-medium mt-2;
 }
 .md-heading-content[data-heading-level="4"],
 .md-heading-content[data-heading-level="5"],
 .md-heading-content[data-heading-level="6"] {
-  @apply text-base font-medium;
+  @apply text-lg font-medium;
 }
 
+/* Keep the hash marks smaller and perfectly centered with the heading text */
 .md-heading-marker {
-  @apply text-(--ui-text-muted) mr-2;
+  @apply text-(--ui-text-muted) opacity-40 font-mono text-[0.7em] mr-2 align-middle select-none;
 }
 
 /* ── Tasks ───────────────────────────────────────────────────────── */
 
 .md-task-marker {
-  @apply font-bold uppercase mr-1;
+  @apply font-bold uppercase text-[0.8em] tracking-wide mr-2 select-none;
 }
 
 [data-task-state="TODO"] {
-  @apply text-red-500;
+  @apply text-red-500/80;
 }
 [data-task-state="DOING"] {
   @apply text-yellow-500;
 }
 [data-task-state="DONE"] {
-  @apply text-green-500;
+  @apply text-green-500/80;
 }
 [data-task-state="LATER"] {
-  @apply text-gray-500;
+  @apply text-gray-400;
 }
 [data-task-state="NOW"] {
   @apply text-blue-500;
@@ -110,51 +151,58 @@
   @apply text-orange-500;
 }
 [data-task-state="CANCELLED"] {
-  @apply text-gray-400 line-through;
+  @apply text-gray-500/50 line-through;
 }
 
 /* ── Priority ────────────────────────────────────────────────────── */
 
 [data-priority="A"] {
-  @apply text-red-600 font-bold;
+  @apply text-red-500 font-bold bg-red-500/10 px-1 rounded-sm;
 }
 [data-priority="B"] {
-  @apply text-orange-500 font-bold;
+  @apply text-orange-500 font-bold bg-orange-500/10 px-1 rounded-sm;
 }
 [data-priority="C"] {
-  @apply text-gray-500;
+  @apply text-(--ui-text-muted) bg-(--ui-bg-elevated) px-1 rounded-sm;
 }
 
 /* ── Blockquote ──────────────────────────────────────────────────── */
 
 .md-blockquote-marker {
-  @apply text-(--ui-primary) font-bold mr-2;
+  @apply text-(--ui-text-muted) opacity-40 font-mono mr-2 select-none;
+}
+.md-blockquote-content {
+  @apply text-(--ui-text-muted) italic border-l-2 border-(--ui-border) pl-4 py-0.5 my-1 inline-block;
 }
 
 /* ── Callouts ────────────────────────────────────────────────────── */
 
+/* Style the literal > [!NOTE] text like a badge so it looks intentional when editing */
 .md-callout-marker {
-  @apply font-bold;
+  @apply font-semibold text-[0.75em] tracking-wider uppercase px-2 py-0.5 rounded-md inline-flex items-center mb-1 select-none;
 }
 
 [data-callout-type="NOTE"] {
-  @apply text-blue-500;
+  @apply bg-blue-500/10 text-blue-600 dark:text-blue-400;
 }
 [data-callout-type="WARNING"] {
-  @apply text-yellow-600;
+  @apply bg-yellow-500/10 text-yellow-600 dark:text-yellow-400;
 }
 [data-callout-type="TIP"] {
-  @apply text-green-500;
+  @apply bg-green-500/10 text-green-600 dark:text-green-400;
 }
 [data-callout-type="DANGER"] {
-  @apply text-red-600;
+  @apply bg-red-500/10 text-red-600 dark:text-red-400;
 }
 [data-callout-type="INFO"] {
-  @apply text-(--ui-primary);
+  @apply bg-(--ui-primary)/10 text-(--ui-primary);
 }
 
 /* ── Date ────────────────────────────────────────────────────────── */
 
 .md-date-marker {
-  @apply text-(--ui-text-muted);
+  @apply opacity-70 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);
 }

+ 19 - 3
packages/editor/src/components/Block.vue

@@ -9,7 +9,10 @@ import {
 import { EditorView } from "prosemirror-view"
 import { onMounted, onUnmounted, useTemplateRef, watch } from "vue"
 import { createBlockKeyboardHandler } from "../composables/useBlockKeyboardHandlers"
-import { createMarkdownDecorationsPlugin } from "../composables/useMarkdownDecorations"
+import {
+  createMarkdownDecorationsPlugin,
+  type MarkerVisibilityMode,
+} from "../composables/useMarkdownDecorations"
 import { createPatternPlugin } from "../composables/usePatternPlugin"
 import type {
   CloseReason,
@@ -24,6 +27,7 @@ const content = defineModel<string>("content")
 const props = defineProps<{
   focused?: boolean
   cursorPosition?: "start" | "end" | number
+  markerMode?: MarkerVisibilityMode
 }>()
 
 const emit = defineEmits<{
@@ -104,8 +108,11 @@ const formattingHandlers = {
 const editorRef = useTemplateRef("editorRef")
 let view: EditorView | null = null
 
-const { plugin: markdownDecorationsPlugin, setFocus: setDecorationsFocus } =
-  createMarkdownDecorationsPlugin()
+const {
+  plugin: markdownDecorationsPlugin,
+  setFocus: setDecorationsFocus,
+  setMarkerMode: updateMarkerMode,
+} = createMarkdownDecorationsPlugin(props.markerMode ?? "live-preview")
 
 const patternPlugin = createPatternPlugin({
   onPatternOpen: (payload) => emit("pattern-open", payload),
@@ -238,6 +245,15 @@ watch(
   },
 )
 
+watch(
+  () => props.markerMode,
+  (newMode) => {
+    if (!view || !newMode) return
+    updateMarkerMode(newMode)
+    view.dispatch(view.state.tr.setMeta("markerMode", newMode))
+  },
+)
+
 watchDebounced(
   () => content.value,
   (newContent) => {

+ 5 - 8
packages/editor/src/composables/__tests__/markdown-decorations.test.ts

@@ -21,7 +21,7 @@ describe("Markdown decorations plugin", () => {
     expect(decorations?.find().length).toBeGreaterThan(0)
   })
 
-  it("returns empty decorations when focused", () => {
+  it("returns decorations with faded markers when focused", () => {
     const { plugin, setFocus } = createMarkdownDecorationsPlugin()
     const doc = contentToDoc("Hello **world**")
 
@@ -33,7 +33,8 @@ describe("Markdown decorations plugin", () => {
     setFocus(true)
 
     const decorations = plugin.spec.props?.decorations?.(editorState)
-    expect(decorations?.find().length).toBe(0)
+    // Should have decorations (structural content), not empty
+    expect(decorations?.find().length).toBeGreaterThan(0)
   })
 
   it("returns full decorations when blurred", () => {
@@ -51,7 +52,7 @@ describe("Markdown decorations plugin", () => {
     expect(decorations?.find().length).toBeGreaterThan(0)
   })
 
-  it("toggles correctly on focus/blur cycle", () => {
+  it("toggles marker visibility based on focus state", () => {
     const { plugin, setFocus } = createMarkdownDecorationsPlugin()
     const doc = contentToDoc("Hello **world**")
 
@@ -62,15 +63,11 @@ describe("Markdown decorations plugin", () => {
 
     setFocus(true)
     const focusedDecorations = plugin.spec.props?.decorations?.(editorState)
-    expect(focusedDecorations?.find().length).toBe(0)
+    expect(focusedDecorations?.find().length).toBeGreaterThan(0)
 
     setFocus(false)
     const blurredDecorations = plugin.spec.props?.decorations?.(editorState)
     expect(blurredDecorations?.find().length).toBeGreaterThan(0)
-
-    setFocus(true)
-    const refocusedDecorations = plugin.spec.props?.decorations?.(editorState)
-    expect(refocusedDecorations?.find().length).toBe(0)
   })
 
   it("returns empty decorations with empty document on init", () => {

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

@@ -9,17 +9,9 @@
  * - Tags #tag
  * - Property lines (key:: value)
  *
- * Focus-based rendering:
- * - When the block has focus: no decorations (raw text for editing)
- * - When the block is blurred: full decorations (styled preview)
- *
- * Architecture:
- * - Rules provide wrapperClass and wrapperAttrs per token
- * - ProseMirror decorations are flat, so wrapper attrs are stamped
- *   onto every segment — no structural dependency between spans
- * - Token array is the source of truth — decorations are a lazy projection
- *   built in props.decorations. Tokens are only re-parsed on document
- *   changes, not cursor moves.
+ * Marker visibility modes:
+ * - 'live-preview': markers hidden when blurred, revealed near cursor when focused
+ * - 'always-visible': markers always visible (traditional markdown editor feel)
  */
 
 import type { Node as ProsemirrorNode } from "prosemirror-model"
@@ -31,7 +23,9 @@ import {
   parseMarkdown,
 } from "../lib/markdown-parser"
 
-type DecorationSpec = ReturnType<typeof Decoration.inline>
+export type MarkerVisibilityMode = "live-preview" | "always-visible"
+
+const defaultMarkerMode: MarkerVisibilityMode = "live-preview"
 
 interface ParsedToken {
   type: string
@@ -105,11 +99,15 @@ function parseBlockTokens(
 /**
  * Build decorations from cached tokens.
  * Runs lazily in props.decorations — only when ProseMirror asks for them.
- * No cursor tracking, no pattern suppression — all ranges always visible.
+ * Handles live preview by toggling marker visibility based on cursor proximity.
  */
 function buildDecorationsFromCache(
   doc: ProsemirrorNode,
   contentCaches: Map<string, BlockTokenCache>,
+  isFocused: boolean,
+  _cursorFrom: number,
+  _cursorTo: number,
+  markerMode: MarkerVisibilityMode,
 ): DecorationSet {
   const decorationSpecs: DecorationSpec[] = []
 
@@ -123,14 +121,38 @@ function buildDecorationsFromCache(
     if (!cache) return
 
     for (const block of cache.blocks) {
-      buildTokenDecorations(block, blockStart, decorationSpecs)
+      buildTokenDecorations(
+        block,
+        blockStart,
+        decorationSpecs,
+        isFocused,
+        _cursorFrom,
+        _cursorTo,
+        markerMode,
+      )
     }
 
     for (const token of cache.content) {
-      buildTokenDecorations(token, blockStart, decorationSpecs)
+      buildTokenDecorations(
+        token,
+        blockStart,
+        decorationSpecs,
+        isFocused,
+        _cursorFrom,
+        _cursorTo,
+        markerMode,
+      )
     }
 
-    buildPropertyDecorations(cache.properties, blockStart, decorationSpecs)
+    buildPropertyDecorations(
+      cache.properties,
+      blockStart,
+      decorationSpecs,
+      isFocused,
+      _cursorFrom,
+      _cursorTo,
+      markerMode,
+    )
   })
 
   return DecorationSet.create(doc, decorationSpecs)
@@ -142,11 +164,19 @@ function buildDecorationsFromCache(
  * ProseMirror decorations are flat — overlapping ranges produce sibling
  * spans, not nested elements. Wrapper attrs are stamped onto every
  * segment so each span is self-contained.
+ *
+ * In live preview mode:
+ * - type: "content" ranges always show
+ * - type: "hidden" ranges (markers) fade based on cursor proximity
  */
 function buildTokenDecorations(
   token: ParsedToken,
   offset: number,
   decorationSpecs: DecorationSpec[],
+  isFocused: boolean,
+  _cursorFrom: number,
+  _cursorTo: number,
+  markerMode: MarkerVisibilityMode,
 ) {
   const ranges = token.decorationRanges
   if (ranges.length === 0) return
@@ -167,9 +197,24 @@ function buildTokenDecorations(
     const rangeFrom = offset + range.from
     const rangeTo = offset + range.to
 
-    const rangeClass =
+    const isHiddenDecorator = range.type === "hidden"
+
+    let rangeClass =
       range.className ??
-      (range.type === "hidden" ? "md-decorator" : `md-${range.type}`)
+      (isHiddenDecorator ? "md-decorator" : `md-${range.type}`)
+
+    // Apply visibility rules based on marker mode
+    if (isHiddenDecorator) {
+      if (markerMode === "always-visible") {
+        // Always show markers
+        rangeClass = "md-decorator"
+      } else if (markerMode === "live-preview") {
+        // Live preview: reveal markers when focused
+        rangeClass = isFocused
+          ? "md-decorator"
+          : "md-decorator md-decorator-hidden"
+      }
+    }
 
     const classes = rangeClass
 
@@ -196,22 +241,33 @@ function buildPropertyDecorations(
   }>,
   childOffset: number,
   decorationSpecs: DecorationSpec[],
+  _isFocused: boolean,
+  _cursorFrom: number,
+  _cursorTo: number,
+  _markerMode: MarkerVisibilityMode,
 ) {
   for (const prop of properties) {
     const [keyFrom, keyTo] = prop.keyRange
     const [delimFrom, delimTo] = prop.delimiterRange
     const [valueFrom, valueTo] = prop.valueRange
 
+    const keyStart = keyFrom + childOffset
+    const keyEnd = keyTo + childOffset
+    const delimStart = delimFrom + childOffset
+    const delimEnd = delimTo + childOffset
+
+    // Property keys and delimiters always show (they're structural elements)
     decorationSpecs.push(
-      Decoration.inline(keyFrom + childOffset, keyTo + childOffset, {
+      Decoration.inline(keyStart, keyEnd, {
         class: "md-property-key",
       }),
     )
     decorationSpecs.push(
-      Decoration.inline(delimFrom + childOffset, delimTo + childOffset, {
+      Decoration.inline(delimStart, delimEnd, {
         class: "md-property-delimiter",
       }),
     )
+
     if (prop.value) {
       decorationSpecs.push(
         Decoration.inline(valueFrom + childOffset, valueTo + childOffset, {
@@ -251,8 +307,11 @@ function invalidateChangedCaches(
   return newCaches
 }
 
-export function createMarkdownDecorationsPlugin() {
+export function createMarkdownDecorationsPlugin(
+  markerMode: MarkerVisibilityMode = defaultMarkerMode,
+) {
   let hasFocus = false
+  let currentMarkerMode = markerMode
 
   const plugin = new Plugin<DecorationState>({
     key: markdownDecorationsKey,
@@ -271,28 +330,39 @@ export function createMarkdownDecorationsPlugin() {
       },
 
       apply(tr, prevState, _oldState, newState) {
-        if (!tr.docChanged) {
-          return prevState
+        // Check for mode change in transaction meta
+        if (tr.getMeta("markerMode") !== undefined) {
+          currentMarkerMode = tr.getMeta("markerMode")
         }
 
-        const contentCaches = invalidateChangedCaches(
-          newState.doc,
-          prevState.contentCaches,
-          prevState.engine,
-        )
+        if (tr.docChanged) {
+          const contentCaches = invalidateChangedCaches(
+            newState.doc,
+            prevState.contentCaches,
+            prevState.engine,
+          )
+          return { engine: prevState.engine, contentCaches }
+        }
 
-        return { engine: prevState.engine, contentCaches }
+        return prevState
       },
     },
 
     props: {
       decorations(state) {
-        if (hasFocus) return DecorationSet.empty
-
         const pluginState = markdownDecorationsKey.getState(state)
         if (!pluginState) return DecorationSet.empty
 
-        return buildDecorationsFromCache(state.doc, pluginState.contentCaches)
+        const { from, to } = state.selection
+
+        return buildDecorationsFromCache(
+          state.doc,
+          pluginState.contentCaches,
+          hasFocus,
+          from,
+          to,
+          currentMarkerMode,
+        )
       },
     },
   })
@@ -302,6 +372,9 @@ export function createMarkdownDecorationsPlugin() {
     setFocus: (focused: boolean) => {
       hasFocus = focused
     },
+    setMarkerMode: (mode: MarkerVisibilityMode) => {
+      currentMarkerMode = mode
+    },
   }
 }
 

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

@@ -527,7 +527,7 @@ export function createDateRule(): LineRule {
           {
             type: "content",
             from: contentStart,
-            to: line.length,
+            to: contentStart + 10,
             className: "md-date-content",
           },
         ],