|
|
@@ -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>
|