|
|
@@ -1,16 +1,35 @@
|
|
|
<script setup lang="ts">
|
|
|
-import { nextTick, ref, watch } from "vue"
|
|
|
+import type { EditorView } from "prosemirror-view"
|
|
|
+import { nextTick, ref, useSlots, watch } from "vue"
|
|
|
import Block from "@/components/Block.vue"
|
|
|
-import { useFocusRegistry } from "@/composables/useFocusRegistry"
|
|
|
+import InsertionZone from "@/components/InsertionZone.vue"
|
|
|
+import {
|
|
|
+ type FocusTarget,
|
|
|
+ useFocusRegistry,
|
|
|
+} from "@/composables/useFocusRegistry"
|
|
|
import type { MarkerVisibilityMode } from "@/composables/useMarkdownDecorations"
|
|
|
import {
|
|
|
type EditorBlock,
|
|
|
+ generateBlockId,
|
|
|
serializeBlocks,
|
|
|
splitMarkdownIntoBlocks,
|
|
|
} from "@/lib/block-parser"
|
|
|
+import type { FormattingHandlers } from "@/lib/formatting"
|
|
|
+import { createLogger } from "@/lib/logger"
|
|
|
+import {
|
|
|
+ createOperationHistory,
|
|
|
+ type EditorOperation,
|
|
|
+ type OperationHistory,
|
|
|
+} from "@/lib/operation-history"
|
|
|
|
|
|
const content = defineModel<string>("content", { required: false })
|
|
|
|
|
|
+const emit = defineEmits<{
|
|
|
+ focus: [payload: { view: EditorView; handlers: FormattingHandlers }]
|
|
|
+ blur: []
|
|
|
+ "selection-change": [payload: { from: number; to: number; empty: boolean }]
|
|
|
+}>()
|
|
|
+
|
|
|
const props = withDefaults(
|
|
|
defineProps<{
|
|
|
focused?: boolean
|
|
|
@@ -29,11 +48,133 @@ const blocks = ref<EditorBlock[]>([])
|
|
|
|
|
|
const registry = useFocusRegistry()
|
|
|
|
|
|
+const log = createLogger("Editor", props.debug)
|
|
|
+const slots = useSlots()
|
|
|
+
|
|
|
+const blockRefs: { setContent: (md: string) => void }[] = []
|
|
|
+const zoneRefs: HTMLElement[] = []
|
|
|
+
|
|
|
+function focusZone(index: number) {
|
|
|
+ const zone = zoneRefs[index]
|
|
|
+ if (!zone) return
|
|
|
+ zone.focus()
|
|
|
+}
|
|
|
+
|
|
|
+const editorShellRef = ref<HTMLElement | null>(null)
|
|
|
+
|
|
|
+// ── Undo / Redo history ──────────────────────────────────────────
|
|
|
+
|
|
|
+const history: OperationHistory = createOperationHistory(100)
|
|
|
+const canUndo = ref(false)
|
|
|
+const canRedo = ref(false)
|
|
|
+
|
|
|
+function applyOperation(op: EditorOperation): void {
|
|
|
+ switch (op.type) {
|
|
|
+ case "split-block": {
|
|
|
+ const block = blocks.value[op.index]
|
|
|
+ if (!block) return
|
|
|
+ block.content = op.beforeContent
|
|
|
+ blocks.value.splice(op.index + 1, 0, {
|
|
|
+ id: op.splitBlockId,
|
|
|
+ depth: op.depth,
|
|
|
+ content: op.afterContent,
|
|
|
+ })
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case "merge-block": {
|
|
|
+ // Remove block at op.index and append its content to the previous block.
|
|
|
+ const prev = blocks.value[op.index - 1]
|
|
|
+ if (prev) {
|
|
|
+ prev.content = op.prevContent
|
|
|
+ prev.content += op.appendedContent
|
|
|
+ }
|
|
|
+ blocks.value.splice(op.index, 1)
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case "insert-block":
|
|
|
+ blocks.value.splice(op.index, 0, {
|
|
|
+ id: op.blockId,
|
|
|
+ depth: op.depth,
|
|
|
+ content: op.content,
|
|
|
+ })
|
|
|
+ break
|
|
|
+ case "delete-block":
|
|
|
+ blocks.value.splice(op.index, 1)
|
|
|
+ break
|
|
|
+ case "indent-block": {
|
|
|
+ const indentBlock = blocks.value[op.index]
|
|
|
+ if (indentBlock) indentBlock.depth = Math.min(op.previousDepth + 1, 10)
|
|
|
+ if (op.childrenPreviousDepths) {
|
|
|
+ for (const c of op.childrenPreviousDepths) {
|
|
|
+ const child = blocks.value[c.index]
|
|
|
+ if (child) child.depth = Math.min(c.previousDepth + 1, 10)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case "outdent-block": {
|
|
|
+ const outdentBlock = blocks.value[op.index]
|
|
|
+ if (outdentBlock) outdentBlock.depth = Math.max(op.previousDepth - 1, 0)
|
|
|
+ if (op.childrenPreviousDepths) {
|
|
|
+ for (const c of op.childrenPreviousDepths) {
|
|
|
+ const child = blocks.value[c.index]
|
|
|
+ if (child) child.depth = Math.max(c.previousDepth - 1, 0)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case "set-block-content": {
|
|
|
+ const setBlock = blocks.value.find((b) => b.id === op.blockId)
|
|
|
+ if (setBlock) setBlock.content = op.newContent
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ onBlockContentChange()
|
|
|
+}
|
|
|
+
|
|
|
+function undo() {
|
|
|
+ const inverse = history.undo()
|
|
|
+ if (!inverse) return
|
|
|
+ applyOperation(inverse)
|
|
|
+ canUndo.value = history.canUndo
|
|
|
+ canRedo.value = history.canRedo
|
|
|
+}
|
|
|
+
|
|
|
+function redo() {
|
|
|
+ const op = history.redo()
|
|
|
+ if (!op) return
|
|
|
+ applyOperation(op)
|
|
|
+ canUndo.value = history.canUndo
|
|
|
+ canRedo.value = history.canRedo
|
|
|
+}
|
|
|
+
|
|
|
+// Undo/redo keyboard shortcuts, scoped to the editor shell.
|
|
|
+// Uses @keydown on the root div so that only focus within the editor
|
|
|
+// triggers the shortcuts, avoiding conflicts with other editors or
|
|
|
+// modals on the same page.
|
|
|
+function onShellKeydown(e: KeyboardEvent) {
|
|
|
+ if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
|
|
|
+ e.preventDefault()
|
|
|
+ undo()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ (e.metaKey || e.ctrlKey) &&
|
|
|
+ (e.key === "Z" || (e.key === "z" && e.shiftKey))
|
|
|
+ ) {
|
|
|
+ e.preventDefault()
|
|
|
+ redo()
|
|
|
+ return
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
function parseContent(md: string | undefined) {
|
|
|
blocks.value = splitMarkdownIntoBlocks(md || "", undefined, blocks.value)
|
|
|
}
|
|
|
|
|
|
-// Sync: parent content → blocks (external updates only)
|
|
|
+// Sync: parent content → blocks (external updates only).
|
|
|
+// External content replacement invalidates all history entries
|
|
|
+// (they reference stale indices/block IDs), so clear the undo stacks.
|
|
|
watch(
|
|
|
() => content.value,
|
|
|
(val) => {
|
|
|
@@ -41,6 +182,9 @@ watch(
|
|
|
updatingInternally = false
|
|
|
return
|
|
|
}
|
|
|
+ history.clear()
|
|
|
+ canUndo.value = false
|
|
|
+ canRedo.value = false
|
|
|
parseContent(val)
|
|
|
},
|
|
|
{ immediate: true },
|
|
|
@@ -52,6 +196,32 @@ function onBlockContentChange() {
|
|
|
content.value = serializeBlocks(blocks.value)
|
|
|
}
|
|
|
|
|
|
+// ── Active block state (toolbar binding) ──────────────────────────────
|
|
|
+
|
|
|
+const activeHandlers = ref<FormattingHandlers | null>(null)
|
|
|
+const selectionVersion = ref(0)
|
|
|
+
|
|
|
+function onBlockFocusHandler(payload: {
|
|
|
+ view: EditorView
|
|
|
+ handlers: FormattingHandlers
|
|
|
+}) {
|
|
|
+ activeHandlers.value = payload.handlers
|
|
|
+ emit("focus", payload)
|
|
|
+}
|
|
|
+
|
|
|
+function onBlockBlurHandler() {
|
|
|
+ emit("blur")
|
|
|
+}
|
|
|
+
|
|
|
+function onBlockSelectionChangeHandler(payload: {
|
|
|
+ from: number
|
|
|
+ to: number
|
|
|
+ empty: boolean
|
|
|
+}) {
|
|
|
+ selectionVersion.value++
|
|
|
+ emit("selection-change", payload)
|
|
|
+}
|
|
|
+
|
|
|
// ── Block event handlers ──────────────────────────────────────────────
|
|
|
|
|
|
function onSplit(index: number, before: string, after: string) {
|
|
|
@@ -60,12 +230,25 @@ function onSplit(index: number, before: string, after: string) {
|
|
|
current.content = before
|
|
|
|
|
|
const newBlock: EditorBlock = {
|
|
|
- id: crypto.randomUUID(),
|
|
|
+ id: generateBlockId(),
|
|
|
depth: current.depth,
|
|
|
content: after,
|
|
|
}
|
|
|
blocks.value.splice(index + 1, 0, newBlock)
|
|
|
onBlockContentChange()
|
|
|
+
|
|
|
+ history.execute({
|
|
|
+ type: "split-block",
|
|
|
+ index,
|
|
|
+ splitBlockId: newBlock.id,
|
|
|
+ depth: current.depth,
|
|
|
+ beforeContent: before,
|
|
|
+ afterContent: after,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ })
|
|
|
+ canUndo.value = history.canUndo
|
|
|
+ canRedo.value = history.canRedo
|
|
|
+
|
|
|
nextTick(() => registry.focus(newBlock.id, "start"))
|
|
|
}
|
|
|
|
|
|
@@ -74,74 +257,276 @@ function onMergePrevious(index: number) {
|
|
|
const current = blocks.value[index]
|
|
|
const prev = blocks.value[index - 1]
|
|
|
if (!current || !prev) return
|
|
|
- prev.content = prev.content
|
|
|
- ? `${prev.content}\n${current.content}`
|
|
|
- : current.content
|
|
|
+
|
|
|
+ const prevContent = prev.content
|
|
|
+ const appendedContent = current.content
|
|
|
+ const removedBlockId = current.id
|
|
|
+ const removedBlockDepth = current.depth
|
|
|
+
|
|
|
+ // PM positions are 1-based within a paragraph: position 1 = before the
|
|
|
+ // first character, so landing after the last char of prev content requires
|
|
|
+ // prev.content.length + 1.
|
|
|
+ const joinPos = prevContent.length > 0 ? prevContent.length + 1 : 0
|
|
|
+ const mergedContent = prevContent
|
|
|
+ ? `${prevContent}${appendedContent}`
|
|
|
+ : appendedContent
|
|
|
+
|
|
|
+ // 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[index - 1]?.setContent(mergedContent)
|
|
|
+
|
|
|
blocks.value.splice(index, 1)
|
|
|
onBlockContentChange()
|
|
|
- nextTick(() => registry.focus(prev.id, "end"))
|
|
|
+
|
|
|
+ history.execute({
|
|
|
+ type: "merge-block",
|
|
|
+ index,
|
|
|
+ removedBlockId,
|
|
|
+ removedBlockDepth,
|
|
|
+ prevContent,
|
|
|
+ appendedContent,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ })
|
|
|
+ canUndo.value = history.canUndo
|
|
|
+ canRedo.value = history.canRedo
|
|
|
+
|
|
|
+ nextTick(() => registry.focus(prev.id, joinPos))
|
|
|
}
|
|
|
|
|
|
function onDeleteIfEmpty(index: number) {
|
|
|
if (blocks.value.length <= 1) return
|
|
|
+ const removedBlock = blocks.value[index]
|
|
|
const nextId =
|
|
|
index < blocks.value.length - 1
|
|
|
? blocks.value[index + 1]?.id
|
|
|
: blocks.value[index - 1]?.id
|
|
|
- if (!nextId) return
|
|
|
+ if (!nextId || !removedBlock) return
|
|
|
+ const removedContent = removedBlock.content
|
|
|
+ const removedDepth = removedBlock.depth
|
|
|
+ const removedId = removedBlock.id
|
|
|
+
|
|
|
blocks.value.splice(index, 1)
|
|
|
onBlockContentChange()
|
|
|
+
|
|
|
+ history.execute({
|
|
|
+ type: "delete-block",
|
|
|
+ index,
|
|
|
+ blockId: removedId,
|
|
|
+ content: removedContent,
|
|
|
+ depth: removedDepth,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ })
|
|
|
+ canUndo.value = history.canUndo
|
|
|
+ canRedo.value = history.canRedo
|
|
|
+
|
|
|
nextTick(() => registry.focus(nextId, "start"))
|
|
|
}
|
|
|
|
|
|
function onArrowUpFromStart(index: number) {
|
|
|
- if (index <= 0) return
|
|
|
+ // Boundary: focus the insertion zone before the first block.
|
|
|
+ if (index <= 0) {
|
|
|
+ focusZone(0)
|
|
|
+ return
|
|
|
+ }
|
|
|
const prev = blocks.value[index - 1]
|
|
|
if (!prev) return
|
|
|
registry.focus(prev.id, "end")
|
|
|
}
|
|
|
|
|
|
function onArrowDownFromEnd(index: number) {
|
|
|
- if (index >= blocks.value.length - 1) return
|
|
|
+ // Boundary: focus the insertion zone after the last block.
|
|
|
+ if (index >= blocks.value.length - 1) {
|
|
|
+ focusZone(blocks.value.length)
|
|
|
+ return
|
|
|
+ }
|
|
|
const next = blocks.value[index + 1]
|
|
|
if (!next) return
|
|
|
registry.focus(next.id, "start")
|
|
|
}
|
|
|
|
|
|
+function onBlockBoundaryExit(index: number, direction: "up" | "down") {
|
|
|
+ const zoneIndex = direction === "up" ? index : index + 1
|
|
|
+ log.info("onBlockBoundaryExit", { index, direction, zoneIndex })
|
|
|
+ focusZone(zoneIndex)
|
|
|
+}
|
|
|
+
|
|
|
+function getChildrenIndices(index: number): number[] {
|
|
|
+ const parentDepth = blocks.value[index]?.depth
|
|
|
+ if (parentDepth === undefined) return []
|
|
|
+ const children: number[] = []
|
|
|
+ for (let i = index + 1; i < blocks.value.length; i++) {
|
|
|
+ const b = blocks.value[i]
|
|
|
+ if (!b || b.depth <= parentDepth) break
|
|
|
+ children.push(i)
|
|
|
+ }
|
|
|
+ return children
|
|
|
+}
|
|
|
+
|
|
|
function onIndent(index: number) {
|
|
|
const block = blocks.value[index]
|
|
|
if (!block) return
|
|
|
- block.depth = Math.min(block.depth + 1, 10)
|
|
|
+ const delta = block.depth < 10 ? 1 : 0
|
|
|
+ if (delta === 0) return
|
|
|
+ const childIndices = getChildrenIndices(index)
|
|
|
+ const previousDepth = block.depth
|
|
|
+ const childEntries = childIndices.map((i) => ({
|
|
|
+ index: i,
|
|
|
+ previousDepth: blocks.value[i]?.depth ?? 0,
|
|
|
+ }))
|
|
|
+ const affected = [index, ...childIndices]
|
|
|
+ for (const i of affected) {
|
|
|
+ const b = blocks.value[i]
|
|
|
+ if (b) b.depth = Math.min(b.depth + delta, 10)
|
|
|
+ }
|
|
|
onBlockContentChange()
|
|
|
+
|
|
|
+ history.execute({
|
|
|
+ type: "indent-block",
|
|
|
+ index,
|
|
|
+ previousDepth,
|
|
|
+ childrenPreviousDepths: childEntries.length > 0 ? childEntries : undefined,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ })
|
|
|
+ canUndo.value = history.canUndo
|
|
|
+ canRedo.value = history.canRedo
|
|
|
}
|
|
|
|
|
|
function onOutdent(index: number) {
|
|
|
const block = blocks.value[index]
|
|
|
if (!block) return
|
|
|
- block.depth = Math.max(block.depth - 1, 0)
|
|
|
+ const delta = block.depth > 0 ? -1 : 0
|
|
|
+ if (delta === 0) return
|
|
|
+ const childIndices = getChildrenIndices(index)
|
|
|
+ const previousDepth = block.depth
|
|
|
+ const childEntries = childIndices.map((i) => ({
|
|
|
+ index: i,
|
|
|
+ previousDepth: blocks.value[i]?.depth ?? 0,
|
|
|
+ }))
|
|
|
+ const affected = [index, ...childIndices]
|
|
|
+ for (const i of affected) {
|
|
|
+ const b = blocks.value[i]
|
|
|
+ if (b) b.depth = Math.max(b.depth + delta, 0)
|
|
|
+ }
|
|
|
onBlockContentChange()
|
|
|
+
|
|
|
+ history.execute({
|
|
|
+ type: "outdent-block",
|
|
|
+ index,
|
|
|
+ previousDepth,
|
|
|
+ childrenPreviousDepths: childEntries.length > 0 ? childEntries : undefined,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ })
|
|
|
+ canUndo.value = history.canUndo
|
|
|
+ canRedo.value = history.canRedo
|
|
|
+}
|
|
|
+
|
|
|
+const focusedTarget = ref<FocusTarget | null>(null)
|
|
|
+
|
|
|
+watch(registry.focusedId, (id) => {
|
|
|
+ if (id !== null) {
|
|
|
+ focusedTarget.value = { kind: "block", id }
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+function onZoneFocus(index: number) {
|
|
|
+ focusedTarget.value = { kind: "zone", index }
|
|
|
+}
|
|
|
+
|
|
|
+function onZoneBlur() {
|
|
|
+ if (focusedTarget.value?.kind === "zone") {
|
|
|
+ focusedTarget.value = null
|
|
|
+ }
|
|
|
}
|
|
|
+
|
|
|
+function onZoneActivate(index: number, content: string) {
|
|
|
+ const newBlock: EditorBlock = {
|
|
|
+ id: generateBlockId(),
|
|
|
+ depth: 0,
|
|
|
+ content,
|
|
|
+ }
|
|
|
+ blocks.value.splice(index, 0, newBlock)
|
|
|
+ focusedTarget.value = null
|
|
|
+
|
|
|
+ history.execute({
|
|
|
+ type: "insert-block",
|
|
|
+ index,
|
|
|
+ blockId: newBlock.id,
|
|
|
+ content,
|
|
|
+ depth: 0,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ })
|
|
|
+ canUndo.value = history.canUndo
|
|
|
+ canRedo.value = history.canRedo
|
|
|
+
|
|
|
+ if (content) {
|
|
|
+ nextTick(() => registry.focus(newBlock.id, "end"))
|
|
|
+ } else {
|
|
|
+ nextTick(() => registry.focus(newBlock.id, "start"))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+defineExpose({ undo, redo, canUndo, canRedo })
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
- <div class="editor-shell">
|
|
|
- <Block
|
|
|
- v-for="(block, i) in blocks"
|
|
|
- :key="block.id"
|
|
|
- :id="block.id"
|
|
|
- v-model:content="block.content"
|
|
|
- :focused="focused && i === 0"
|
|
|
- :marker-mode="markerMode"
|
|
|
- :debug="debug"
|
|
|
- :registry="registry"
|
|
|
- @update:content="onBlockContentChange"
|
|
|
- @split="(before, after) => onSplit(i, before, after)"
|
|
|
- @merge-previous="onMergePrevious(i)"
|
|
|
- @delete-if-empty="onDeleteIfEmpty(i)"
|
|
|
- @indent="onIndent(i)"
|
|
|
- @outdent="onOutdent(i)"
|
|
|
- @arrow-up-from-start="onArrowUpFromStart(i)"
|
|
|
- @arrow-down-from-end="onArrowDownFromEnd(i)"
|
|
|
+ <div ref="editorShellRef" class="editor-shell" @keydown="onShellKeydown">
|
|
|
+ <div v-if="$slots.toolbar" class="editor-toolbar-wrapper">
|
|
|
+ <slot
|
|
|
+ name="toolbar"
|
|
|
+ :handlers="activeHandlers"
|
|
|
+ :selection-version="selectionVersion"
|
|
|
+ :can-undo="canUndo"
|
|
|
+ :can-redo="canRedo"
|
|
|
+ :undo="undo"
|
|
|
+ :redo="redo"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <template v-for="(block, i) in blocks" :key="block.id">
|
|
|
+ <InsertionZone
|
|
|
+ :ref="(el: any) => { if (el) zoneRefs[i] = el }"
|
|
|
+ @activate="(content) => onZoneActivate(i, content)"
|
|
|
+ @focus="onZoneFocus(i)"
|
|
|
+ @blur="onZoneBlur"
|
|
|
+ />
|
|
|
+ <div class="flex items-start">
|
|
|
+ <div
|
|
|
+ class="flex items-center justify-end h-7 select-none pointer-events-none"
|
|
|
+ :style="{ width: (block.depth + 1) * 20 + 'px' }"
|
|
|
+ aria-hidden="true"
|
|
|
+ >
|
|
|
+ <UIcon name="i-lucide-dot" class="size-6 text-muted" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Block
|
|
|
+ :ref="(el: any) => { if (el) blockRefs[i] = el }"
|
|
|
+ :id="block.id"
|
|
|
+ :on-boundary-exit="(dir) => onBlockBoundaryExit(i, dir)"
|
|
|
+ v-model:content="block.content"
|
|
|
+ :focused="focused && i === 0"
|
|
|
+ :marker-mode="markerMode"
|
|
|
+ :debug="debug"
|
|
|
+ :registry="registry"
|
|
|
+ class="flex-1 min-w-0"
|
|
|
+ @update:content="onBlockContentChange"
|
|
|
+ @split="(before, after) => onSplit(i, before, after)"
|
|
|
+ @merge-previous="onMergePrevious(i)"
|
|
|
+ @delete-if-empty="onDeleteIfEmpty(i)"
|
|
|
+ @indent="onIndent(i)"
|
|
|
+ @outdent="onOutdent(i)"
|
|
|
+ @arrow-up-from-start="onArrowUpFromStart(i)"
|
|
|
+ @arrow-down-from-end="onArrowDownFromEnd(i)"
|
|
|
+ @focus="onBlockFocusHandler"
|
|
|
+ @blur="onBlockBlurHandler"
|
|
|
+ @selection-change="onBlockSelectionChangeHandler"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <InsertionZone
|
|
|
+ :ref="(el: any) => { if (el) zoneRefs[blocks.length] = el }"
|
|
|
+ @activate="(content) => onZoneActivate(blocks.length, content)"
|
|
|
+ @focus="onZoneFocus(blocks.length)"
|
|
|
+ @blur="onZoneBlur"
|
|
|
/>
|
|
|
</div>
|
|
|
</template>
|