|
|
@@ -26,11 +26,11 @@ import {
|
|
|
splitMarkdownIntoBlocks,
|
|
|
} from "@/lib/block-parser"
|
|
|
import {
|
|
|
+ moveBlock,
|
|
|
deleteIfEmpty as opDeleteIfEmpty,
|
|
|
indentBlock as opIndentBlock,
|
|
|
insertBlock as opInsertBlock,
|
|
|
mergePrevious as opMergePrevious,
|
|
|
- moveBlock,
|
|
|
outdentBlock as opOutdentBlock,
|
|
|
splitBlocks as opSplitBlocks,
|
|
|
} from "@/lib/editor-operations"
|
|
|
@@ -76,18 +76,16 @@ const themeCSSVars = computed(() => {
|
|
|
return themeToCSSVars(resolved)
|
|
|
})
|
|
|
|
|
|
-// 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
|
|
|
+/**
|
|
|
+ * Generation counter disambiguates internal content updates from external
|
|
|
+ * ones. onBlockContentChange increments it synchronously before setting
|
|
|
+ * content.value; the watcher compares it against lastContentGeneration.
|
|
|
+ * If Vue collapses N writes into one watcher invocation, the generational
|
|
|
+ * gap is still detected. Unlike a boolean, this survives interleaved async
|
|
|
+ * calls (e.g. resolveAsset then nextTick then another content change).
|
|
|
+ */
|
|
|
+let contentGeneration = 0
|
|
|
+let lastContentGeneration = 0
|
|
|
|
|
|
const blocks = ref<EditorBlockData[]>([])
|
|
|
|
|
|
@@ -97,7 +95,9 @@ let announceTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
function announce(msg: string) {
|
|
|
liveAnnouncement.value = msg
|
|
|
if (announceTimer) clearTimeout(announceTimer)
|
|
|
- announceTimer = setTimeout(() => { liveAnnouncement.value = "" }, 2000)
|
|
|
+ announceTimer = setTimeout(() => {
|
|
|
+ liveAnnouncement.value = ""
|
|
|
+ }, 2000)
|
|
|
}
|
|
|
|
|
|
const registry = useFocusRegistry()
|
|
|
@@ -105,14 +105,29 @@ const registry = useFocusRegistry()
|
|
|
const log = createLogger("Editor", props.debug)
|
|
|
|
|
|
const blockRefs = new Map<string, { setContent: (md: string) => void }>()
|
|
|
-const zoneRefs: HTMLElement[] = []
|
|
|
+const zoneRefs = new Map<number, HTMLElement>()
|
|
|
|
|
|
+/**
|
|
|
+ * Focus the insertion zone at the given index. Used for keyboard
|
|
|
+ * navigation when the cursor exits the first or last block.
|
|
|
+ */
|
|
|
function focusZone(index: number) {
|
|
|
- const zone = zoneRefs[index]
|
|
|
+ const zone = zoneRefs.get(index)
|
|
|
if (!zone) return
|
|
|
zone.focus()
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * Push content into a block's ProseMirror view, but only if the block
|
|
|
+ * still exists in the model. Without this guard, an async content
|
|
|
+ * change that resolves after the block is removed could schedule a
|
|
|
+ * doc replacement in a component about to be unmounted.
|
|
|
+ */
|
|
|
+function syncBlockContent(id: string, content: string) {
|
|
|
+ if (!blocks.value.some((b) => b.id === id)) return
|
|
|
+ blockRefs.get(id)?.setContent(content)
|
|
|
+}
|
|
|
+
|
|
|
// ── Drag-to-reorder ────────────────────────────────────────────────
|
|
|
|
|
|
const dragState = ref<{
|
|
|
@@ -167,8 +182,7 @@ function onDropOnZone(zoneIndex: number) {
|
|
|
|
|
|
// Zone i = insert before block i. In post-removal coordinates:
|
|
|
// if fromIndex < zoneIndex, the zone shifts left by 1.
|
|
|
- const targetIndex =
|
|
|
- state.fromIndex < zoneIndex ? zoneIndex - 1 : zoneIndex
|
|
|
+ const targetIndex = state.fromIndex < zoneIndex ? zoneIndex - 1 : zoneIndex
|
|
|
|
|
|
if (state.fromIndex === targetIndex) {
|
|
|
dragState.value = null
|
|
|
@@ -211,7 +225,7 @@ function applyOperation(op: EditorOperation): void {
|
|
|
const block = blocks.value[op.index]
|
|
|
if (!block) return
|
|
|
block.content = op.beforeContent
|
|
|
- blockRefs.get(block.id)?.setContent(op.beforeContent)
|
|
|
+ syncBlockContent(block.id, op.beforeContent)
|
|
|
blocks.value.splice(op.index + 1, 0, {
|
|
|
id: op.splitBlockId,
|
|
|
depth: op.depth,
|
|
|
@@ -224,7 +238,7 @@ function applyOperation(op: EditorOperation): void {
|
|
|
const prev = blocks.value[op.index - 1]
|
|
|
if (prev) {
|
|
|
prev.content = op.prevContent + op.appendedContent
|
|
|
- blockRefs.get(prev.id)?.setContent(prev.content)
|
|
|
+ syncBlockContent(prev.id, prev.content)
|
|
|
}
|
|
|
blocks.value.splice(op.index, 1)
|
|
|
// Cursor lands at the join point: PM's 1-indexed position after
|
|
|
@@ -273,10 +287,12 @@ function applyOperation(op: EditorOperation): void {
|
|
|
break
|
|
|
}
|
|
|
case "set-block-content": {
|
|
|
- const setBlock = blocks.value.find((b: EditorBlockData) => b.id === op.blockId)
|
|
|
+ const setBlock = blocks.value.find(
|
|
|
+ (b: EditorBlockData) => b.id === op.blockId,
|
|
|
+ )
|
|
|
if (setBlock) {
|
|
|
setBlock.content = op.newContent
|
|
|
- blockRefs.get(setBlock.id)?.setContent(op.newContent)
|
|
|
+ syncBlockContent(setBlock.id, op.newContent)
|
|
|
}
|
|
|
if (setBlock) focusInfo = { id: setBlock.id, pos: "end" }
|
|
|
break
|
|
|
@@ -350,8 +366,8 @@ function parseContent(md: string | undefined) {
|
|
|
watch(
|
|
|
() => content.value,
|
|
|
(val) => {
|
|
|
- if (internalUpdatePending) {
|
|
|
- internalUpdatePending = false
|
|
|
+ if (contentGeneration !== lastContentGeneration) {
|
|
|
+ lastContentGeneration = contentGeneration
|
|
|
return
|
|
|
}
|
|
|
history.clear()
|
|
|
@@ -364,10 +380,10 @@ watch(
|
|
|
|
|
|
// Serialize and emit when any block's content changes.
|
|
|
function onBlockContentChange() {
|
|
|
- internalUpdatePending = true
|
|
|
+ contentGeneration++
|
|
|
content.value = serializeBlocks(blocks.value)
|
|
|
nextTick(() => {
|
|
|
- internalUpdatePending = false
|
|
|
+ lastContentGeneration = contentGeneration
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -487,7 +503,7 @@ function onMergePrevious(index: number) {
|
|
|
// Directly update the prev block's PM + model so the debounced watch
|
|
|
// (which fires ~50ms later) sees currentContent === newContent and is a
|
|
|
// no-op — preventing a cursor-resetting doc replacement.
|
|
|
- blockRefs.get(prev.id)?.setContent(result.mergedContent)
|
|
|
+ syncBlockContent(prev.id, result.mergedContent)
|
|
|
|
|
|
// Defensively ensure the result block's content matches mergedContent,
|
|
|
// even if mergePrevious's implementation evolves. Without this, the new
|
|
|
@@ -755,7 +771,7 @@ defineExpose({
|
|
|
<div class="editor-body" @dragover.prevent>
|
|
|
<template v-for="(block, i) in blocks" :key="block.id">
|
|
|
<EditorInsertionZone
|
|
|
- :ref="(el: any) => { if (el) zoneRefs[i] = el; else delete zoneRefs[i] }"
|
|
|
+ :ref="(el: any) => { if (el) zoneRefs.set(i, el); else zoneRefs.delete(i) }"
|
|
|
:class="dropZoneIndex === i ? 'bg-(--ui-primary) rounded' : ''"
|
|
|
@activate="(content: string) => onZoneActivate(i, content)"
|
|
|
@focus="onZoneFocus(i)"
|
|
|
@@ -822,7 +838,7 @@ defineExpose({
|
|
|
</div>
|
|
|
</template>
|
|
|
<EditorInsertionZone
|
|
|
- :ref="(el: any) => { if (el) zoneRefs[blocks.length] = el; else delete zoneRefs[blocks.length] }"
|
|
|
+ :ref="(el: any) => { if (el) zoneRefs.set(blocks.length, el); else zoneRefs.delete(blocks.length) }"
|
|
|
:class="dropZoneIndex === blocks.length ? 'bg-(--ui-primary) rounded' : ''"
|
|
|
@activate="(content: string) => onZoneActivate(blocks.length, content)"
|
|
|
@focus="onZoneFocus(blocks.length)"
|