Преглед на файлове

feat(editor): add character-level undo/redo with coalescing and toolbar buttons

- Emit SetBlockContentOp from EditorBlock on every content change,
  routing keystrokes through shell's single OperationHistory
- Add 500ms coalescing window (matching PM's newGroupDelay) so typing
  bursts undo as a group, not character-by-character
- Add peek() to OperationHistory for caller-side coalescing checks
- Fix useKeyboard.ts: keydown listener always active (was palette-only),
  so Cmd+Z/Ctrl+Z works without suggestion menu open
- Add undo/redo buttons to EditorToolbar with canUndo/canRedo bindings
- Add 4 tests: peek(), rapid typing coalescing, pause-boundary
  separation, cross-block separation
- Switch toolbar active-variant from subtle to soft (Nuxt UI style)
- Wrap editor blocks in .editor-body div for gap-4 toolbar spacing
Zander Hawke преди 3 дни
родител
ревизия
9264c0c4b9

+ 9 - 2
apps/dev/src/pages/toolbar.vue

@@ -31,8 +31,15 @@ const content =
 
           <div class="flex flex-col gap-3 rounded-lg border border-default dark:bg-neutral-950/50 p-4">
             <Editor v-model:content="content" :focused="true" marker-mode="always-visible">
-              <template #toolbar="{ handlers, selectionVersion }">
-                <EditorToolbar :handlers="handlers" :selection-version="selectionVersion" />
+              <template #toolbar="{ handlers, selectionVersion, canUndo, canRedo, undo, redo }">
+                <EditorToolbar
+                  :handlers="handlers"
+                  :selection-version="selectionVersion"
+                  :can-undo="canUndo"
+                  :can-redo="canRedo"
+                  :undo="undo"
+                  :redo="redo"
+                />
               </template>
             </Editor>
           </div>

+ 37 - 3
packages/editor/src/components/Editor.vue

@@ -208,6 +208,37 @@ function redo() {
   canRedo.value = history.canRedo
 }
 
+/** Coalescing window for SetBlockContentOp — mirrors PM's newGroupDelay. */
+const COALESCE_MS = 500
+
+function onBlockContentChangeOp(op: {
+  blockId: string
+  previousContent: string
+  newContent: string
+  timestamp: number
+}) {
+  const last = history.peek()
+  if (
+    last?.type === "set-block-content" &&
+    last.blockId === op.blockId &&
+    op.timestamp - last.timestamp < COALESCE_MS
+  ) {
+    ;(last as import("@/lib/operation-history").SetBlockContentOp).newContent =
+      op.newContent
+    last.timestamp = op.timestamp
+  } else {
+    void history.execute({
+      type: "set-block-content",
+      blockId: op.blockId,
+      previousContent: op.previousContent,
+      newContent: op.newContent,
+      timestamp: op.timestamp,
+    })
+  }
+  canUndo.value = history.canUndo
+  canRedo.value = history.canRedo
+}
+
 function parseContent(md: string | undefined) {
   blocks.value = splitMarkdownIntoBlocks(md || "", undefined, blocks.value)
 }
@@ -585,7 +616,7 @@ defineExpose({ undo, redo, canUndo, canRedo })
 </script>
 
 <template>
-  <div class="editor-shell" :style="themeCSSVars">
+  <div class="editor-shell flex flex-col gap-4" :style="themeCSSVars">
     <div v-if="$slots.toolbar" class="editor-toolbar-wrapper">
       <slot
         name="toolbar"
@@ -597,8 +628,9 @@ defineExpose({ undo, redo, canUndo, canRedo })
         :redo="redo"
       />
     </div>
-    <template v-for="(block, i) in blocks" :key="block.id">
-      <EditorInsertionZone
+    <div class="editor-body">
+      <template v-for="(block, i) in blocks" :key="block.id">
+        <EditorInsertionZone
         :ref="(el: any) => { if (el) zoneRefs[i] = el; else delete zoneRefs[i] }"
         @activate="(content: string) => onZoneActivate(i, content)"
         @focus="onZoneFocus(i)"
@@ -641,6 +673,7 @@ defineExpose({ undo, redo, canUndo, canRedo })
           @pattern-open="onPatternOpen"
           @pattern-update="onPatternUpdate"
           @pattern-close="onPatternClose"
+          @content-change-op="onBlockContentChangeOp"
         />
       </div>
     </template>
@@ -652,5 +685,6 @@ defineExpose({ undo, redo, canUndo, canRedo })
       @navigate-up="onZoneNavigateUp(blocks.length)"
       @navigate-down="onZoneNavigateDown(blocks.length)"
     />
+    </div>
   </div>
 </template>

+ 13 - 0
packages/editor/src/components/EditorBlock.vue

@@ -114,6 +114,12 @@ const emit = defineEmits<{
   focus: [payload: { view: EditorView; handlers: FormattingHandlers }]
   blur: []
   "selection-change": [payload: { from: number; to: number; empty: boolean }]
+  "content-change-op": [payload: {
+    blockId: string
+    previousContent: string
+    newContent: string
+    timestamp: number
+  }]
 }>()
 
 const log = createLogger("Block", props.debug)
@@ -568,6 +574,7 @@ onMounted(() => {
     dispatchTransaction(transaction: Transaction) {
       const currentView = view
       if (!currentView) return
+      const prevContent = docToContent(currentView.state.doc)
       const newState = currentView.state.apply(transaction)
       currentView.updateState(newState)
 
@@ -579,6 +586,12 @@ onMounted(() => {
         })
         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

+ 37 - 7
packages/editor/src/components/EditorToolbar.vue

@@ -7,6 +7,10 @@ type PromptKind = "page" | "block" | "tag"
 const props = defineProps<{
   handlers: FormattingHandlers | null
   selectionVersion?: number
+  canUndo?: boolean
+  canRedo?: boolean
+  undo?: () => void
+  redo?: () => void
 }>()
 
 const active = computed(() => {
@@ -123,6 +127,32 @@ function cancelPrompt() {
     data-slot="base"
     class="flex items-stretch gap-1.5"
   >
+    <!-- Undo / Redo -->
+    <div role="group" aria-label="History" class="flex items-center gap-0.5">
+      <UTooltip text="Undo (⌘Z)">
+        <UButton
+          icon="i-lucide-undo-2"
+          color="neutral"
+          variant="ghost"
+          size="sm"
+          :disabled="!canUndo"
+          @click="undo?.()"
+        />
+      </UTooltip>
+      <UTooltip text="Redo (⌘⇧Z)">
+        <UButton
+          icon="i-lucide-redo-2"
+          color="neutral"
+          variant="ghost"
+          size="sm"
+          :disabled="!canRedo"
+          @click="redo?.()"
+        />
+      </UTooltip>
+    </div>
+
+    <USeparator orientation="vertical" class="w-px self-stretch bg-border" />
+
     <!-- Block -->
     <div role="group" aria-label="Block" class="flex items-center gap-0.5">
       <UDropdownMenu :items="headingItems" :disabled="!handlers">
@@ -140,7 +170,7 @@ function cancelPrompt() {
         color="neutral"
         active-color="primary"
         variant="ghost"
-        active-variant="subtle"
+        active-variant="soft"
         size="sm"
         :active="currentTask !== null"
         :disabled="!handlers"
@@ -158,7 +188,7 @@ function cancelPrompt() {
           color="neutral"
           active-color="primary"
           variant="ghost"
-          active-variant="subtle"
+          active-variant="soft"
           size="sm"
           :active="active.bold"
           :disabled="!handlers"
@@ -171,7 +201,7 @@ function cancelPrompt() {
           color="neutral"
           active-color="primary"
           variant="ghost"
-          active-variant="subtle"
+          active-variant="soft"
           size="sm"
           :active="active.italic"
           :disabled="!handlers"
@@ -184,7 +214,7 @@ function cancelPrompt() {
           color="neutral"
           active-color="primary"
           variant="ghost"
-          active-variant="subtle"
+          active-variant="soft"
           size="sm"
           :active="active.code"
           :disabled="!handlers"
@@ -197,7 +227,7 @@ function cancelPrompt() {
           color="neutral"
           active-color="primary"
           variant="ghost"
-          active-variant="subtle"
+          active-variant="soft"
           size="sm"
           :active="active.strikethrough"
           :disabled="!handlers"
@@ -210,7 +240,7 @@ function cancelPrompt() {
           color="neutral"
           active-color="primary"
           variant="ghost"
-          active-variant="subtle"
+          active-variant="soft"
           size="sm"
           :active="active.highlight"
           :disabled="!handlers"
@@ -239,7 +269,7 @@ function cancelPrompt() {
           color="neutral"
           active-color="primary"
           variant="ghost"
-          active-variant="subtle"
+          active-variant="soft"
           size="sm"
           :active="active.inlineMath"
           :disabled="!handlers"

+ 142 - 0
packages/editor/src/components/__tests__/editor-integration.test.ts

@@ -6,6 +6,10 @@ import {
   useSlashGroups,
 } from "@/composables/useSuggestionGroups"
 import type { FormattingHandlers } from "@/lib/formatting"
+import {
+  createOperationHistory,
+  type SetBlockContentOp,
+} from "@/lib/operation-history"
 
 function createMockSession(
   kind: string = "command",
@@ -105,3 +109,141 @@ describe("Editor integration — menu routing", () => {
     expect(activeSession.value!.range).toEqual({ from: 0, to: 9 })
   })
 })
+
+describe("content-change-op coalescing", () => {
+  it("coalesces rapid changes for the same block", () => {
+    const history = createOperationHistory()
+    const COALESCE_MS = 500
+    let canUndo = false
+    let canRedo = false
+
+    function handleOp(op: {
+      blockId: string
+      previousContent: string
+      newContent: string
+      timestamp: number
+    }) {
+      const last = history.peek()
+      if (
+        last?.type === "set-block-content" &&
+        last.blockId === op.blockId &&
+        op.timestamp - last.timestamp < COALESCE_MS
+      ) {
+        ;(last as SetBlockContentOp).newContent = op.newContent
+        last.timestamp = op.timestamp
+      } else {
+        void history.execute({
+          type: "set-block-content",
+          blockId: op.blockId,
+          previousContent: op.previousContent,
+          newContent: op.newContent,
+          timestamp: op.timestamp,
+        })
+      }
+      canUndo = history.canUndo
+      canRedo = history.canRedo
+    }
+
+    // Rapid typing — should coalesce into 1 entry
+    handleOp({ blockId: "a", previousContent: "", newContent: "h", timestamp: 1000 })
+    handleOp({ blockId: "a", previousContent: "h", newContent: "he", timestamp: 1050 })
+    handleOp({ blockId: "a", previousContent: "he", newContent: "hel", timestamp: 1120 })
+    handleOp({ blockId: "a", previousContent: "hel", newContent: "hell", timestamp: 1180 })
+    handleOp({ blockId: "a", previousContent: "hell", newContent: "hello", timestamp: 1250 })
+
+    expect(canUndo).toBe(true)
+    expect(history.peek()?.type).toBe("set-block-content")
+    expect((history.peek() as SetBlockContentOp).newContent).toBe("hello")
+    expect(history.peek() as SetBlockContentOp).not.toBeNull()
+
+    // Undo should revert directly from "hello" to ""
+    const inverse = history.undo()
+    expect(inverse).not.toBeNull()
+    expect((inverse as SetBlockContentOp).newContent).toBe("")
+    canUndo = history.canUndo
+    expect(canUndo).toBe(false)
+  })
+
+  it("creates separate entries after a pause", () => {
+    const history = createOperationHistory()
+    const COALESCE_MS = 500
+
+    function handleOp(op: {
+      blockId: string
+      previousContent: string
+      newContent: string
+      timestamp: number
+    }) {
+      const last = history.peek()
+      if (
+        last?.type === "set-block-content" &&
+        last.blockId === op.blockId &&
+        op.timestamp - last.timestamp < COALESCE_MS
+      ) {
+        ;(last as SetBlockContentOp).newContent = op.newContent
+        last.timestamp = op.timestamp
+      } else {
+        void history.execute({
+          type: "set-block-content",
+          blockId: op.blockId,
+          previousContent: op.previousContent,
+          newContent: op.newContent,
+          timestamp: op.timestamp,
+        })
+      }
+    }
+
+    // First burst
+    handleOp({ blockId: "a", previousContent: "", newContent: "hi", timestamp: 1000 })
+    // Pause > 500ms
+    handleOp({ blockId: "a", previousContent: "hi", newContent: "hi there", timestamp: 1600 })
+
+    // Two separate entries
+    const first = history.undo()
+    expect((first as SetBlockContentOp).newContent).toBe("hi")
+    expect(history.canUndo).toBe(true)
+    const second = history.undo()
+    expect((second as SetBlockContentOp).newContent).toBe("")
+    expect(history.canUndo).toBe(false)
+  })
+
+  it("creates separate entries for different blocks", () => {
+    const history = createOperationHistory()
+    const COALESCE_MS = 500
+
+    function handleOp(op: {
+      blockId: string
+      previousContent: string
+      newContent: string
+      timestamp: number
+    }) {
+      const last = history.peek()
+      if (
+        last?.type === "set-block-content" &&
+        last.blockId === op.blockId &&
+        op.timestamp - last.timestamp < COALESCE_MS
+      ) {
+        ;(last as SetBlockContentOp).newContent = op.newContent
+        last.timestamp = op.timestamp
+      } else {
+        void history.execute({
+          type: "set-block-content",
+          blockId: op.blockId,
+          previousContent: op.previousContent,
+          newContent: op.newContent,
+          timestamp: op.timestamp,
+        })
+      }
+    }
+
+    handleOp({ blockId: "a", previousContent: "", newContent: "hello", timestamp: 1000 })
+    handleOp({ blockId: "b", previousContent: "", newContent: "world", timestamp: 1100 })
+
+    // Two separate entries (different block)
+    const first = history.undo()
+    expect((first as SetBlockContentOp).blockId).toBe("b")
+    expect((first as SetBlockContentOp).newContent).toBe("") // reverted to ""
+    expect(history.canUndo).toBe(true)
+    expect(history.undo()).not.toBeNull()
+  })
+})

+ 11 - 8
packages/editor/src/composables/useKeyboard.ts

@@ -78,21 +78,24 @@ export function useKeyboard(config: KeyboardConfig) {
     }
   }
 
-  // Listeners active only when the palette is open.
+  // Keydown listener is always active (undo/redo are always-available shortcuts).
+  // Focus trap is active only when the palette is open.
+  onUnmounted(() => {
+    document.removeEventListener("keydown", onKeydownCapture, true)
+    document.removeEventListener("focusin", onFocusTrap, true)
+  })
+
+  // Add the permanent keydown listener on mount. The palette-only focusin
+  // listener is managed by the activeSession watch below.
+  document.addEventListener("keydown", onKeydownCapture, true)
+
   watch(activeSession, (session, oldSession) => {
     if (session && !oldSession) {
-      document.addEventListener("keydown", onKeydownCapture, true)
       document.addEventListener("focusin", onFocusTrap, true)
     } else if (!session && oldSession) {
-      document.removeEventListener("keydown", onKeydownCapture, true)
       document.removeEventListener("focusin", onFocusTrap, true)
     }
   })
-
-  onUnmounted(() => {
-    document.removeEventListener("keydown", onKeydownCapture, true)
-    document.removeEventListener("focusin", onFocusTrap, true)
-  })
 }
 
 // ── Built-in keybindings ─────────────────────────────────────────

+ 20 - 0
packages/editor/src/lib/__tests__/operation-history.test.ts

@@ -337,4 +337,24 @@ describe("createOperationHistory", () => {
     expect(h.undo()).toBeNull()
     expect(h.redo()).toBeNull()
   })
+
+  it("peek returns last operation without popping", () => {
+    const h = createOperationHistory()
+    expect(h.peek()).toBeNull()
+
+    h.execute({
+      type: "indent-block",
+      index: 0,
+      previousDepth: 0,
+      timestamp: 1,
+    })
+    expect(h.peek()).toEqual({
+      type: "indent-block",
+      index: 0,
+      previousDepth: 0,
+      timestamp: 1,
+    })
+    expect(h.canUndo).toBe(true)
+    expect(h.canUndo).toBe(true) // peek did not pop
+  })
 })

+ 10 - 0
packages/editor/src/lib/operation-history.ts

@@ -237,6 +237,11 @@ export interface OperationHistory {
   readonly canRedo: boolean
   /** Clear all history */
   clear(): void
+  /**
+   * Peek at the most recent past operation without popping it.
+   * Returns null if the history is empty.
+   */
+  peek(): EditorOperation | null
 }
 
 interface HistoryEntry {
@@ -287,5 +292,10 @@ export function createOperationHistory(
       past.length = 0
       future.length = 0
     },
+
+    peek(): EditorOperation | null {
+      if (past.length === 0) return null
+      return past[past.length - 1]!.operation
+    },
   }
 }