Editor.vue 31 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025
  1. <script setup lang="ts">
  2. import type { EditorView } from "prosemirror-view"
  3. import { TextSelection } from "prosemirror-state"
  4. import { computed, nextTick, onMounted, reactive, type Ref, ref, watch } from "vue"
  5. import EditorBlock from "@/components/EditorBlock.vue"
  6. import EditorInsertionZone from "@/components/EditorInsertionZone.vue"
  7. import {
  8. type FocusPosition,
  9. type FocusTarget,
  10. useFocusRegistry,
  11. } from "@/composables/useFocusRegistry"
  12. import {
  13. defaultKeyBinding,
  14. type KeyBinding,
  15. useKeyboard,
  16. } from "@/composables/useKeyboard"
  17. import type { MarkerVisibilityMode } from "@/composables/useMarkdownDecorations"
  18. import type {
  19. PatternClosePayload,
  20. PatternOpenPayload,
  21. PatternUpdatePayload,
  22. } from "@/composables/usePatternPlugin"
  23. import {
  24. type EditorBlockData,
  25. generateBlockId,
  26. serializeBlocks,
  27. splitMarkdownIntoBlocks,
  28. } from "@/lib/block-parser"
  29. import {
  30. moveBlock,
  31. deleteIfEmpty as opDeleteIfEmpty,
  32. indentBlock as opIndentBlock,
  33. insertBlock as opInsertBlock,
  34. mergePrevious as opMergePrevious,
  35. outdentBlock as opOutdentBlock,
  36. splitBlocks as opSplitBlocks,
  37. } from "@/lib/editor-operations"
  38. import type { FormattingHandlers } from "@/lib/formatting"
  39. import { inlineToString } from "@/lib/content-model"
  40. import { createLogger } from "@/lib/logger"
  41. import {
  42. createOperationHistory,
  43. type EditorOperation,
  44. type OperationHistory,
  45. } from "@/lib/operation-history"
  46. import { resolveTheme, type ThemeInput, themeToCSSVars } from "@/lib/theme"
  47. const content = defineModel<string>("content", { required: false })
  48. const emit = defineEmits<{
  49. focus: [payload: { view: EditorView; handlers: FormattingHandlers }]
  50. blur: []
  51. "selection-change": [payload: { from: number; to: number; empty: boolean }]
  52. error: [payload: { code: string; message?: string; blockId?: string }]
  53. "page-ref-clicked": [payload: { pageName: string; alias?: string }]
  54. "block-ref-clicked": [payload: { blockId: string }]
  55. "tag-clicked": [payload: { tag: string }]
  56. "asset-dropped": [payload: { file: File; pos: number }]
  57. }>()
  58. const props = withDefaults(
  59. defineProps<{
  60. focused?: boolean
  61. markerMode?: MarkerVisibilityMode
  62. debug?: string
  63. theme?: ThemeInput
  64. keyBinding?: KeyBinding
  65. extraPatterns?: import("@/composables/usePatternPlugin").PatternSpec[]
  66. resolvedRefs?: Set<string>
  67. resolvedRefsVersion?: number
  68. resolveAsset?: (file: File) => Promise<string>
  69. /** Enables window.__testUtils__ for Playwright E2E tests */
  70. testMode?: boolean
  71. }>(),
  72. {
  73. focused: false,
  74. },
  75. )
  76. const themeCSSVars = computed(() => {
  77. const resolved = resolveTheme(props.theme)
  78. return themeToCSSVars(resolved)
  79. })
  80. /**
  81. * Tracks the string last set by an internal content update. The watcher
  82. * compares content.value against this; if they match, the watcher was
  83. * triggered by us and the update is skipped. If they differ, the change
  84. * came from the parent (external) and the editor reloads.
  85. * Unlike a generation counter + nextTick approach, this has no race
  86. * window where an external update between set and nextTick can be
  87. * mistaken for internal.
  88. */
  89. let lastSerialized: string | undefined
  90. let isInitialized = false
  91. const blocks = ref<EditorBlockData[]>([])
  92. const liveAnnouncement = ref("")
  93. let announceTimer: ReturnType<typeof setTimeout> | null = null
  94. function announce(msg: string) {
  95. liveAnnouncement.value = msg
  96. if (announceTimer) clearTimeout(announceTimer)
  97. announceTimer = setTimeout(() => {
  98. liveAnnouncement.value = ""
  99. }, 2000)
  100. }
  101. const registry = useFocusRegistry()
  102. const log = createLogger("Editor", props.debug)
  103. const blockRefs = new Map<string, { setContent: (md: string) => void }>()
  104. const zoneRefs = new Map<number, HTMLElement>()
  105. /**
  106. * Focus the insertion zone at the given index. Used for keyboard
  107. * navigation when the cursor exits the first or last block.
  108. */
  109. function focusZone(index: number) {
  110. const zone = zoneRefs.get(index)
  111. if (!zone) return
  112. zone.focus()
  113. }
  114. /**
  115. * Push content into a block's ProseMirror view, but only if the block
  116. * still exists in the model. Without this guard, an async content
  117. * change that resolves after the block is removed could schedule a
  118. * doc replacement in a component about to be unmounted.
  119. */
  120. function syncBlockContent(id: string, content: string) {
  121. if (!blocks.value.some((b) => b.id === id)) return
  122. blockRefs.get(id)?.setContent(content)
  123. }
  124. // ── Drag-to-reorder ────────────────────────────────────────────────
  125. const dragState = ref<{
  126. fromIndex: number
  127. blockId: string
  128. content: string
  129. depth: number
  130. } | null>(null)
  131. const dropZoneIndex = ref<number | null>(null)
  132. function onDragStart(
  133. index: number,
  134. blockId: string,
  135. content: string,
  136. depth: number,
  137. event: DragEvent,
  138. ) {
  139. dragState.value = { fromIndex: index, blockId, content, depth }
  140. event.dataTransfer?.setData("text/plain", blockId)
  141. if (event.dataTransfer) {
  142. event.dataTransfer.effectAllowed = "move"
  143. }
  144. }
  145. function onDragOverZone(zoneIndex: number, event: DragEvent) {
  146. if (!dragState.value) {
  147. dropZoneIndex.value = null
  148. return
  149. }
  150. dropZoneIndex.value = zoneIndex
  151. event.dataTransfer!.dropEffect = "move"
  152. }
  153. function onDragLeaveZone(zoneIndex: number) {
  154. if (dropZoneIndex.value === zoneIndex) {
  155. dropZoneIndex.value = null
  156. }
  157. }
  158. function onDragEnd() {
  159. dragState.value = null
  160. dropZoneIndex.value = null
  161. }
  162. function onDropOnZone(zoneIndex: number) {
  163. const state = dragState.value
  164. if (!state) {
  165. dragState.value = null
  166. dropZoneIndex.value = null
  167. return
  168. }
  169. // Zone i = insert before block i. In post-removal coordinates:
  170. // if fromIndex < zoneIndex, the zone shifts left by 1.
  171. const targetIndex = state.fromIndex < zoneIndex ? zoneIndex - 1 : zoneIndex
  172. if (state.fromIndex === targetIndex) {
  173. dragState.value = null
  174. dropZoneIndex.value = null
  175. return
  176. }
  177. blocks.value = moveBlock(blocks.value, state.fromIndex, targetIndex)
  178. void history.execute({
  179. type: "move-block",
  180. fromIndex: state.fromIndex,
  181. toIndex: targetIndex,
  182. blockId: state.blockId,
  183. content: state.content,
  184. depth: state.depth,
  185. timestamp: Date.now(),
  186. })
  187. canUndo.value = history.canUndo
  188. canRedo.value = history.canRedo
  189. zoneRefs.clear()
  190. onBlockContentChange()
  191. dragState.value = null
  192. dropZoneIndex.value = null
  193. }
  194. // ── Undo / Redo history ──────────────────────────────────────────
  195. const history: OperationHistory = createOperationHistory(100)
  196. const canUndo = ref(false)
  197. const canRedo = ref(false)
  198. // Applies an operation's model mutation and syncs PM views. Does NOT
  199. // update canUndo/canRedo — that's the caller's responsibility (undo/redo
  200. // update the flags afterward; direct operation callsites do so inline).
  201. function applyOperation(op: EditorOperation): void {
  202. zoneRefs.clear()
  203. let focusInfo: { id: string; pos: "start" | "end" | number } | null = null
  204. switch (op.type) {
  205. case "split-block": {
  206. const block = blocks.value[op.index]
  207. if (!block) return
  208. block.content = op.beforeContent
  209. syncBlockContent(block.id, op.beforeContent)
  210. blocks.value.splice(op.index + 1, 0, {
  211. id: op.splitBlockId,
  212. depth: op.depth,
  213. content: op.afterContent,
  214. })
  215. focusInfo = { id: op.splitBlockId, pos: "start" }
  216. break
  217. }
  218. case "merge-block": {
  219. const prev = blocks.value[op.index - 1]
  220. if (prev) {
  221. prev.content = op.prevContent + op.appendedContent
  222. syncBlockContent(prev.id, prev.content)
  223. }
  224. blocks.value.splice(op.index, 1)
  225. // Cursor lands at the join point: PM's 1-indexed position after
  226. // the last character of the pre-merge content.
  227. if (prev) focusInfo = { id: prev.id, pos: op.prevContent.length + 1 }
  228. break
  229. }
  230. case "insert-block": {
  231. blocks.value.splice(op.index, 0, {
  232. id: op.blockId,
  233. depth: op.depth,
  234. content: op.content,
  235. })
  236. focusInfo = { id: op.blockId, pos: "start" }
  237. break
  238. }
  239. case "delete-block": {
  240. blocks.value.splice(op.index, 1)
  241. const target = blocks.value[op.index] ?? blocks.value[op.index - 1]
  242. if (target) focusInfo = { id: target.id, pos: "start" }
  243. break
  244. }
  245. case "indent-block": {
  246. const indentBlock = blocks.value[op.index]
  247. if (indentBlock) indentBlock.depth = Math.min(op.previousDepth + 1, 10)
  248. if (op.childrenPreviousDepths) {
  249. for (const c of op.childrenPreviousDepths) {
  250. const child = blocks.value[c.index]
  251. if (child) child.depth = Math.min(c.previousDepth + 1, 10)
  252. }
  253. }
  254. // No focusInfo here — indent only changes depth metadata, not PM
  255. // content, so moving the cursor would be jarring.
  256. break
  257. }
  258. case "outdent-block": {
  259. const outdentBlock = blocks.value[op.index]
  260. if (outdentBlock) outdentBlock.depth = Math.max(op.previousDepth - 1, 0)
  261. if (op.childrenPreviousDepths) {
  262. for (const c of op.childrenPreviousDepths) {
  263. const child = blocks.value[c.index]
  264. if (child) child.depth = Math.max(c.previousDepth - 1, 0)
  265. }
  266. }
  267. // No focusInfo — same rationale as indent-block.
  268. break
  269. }
  270. case "set-block-content": {
  271. const setBlock = blocks.value.find(
  272. (b: EditorBlockData) => b.id === op.blockId,
  273. )
  274. if (setBlock) {
  275. setBlock.content = op.newContent
  276. syncBlockContent(setBlock.id, op.newContent)
  277. }
  278. if (setBlock) focusInfo = { id: setBlock.id, pos: "end" }
  279. break
  280. }
  281. case "move-block": {
  282. blocks.value = moveBlock(blocks.value, op.fromIndex, op.toIndex)
  283. break
  284. }
  285. }
  286. onBlockContentChange()
  287. if (focusInfo) {
  288. nextTick(() => registry.focus(focusInfo!.id, focusInfo!.pos))
  289. }
  290. }
  291. function undo() {
  292. const inverse = history.undo()
  293. if (!inverse) return
  294. applyOperation(inverse)
  295. canUndo.value = history.canUndo
  296. canRedo.value = history.canRedo
  297. }
  298. function redo() {
  299. const op = history.redo()
  300. if (!op) return
  301. applyOperation(op)
  302. canUndo.value = history.canUndo
  303. canRedo.value = history.canRedo
  304. }
  305. /** Coalescing window for SetBlockContentOp — mirrors PM's newGroupDelay. */
  306. const COALESCE_MS = 500
  307. function onBlockContentChangeOp(op: {
  308. blockId: string
  309. previousContent: string
  310. newContent: string
  311. timestamp: number
  312. }) {
  313. const last = history.peek()
  314. if (
  315. last?.type === "set-block-content" &&
  316. last.blockId === op.blockId &&
  317. op.timestamp - last.timestamp < COALESCE_MS
  318. ) {
  319. history.coalesce({
  320. type: "set-block-content",
  321. blockId: op.blockId,
  322. previousContent: last.previousContent,
  323. newContent: op.newContent,
  324. timestamp: op.timestamp,
  325. })
  326. } else {
  327. void history.execute({
  328. type: "set-block-content",
  329. blockId: op.blockId,
  330. previousContent: op.previousContent,
  331. newContent: op.newContent,
  332. timestamp: op.timestamp,
  333. })
  334. }
  335. canUndo.value = history.canUndo
  336. canRedo.value = history.canRedo
  337. }
  338. function parseContent(md: string | undefined) {
  339. blocks.value = splitMarkdownIntoBlocks(md || "", undefined, blocks.value)
  340. }
  341. // Sync: parent content → blocks (external updates only).
  342. // External content replacement invalidates all history entries
  343. // (they reference stale indices/block IDs), so clear the undo stacks.
  344. watch(
  345. () => content.value,
  346. (val) => {
  347. if (!isInitialized) {
  348. isInitialized = true
  349. parseContent(val)
  350. return
  351. }
  352. if (val === lastSerialized) return
  353. history.clear()
  354. canUndo.value = false
  355. canRedo.value = false
  356. parseContent(val)
  357. },
  358. { immediate: true },
  359. )
  360. // Serialize and emit when any block's content changes.
  361. function onBlockContentChange(blockId?: string, newContent?: string) {
  362. if (blockId && newContent) {
  363. const idx = blocks.value.findIndex((b) => b.id === blockId)
  364. if (idx >= 0) {
  365. blocks.value[idx] = { ...blocks.value[idx], content: newContent }
  366. }
  367. }
  368. lastSerialized = serializeBlocks(blocks.value)
  369. content.value = lastSerialized
  370. }
  371. // ── Active block state (toolbar binding) ──────────────────────────────
  372. const activeHandlers = ref<FormattingHandlers | null>(null)
  373. const selectionVersion = ref(0)
  374. function onBlockFocusHandler(payload: {
  375. view: EditorView
  376. handlers: FormattingHandlers
  377. }) {
  378. activeView.value = payload.view
  379. activeHandlers.value = payload.handlers
  380. emit("focus", payload)
  381. }
  382. function onBlockBlurHandler() {
  383. emit("blur")
  384. }
  385. function onBlockSelectionChangeHandler(payload: {
  386. from: number
  387. to: number
  388. empty: boolean
  389. }) {
  390. selectionVersion.value++
  391. emit("selection-change", payload)
  392. }
  393. // ── First paragraph height tracking (bullet alignment) ────────────
  394. const blockHeights = reactive<Record<string, number>>({})
  395. function onFirstParaHeight(blockId: string, height: number) {
  396. blockHeights[blockId] = height
  397. }
  398. // ── Pattern session (used only for keyboard capture lifecycle) ─────
  399. const activeSession = ref<PatternOpenPayload | null>(null)
  400. const activeView: Ref<EditorView | null> = ref(null)
  401. useKeyboard({
  402. keybinding: props.keyBinding ?? defaultKeyBinding,
  403. activeSession,
  404. activeView,
  405. onUndo: undo,
  406. onRedo: redo,
  407. })
  408. function onPatternOpen(payload: PatternOpenPayload) {
  409. log.info("pattern-open", { kind: payload.kind, query: payload.query })
  410. activeSession.value = payload
  411. }
  412. function onPatternUpdate(payload: PatternUpdatePayload) {
  413. if (!activeSession.value) return
  414. activeSession.value = {
  415. ...activeSession.value,
  416. query: payload.query,
  417. position: payload.position,
  418. range: payload.range,
  419. }
  420. }
  421. function onPatternClose(_payload: PatternClosePayload) {
  422. log.info("pattern-close")
  423. activeSession.value = null
  424. }
  425. function onZoneNavigateUp(zoneIndex: number) {
  426. if (zoneIndex <= 0) return
  427. const prev = blocks.value[zoneIndex - 1]
  428. if (prev) registry.focus(prev.id, "end")
  429. }
  430. function onZoneNavigateDown(zoneIndex: number) {
  431. if (zoneIndex >= blocks.value.length) return
  432. const next = blocks.value[zoneIndex]
  433. if (next) registry.focus(next.id, "start")
  434. }
  435. // ── Block event handlers ──────────────────────────────────────────────
  436. function onSplit(index: number, before: string, after: string) {
  437. const result = opSplitBlocks(
  438. blocks.value,
  439. index,
  440. before,
  441. after,
  442. generateBlockId,
  443. )
  444. if (!result.newBlock) return
  445. announce("Block split")
  446. blocks.value = result.blocks
  447. zoneRefs.clear()
  448. onBlockContentChange()
  449. void history.execute({
  450. type: "split-block",
  451. index,
  452. splitBlockId: result.newBlock.id,
  453. depth: result.newBlock.depth,
  454. beforeContent: before,
  455. afterContent: after,
  456. timestamp: Date.now(),
  457. })
  458. canUndo.value = history.canUndo
  459. canRedo.value = history.canRedo
  460. nextTick(() => registry.focus(result.newBlock!.id, "start"))
  461. }
  462. function onMergePrevious(index: number) {
  463. const result = opMergePrevious(blocks.value, index)
  464. if (result.blocks.length >= blocks.value.length) return
  465. announce("Merged with previous block")
  466. const current = blocks.value[index]
  467. const prev = blocks.value[index - 1]
  468. if (!current || !prev) return
  469. const removedBlockId = current.id
  470. const removedBlockDepth = current.depth
  471. // Directly update the prev block's PM + model so the debounced watch
  472. // (which fires ~50ms later) sees currentContent === newContent and is a
  473. // no-op — preventing a cursor-resetting doc replacement.
  474. syncBlockContent(prev.id, result.mergedContent)
  475. // Defensively ensure the result block's content matches mergedContent,
  476. // even if mergePrevious's implementation evolves. Without this, the new
  477. // block objects in result.blocks might have stale content, causing the
  478. // debounced watch in Block.vue to re-apply the old value and lose state.
  479. const prevIndex = result.blocks.findIndex((b) => b.id === prev.id)
  480. if (prevIndex !== -1) result.blocks[prevIndex]!.content = result.mergedContent
  481. blocks.value = result.blocks
  482. zoneRefs.clear()
  483. onBlockContentChange()
  484. void history.execute({
  485. type: "merge-block",
  486. index,
  487. removedBlockId,
  488. removedBlockDepth,
  489. prevContent: prev.content,
  490. appendedContent: current.content,
  491. timestamp: Date.now(),
  492. })
  493. canUndo.value = history.canUndo
  494. canRedo.value = history.canRedo
  495. nextTick(() => registry.focus(prev.id, result.joinPos))
  496. }
  497. function onDeleteIfEmpty(index: number) {
  498. const result = opDeleteIfEmpty(blocks.value, index)
  499. if (!result.nextId || result.blocks.length >= blocks.value.length) return
  500. const removedBlock = blocks.value[index]
  501. if (!removedBlock) return
  502. announce("Block deleted")
  503. blocks.value = result.blocks
  504. zoneRefs.clear()
  505. onBlockContentChange()
  506. void history.execute({
  507. type: "delete-block",
  508. index,
  509. blockId: removedBlock.id,
  510. content: removedBlock.content,
  511. depth: removedBlock.depth,
  512. timestamp: Date.now(),
  513. })
  514. canUndo.value = history.canUndo
  515. canRedo.value = history.canRedo
  516. nextTick(() => registry.focus(result.nextId!, "start"))
  517. }
  518. function onArrowUpFromStart(index: number, leftOffset?: number) {
  519. // Boundary: focus the insertion zone before the first block.
  520. if (index <= 0) {
  521. focusZone(0)
  522. return
  523. }
  524. const prev = blocks.value[index - 1]
  525. if (!prev) return
  526. const target: FocusPosition =
  527. leftOffset !== undefined ? { left: leftOffset, edge: "bottom" } : "end"
  528. registry.focus(prev.id, target)
  529. }
  530. function onArrowDownFromEnd(index: number, leftOffset?: number) {
  531. // Boundary: focus the insertion zone after the last block.
  532. if (index >= blocks.value.length - 1) {
  533. focusZone(blocks.value.length)
  534. return
  535. }
  536. const next = blocks.value[index + 1]
  537. if (!next) return
  538. const target: FocusPosition =
  539. leftOffset !== undefined ? { left: leftOffset, edge: "top" } : "start"
  540. registry.focus(next.id, target)
  541. }
  542. function onBlockBoundaryExit(index: number, direction: "up" | "down") {
  543. log.info("onBlockBoundaryExit", { index, direction })
  544. if (direction === "up") {
  545. const prev = blocks.value[index - 1]
  546. if (prev) {
  547. registry.focus(prev.id, "end")
  548. return
  549. }
  550. } else {
  551. const next = blocks.value[index + 1]
  552. if (next) {
  553. registry.focus(next.id, "start")
  554. return
  555. }
  556. }
  557. // Fallback to insertion zone if no adjacent block
  558. const zoneIndex = direction === "up" ? index : index + 1
  559. focusZone(zoneIndex)
  560. }
  561. function onIndent(index: number) {
  562. const block = blocks.value[index]
  563. if (!block) return
  564. const result = opIndentBlock(blocks.value, index)
  565. if (result.childIndices.length === 0 && block.depth >= 10) return
  566. const previousDepth = block.depth
  567. const childEntries = result.childIndices.map((i) => ({
  568. index: i,
  569. previousDepth: blocks.value[i]?.depth ?? 0,
  570. }))
  571. blocks.value = result.blocks
  572. zoneRefs.clear()
  573. onBlockContentChange()
  574. void history.execute({
  575. type: "indent-block",
  576. index,
  577. previousDepth,
  578. childrenPreviousDepths: childEntries.length > 0 ? childEntries : undefined,
  579. timestamp: Date.now(),
  580. })
  581. canUndo.value = history.canUndo
  582. canRedo.value = history.canRedo
  583. }
  584. function onOutdent(index: number) {
  585. const block = blocks.value[index]
  586. if (!block) return
  587. const result = opOutdentBlock(blocks.value, index)
  588. if (result.childIndices.length === 0 && block.depth <= 0) return
  589. const previousDepth = block.depth
  590. const childEntries = result.childIndices.map((i) => ({
  591. index: i,
  592. previousDepth: blocks.value[i]?.depth ?? 0,
  593. }))
  594. blocks.value = result.blocks
  595. zoneRefs.clear()
  596. onBlockContentChange()
  597. void history.execute({
  598. type: "outdent-block",
  599. index,
  600. previousDepth,
  601. childrenPreviousDepths: childEntries.length > 0 ? childEntries : undefined,
  602. timestamp: Date.now(),
  603. })
  604. canUndo.value = history.canUndo
  605. canRedo.value = history.canRedo
  606. }
  607. const focusedTarget = ref<FocusTarget | null>(null)
  608. watch(registry.focusedId, (id) => {
  609. if (id !== null) {
  610. focusedTarget.value = { kind: "block", id }
  611. }
  612. })
  613. function onZoneFocus(index: number) {
  614. focusedTarget.value = { kind: "zone", index }
  615. }
  616. function onZoneBlur() {
  617. if (focusedTarget.value?.kind === "zone") {
  618. focusedTarget.value = null
  619. }
  620. }
  621. function onZoneActivate(index: number, content: string) {
  622. focusedTarget.value = null
  623. const lines = content.split("\n")
  624. if (lines.length > 1) {
  625. // Multi-line paste: create a block for each non-empty line.
  626. // Only the first insert is recorded in history — undoing a paste should
  627. // remove all inserted blocks at once, not step through them one by one.
  628. // TODO: Replace with a batch operation type when the history model
  629. // supports atomic multi-op undo.
  630. let insertedId: string | undefined
  631. let insertIndex = index
  632. let firstInsertRecorded = false
  633. for (let i = 0; i < lines.length; i++) {
  634. const line = lines[i]
  635. if (line === undefined) continue
  636. if (line === "" && i < lines.length - 1) continue
  637. const result = opInsertBlock(
  638. blocks.value,
  639. insertIndex,
  640. line,
  641. 0,
  642. generateBlockId,
  643. )
  644. blocks.value = result.blocks
  645. insertedId = result.newBlock.id
  646. if (!firstInsertRecorded) {
  647. void history.execute({
  648. type: "insert-block",
  649. index: insertIndex,
  650. blockId: result.newBlock.id,
  651. content: line,
  652. depth: 0,
  653. timestamp: Date.now(),
  654. })
  655. firstInsertRecorded = true
  656. }
  657. insertIndex++
  658. }
  659. zoneRefs.clear()
  660. onBlockContentChange()
  661. canUndo.value = history.canUndo
  662. canRedo.value = history.canRedo
  663. const lastInsertedId = insertedId
  664. if (lastInsertedId) {
  665. announce("Inserted block")
  666. nextTick(() => registry.focus(lastInsertedId, "end"))
  667. }
  668. } else {
  669. const result = opInsertBlock(
  670. blocks.value,
  671. index,
  672. content,
  673. 0,
  674. generateBlockId,
  675. )
  676. blocks.value = result.blocks
  677. zoneRefs.clear()
  678. onBlockContentChange()
  679. void history.execute({
  680. type: "insert-block",
  681. index,
  682. blockId: result.newBlock.id,
  683. content,
  684. depth: 0,
  685. timestamp: Date.now(),
  686. })
  687. canUndo.value = history.canUndo
  688. canRedo.value = history.canRedo
  689. if (content) {
  690. nextTick(() => registry.focus(result.newBlock.id, "end"))
  691. } else {
  692. nextTick(() => registry.focus(result.newBlock.id, "start"))
  693. }
  694. }
  695. }
  696. // ── E2E test mode (dev only) — injects window.__testUtils__ ───────
  697. // The test utilities access block internals directly via blockRefs and
  698. // blocks.value. In production (testMode=false), this entire block is
  699. // tree-shaken since the prop is never true.
  700. onMounted(() => {
  701. if (!props.testMode) return
  702. ;(window as any).__testUtils__ = {
  703. selectText(blockId: string, from: number, to: number) {
  704. const api = blockRefs.get(blockId) as any
  705. if (!api?.view) return
  706. const v = api.view as EditorView
  707. v.focus()
  708. // Map 0-based character offsets (as returned by docToContent) → PM document positions.
  709. // Uses inlineToString to account for hard_break nodes (textContent omits them).
  710. const doc = v.state.doc
  711. let charPos = 0
  712. let pmFrom: number | null = null
  713. let pmTo: number | null = null
  714. doc.descendants((node, pos) => {
  715. if (!node.isTextblock) return true
  716. const textLen = inlineToString(node).length
  717. const contentStart = pos + 1
  718. const charEnd = charPos + textLen
  719. if (pmFrom === null && from < charEnd) {
  720. pmFrom = contentStart + Math.max(0, from - charPos)
  721. }
  722. if (pmTo === null && to <= charEnd) {
  723. pmTo = contentStart + Math.max(0, to - charPos)
  724. }
  725. charPos = charEnd + 2 // docToContent joins with "\n\n"
  726. return pmFrom === null || pmTo === null
  727. })
  728. if (pmFrom === null) pmFrom = doc.content.size
  729. if (pmTo === null) pmTo = doc.content.size
  730. v.dispatch(
  731. v.state.tr.setSelection(
  732. TextSelection.create(doc, pmFrom, pmTo),
  733. ),
  734. )
  735. },
  736. getBlockIds() {
  737. return Array.from(blockRefs.keys())
  738. },
  739. insertText(blockId: string, text: string) {
  740. const api = blockRefs.get(blockId) as any
  741. if (!api?.view) return
  742. const v = api.view as EditorView
  743. v.focus()
  744. v.dispatch(v.state.tr.insertText(text))
  745. },
  746. /** Replace the block's entire content in a single PM transaction */
  747. replaceContent(blockId: string, text: string) {
  748. const api = blockRefs.get(blockId) as any
  749. if (!api?.setContent) {
  750. console.warn(
  751. `[Editor] replaceContent: block ${blockId} has no setContent method`,
  752. )
  753. return
  754. }
  755. api.setContent(text)
  756. },
  757. getContent() {
  758. return serializeBlocks(blocks.value)
  759. },
  760. focusAtEnd(blockId: string) {
  761. const api = blockRefs.get(blockId) as any
  762. if (!api?.view) return
  763. const v = api.view as EditorView
  764. const end = v.state.doc.content.size
  765. v.focus()
  766. v.dispatch(
  767. v.state.tr.setSelection(
  768. TextSelection.create(v.state.doc, end, end),
  769. ).scrollIntoView(),
  770. )
  771. },
  772. focusAtStart(blockId: string) {
  773. const api = blockRefs.get(blockId) as any
  774. if (!api?.view) return
  775. const v = api.view as EditorView
  776. v.focus()
  777. let start = 0
  778. v.state.doc.descendants((node, pos) => {
  779. if (node.isTextblock) {
  780. start = pos + 1
  781. return false
  782. }
  783. })
  784. v.dispatch(
  785. v.state.tr.setSelection(
  786. TextSelection.create(v.state.doc, start, start),
  787. ).scrollIntoView(),
  788. )
  789. },
  790. toggleBlur() {
  791. return new Promise<void>((resolve) => {
  792. const btn = document.createElement("button")
  793. btn.style.position = "fixed"
  794. btn.style.top = "0"
  795. btn.style.left = "0"
  796. document.body.appendChild(btn)
  797. btn.focus()
  798. requestAnimationFrame(() => {
  799. document.body.removeChild(btn)
  800. resolve()
  801. })
  802. })
  803. },
  804. getParseTree() {
  805. const firstApi = blockRefs.values().next().value as any
  806. if (!firstApi?.view) return ""
  807. return (firstApi.view as EditorView).state.doc.toString()
  808. },
  809. setTimeSource(fakeMs: number) {
  810. ;(window as any).__testTimeSource = fakeMs
  811. },
  812. }
  813. })
  814. defineExpose({
  815. undo,
  816. redo,
  817. canUndo,
  818. canRedo,
  819. getHistoryState: () => ({
  820. canUndo: history.canUndo,
  821. canRedo: history.canRedo,
  822. }),
  823. applyHistory: (op: EditorOperation) => {
  824. history.execute(op)
  825. canUndo.value = history.canUndo
  826. canRedo.value = history.canRedo
  827. },
  828. })
  829. </script>
  830. <template>
  831. <div class="editor-shell flex flex-col gap-4" :style="themeCSSVars">
  832. <div
  833. aria-live="polite"
  834. aria-atomic="true"
  835. class="sr-only"
  836. >{{ liveAnnouncement }}</div>
  837. <div v-if="$slots.toolbar" class="editor-toolbar-wrapper">
  838. <slot
  839. name="toolbar"
  840. :handlers="activeHandlers"
  841. :selection-version="selectionVersion"
  842. :can-undo="canUndo"
  843. :can-redo="canRedo"
  844. :undo="undo"
  845. :redo="redo"
  846. />
  847. </div>
  848. <div class="editor-body" @dragover.prevent>
  849. <template v-for="(block, i) in blocks" :key="block.id">
  850. <EditorInsertionZone
  851. :ref="(el: any) => { if (el) zoneRefs.set(i, el); else zoneRefs.delete(i) }"
  852. :class="dropZoneIndex === i ? 'bg-(--ui-primary) rounded' : ''"
  853. @activate="(content: string) => onZoneActivate(i, content)"
  854. @focus="onZoneFocus(i)"
  855. @blur="onZoneBlur"
  856. @navigate-up="onZoneNavigateUp(i)"
  857. @navigate-down="onZoneNavigateDown(i)"
  858. @dragover.prevent="onDragOverZone(i, $event)"
  859. @dragleave="onDragLeaveZone(i)"
  860. @drop="onDropOnZone(i)"
  861. />
  862. <div
  863. class="flex items-start group/block"
  864. :data-block-index="i"
  865. :data-block-id="block.id"
  866. >
  867. <div
  868. class="flex items-center justify-end min-h-7 select-none"
  869. :style="{ height: blockHeights[block.id] ? blockHeights[block.id] + 'px' : undefined, width: (block.depth + 1) * 20 + 'px' }"
  870. >
  871. <div
  872. draggable="true"
  873. class="flex items-center justify-center cursor-grab active:cursor-grabbing rounded transition-all"
  874. data-drag-handle
  875. :class="dragState?.blockId === block.id ? 'size-7 text-(--ui-primary)' : 'size-6 text-(--ui-text-muted) group-hover/block:text-(--ui-primary)'"
  876. @dragstart="onDragStart(i, block.id, block.content, block.depth, $event)"
  877. @dragend="onDragEnd"
  878. >
  879. <UIcon name="i-ph-dot-duotone" class="size-6" />
  880. </div>
  881. </div>
  882. <EditorBlock
  883. :ref="(el: any) => { if (el) blockRefs.set(block.id, el); else blockRefs.delete(block.id) }"
  884. :id="block.id"
  885. :on-boundary-exit="(dir) => onBlockBoundaryExit(i, dir)"
  886. v-model:content="block.content"
  887. :focused="focused && i === 0"
  888. :marker-mode="markerMode"
  889. :depth="block.depth"
  890. :debug="debug"
  891. :registry="registry"
  892. :extra-patterns="extraPatterns"
  893. :resolved-refs="resolvedRefs"
  894. :resolved-refs-version="resolvedRefsVersion"
  895. :resolve-asset="resolveAsset"
  896. class="flex-1 min-w-0"
  897. @update:content="(val: string) => onBlockContentChange(block.id, val)"
  898. @split="(before, after) => onSplit(i, before, after)"
  899. @merge-previous="onMergePrevious(i)"
  900. @delete-if-empty="onDeleteIfEmpty(i)"
  901. @indent="onIndent(i)"
  902. @outdent="onOutdent(i)"
  903. @arrow-up-from-start="(leftOffset) => onArrowUpFromStart(i, leftOffset)"
  904. @arrow-down-from-end="(leftOffset) => onArrowDownFromEnd(i, leftOffset)"
  905. @first-para-height="(h: number) => onFirstParaHeight(block.id, h)"
  906. @focus="onBlockFocusHandler"
  907. @blur="onBlockBlurHandler"
  908. @selection-change="onBlockSelectionChangeHandler"
  909. @pattern-open="onPatternOpen"
  910. @pattern-update="onPatternUpdate"
  911. @pattern-close="onPatternClose"
  912. @content-change-op="onBlockContentChangeOp"
  913. @error="emit('error', $event)"
  914. @page-ref-clicked="emit('page-ref-clicked', $event)"
  915. @block-ref-clicked="emit('block-ref-clicked', $event)"
  916. @tag-clicked="emit('tag-clicked', $event)"
  917. @asset-dropped="emit('asset-dropped', $event)"
  918. />
  919. </div>
  920. </template>
  921. <EditorInsertionZone
  922. :ref="(el: any) => { if (el) zoneRefs.set(blocks.length, el); else zoneRefs.delete(blocks.length) }"
  923. :class="dropZoneIndex === blocks.length ? 'bg-(--ui-primary) rounded' : ''"
  924. @activate="(content: string) => onZoneActivate(blocks.length, content)"
  925. @focus="onZoneFocus(blocks.length)"
  926. @blur="onZoneBlur"
  927. @navigate-up="onZoneNavigateUp(blocks.length)"
  928. @navigate-down="onZoneNavigateDown(blocks.length)"
  929. @dragover.prevent="onDragOverZone(blocks.length, $event)"
  930. @dragleave="onDragLeaveZone(blocks.length)"
  931. @drop="onDropOnZone(blocks.length)"
  932. />
  933. </div>
  934. </div>
  935. </template>