Jelajahi Sumber

fix(editor): trigger mobile soft keyboard in insertion zone

Replace tabindex="0" div with a real <input> element so the soft
keyboard appears on mobile. Split keydown into root (character
keys) vs input (navigation only) to avoid duplicate blocks from
mobile autocorrect firing both keydown and input events. Replace
@focus/@blur with @focusout for subtree containment. Add
comprehensive JSDoc for all subtle behaviors.
Zander Hawke 2 hari lalu
induk
melakukan
5c60ab8e7d
1 mengubah file dengan 94 tambahan dan 13 penghapusan
  1. 94 13
      packages/editor/src/components/EditorInsertionZone.vue

+ 94 - 13
packages/editor/src/components/EditorInsertionZone.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { ref } from "vue"
+import { nextTick, ref } from "vue"
 
 const emit = defineEmits<{
   activate: [content: string]
@@ -12,7 +12,15 @@ const emit = defineEmits<{
 const hovered = ref(false)
 const focused = ref(false)
 const rootEl = ref<HTMLElement | null>(null)
+const inputEl = ref<HTMLInputElement | null>(null)
 
+/**
+ * Keydown handler on the root `<div>`. Handles the full set of keys
+ * including character-by-character text entry. This is the active
+ * handler when the zone is focused programmatically (via `focusZone()`
+ * in Editor.vue). When the `<input>` is the active element, events
+ * are handled by `onInputKeydown` instead.
+ */
 function onKeydown(e: KeyboardEvent) {
   if (e.key === "ArrowUp") {
     e.preventDefault()
@@ -28,7 +36,7 @@ function onKeydown(e: KeyboardEvent) {
 
   if (e.key === "Escape") {
     e.preventDefault()
-    rootEl.value?.blur()
+    deactivate()
     return
   }
 
@@ -45,28 +53,100 @@ function onKeydown(e: KeyboardEvent) {
   }
 }
 
+/**
+ * Keydown handler for the `<input>` element. Only handles navigation
+ * keys (ArrowUp/Down, Escape, Enter). Character keys are intentionally
+ * NOT intercepted — they flow into the input naturally and are captured
+ * by `onInput`. This avoids duplicate blocks on mobile where:
+ * 1. The first character's `keydown` fires
+ * 2. Autocorrect/IME replaces the field value
+ * 3. `@input` fires with the full word
+ * If character keys were handled here, steps 1 and 3 would each emit
+ * `activate`, creating two blocks.
+ */
+function onInputKeydown(e: KeyboardEvent) {
+  if (e.key === "ArrowUp") {
+    e.preventDefault()
+    emit("navigate-up")
+    return
+  }
+
+  if (e.key === "ArrowDown") {
+    e.preventDefault()
+    emit("navigate-down")
+    return
+  }
+
+  if (e.key === "Escape") {
+    e.preventDefault()
+    deactivate()
+    return
+  }
+
+  if (e.key === "Enter") {
+    e.preventDefault()
+    const value = (e.target as HTMLInputElement).value
+    emit("activate", value)
+    return
+  }
+}
+
 function onPaste(e: ClipboardEvent) {
   e.preventDefault()
   const text = e.clipboardData?.getData("text/plain") ?? ""
   emit("activate", text)
 }
 
-function onFocus() {
+/**
+ * Activate the zone: show the input and request focus. Uses `nextTick`
+ * so the `<input>` is in the DOM before `.focus()` is called. A native
+ * `<input>` is required here — `tabindex` on a `<div>` does not trigger
+ * the mobile soft keyboard.
+ */
+function activate() {
+  if (focused.value) return
   focused.value = true
   emit("focus")
+  nextTick(() => inputEl.value?.focus())
 }
 
-function onBlur() {
+function deactivate() {
   focused.value = false
   emit("blur")
 }
 
 function onClick() {
-  rootEl.value?.focus()
+  activate()
+}
+
+/**
+ * Deactivate only when focus leaves the entire zone subtree. Without
+ * this check, focusing the child `<input>` would trigger `blur` on the
+ * root `<div>` and immediately close the zone. `focusout` bubbles, and
+ * `relatedTarget` tells us where focus went — if it's still inside the
+ * zone (e.g. the input), we stay active.
+ */
+function onFocusout(e: FocusEvent) {
+  if (!rootEl.value?.contains(e.relatedTarget as Node)) {
+    deactivate()
+  }
+}
+
+/**
+ * Single source of truth for text entry. On desktop, characters arrive
+ * one by one (each fires `activate`). On mobile, autocorrect may flush
+ * the entire word at once — `activate` is called once with the full
+ * value, avoiding duplicate blocks.
+ */
+function onInput(e: Event) {
+  const value = (e.target as HTMLInputElement).value
+  if (value) {
+    emit("activate", value)
+  }
 }
 
 defineExpose({
-  focus: () => rootEl.value?.focus(),
+  focus: activate,
 })
 </script>
 
@@ -75,14 +155,10 @@ defineExpose({
     ref="rootEl"
     class="insertion-zone relative outline-none cursor-pointer transition-all duration-150"
     :class="focused ? 'h-10' : 'h-1'"
-    tabindex="0"
-    role="button"
-    aria-label="Insert block"
     @keydown="onKeydown"
     @paste="onPaste"
     @click="onClick"
-    @focus="onFocus"
-    @blur="onBlur"
+    @focusout="onFocusout"
     @mouseenter="hovered = true"
     @mouseleave="hovered = false"
   >
@@ -103,8 +179,13 @@ defineExpose({
       v-if="focused"
       class="absolute inset-0 rounded-lg border-2 bg-(--ui-bg-elevated) flex items-center gap-2.5 px-4 shadow-sm border-(--ui-text-muted)/40"
     >
-      <span class="inline-block w-0.5 h-5 bg-(--ui-primary) rounded-full animate-pulse" />
-      <span class="text-sm text-(--ui-text-muted) select-none">Type to add a block</span>
+      <input
+        ref="inputEl"
+        class="w-full bg-transparent outline-none text-sm placeholder-(--ui-text-muted)"
+        placeholder="Type to add a block..."
+        @input="onInput"
+        @keydown="onInputKeydown"
+      />
     </div>
   </div>
 </template>