Parcourir la source

fix: restore arrow navigation and add coordinate handoff for block-to-block focus

- Remove `display: inline-flex` from delimiter spans (`.md-decorator`)
  to prevent browser caret from snapping to decoration boundaries
- Add `CoordinateFocus` / `FocusPosition` types to focus registry
- Capture `view.coordsAtPos().left` on block exit and route through
  Editor.vue shell via emit payload
- Resolve screen X coordinate to document position in target block
  via `view.posAtCoords()` with `requestAnimationFrame` for layout settle
Zander Hawke il y a 4 jours
Parent
commit
f226d65e19

+ 1 - 6
packages/editor/src/assets/style.css

@@ -19,12 +19,9 @@
 .md-heading-marker,
 .md-task-marker,
 .md-marker {
-  display: inline-flex;
-  align-items: center;
   white-space: nowrap;
   vertical-align: baseline;
   transition:
-    max-width 300ms cubic-bezier(0.4, 0, 0.2, 1),
     opacity 250ms ease-in-out,
     letter-spacing 300ms ease-in-out,
     margin 300ms cubic-bezier(0.4, 0, 0.2, 1);
@@ -40,14 +37,12 @@
 
 /* Compressed / Squeezed state when blurred in live-preview mode */
 .md-decorator-hidden {
-  max-width: 0px !important;
   opacity: 0 !important;
   letter-spacing: -0.5em !important;
   padding-left: 0 !important;
   padding-right: 0 !important;
   margin-left: 0px !important;
   margin-right: 0px !important;
-  overflow: hidden;
   pointer-events: none;
 }
 
@@ -78,7 +73,7 @@
 .md-page-ref,
 .md-block-ref,
 .md-tag {
-  @apply inline-flex items-baseline px-1.5 py-px rounded-md border text-[0.9em] font-medium cursor-pointer transition-colors duration-200 align-baseline;
+  @apply inline px-1.5 py-px rounded-md border text-[0.9em] font-medium cursor-pointer transition-colors duration-200 align-baseline;
 }
 
 .md-page-ref {

+ 29 - 7
packages/editor/src/components/Block.vue

@@ -16,7 +16,7 @@ import {
   type CodeBlockNavigationDelegate,
   CodeBlockView,
 } from "@/composables/useCodeBlockView"
-import type { FocusRegistry } from "@/composables/useFocusRegistry"
+import type { FocusPosition, FocusRegistry } from "@/composables/useFocusRegistry"
 import {
   createMarkdownDecorationsPlugin,
   type MarkerVisibilityMode,
@@ -66,7 +66,7 @@ const content = defineModel<string>("content")
 const props = defineProps<{
   id?: string
   focused?: boolean
-  cursorPosition?: "start" | "end" | number
+  cursorPosition?: FocusPosition
   markerMode?: MarkerVisibilityMode
   debug?: string
   registry?: FocusRegistry
@@ -85,8 +85,8 @@ const emit = defineEmits<{
   indent: []
   outdent: []
   "delete-if-empty": []
-  "arrow-up-from-start": []
-  "arrow-down-from-end": []
+  "arrow-up-from-start": [leftOffset?: number]
+  "arrow-down-from-end": [leftOffset?: number]
   "pattern-open": [payload: PatternOpenPayload]
   "pattern-update": [payload: PatternUpdatePayload]
   "pattern-close": [PatternClosePayload]
@@ -111,8 +111,8 @@ const handleKeyDown = createBlockKeyboardHandler({
   "delete-if-empty": () => emit("delete-if-empty"),
   indent: () => emit("indent"),
   outdent: () => emit("outdent"),
-  "arrow-up-from-start": () => emit("arrow-up-from-start"),
-  "arrow-down-from-end": () => emit("arrow-down-from-end"),
+  "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) {
@@ -188,7 +188,7 @@ const patternPlugin = createPatternPlugin({
   onPatternClose: (payload) => emit("pattern-close", payload),
 })
 
-function focusAt(cursorPosition?: "start" | "end" | number) {
+function focusAt(cursorPosition?: FocusPosition) {
   if (!view) return
   const doc = view.state.doc
 
@@ -199,6 +199,28 @@ function focusAt(cursorPosition?: "start" | "end" | number) {
     v.dispatch(v.state.tr.setSelection(safeSelection))
   }
 
+  // Coordinate-based focus: resolve screen X to a document position
+  // for column-preserving block-to-block navigation.
+  if (cursorPosition && typeof cursorPosition === "object" && "left" in cursorPosition) {
+    v.focus()
+    requestAnimationFrame(() => {
+      if (!view) return
+      const rect = view.dom.getBoundingClientRect()
+      const targetY =
+        cursorPosition.edge === "top" ? rect.top + 5 : rect.bottom - 5
+      const pos = view.posAtCoords({
+        left: cursorPosition.left,
+        top: targetY,
+      })
+      if (pos) {
+        setSafeSelection(pos.pos)
+      } else {
+        setSafeSelection(cursorPosition.edge === "top" ? 1 : Math.max(1, doc.content.size - 1))
+      }
+    })
+    return
+  }
+
   if (cursorPosition === "start") {
     if (doc.content.size > 0) {
       setSafeSelection(1)

+ 15 - 6
packages/editor/src/components/Editor.vue

@@ -5,6 +5,7 @@ import Block from "@/components/Block.vue"
 import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
 import InsertionZone from "@/components/InsertionZone.vue"
 import {
+  type FocusPosition,
   type FocusTarget,
   useFocusRegistry,
 } from "@/composables/useFocusRegistry"
@@ -420,7 +421,7 @@ function onDeleteIfEmpty(index: number) {
   nextTick(() => registry.focus(result.nextId!, "start"))
 }
 
-function onArrowUpFromStart(index: number) {
+function onArrowUpFromStart(index: number, leftOffset?: number) {
   // Boundary: focus the insertion zone before the first block.
   if (index <= 0) {
     focusZone(0)
@@ -428,10 +429,14 @@ function onArrowUpFromStart(index: number) {
   }
   const prev = blocks.value[index - 1]
   if (!prev) return
-  registry.focus(prev.id, "end")
+  const target: FocusPosition =
+    leftOffset !== undefined
+      ? { left: leftOffset, edge: "bottom" }
+      : "end"
+  registry.focus(prev.id, target)
 }
 
-function onArrowDownFromEnd(index: number) {
+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)
@@ -439,7 +444,11 @@ function onArrowDownFromEnd(index: number) {
   }
   const next = blocks.value[index + 1]
   if (!next) return
-  registry.focus(next.id, "start")
+  const target: FocusPosition =
+    leftOffset !== undefined
+      ? { left: leftOffset, edge: "top" }
+      : "start"
+  registry.focus(next.id, target)
 }
 
 function onBlockBoundaryExit(index: number, direction: "up" | "down") {
@@ -645,8 +654,8 @@ defineExpose({ undo, redo, canUndo, canRedo })
           @delete-if-empty="onDeleteIfEmpty(i)"
           @indent="onIndent(i)"
           @outdent="onOutdent(i)"
-          @arrow-up-from-start="onArrowUpFromStart(i)"
-          @arrow-down-from-end="onArrowDownFromEnd(i)"
+          @arrow-up-from-start="(leftOffset) => onArrowUpFromStart(i, leftOffset)"
+          @arrow-down-from-end="(leftOffset) => onArrowDownFromEnd(i, leftOffset)"
           @focus="onBlockFocusHandler"
           @blur="onBlockBlurHandler"
           @selection-change="onBlockSelectionChangeHandler"

+ 8 - 6
packages/editor/src/composables/useBlockKeyboardHandlers.ts

@@ -29,8 +29,8 @@ export interface BlockKeyboardHandlers {
   "delete-if-empty": () => void
   indent: () => void
   outdent: () => void
-  "arrow-up-from-start": () => void
-  "arrow-down-from-end": () => void
+  "arrow-up-from-start": (leftOffset?: number) => void
+  "arrow-down-from-end": (leftOffset?: number) => void
 }
 
 // Position detection helpers
@@ -139,19 +139,20 @@ export function createBlockKeyboardHandler(handlers: BlockKeyboardHandlers) {
       return true
     }
 
-    // ArrowUp: emit at document start
+    // ArrowUp: emit at document start with X coordinate for column-preserving focus
     if (event.key === "ArrowUp" && state.selection.empty) {
       const $head = state.selection.$head
       const isFirstParagraph = $head.index($head.depth - 1) === 0
 
       if (isFirstParagraph && atParagraphStart(state, view)) {
-        handlers["arrow-up-from-start"]()
+        const coords = view.coordsAtPos(state.selection.head)
+        handlers["arrow-up-from-start"](coords?.left)
         return true
       }
       return false
     }
 
-    // ArrowDown: emit at document end
+    // ArrowDown: emit at document end with X coordinate for column-preserving focus
     if (event.key === "ArrowDown" && state.selection.empty) {
       const $head = state.selection.$head
       const docNode = $head.node($head.depth - 1)
@@ -159,7 +160,8 @@ export function createBlockKeyboardHandler(handlers: BlockKeyboardHandlers) {
         $head.index($head.depth - 1) === docNode.childCount - 1
 
       if (isLastParagraph && atParagraphEnd(state, view)) {
-        handlers["arrow-down-from-end"]()
+        const coords = view.coordsAtPos(state.selection.head)
+        handlers["arrow-down-from-end"](coords?.left)
         return true
       }
       return false

+ 12 - 3
packages/editor/src/composables/useFocusRegistry.ts

@@ -4,15 +4,24 @@ export type FocusTarget =
   | { kind: "block"; id: string }
   | { kind: "zone"; index: number }
 
+/** A screen-coordinate-guided focus request for preserving horizontal
+ *  column position when navigating between blocks. */
+export interface CoordinateFocus {
+  left: number
+  edge: "top" | "bottom"
+}
+
+export type FocusPosition = "start" | "end" | number | CoordinateFocus
+
 export interface FocusRegistryHandle {
-  focus: (pos?: "start" | "end" | number) => void
+  focus: (pos?: FocusPosition) => void
   element?: HTMLElement
 }
 
 export interface FocusRegistry {
   focusedId: Ref<string | null>
   notifyFocused: (id: string) => void
-  focus: (id: string, pos?: "start" | "end" | number) => boolean
+  focus: (id: string, pos?: FocusPosition) => boolean
   focusFirst: () => boolean
   focusLast: () => boolean
   focusNext: () => boolean
@@ -46,7 +55,7 @@ export function useFocusRegistry(): FocusRegistry {
     focusedId.value = id
   }
 
-  function focus(id: string, pos?: "start" | "end" | number): boolean {
+  function focus(id: string, pos?: FocusPosition): boolean {
     const handle = handles.get(id)
     if (!handle) return false
     handle.focus(pos)