|
|
@@ -1,16 +1,26 @@
|
|
|
<script setup lang="ts">
|
|
|
import { watchDebounced } from "@vueuse/core"
|
|
|
+import { GapCursor, gapCursor } from "prosemirror-gapcursor"
|
|
|
import { keymap } from "prosemirror-keymap"
|
|
|
-import { EditorState, Selection, type Transaction } from "prosemirror-state"
|
|
|
+import {
|
|
|
+ EditorState,
|
|
|
+ Selection,
|
|
|
+ TextSelection,
|
|
|
+ type Transaction,
|
|
|
+} from "prosemirror-state"
|
|
|
import { EditorView } from "prosemirror-view"
|
|
|
import { onMounted, onUnmounted, useTemplateRef, watch } from "vue"
|
|
|
import { createBlockKeyboardHandler } from "@/composables/useBlockKeyboardHandlers"
|
|
|
-import { CodeBlockView } from "@/composables/useCodeBlockView"
|
|
|
-import { MathBlockView } from "@/composables/useMathBlockView"
|
|
|
+import {
|
|
|
+ addFences,
|
|
|
+ type CodeBlockNavigationDelegate,
|
|
|
+ CodeBlockView,
|
|
|
+} from "@/composables/useCodeBlockView"
|
|
|
import {
|
|
|
createMarkdownDecorationsPlugin,
|
|
|
type MarkerVisibilityMode,
|
|
|
} from "@/composables/useMarkdownDecorations"
|
|
|
+import { MathBlockView } from "@/composables/useMathBlockView"
|
|
|
import { createPasteHandler } from "@/composables/usePasteHandler"
|
|
|
import type {
|
|
|
PatternClosePayload,
|
|
|
@@ -29,6 +39,7 @@ import {
|
|
|
} from "@/lib/auto-close-plugin"
|
|
|
import { contentToDoc, docToContent } from "@/lib/content-model"
|
|
|
import {
|
|
|
+ type FormattingHandlers,
|
|
|
getActiveHeading,
|
|
|
getActiveTask,
|
|
|
insertBlockMath,
|
|
|
@@ -45,7 +56,6 @@ import {
|
|
|
toggleItalic,
|
|
|
toggleStrikethrough,
|
|
|
toggleTask,
|
|
|
- type FormattingHandlers,
|
|
|
} from "@/lib/formatting"
|
|
|
import { createLogger } from "@/lib/logger"
|
|
|
|
|
|
@@ -77,6 +87,17 @@ const emit = defineEmits<{
|
|
|
|
|
|
const log = createLogger("Block", props.debug)
|
|
|
|
|
|
+// GapCursor.valid exists at runtime but is missing from the published type defs.
|
|
|
+// Try-construct as a proxy — the constructor throws on invalid positions.
|
|
|
+function isValidGapCursor($pos: unknown): boolean {
|
|
|
+ try {
|
|
|
+ new GapCursor($pos as never)
|
|
|
+ return true
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
const handleKeyDown = createBlockKeyboardHandler({
|
|
|
split: (before, after) => emit("split", before, after),
|
|
|
"merge-previous": () => emit("merge-previous"),
|
|
|
@@ -210,6 +231,7 @@ onMounted(() => {
|
|
|
const editorState = EditorState.create({
|
|
|
doc: initialDoc,
|
|
|
plugins: [
|
|
|
+ gapCursor(),
|
|
|
markdownDecorationsPlugin,
|
|
|
patternPlugin,
|
|
|
autoClosePlugin([
|
|
|
@@ -267,11 +289,159 @@ onMounted(() => {
|
|
|
const mountEl = editorRef.value
|
|
|
if (!mountEl) return
|
|
|
|
|
|
+ const delegate: CodeBlockNavigationDelegate = {
|
|
|
+ requestNavigateOut(direction, nodePos) {
|
|
|
+ log.info("requestNavigateOut", { direction, nodePos })
|
|
|
+ const currentView = view
|
|
|
+ if (!currentView) return
|
|
|
+ const node = currentView.state.doc.nodeAt(nodePos)
|
|
|
+ if (!node) return
|
|
|
+
|
|
|
+ const targetPos =
|
|
|
+ direction === "up" ? nodePos - 1 : nodePos + node.nodeSize
|
|
|
+
|
|
|
+ // FIXME: Replace with Editor shell insertion zone.
|
|
|
+ // ArrowUp from the very first node — materialize a paragraph before.
|
|
|
+ if (direction === "up" && targetPos < 0) {
|
|
|
+ log.info(
|
|
|
+ "requestNavigateOut — materializing paragraph before code block (first node)",
|
|
|
+ )
|
|
|
+ const para = currentView.state.schema.nodes.paragraph!.create()
|
|
|
+ const tr = currentView.state.tr.insert(0, para)
|
|
|
+ const cursor$pos = tr.doc.resolve(0)
|
|
|
+ const cursorSel = TextSelection.near(cursor$pos, 1)
|
|
|
+ if (cursorSel) {
|
|
|
+ currentView.dispatch(tr.setSelection(cursorSel).scrollIntoView())
|
|
|
+ requestAnimationFrame(() => currentView.focus())
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // FIXME: Replace with Editor shell insertion zone.
|
|
|
+ // ArrowDown past the end — materialize a paragraph after.
|
|
|
+ if (targetPos > currentView.state.doc.content.size) {
|
|
|
+ log.info(
|
|
|
+ "requestNavigateOut — materializing paragraph after code block (last node)",
|
|
|
+ )
|
|
|
+ const insertPos = currentView.state.doc.content.size
|
|
|
+ const para = currentView.state.schema.nodes.paragraph!.create()
|
|
|
+ const tr = currentView.state.tr.insert(insertPos, para)
|
|
|
+ const cursor$pos = tr.doc.resolve(insertPos)
|
|
|
+ const cursorSel = TextSelection.near(cursor$pos, 1)
|
|
|
+ if (cursorSel) {
|
|
|
+ currentView.dispatch(tr.setSelection(cursorSel).scrollIntoView())
|
|
|
+ requestAnimationFrame(() => currentView.focus())
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const $pos = currentView.state.doc.resolve(targetPos)
|
|
|
+ let sel = TextSelection.near($pos, direction === "up" ? -1 : 1)
|
|
|
+ if (!sel && isValidGapCursor($pos)) {
|
|
|
+ log.info("requestNavigateOut — falling back to GapCursor")
|
|
|
+ sel = new GapCursor($pos)
|
|
|
+ }
|
|
|
+ if (sel) {
|
|
|
+ currentView.dispatch(
|
|
|
+ currentView.state.tr.setSelection(sel).scrollIntoView(),
|
|
|
+ )
|
|
|
+ currentView.focus()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // FIXME: Replace with Editor shell insertion zone.
|
|
|
+ // No valid selection or gap cursor — materialize a paragraph.
|
|
|
+ if (direction === "up") {
|
|
|
+ log.info(
|
|
|
+ "requestNavigateOut — materializing paragraph before code block",
|
|
|
+ )
|
|
|
+ const para = currentView.state.schema.nodes.paragraph!.create()
|
|
|
+ const tr = currentView.state.tr.insert(nodePos, para)
|
|
|
+ const cursor$pos = tr.doc.resolve(nodePos)
|
|
|
+ const cursorSel = TextSelection.near(cursor$pos, 1)
|
|
|
+ if (cursorSel) {
|
|
|
+ currentView.dispatch(tr.setSelection(cursorSel).scrollIntoView())
|
|
|
+ requestAnimationFrame(() => currentView.focus())
|
|
|
+ }
|
|
|
+ // FIXME: Replace with Editor shell insertion zone.
|
|
|
+ } else {
|
|
|
+ log.info(
|
|
|
+ "requestNavigateOut — materializing paragraph after code block",
|
|
|
+ )
|
|
|
+ const insertPos = nodePos + node.nodeSize
|
|
|
+ const para = currentView.state.schema.nodes.paragraph!.create()
|
|
|
+ const tr = currentView.state.tr.insert(insertPos, para)
|
|
|
+ const cursor$pos = tr.doc.resolve(insertPos)
|
|
|
+ const cursorSel = TextSelection.near(cursor$pos, 1)
|
|
|
+ if (cursorSel) {
|
|
|
+ currentView.dispatch(tr.setSelection(cursorSel).scrollIntoView())
|
|
|
+ requestAnimationFrame(() => currentView.focus())
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ requestRemoveBlock(nodePos) {
|
|
|
+ log.info("requestRemoveBlock", { nodePos })
|
|
|
+ const currentView = view
|
|
|
+ if (!currentView) return
|
|
|
+ const node = currentView.state.doc.nodeAt(nodePos)
|
|
|
+ if (!node) return
|
|
|
+
|
|
|
+ if (currentView.state.doc.childCount === 1) {
|
|
|
+ log.info("requestRemoveBlock — single node, replacing with paragraph")
|
|
|
+ const para = currentView.state.schema.nodes.paragraph!.create()
|
|
|
+ const tr = currentView.state.tr.replaceWith(
|
|
|
+ nodePos,
|
|
|
+ nodePos + node.nodeSize,
|
|
|
+ para,
|
|
|
+ )
|
|
|
+ currentView.dispatch(tr)
|
|
|
+ currentView.focus()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ log.info("requestRemoveBlock — deleting code block")
|
|
|
+ const tr = currentView.state.tr.delete(nodePos, nodePos + node.nodeSize)
|
|
|
+ const $pos = tr.doc.resolve(nodePos)
|
|
|
+ const sel = TextSelection.near($pos, -1)
|
|
|
+ if (sel) {
|
|
|
+ currentView.dispatch(tr.setSelection(sel).scrollIntoView())
|
|
|
+ currentView.focus()
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ requestChangeLanguage(newLanguage, body, nodePos) {
|
|
|
+ log.info("requestChangeLanguage", {
|
|
|
+ newLanguage,
|
|
|
+ nodePos,
|
|
|
+ bodyLength: body.length,
|
|
|
+ })
|
|
|
+ const currentView = view
|
|
|
+ if (!currentView) return
|
|
|
+ const node = currentView.state.doc.nodeAt(nodePos)
|
|
|
+ if (!node) return
|
|
|
+
|
|
|
+ const fencedContent = addFences(body, newLanguage)
|
|
|
+ const textNode = currentView.state.schema.text(fencedContent)
|
|
|
+ const tr = currentView.state.tr
|
|
|
+ .setNodeAttribute(nodePos, "language", newLanguage)
|
|
|
+ .replaceWith(nodePos + 1, nodePos + node.nodeSize - 1, textNode)
|
|
|
+ currentView.dispatch(tr)
|
|
|
+ requestAnimationFrame(() => currentView.focus())
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
view = new EditorView(mountEl, {
|
|
|
state: editorState,
|
|
|
nodeViews: {
|
|
|
code_block: (node, view, getPos) =>
|
|
|
- new CodeBlockView(node, view, () => getPos() ?? 0, props.debug),
|
|
|
+ new CodeBlockView(
|
|
|
+ node,
|
|
|
+ view,
|
|
|
+ () => getPos() ?? 0,
|
|
|
+ delegate,
|
|
|
+ props.debug,
|
|
|
+ ),
|
|
|
math_block: (node, view, getPos) =>
|
|
|
new MathBlockView(node, view, () => getPos() ?? 0),
|
|
|
},
|