| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025 |
- <script setup lang="ts">
- import type { EditorView } from "prosemirror-view"
- import { TextSelection } from "prosemirror-state"
- import { computed, nextTick, onMounted, reactive, type Ref, ref, watch } from "vue"
- import EditorBlock from "@/components/EditorBlock.vue"
- import EditorInsertionZone from "@/components/EditorInsertionZone.vue"
- import {
- type FocusPosition,
- type FocusTarget,
- useFocusRegistry,
- } from "@/composables/useFocusRegistry"
- import {
- defaultKeyBinding,
- type KeyBinding,
- useKeyboard,
- } from "@/composables/useKeyboard"
- import type { MarkerVisibilityMode } from "@/composables/useMarkdownDecorations"
- import type {
- PatternClosePayload,
- PatternOpenPayload,
- PatternUpdatePayload,
- } from "@/composables/usePatternPlugin"
- import {
- type EditorBlockData,
- generateBlockId,
- serializeBlocks,
- splitMarkdownIntoBlocks,
- } from "@/lib/block-parser"
- import {
- moveBlock,
- deleteIfEmpty as opDeleteIfEmpty,
- indentBlock as opIndentBlock,
- insertBlock as opInsertBlock,
- mergePrevious as opMergePrevious,
- outdentBlock as opOutdentBlock,
- splitBlocks as opSplitBlocks,
- } from "@/lib/editor-operations"
- import type { FormattingHandlers } from "@/lib/formatting"
- import { inlineToString } from "@/lib/content-model"
- import { createLogger } from "@/lib/logger"
- import {
- createOperationHistory,
- type EditorOperation,
- type OperationHistory,
- } from "@/lib/operation-history"
- import { resolveTheme, type ThemeInput, themeToCSSVars } from "@/lib/theme"
- 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 }]
- error: [payload: { code: string; message?: string; blockId?: string }]
- "page-ref-clicked": [payload: { pageName: string; alias?: string }]
- "block-ref-clicked": [payload: { blockId: string }]
- "tag-clicked": [payload: { tag: string }]
- "asset-dropped": [payload: { file: File; pos: number }]
- }>()
- const props = withDefaults(
- defineProps<{
- focused?: boolean
- markerMode?: MarkerVisibilityMode
- debug?: string
- theme?: ThemeInput
- keyBinding?: KeyBinding
- extraPatterns?: import("@/composables/usePatternPlugin").PatternSpec[]
- resolvedRefs?: Set<string>
- resolvedRefsVersion?: number
- resolveAsset?: (file: File) => Promise<string>
- /** Enables window.__testUtils__ for Playwright E2E tests */
- testMode?: boolean
- }>(),
- {
- focused: false,
- },
- )
- const themeCSSVars = computed(() => {
- const resolved = resolveTheme(props.theme)
- return themeToCSSVars(resolved)
- })
- /**
- * Tracks the string last set by an internal content update. The watcher
- * compares content.value against this; if they match, the watcher was
- * triggered by us and the update is skipped. If they differ, the change
- * came from the parent (external) and the editor reloads.
- * Unlike a generation counter + nextTick approach, this has no race
- * window where an external update between set and nextTick can be
- * mistaken for internal.
- */
- let lastSerialized: string | undefined
- let isInitialized = false
- const blocks = ref<EditorBlockData[]>([])
- const liveAnnouncement = ref("")
- let announceTimer: ReturnType<typeof setTimeout> | null = null
- function announce(msg: string) {
- liveAnnouncement.value = msg
- if (announceTimer) clearTimeout(announceTimer)
- announceTimer = setTimeout(() => {
- liveAnnouncement.value = ""
- }, 2000)
- }
- const registry = useFocusRegistry()
- const log = createLogger("Editor", props.debug)
- const blockRefs = new Map<string, { setContent: (md: string) => void }>()
- 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.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<{
- fromIndex: number
- blockId: string
- content: string
- depth: number
- } | null>(null)
- const dropZoneIndex = ref<number | null>(null)
- function onDragStart(
- index: number,
- blockId: string,
- content: string,
- depth: number,
- event: DragEvent,
- ) {
- dragState.value = { fromIndex: index, blockId, content, depth }
- event.dataTransfer?.setData("text/plain", blockId)
- if (event.dataTransfer) {
- event.dataTransfer.effectAllowed = "move"
- }
- }
- function onDragOverZone(zoneIndex: number, event: DragEvent) {
- if (!dragState.value) {
- dropZoneIndex.value = null
- return
- }
- dropZoneIndex.value = zoneIndex
- event.dataTransfer!.dropEffect = "move"
- }
- function onDragLeaveZone(zoneIndex: number) {
- if (dropZoneIndex.value === zoneIndex) {
- dropZoneIndex.value = null
- }
- }
- function onDragEnd() {
- dragState.value = null
- dropZoneIndex.value = null
- }
- function onDropOnZone(zoneIndex: number) {
- const state = dragState.value
- if (!state) {
- dragState.value = null
- dropZoneIndex.value = null
- return
- }
- // 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
- if (state.fromIndex === targetIndex) {
- dragState.value = null
- dropZoneIndex.value = null
- return
- }
- blocks.value = moveBlock(blocks.value, state.fromIndex, targetIndex)
- void history.execute({
- type: "move-block",
- fromIndex: state.fromIndex,
- toIndex: targetIndex,
- blockId: state.blockId,
- content: state.content,
- depth: state.depth,
- timestamp: Date.now(),
- })
- canUndo.value = history.canUndo
- canRedo.value = history.canRedo
- zoneRefs.clear()
- onBlockContentChange()
- dragState.value = null
- dropZoneIndex.value = null
- }
- // ── Undo / Redo history ──────────────────────────────────────────
- const history: OperationHistory = createOperationHistory(100)
- const canUndo = 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 {
- zoneRefs.clear()
- let focusInfo: { id: string; pos: "start" | "end" | number } | null = null
- switch (op.type) {
- case "split-block": {
- const block = blocks.value[op.index]
- if (!block) return
- block.content = op.beforeContent
- syncBlockContent(block.id, op.beforeContent)
- blocks.value.splice(op.index + 1, 0, {
- id: op.splitBlockId,
- depth: op.depth,
- content: op.afterContent,
- })
- focusInfo = { id: op.splitBlockId, pos: "start" }
- break
- }
- case "merge-block": {
- const prev = blocks.value[op.index - 1]
- if (prev) {
- prev.content = op.prevContent + op.appendedContent
- syncBlockContent(prev.id, prev.content)
- }
- 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
- }
- case "insert-block": {
- blocks.value.splice(op.index, 0, {
- id: op.blockId,
- depth: op.depth,
- content: op.content,
- })
- focusInfo = { id: op.blockId, pos: "start" }
- break
- }
- case "delete-block": {
- 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
- }
- 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)
- }
- }
- // No focusInfo here — indent only changes depth metadata, not PM
- // content, so moving the cursor would be jarring.
- 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)
- }
- }
- // No focusInfo — same rationale as indent-block.
- break
- }
- case "set-block-content": {
- const setBlock = blocks.value.find(
- (b: EditorBlockData) => b.id === op.blockId,
- )
- if (setBlock) {
- setBlock.content = op.newContent
- syncBlockContent(setBlock.id, op.newContent)
- }
- if (setBlock) focusInfo = { id: setBlock.id, pos: "end" }
- break
- }
- case "move-block": {
- blocks.value = moveBlock(blocks.value, op.fromIndex, op.toIndex)
- break
- }
- }
- onBlockContentChange()
- if (focusInfo) {
- nextTick(() => registry.focus(focusInfo!.id, focusInfo!.pos))
- }
- }
- 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
- }
- /** 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
- ) {
- history.coalesce({
- type: "set-block-content",
- blockId: op.blockId,
- previousContent: last.previousContent,
- newContent: op.newContent,
- 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)
- }
- // 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) => {
- if (!isInitialized) {
- isInitialized = true
- parseContent(val)
- return
- }
- if (val === lastSerialized) return
- history.clear()
- canUndo.value = false
- canRedo.value = false
- parseContent(val)
- },
- { immediate: true },
- )
- // Serialize and emit when any block's content changes.
- function onBlockContentChange(blockId?: string, newContent?: string) {
- if (blockId && newContent) {
- const idx = blocks.value.findIndex((b) => b.id === blockId)
- if (idx >= 0) {
- blocks.value[idx] = { ...blocks.value[idx], content: newContent }
- }
- }
- lastSerialized = serializeBlocks(blocks.value)
- content.value = lastSerialized
- }
- // ── Active block state (toolbar binding) ──────────────────────────────
- const activeHandlers = ref<FormattingHandlers | null>(null)
- const selectionVersion = ref(0)
- function onBlockFocusHandler(payload: {
- view: EditorView
- handlers: FormattingHandlers
- }) {
- activeView.value = payload.view
- 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)
- }
- // ── First paragraph height tracking (bullet alignment) ────────────
- const blockHeights = reactive<Record<string, number>>({})
- function onFirstParaHeight(blockId: string, height: number) {
- blockHeights[blockId] = height
- }
- // ── Pattern session (used only for keyboard capture lifecycle) ─────
- const activeSession = ref<PatternOpenPayload | null>(null)
- const activeView: Ref<EditorView | null> = ref(null)
- useKeyboard({
- keybinding: props.keyBinding ?? defaultKeyBinding,
- activeSession,
- activeView,
- onUndo: undo,
- onRedo: redo,
- })
- function onPatternOpen(payload: PatternOpenPayload) {
- log.info("pattern-open", { kind: payload.kind, query: payload.query })
- activeSession.value = payload
- }
- function onPatternUpdate(payload: PatternUpdatePayload) {
- if (!activeSession.value) return
- activeSession.value = {
- ...activeSession.value,
- query: payload.query,
- position: payload.position,
- range: payload.range,
- }
- }
- function onPatternClose(_payload: PatternClosePayload) {
- log.info("pattern-close")
- activeSession.value = null
- }
- function onZoneNavigateUp(zoneIndex: number) {
- if (zoneIndex <= 0) return
- const prev = blocks.value[zoneIndex - 1]
- if (prev) registry.focus(prev.id, "end")
- }
- function onZoneNavigateDown(zoneIndex: number) {
- if (zoneIndex >= blocks.value.length) return
- const next = blocks.value[zoneIndex]
- if (next) registry.focus(next.id, "start")
- }
- // ── Block event handlers ──────────────────────────────────────────────
- function onSplit(index: number, before: string, after: string) {
- const result = opSplitBlocks(
- blocks.value,
- index,
- before,
- after,
- generateBlockId,
- )
- if (!result.newBlock) return
- announce("Block split")
- blocks.value = result.blocks
- zoneRefs.clear()
- onBlockContentChange()
- void history.execute({
- type: "split-block",
- index,
- splitBlockId: result.newBlock.id,
- depth: result.newBlock.depth,
- beforeContent: before,
- afterContent: after,
- timestamp: Date.now(),
- })
- canUndo.value = history.canUndo
- canRedo.value = history.canRedo
- nextTick(() => registry.focus(result.newBlock!.id, "start"))
- }
- function onMergePrevious(index: number) {
- const result = opMergePrevious(blocks.value, index)
- if (result.blocks.length >= blocks.value.length) return
- announce("Merged with previous block")
- const current = blocks.value[index]
- const prev = blocks.value[index - 1]
- if (!current || !prev) return
- const removedBlockId = current.id
- const removedBlockDepth = current.depth
- // 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.
- syncBlockContent(prev.id, 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
- zoneRefs.clear()
- onBlockContentChange()
- void history.execute({
- type: "merge-block",
- index,
- removedBlockId,
- removedBlockDepth,
- prevContent: prev.content,
- appendedContent: current.content,
- timestamp: Date.now(),
- })
- canUndo.value = history.canUndo
- canRedo.value = history.canRedo
- nextTick(() => registry.focus(prev.id, result.joinPos))
- }
- function onDeleteIfEmpty(index: number) {
- const result = opDeleteIfEmpty(blocks.value, index)
- if (!result.nextId || result.blocks.length >= blocks.value.length) return
- const removedBlock = blocks.value[index]
- if (!removedBlock) return
- announce("Block deleted")
- blocks.value = result.blocks
- zoneRefs.clear()
- onBlockContentChange()
- void history.execute({
- type: "delete-block",
- index,
- blockId: removedBlock.id,
- content: removedBlock.content,
- depth: removedBlock.depth,
- timestamp: Date.now(),
- })
- canUndo.value = history.canUndo
- canRedo.value = history.canRedo
- nextTick(() => registry.focus(result.nextId!, "start"))
- }
- function onArrowUpFromStart(index: number, leftOffset?: number) {
- // Boundary: focus the insertion zone before the first block.
- if (index <= 0) {
- focusZone(0)
- return
- }
- const prev = blocks.value[index - 1]
- if (!prev) return
- const target: FocusPosition =
- leftOffset !== undefined ? { left: leftOffset, edge: "bottom" } : "end"
- registry.focus(prev.id, target)
- }
- function onArrowDownFromEnd(index: number, leftOffset?: number) {
- // 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
- const target: FocusPosition =
- leftOffset !== undefined ? { left: leftOffset, edge: "top" } : "start"
- registry.focus(next.id, target)
- }
- function onBlockBoundaryExit(index: number, direction: "up" | "down") {
- log.info("onBlockBoundaryExit", { index, direction })
- if (direction === "up") {
- const prev = blocks.value[index - 1]
- if (prev) {
- registry.focus(prev.id, "end")
- return
- }
- } else {
- const next = blocks.value[index + 1]
- if (next) {
- registry.focus(next.id, "start")
- return
- }
- }
- // Fallback to insertion zone if no adjacent block
- const zoneIndex = direction === "up" ? index : index + 1
- focusZone(zoneIndex)
- }
- function onIndent(index: number) {
- const block = blocks.value[index]
- if (!block) return
- const result = opIndentBlock(blocks.value, index)
- if (result.childIndices.length === 0 && block.depth >= 10) return
- const previousDepth = block.depth
- const childEntries = result.childIndices.map((i) => ({
- index: i,
- previousDepth: blocks.value[i]?.depth ?? 0,
- }))
- blocks.value = result.blocks
- zoneRefs.clear()
- onBlockContentChange()
- void 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
- const result = opOutdentBlock(blocks.value, index)
- if (result.childIndices.length === 0 && block.depth <= 0) return
- const previousDepth = block.depth
- const childEntries = result.childIndices.map((i) => ({
- index: i,
- previousDepth: blocks.value[i]?.depth ?? 0,
- }))
- blocks.value = result.blocks
- zoneRefs.clear()
- onBlockContentChange()
- void 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) {
- focusedTarget.value = null
- 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 === undefined) continue
- 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++
- }
- zoneRefs.clear()
- onBlockContentChange()
- canUndo.value = history.canUndo
- canRedo.value = history.canRedo
- const lastInsertedId = insertedId
- if (lastInsertedId) {
- announce("Inserted block")
- nextTick(() => registry.focus(lastInsertedId, "end"))
- }
- } else {
- const result = opInsertBlock(
- blocks.value,
- index,
- content,
- 0,
- generateBlockId,
- )
- blocks.value = result.blocks
- zoneRefs.clear()
- 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"))
- }
- }
- }
- // ── E2E test mode (dev only) — injects window.__testUtils__ ───────
- // The test utilities access block internals directly via blockRefs and
- // blocks.value. In production (testMode=false), this entire block is
- // tree-shaken since the prop is never true.
- onMounted(() => {
- if (!props.testMode) return
- ;(window as any).__testUtils__ = {
- selectText(blockId: string, from: number, to: number) {
- const api = blockRefs.get(blockId) as any
- if (!api?.view) return
- const v = api.view as EditorView
- v.focus()
- // Map 0-based character offsets (as returned by docToContent) → PM document positions.
- // Uses inlineToString to account for hard_break nodes (textContent omits them).
- const doc = v.state.doc
- let charPos = 0
- let pmFrom: number | null = null
- let pmTo: number | null = null
- doc.descendants((node, pos) => {
- if (!node.isTextblock) return true
- const textLen = inlineToString(node).length
- const contentStart = pos + 1
- const charEnd = charPos + textLen
- if (pmFrom === null && from < charEnd) {
- pmFrom = contentStart + Math.max(0, from - charPos)
- }
- if (pmTo === null && to <= charEnd) {
- pmTo = contentStart + Math.max(0, to - charPos)
- }
- charPos = charEnd + 2 // docToContent joins with "\n\n"
- return pmFrom === null || pmTo === null
- })
- if (pmFrom === null) pmFrom = doc.content.size
- if (pmTo === null) pmTo = doc.content.size
- v.dispatch(
- v.state.tr.setSelection(
- TextSelection.create(doc, pmFrom, pmTo),
- ),
- )
- },
- getBlockIds() {
- return Array.from(blockRefs.keys())
- },
- insertText(blockId: string, text: string) {
- const api = blockRefs.get(blockId) as any
- if (!api?.view) return
- const v = api.view as EditorView
- v.focus()
- v.dispatch(v.state.tr.insertText(text))
- },
- /** Replace the block's entire content in a single PM transaction */
- replaceContent(blockId: string, text: string) {
- const api = blockRefs.get(blockId) as any
- if (!api?.setContent) {
- console.warn(
- `[Editor] replaceContent: block ${blockId} has no setContent method`,
- )
- return
- }
- api.setContent(text)
- },
- getContent() {
- return serializeBlocks(blocks.value)
- },
- focusAtEnd(blockId: string) {
- const api = blockRefs.get(blockId) as any
- if (!api?.view) return
- const v = api.view as EditorView
- const end = v.state.doc.content.size
- v.focus()
- v.dispatch(
- v.state.tr.setSelection(
- TextSelection.create(v.state.doc, end, end),
- ).scrollIntoView(),
- )
- },
- focusAtStart(blockId: string) {
- const api = blockRefs.get(blockId) as any
- if (!api?.view) return
- const v = api.view as EditorView
- v.focus()
- let start = 0
- v.state.doc.descendants((node, pos) => {
- if (node.isTextblock) {
- start = pos + 1
- return false
- }
- })
- v.dispatch(
- v.state.tr.setSelection(
- TextSelection.create(v.state.doc, start, start),
- ).scrollIntoView(),
- )
- },
- toggleBlur() {
- return new Promise<void>((resolve) => {
- const btn = document.createElement("button")
- btn.style.position = "fixed"
- btn.style.top = "0"
- btn.style.left = "0"
- document.body.appendChild(btn)
- btn.focus()
- requestAnimationFrame(() => {
- document.body.removeChild(btn)
- resolve()
- })
- })
- },
- getParseTree() {
- const firstApi = blockRefs.values().next().value as any
- if (!firstApi?.view) return ""
- return (firstApi.view as EditorView).state.doc.toString()
- },
- setTimeSource(fakeMs: number) {
- ;(window as any).__testTimeSource = fakeMs
- },
- }
- })
- defineExpose({
- undo,
- redo,
- canUndo,
- canRedo,
- getHistoryState: () => ({
- canUndo: history.canUndo,
- canRedo: history.canRedo,
- }),
- applyHistory: (op: EditorOperation) => {
- history.execute(op)
- canUndo.value = history.canUndo
- canRedo.value = history.canRedo
- },
- })
- </script>
- <template>
- <div class="editor-shell flex flex-col gap-4" :style="themeCSSVars">
- <div
- aria-live="polite"
- aria-atomic="true"
- class="sr-only"
- >{{ liveAnnouncement }}</div>
- <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>
- <div class="editor-body" @dragover.prevent>
- <template v-for="(block, i) in blocks" :key="block.id">
- <EditorInsertionZone
- :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)"
- @blur="onZoneBlur"
- @navigate-up="onZoneNavigateUp(i)"
- @navigate-down="onZoneNavigateDown(i)"
- @dragover.prevent="onDragOverZone(i, $event)"
- @dragleave="onDragLeaveZone(i)"
- @drop="onDropOnZone(i)"
- />
- <div
- class="flex items-start group/block"
- :data-block-index="i"
- :data-block-id="block.id"
- >
- <div
- class="flex items-center justify-end min-h-7 select-none"
- :style="{ height: blockHeights[block.id] ? blockHeights[block.id] + 'px' : undefined, width: (block.depth + 1) * 20 + 'px' }"
- >
- <div
- draggable="true"
- class="flex items-center justify-center cursor-grab active:cursor-grabbing rounded transition-all"
- data-drag-handle
- :class="dragState?.blockId === block.id ? 'size-7 text-(--ui-primary)' : 'size-6 text-(--ui-text-muted) group-hover/block:text-(--ui-primary)'"
- @dragstart="onDragStart(i, block.id, block.content, block.depth, $event)"
- @dragend="onDragEnd"
- >
- <UIcon name="i-ph-dot-duotone" class="size-6" />
- </div>
- </div>
- <EditorBlock
- :ref="(el: any) => { if (el) blockRefs.set(block.id, el); else blockRefs.delete(block.id) }"
- :id="block.id"
- :on-boundary-exit="(dir) => onBlockBoundaryExit(i, dir)"
- v-model:content="block.content"
- :focused="focused && i === 0"
- :marker-mode="markerMode"
- :depth="block.depth"
- :debug="debug"
- :registry="registry"
- :extra-patterns="extraPatterns"
- :resolved-refs="resolvedRefs"
- :resolved-refs-version="resolvedRefsVersion"
- :resolve-asset="resolveAsset"
- class="flex-1 min-w-0"
- @update:content="(val: string) => onBlockContentChange(block.id, val)"
- @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="(leftOffset) => onArrowUpFromStart(i, leftOffset)"
- @arrow-down-from-end="(leftOffset) => onArrowDownFromEnd(i, leftOffset)"
- @first-para-height="(h: number) => onFirstParaHeight(block.id, h)"
- @focus="onBlockFocusHandler"
- @blur="onBlockBlurHandler"
- @selection-change="onBlockSelectionChangeHandler"
- @pattern-open="onPatternOpen"
- @pattern-update="onPatternUpdate"
- @pattern-close="onPatternClose"
- @content-change-op="onBlockContentChangeOp"
- @error="emit('error', $event)"
- @page-ref-clicked="emit('page-ref-clicked', $event)"
- @block-ref-clicked="emit('block-ref-clicked', $event)"
- @tag-clicked="emit('tag-clicked', $event)"
- @asset-dropped="emit('asset-dropped', $event)"
- />
- </div>
- </template>
- <EditorInsertionZone
- :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)"
- @blur="onZoneBlur"
- @navigate-up="onZoneNavigateUp(blocks.length)"
- @navigate-down="onZoneNavigateDown(blocks.length)"
- @dragover.prevent="onDragOverZone(blocks.length, $event)"
- @dragleave="onDragLeaveZone(blocks.length)"
- @drop="onDropOnZone(blocks.length)"
- />
- </div>
- </div>
- </template>
|