From 6dae60ea1f111a231966a255374572672b3fbfb5 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 12:03:33 -0800 Subject: [PATCH] feat: single-click text inputs to edit, drag/resize on shape body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shapes are now grabbable/draggable/resizable on first click (body or handles), editable on double-click, but clicking directly on a text input (textarea, contenteditable, text input) immediately enters edit mode and focuses the input — no double-click required. Works by hit-testing text inputs at pointer coordinates before initiating drag, checking both light DOM and shadow DOM of slotted content. Co-Authored-By: Claude Opus 4.6 --- lib/folk-shape.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index 81cf085..dc70f5a 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -340,6 +340,36 @@ export class FolkShape extends FolkElement { this.dispatchEvent(new CustomEvent("edit-enter")); } + /** Check if a screen-space point overlaps a text input inside this shape's slotted content. */ + #findTextInputAtPoint(clientX: number, clientY: number): HTMLElement | null { + const slot = (this.renderRoot as ShadowRoot).querySelector("slot"); + if (!slot) return null; + + const TEXT_SELECTOR = 'textarea, [contenteditable="true"], input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="hidden"])'; + + for (const el of slot.assignedElements()) { + // Check light DOM children + const lightInputs = el.querySelectorAll(TEXT_SELECTOR); + for (const input of lightInputs) { + const rect = input.getBoundingClientRect(); + if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) { + return input; + } + } + // Check shadow DOM children (one level deep — covers all folk-* shapes) + if ((el as Element).shadowRoot) { + const shadowInputs = (el as Element).shadowRoot!.querySelectorAll(TEXT_SELECTOR); + for (const input of shadowInputs) { + const rect = input.getBoundingClientRect(); + if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) { + return input; + } + } + } + } + return null; + } + exitEditMode() { if (!this.#editing) return; this.#editing = false; @@ -462,7 +492,15 @@ export class FolkShape extends FolkElement { if (event.type === "touchstart" && event.touches.length === 1) { if (!isValidDragTarget) return; + // Check if touch is over a text input — enter edit mode instead of dragging const touch = event.touches[0]; + const textInput = this.#findTextInputAtPoint(touch.clientX, touch.clientY); + if (textInput) { + this.enterEditMode(); + requestAnimationFrame(() => textInput.focus()); + return; + } + this.#lastTouchPos = { x: touch.clientX, y: touch.clientY }; this.#isTouchDragging = true; this.#internals.states.add("move"); @@ -520,6 +558,18 @@ export class FolkShape extends FolkElement { // Allow drag from: the host itself, a handle, or a drag handle element if (target !== this && !handle && !isDragHandle) return; + // If clicking on the shape body (not a handle), check if a text input + // is under the pointer — if so, enter edit mode immediately instead of dragging. + if (!handle && (target === this || isDragHandle)) { + const textInput = this.#findTextInputAtPoint(event.clientX, event.clientY); + if (textInput) { + this.enterEditMode(); + // Re-dispatch click so the input receives focus after pointer-events are enabled + requestAnimationFrame(() => textInput.focus()); + return; + } + } + if (handle?.startsWith("rotation")) { const parentRotateOrigin = this.#rect.toParentSpace({ x: this.#rect.width * this.#rect.rotateOrigin.x,