|
@@ -1,6 +1,6 @@
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
import type { EditorView } from "prosemirror-view"
|
|
import type { EditorView } from "prosemirror-view"
|
|
|
-import { computed, nextTick, ref, watch, type Ref } from "vue"
|
|
|
|
|
|
|
+import { computed, nextTick, type Ref, ref, watch } from "vue"
|
|
|
import Block from "@/components/Block.vue"
|
|
import Block from "@/components/Block.vue"
|
|
|
import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
|
|
import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
|
|
|
import InsertionZone from "@/components/InsertionZone.vue"
|
|
import InsertionZone from "@/components/InsertionZone.vue"
|
|
@@ -8,7 +8,21 @@ import {
|
|
|
type FocusTarget,
|
|
type FocusTarget,
|
|
|
useFocusRegistry,
|
|
useFocusRegistry,
|
|
|
} from "@/composables/useFocusRegistry"
|
|
} from "@/composables/useFocusRegistry"
|
|
|
|
|
+import {
|
|
|
|
|
+ defaultKeyBinding,
|
|
|
|
|
+ type KeyBinding,
|
|
|
|
|
+ useKeyboard,
|
|
|
|
|
+} from "@/composables/useKeyboard"
|
|
|
import type { MarkerVisibilityMode } from "@/composables/useMarkdownDecorations"
|
|
import type { MarkerVisibilityMode } from "@/composables/useMarkdownDecorations"
|
|
|
|
|
+import type {
|
|
|
|
|
+ PatternClosePayload,
|
|
|
|
|
+ PatternOpenPayload,
|
|
|
|
|
+ PatternUpdatePayload,
|
|
|
|
|
+} from "@/composables/usePatternPlugin"
|
|
|
|
|
+import {
|
|
|
|
|
+ useMentionGroups,
|
|
|
|
|
+ useSlashGroups,
|
|
|
|
|
+} from "@/composables/useSuggestionGroups"
|
|
|
import {
|
|
import {
|
|
|
type EditorBlock,
|
|
type EditorBlock,
|
|
|
generateBlockId,
|
|
generateBlockId,
|
|
@@ -25,20 +39,6 @@ import {
|
|
|
} from "@/lib/editor-operations"
|
|
} from "@/lib/editor-operations"
|
|
|
import type { FormattingHandlers } from "@/lib/formatting"
|
|
import type { FormattingHandlers } from "@/lib/formatting"
|
|
|
import { createLogger } from "@/lib/logger"
|
|
import { createLogger } from "@/lib/logger"
|
|
|
-import type {
|
|
|
|
|
- PatternClosePayload,
|
|
|
|
|
- PatternOpenPayload,
|
|
|
|
|
- PatternUpdatePayload,
|
|
|
|
|
-} from "@/composables/usePatternPlugin"
|
|
|
|
|
-import {
|
|
|
|
|
- useSlashGroups,
|
|
|
|
|
- useMentionGroups,
|
|
|
|
|
-} from "@/composables/useSuggestionGroups"
|
|
|
|
|
-import {
|
|
|
|
|
- defaultKeyBinding,
|
|
|
|
|
- useKeyboard,
|
|
|
|
|
- type KeyBinding,
|
|
|
|
|
-} from "@/composables/useKeyboard"
|
|
|
|
|
import {
|
|
import {
|
|
|
createOperationHistory,
|
|
createOperationHistory,
|
|
|
type EditorOperation,
|
|
type EditorOperation,
|
|
@@ -72,11 +72,18 @@ const themeCSSVars = computed(() => {
|
|
|
return themeToCSSVars(resolved)
|
|
return themeToCSSVars(resolved)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-// Counter tracking nested internal updates, used to prevent the external
|
|
|
|
|
-// content watcher from re-parsing when we already pushed the change through.
|
|
|
|
|
-// Using a counter instead of a boolean avoids race conditions when batched
|
|
|
|
|
-// watcher callbacks fire sequentially (e.g. rapid undo/redo).
|
|
|
|
|
-let updatingDepth = 0
|
|
|
|
|
|
|
+// Tracks whether an internal content update is in flight. When the watcher
|
|
|
|
|
+// fires for a content.value change that originated from onBlockContentChange
|
|
|
|
|
+// (not from an external source), it resets and skips re-parsing.
|
|
|
|
|
+// Using a boolean avoids the counter-desync problem: if Vue collapses N
|
|
|
|
|
+// writes to content.value into one watcher invocation, resetting to false
|
|
|
|
|
+// on every skip handles any number of batched internal updates.
|
|
|
|
|
+// Edge case: if two onBlockContentChange calls fire in separate microtask
|
|
|
|
|
+// turns (the first's nextTick clears the flag before the second's watcher
|
|
|
|
|
+// fires), the boolean would prematurely reset. This doesn't happen in the
|
|
|
|
|
+// current call graph (all internal changes are synchronous), but is noted
|
|
|
|
|
+// for future refactoring.
|
|
|
|
|
+let internalUpdatePending = false
|
|
|
|
|
|
|
|
const blocks = ref<EditorBlock[]>([])
|
|
const blocks = ref<EditorBlock[]>([])
|
|
|
|
|
|
|
@@ -99,39 +106,53 @@ const history: OperationHistory = createOperationHistory(100)
|
|
|
const canUndo = ref(false)
|
|
const canUndo = ref(false)
|
|
|
const canRedo = ref(false)
|
|
const canRedo = ref(false)
|
|
|
|
|
|
|
|
|
|
+// Applies an operation's model mutation and syncs PM views. Does NOT
|
|
|
|
|
+// update canUndo/canRedo — that's the caller's responsibility (undo/redo
|
|
|
|
|
+// update the flags afterward; direct operation callsites do so inline).
|
|
|
function applyOperation(op: EditorOperation): void {
|
|
function applyOperation(op: EditorOperation): void {
|
|
|
|
|
+ let focusInfo: { id: string; pos: "start" | "end" | number } | null = null
|
|
|
|
|
+
|
|
|
switch (op.type) {
|
|
switch (op.type) {
|
|
|
case "split-block": {
|
|
case "split-block": {
|
|
|
const block = blocks.value[op.index]
|
|
const block = blocks.value[op.index]
|
|
|
if (!block) return
|
|
if (!block) return
|
|
|
block.content = op.beforeContent
|
|
block.content = op.beforeContent
|
|
|
|
|
+ blockRefs.get(block.id)?.setContent(op.beforeContent)
|
|
|
blocks.value.splice(op.index + 1, 0, {
|
|
blocks.value.splice(op.index + 1, 0, {
|
|
|
id: op.splitBlockId,
|
|
id: op.splitBlockId,
|
|
|
depth: op.depth,
|
|
depth: op.depth,
|
|
|
content: op.afterContent,
|
|
content: op.afterContent,
|
|
|
})
|
|
})
|
|
|
|
|
+ focusInfo = { id: op.splitBlockId, pos: "start" }
|
|
|
break
|
|
break
|
|
|
}
|
|
}
|
|
|
case "merge-block": {
|
|
case "merge-block": {
|
|
|
- // Remove block at op.index and append its content to the previous block.
|
|
|
|
|
const prev = blocks.value[op.index - 1]
|
|
const prev = blocks.value[op.index - 1]
|
|
|
if (prev) {
|
|
if (prev) {
|
|
|
- prev.content = op.prevContent
|
|
|
|
|
- prev.content += op.appendedContent
|
|
|
|
|
|
|
+ prev.content = op.prevContent + op.appendedContent
|
|
|
|
|
+ blockRefs.get(prev.id)?.setContent(prev.content)
|
|
|
}
|
|
}
|
|
|
blocks.value.splice(op.index, 1)
|
|
blocks.value.splice(op.index, 1)
|
|
|
|
|
+ // Cursor lands at the join point: PM's 1-indexed position after
|
|
|
|
|
+ // the last character of the pre-merge content.
|
|
|
|
|
+ if (prev) focusInfo = { id: prev.id, pos: op.prevContent.length + 1 }
|
|
|
break
|
|
break
|
|
|
}
|
|
}
|
|
|
- case "insert-block":
|
|
|
|
|
|
|
+ case "insert-block": {
|
|
|
blocks.value.splice(op.index, 0, {
|
|
blocks.value.splice(op.index, 0, {
|
|
|
id: op.blockId,
|
|
id: op.blockId,
|
|
|
depth: op.depth,
|
|
depth: op.depth,
|
|
|
content: op.content,
|
|
content: op.content,
|
|
|
})
|
|
})
|
|
|
|
|
+ focusInfo = { id: op.blockId, pos: "start" }
|
|
|
break
|
|
break
|
|
|
- case "delete-block":
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ case "delete-block": {
|
|
|
blocks.value.splice(op.index, 1)
|
|
blocks.value.splice(op.index, 1)
|
|
|
|
|
+ const target = blocks.value[op.index] ?? blocks.value[op.index - 1]
|
|
|
|
|
+ if (target) focusInfo = { id: target.id, pos: "start" }
|
|
|
break
|
|
break
|
|
|
|
|
+ }
|
|
|
case "indent-block": {
|
|
case "indent-block": {
|
|
|
const indentBlock = blocks.value[op.index]
|
|
const indentBlock = blocks.value[op.index]
|
|
|
if (indentBlock) indentBlock.depth = Math.min(op.previousDepth + 1, 10)
|
|
if (indentBlock) indentBlock.depth = Math.min(op.previousDepth + 1, 10)
|
|
@@ -141,6 +162,8 @@ function applyOperation(op: EditorOperation): void {
|
|
|
if (child) child.depth = Math.min(c.previousDepth + 1, 10)
|
|
if (child) child.depth = Math.min(c.previousDepth + 1, 10)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ // No focusInfo here — indent only changes depth metadata, not PM
|
|
|
|
|
+ // content, so moving the cursor would be jarring.
|
|
|
break
|
|
break
|
|
|
}
|
|
}
|
|
|
case "outdent-block": {
|
|
case "outdent-block": {
|
|
@@ -152,15 +175,24 @@ function applyOperation(op: EditorOperation): void {
|
|
|
if (child) child.depth = Math.max(c.previousDepth - 1, 0)
|
|
if (child) child.depth = Math.max(c.previousDepth - 1, 0)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ // No focusInfo — same rationale as indent-block.
|
|
|
break
|
|
break
|
|
|
}
|
|
}
|
|
|
case "set-block-content": {
|
|
case "set-block-content": {
|
|
|
const setBlock = blocks.value.find((b) => b.id === op.blockId)
|
|
const setBlock = blocks.value.find((b) => b.id === op.blockId)
|
|
|
- if (setBlock) setBlock.content = op.newContent
|
|
|
|
|
|
|
+ if (setBlock) {
|
|
|
|
|
+ setBlock.content = op.newContent
|
|
|
|
|
+ blockRefs.get(setBlock.id)?.setContent(op.newContent)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (setBlock) focusInfo = { id: setBlock.id, pos: "end" }
|
|
|
break
|
|
break
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
onBlockContentChange()
|
|
onBlockContentChange()
|
|
|
|
|
+
|
|
|
|
|
+ if (focusInfo) {
|
|
|
|
|
+ nextTick(() => registry.focus(focusInfo!.id, focusInfo!.pos))
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function undo() {
|
|
function undo() {
|
|
@@ -189,8 +221,8 @@ function parseContent(md: string | undefined) {
|
|
|
watch(
|
|
watch(
|
|
|
() => content.value,
|
|
() => content.value,
|
|
|
(val) => {
|
|
(val) => {
|
|
|
- if (updatingDepth > 0) {
|
|
|
|
|
- updatingDepth--
|
|
|
|
|
|
|
+ if (internalUpdatePending) {
|
|
|
|
|
+ internalUpdatePending = false
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
history.clear()
|
|
history.clear()
|
|
@@ -203,8 +235,11 @@ watch(
|
|
|
|
|
|
|
|
// Serialize and emit when any block's content changes.
|
|
// Serialize and emit when any block's content changes.
|
|
|
function onBlockContentChange() {
|
|
function onBlockContentChange() {
|
|
|
- updatingDepth++
|
|
|
|
|
|
|
+ internalUpdatePending = true
|
|
|
content.value = serializeBlocks(blocks.value)
|
|
content.value = serializeBlocks(blocks.value)
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ internalUpdatePending = false
|
|
|
|
|
+ })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ── Active block state (toolbar binding) ──────────────────────────────
|
|
// ── Active block state (toolbar binding) ──────────────────────────────
|
|
@@ -296,12 +331,18 @@ function onZoneNavigateDown(zoneIndex: number) {
|
|
|
// ── Block event handlers ──────────────────────────────────────────────
|
|
// ── Block event handlers ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
function onSplit(index: number, before: string, after: string) {
|
|
function onSplit(index: number, before: string, after: string) {
|
|
|
- const result = opSplitBlocks(blocks.value, index, before, after, generateBlockId)
|
|
|
|
|
|
|
+ const result = opSplitBlocks(
|
|
|
|
|
+ blocks.value,
|
|
|
|
|
+ index,
|
|
|
|
|
+ before,
|
|
|
|
|
+ after,
|
|
|
|
|
+ generateBlockId,
|
|
|
|
|
+ )
|
|
|
if (!result.newBlock) return
|
|
if (!result.newBlock) return
|
|
|
blocks.value = result.blocks
|
|
blocks.value = result.blocks
|
|
|
onBlockContentChange()
|
|
onBlockContentChange()
|
|
|
|
|
|
|
|
- history.execute({
|
|
|
|
|
|
|
+ void history.execute({
|
|
|
type: "split-block",
|
|
type: "split-block",
|
|
|
index,
|
|
index,
|
|
|
splitBlockId: result.newBlock.id,
|
|
splitBlockId: result.newBlock.id,
|
|
@@ -331,10 +372,17 @@ function onMergePrevious(index: number) {
|
|
|
// no-op — preventing a cursor-resetting doc replacement.
|
|
// no-op — preventing a cursor-resetting doc replacement.
|
|
|
blockRefs.get(prev.id)?.setContent(result.mergedContent)
|
|
blockRefs.get(prev.id)?.setContent(result.mergedContent)
|
|
|
|
|
|
|
|
|
|
+ // Defensively ensure the result block's content matches mergedContent,
|
|
|
|
|
+ // even if mergePrevious's implementation evolves. Without this, the new
|
|
|
|
|
+ // block objects in result.blocks might have stale content, causing the
|
|
|
|
|
+ // debounced watch in Block.vue to re-apply the old value and lose state.
|
|
|
|
|
+ const prevIndex = result.blocks.findIndex((b) => b.id === prev.id)
|
|
|
|
|
+ if (prevIndex !== -1) result.blocks[prevIndex].content = result.mergedContent
|
|
|
|
|
+
|
|
|
blocks.value = result.blocks
|
|
blocks.value = result.blocks
|
|
|
onBlockContentChange()
|
|
onBlockContentChange()
|
|
|
|
|
|
|
|
- history.execute({
|
|
|
|
|
|
|
+ void history.execute({
|
|
|
type: "merge-block",
|
|
type: "merge-block",
|
|
|
index,
|
|
index,
|
|
|
removedBlockId,
|
|
removedBlockId,
|
|
@@ -358,7 +406,7 @@ function onDeleteIfEmpty(index: number) {
|
|
|
blocks.value = result.blocks
|
|
blocks.value = result.blocks
|
|
|
onBlockContentChange()
|
|
onBlockContentChange()
|
|
|
|
|
|
|
|
- history.execute({
|
|
|
|
|
|
|
+ void history.execute({
|
|
|
type: "delete-block",
|
|
type: "delete-block",
|
|
|
index,
|
|
index,
|
|
|
blockId: removedBlock.id,
|
|
blockId: removedBlock.id,
|
|
@@ -415,7 +463,7 @@ function onIndent(index: number) {
|
|
|
blocks.value = result.blocks
|
|
blocks.value = result.blocks
|
|
|
onBlockContentChange()
|
|
onBlockContentChange()
|
|
|
|
|
|
|
|
- history.execute({
|
|
|
|
|
|
|
+ void history.execute({
|
|
|
type: "indent-block",
|
|
type: "indent-block",
|
|
|
index,
|
|
index,
|
|
|
previousDepth,
|
|
previousDepth,
|
|
@@ -441,7 +489,7 @@ function onOutdent(index: number) {
|
|
|
blocks.value = result.blocks
|
|
blocks.value = result.blocks
|
|
|
onBlockContentChange()
|
|
onBlockContentChange()
|
|
|
|
|
|
|
|
- history.execute({
|
|
|
|
|
|
|
+ void history.execute({
|
|
|
type: "outdent-block",
|
|
type: "outdent-block",
|
|
|
index,
|
|
index,
|
|
|
previousDepth,
|
|
previousDepth,
|
|
@@ -471,25 +519,77 @@ function onZoneBlur() {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function onZoneActivate(index: number, content: string) {
|
|
function onZoneActivate(index: number, content: string) {
|
|
|
- const result = opInsertBlock(blocks.value, index, content, 0, generateBlockId)
|
|
|
|
|
- blocks.value = result.blocks
|
|
|
|
|
focusedTarget.value = null
|
|
focusedTarget.value = null
|
|
|
-
|
|
|
|
|
- history.execute({
|
|
|
|
|
- type: "insert-block",
|
|
|
|
|
- index,
|
|
|
|
|
- blockId: result.newBlock.id,
|
|
|
|
|
- content,
|
|
|
|
|
- depth: 0,
|
|
|
|
|
- timestamp: Date.now(),
|
|
|
|
|
- })
|
|
|
|
|
- canUndo.value = history.canUndo
|
|
|
|
|
- canRedo.value = history.canRedo
|
|
|
|
|
-
|
|
|
|
|
- if (content) {
|
|
|
|
|
- nextTick(() => registry.focus(result.newBlock.id, "end"))
|
|
|
|
|
|
|
+ const lines = content.split("\n")
|
|
|
|
|
+
|
|
|
|
|
+ if (lines.length > 1) {
|
|
|
|
|
+ // Multi-line paste: create a block for each non-empty line.
|
|
|
|
|
+ // Only the first insert is recorded in history — undoing a paste should
|
|
|
|
|
+ // remove all inserted blocks at once, not step through them one by one.
|
|
|
|
|
+ // TODO: Replace with a batch operation type when the history model
|
|
|
|
|
+ // supports atomic multi-op undo.
|
|
|
|
|
+ let insertedId: string | undefined
|
|
|
|
|
+ let insertIndex = index
|
|
|
|
|
+ let firstInsertRecorded = false
|
|
|
|
|
+ for (let i = 0; i < lines.length; i++) {
|
|
|
|
|
+ const line = lines[i]
|
|
|
|
|
+ if (line === "" && i < lines.length - 1) continue
|
|
|
|
|
+ const result = opInsertBlock(
|
|
|
|
|
+ blocks.value,
|
|
|
|
|
+ insertIndex,
|
|
|
|
|
+ line,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ generateBlockId,
|
|
|
|
|
+ )
|
|
|
|
|
+ blocks.value = result.blocks
|
|
|
|
|
+ insertedId = result.newBlock.id
|
|
|
|
|
+ if (!firstInsertRecorded) {
|
|
|
|
|
+ void history.execute({
|
|
|
|
|
+ type: "insert-block",
|
|
|
|
|
+ index: insertIndex,
|
|
|
|
|
+ blockId: result.newBlock.id,
|
|
|
|
|
+ content: line,
|
|
|
|
|
+ depth: 0,
|
|
|
|
|
+ timestamp: Date.now(),
|
|
|
|
|
+ })
|
|
|
|
|
+ firstInsertRecorded = true
|
|
|
|
|
+ }
|
|
|
|
|
+ insertIndex++
|
|
|
|
|
+ }
|
|
|
|
|
+ onBlockContentChange()
|
|
|
|
|
+ canUndo.value = history.canUndo
|
|
|
|
|
+ canRedo.value = history.canRedo
|
|
|
|
|
+ const lastInsertedId = insertedId
|
|
|
|
|
+ if (lastInsertedId) {
|
|
|
|
|
+ nextTick(() => registry.focus(lastInsertedId, "end"))
|
|
|
|
|
+ }
|
|
|
} else {
|
|
} else {
|
|
|
- nextTick(() => registry.focus(result.newBlock.id, "start"))
|
|
|
|
|
|
|
+ const result = opInsertBlock(
|
|
|
|
|
+ blocks.value,
|
|
|
|
|
+ index,
|
|
|
|
|
+ content,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ generateBlockId,
|
|
|
|
|
+ )
|
|
|
|
|
+ blocks.value = result.blocks
|
|
|
|
|
+ onBlockContentChange()
|
|
|
|
|
+
|
|
|
|
|
+ void history.execute({
|
|
|
|
|
+ type: "insert-block",
|
|
|
|
|
+ index,
|
|
|
|
|
+ blockId: result.newBlock.id,
|
|
|
|
|
+ content,
|
|
|
|
|
+ depth: 0,
|
|
|
|
|
+ timestamp: Date.now(),
|
|
|
|
|
+ })
|
|
|
|
|
+ canUndo.value = history.canUndo
|
|
|
|
|
+ canRedo.value = history.canRedo
|
|
|
|
|
+
|
|
|
|
|
+ if (content) {
|
|
|
|
|
+ nextTick(() => registry.focus(result.newBlock.id, "end"))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ nextTick(() => registry.focus(result.newBlock.id, "start"))
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -511,7 +611,7 @@ defineExpose({ undo, redo, canUndo, canRedo })
|
|
|
</div>
|
|
</div>
|
|
|
<template v-for="(block, i) in blocks" :key="block.id">
|
|
<template v-for="(block, i) in blocks" :key="block.id">
|
|
|
<InsertionZone
|
|
<InsertionZone
|
|
|
- :ref="(el: any) => { if (el) zoneRefs[i] = el }"
|
|
|
|
|
|
|
+ :ref="(el: any) => { if (el) zoneRefs[i] = el; else delete zoneRefs[i] }"
|
|
|
@activate="(content) => onZoneActivate(i, content)"
|
|
@activate="(content) => onZoneActivate(i, content)"
|
|
|
@focus="onZoneFocus(i)"
|
|
@focus="onZoneFocus(i)"
|
|
|
@blur="onZoneBlur"
|
|
@blur="onZoneBlur"
|
|
@@ -556,7 +656,7 @@ defineExpose({ undo, redo, canUndo, canRedo })
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
<InsertionZone
|
|
<InsertionZone
|
|
|
- :ref="(el: any) => { if (el) zoneRefs[blocks.length] = el }"
|
|
|
|
|
|
|
+ :ref="(el: any) => { if (el) zoneRefs[blocks.length] = el; else delete zoneRefs[blocks.length] }"
|
|
|
@activate="(content) => onZoneActivate(blocks.length, content)"
|
|
@activate="(content) => onZoneActivate(blocks.length, content)"
|
|
|
@focus="onZoneFocus(blocks.length)"
|
|
@focus="onZoneFocus(blocks.length)"
|
|
|
@blur="onZoneBlur"
|
|
@blur="onZoneBlur"
|