|
@@ -9,14 +9,24 @@ import {
|
|
|
type Transaction,
|
|
type Transaction,
|
|
|
} from "prosemirror-state"
|
|
} from "prosemirror-state"
|
|
|
import { EditorView } from "prosemirror-view"
|
|
import { EditorView } from "prosemirror-view"
|
|
|
-import { onMounted, onUnmounted, useTemplateRef, watch } from "vue"
|
|
|
|
|
|
|
+import {
|
|
|
|
|
+ onMounted,
|
|
|
|
|
+ onUnmounted,
|
|
|
|
|
+ ref,
|
|
|
|
|
+ useTemplateRef,
|
|
|
|
|
+ watch,
|
|
|
|
|
+} from "vue"
|
|
|
|
|
+import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
|
|
|
import { createBlockKeyboardHandler } from "@/composables/useBlockKeyboardHandlers"
|
|
import { createBlockKeyboardHandler } from "@/composables/useBlockKeyboardHandlers"
|
|
|
import {
|
|
import {
|
|
|
addFences,
|
|
addFences,
|
|
|
type CodeBlockNavigationDelegate,
|
|
type CodeBlockNavigationDelegate,
|
|
|
CodeBlockView,
|
|
CodeBlockView,
|
|
|
} from "@/composables/useCodeBlockView"
|
|
} from "@/composables/useCodeBlockView"
|
|
|
-import type { FocusPosition, FocusRegistry } from "@/composables/useFocusRegistry"
|
|
|
|
|
|
|
+import type {
|
|
|
|
|
+ FocusPosition,
|
|
|
|
|
+ FocusRegistry,
|
|
|
|
|
+} from "@/composables/useFocusRegistry"
|
|
|
import {
|
|
import {
|
|
|
createMarkdownDecorationsPlugin,
|
|
createMarkdownDecorationsPlugin,
|
|
|
type MarkerVisibilityMode,
|
|
type MarkerVisibilityMode,
|
|
@@ -29,6 +39,10 @@ import type {
|
|
|
PatternUpdatePayload,
|
|
PatternUpdatePayload,
|
|
|
} from "@/composables/usePatternPlugin"
|
|
} from "@/composables/usePatternPlugin"
|
|
|
import { createPatternPlugin } from "@/composables/usePatternPlugin"
|
|
import { createPatternPlugin } from "@/composables/usePatternPlugin"
|
|
|
|
|
+import {
|
|
|
|
|
+ useMentionGroups,
|
|
|
|
|
+ useSlashGroups,
|
|
|
|
|
+} from "@/composables/useSuggestionGroups"
|
|
|
import {
|
|
import {
|
|
|
autoClosePlugin,
|
|
autoClosePlugin,
|
|
|
createPairRule,
|
|
createPairRule,
|
|
@@ -111,8 +125,10 @@ const handleKeyDown = createBlockKeyboardHandler({
|
|
|
"delete-if-empty": () => emit("delete-if-empty"),
|
|
"delete-if-empty": () => emit("delete-if-empty"),
|
|
|
indent: () => emit("indent"),
|
|
indent: () => emit("indent"),
|
|
|
outdent: () => emit("outdent"),
|
|
outdent: () => emit("outdent"),
|
|
|
- "arrow-up-from-start": (leftOffset) => emit("arrow-up-from-start", leftOffset),
|
|
|
|
|
- "arrow-down-from-end": (leftOffset) => emit("arrow-down-from-end", leftOffset),
|
|
|
|
|
|
|
+ "arrow-up-from-start": (leftOffset) =>
|
|
|
|
|
+ emit("arrow-up-from-start", leftOffset),
|
|
|
|
|
+ "arrow-down-from-end": (leftOffset) =>
|
|
|
|
|
+ emit("arrow-down-from-end", leftOffset),
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
function handlerWithRefocus(fn: (view: EditorView) => void) {
|
|
function handlerWithRefocus(fn: (view: EditorView) => void) {
|
|
@@ -182,10 +198,82 @@ const { plugin: markdownDecorationsPlugin } = createMarkdownDecorationsPlugin(
|
|
|
props.markerMode ?? "live-preview",
|
|
props.markerMode ?? "live-preview",
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+// ── Suggestion menu ──────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+const localSession = ref<PatternOpenPayload | null>(null)
|
|
|
|
|
+const menuPosition = ref({ x: 0, y: 0 })
|
|
|
|
|
+const menuRef = useTemplateRef<{ forwardKey: (key: string) => void }>("menuRef")
|
|
|
|
|
+const menuWrapperRef = useTemplateRef<HTMLElement>("menuWrapperRef")
|
|
|
|
|
+
|
|
|
|
|
+function closeSession() {
|
|
|
|
|
+ localSession.value = null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const slashGroups = useSlashGroups(
|
|
|
|
|
+ localSession,
|
|
|
|
|
+ ref<FormattingHandlers | null>(formattingHandlers),
|
|
|
|
|
+ closeSession,
|
|
|
|
|
+)
|
|
|
|
|
+const mentionGroups = useMentionGroups(localSession, closeSession)
|
|
|
|
|
+
|
|
|
|
|
+function onCaptureKeydown(e: KeyboardEvent) {
|
|
|
|
|
+ if (!localSession.value) return
|
|
|
|
|
+ if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(e.key)) {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ e.stopPropagation()
|
|
|
|
|
+ if (e.key === "Escape") {
|
|
|
|
|
+ closeSession()
|
|
|
|
|
+ // Also notify the pattern plugin via the session's close callback.
|
|
|
|
|
+ localSession.value?.close("cancelled")
|
|
|
|
|
+ } else {
|
|
|
|
|
+ menuRef.value?.forwardKey(e.key)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function recalculateMenuPosition() {
|
|
|
|
|
+ if (!localSession.value || !view || !menuWrapperRef.value) return
|
|
|
|
|
+ const pos = view.coordsAtPos(localSession.value.range.from)
|
|
|
|
|
+ if (!pos) return
|
|
|
|
|
+ const rect = menuWrapperRef.value.getBoundingClientRect()
|
|
|
|
|
+ menuPosition.value = {
|
|
|
|
|
+ x: pos.left - rect.left,
|
|
|
|
|
+ y: pos.top - rect.top,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+watch(localSession, (session, prev) => {
|
|
|
|
|
+ if (session && !prev) {
|
|
|
|
|
+ document.addEventListener("keydown", onCaptureKeydown, true)
|
|
|
|
|
+ window.addEventListener("scroll", recalculateMenuPosition, {
|
|
|
|
|
+ passive: true,
|
|
|
|
|
+ })
|
|
|
|
|
+ recalculateMenuPosition()
|
|
|
|
|
+ } else if (!session && prev) {
|
|
|
|
|
+ document.removeEventListener("keydown", onCaptureKeydown, true)
|
|
|
|
|
+ window.removeEventListener("scroll", recalculateMenuPosition)
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
const patternPlugin = createPatternPlugin({
|
|
const patternPlugin = createPatternPlugin({
|
|
|
- onPatternOpen: (payload) => emit("pattern-open", payload),
|
|
|
|
|
- onPatternUpdate: (payload) => emit("pattern-update", payload),
|
|
|
|
|
- onPatternClose: (payload) => emit("pattern-close", payload),
|
|
|
|
|
|
|
+ onPatternOpen: (payload) => {
|
|
|
|
|
+ localSession.value = payload
|
|
|
|
|
+ emit("pattern-open", payload)
|
|
|
|
|
+ },
|
|
|
|
|
+ onPatternUpdate: (payload) => {
|
|
|
|
|
+ if (!localSession.value) return
|
|
|
|
|
+ localSession.value = {
|
|
|
|
|
+ ...localSession.value,
|
|
|
|
|
+ query: payload.query,
|
|
|
|
|
+ position: payload.position,
|
|
|
|
|
+ range: payload.range,
|
|
|
|
|
+ }
|
|
|
|
|
+ emit("pattern-update", payload)
|
|
|
|
|
+ },
|
|
|
|
|
+ onPatternClose: (payload) => {
|
|
|
|
|
+ localSession.value = null
|
|
|
|
|
+ emit("pattern-close", payload)
|
|
|
|
|
+ },
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
function focusAt(cursorPosition?: FocusPosition) {
|
|
function focusAt(cursorPosition?: FocusPosition) {
|
|
@@ -201,7 +289,11 @@ function focusAt(cursorPosition?: FocusPosition) {
|
|
|
|
|
|
|
|
// Coordinate-based focus: resolve screen X to a document position
|
|
// Coordinate-based focus: resolve screen X to a document position
|
|
|
// for column-preserving block-to-block navigation.
|
|
// for column-preserving block-to-block navigation.
|
|
|
- if (cursorPosition && typeof cursorPosition === "object" && "left" in cursorPosition) {
|
|
|
|
|
|
|
+ if (
|
|
|
|
|
+ cursorPosition &&
|
|
|
|
|
+ typeof cursorPosition === "object" &&
|
|
|
|
|
+ "left" in cursorPosition
|
|
|
|
|
+ ) {
|
|
|
v.focus()
|
|
v.focus()
|
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
|
if (!view) return
|
|
if (!view) return
|
|
@@ -215,7 +307,9 @@ function focusAt(cursorPosition?: FocusPosition) {
|
|
|
if (pos) {
|
|
if (pos) {
|
|
|
setSafeSelection(pos.pos)
|
|
setSafeSelection(pos.pos)
|
|
|
} else {
|
|
} else {
|
|
|
- setSafeSelection(cursorPosition.edge === "top" ? 1 : Math.max(1, doc.content.size - 1))
|
|
|
|
|
|
|
+ setSafeSelection(
|
|
|
|
|
+ cursorPosition.edge === "top" ? 1 : Math.max(1, doc.content.size - 1),
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
return
|
|
return
|
|
@@ -589,5 +683,16 @@ defineExpose({
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
<template>
|
|
|
- <div ref="editorRef" class="w-full min-h-7 text-base"></div>
|
|
|
|
|
|
|
+ <div ref="menuWrapperRef" style="position: relative">
|
|
|
|
|
+ <div ref="editorRef" class="w-full min-h-7 text-base"></div>
|
|
|
|
|
+
|
|
|
|
|
+ <EditorSuggestionMenu
|
|
|
|
|
+ ref="menuRef"
|
|
|
|
|
+ :active="localSession !== null"
|
|
|
|
|
+ :groups="localSession?.kind === 'command' ? slashGroups : mentionGroups"
|
|
|
|
|
+ :position="menuPosition"
|
|
|
|
|
+ :query="localSession?.query ?? ''"
|
|
|
|
|
+ @close="closeSession"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
</template>
|
|
</template>
|