From 0d7c6d08b3e2b2d2be197e11d61904eaa1c26abc Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:58:11 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(canvas):=20mobile=20touch=20=E2=80=94?= =?UTF-8?q?=20first=20touch=20moves,=20long-press=20selects,=20extra-long?= =?UTF-8?q?=20opens=20context=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-tier touch interaction on mobile canvas: - Immediate drag on finger movement (8px threshold) - Long press (500ms) selects shape with haptic feedback - Extra long press (1000ms) opens context menu - Cancel timers on movement or two-finger gesture Skip pointerdown selection for touch events in canvas.html, handle via touch-select custom event from folk-shape instead. Co-Authored-By: Claude Opus 4.6 --- lib/folk-shape.ts | 78 +++++++++++++++++++++++++++++++++++++++------ website/canvas.html | 13 +++++++- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index 53cd4db..3f076f4 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -235,6 +235,13 @@ export class FolkShape extends FolkElement { #lastTouchPos: Point | null = null; #isTouchDragging = false; #dragOffset: Point | null = null; + #touchStartPos: Point | null = null; + #longPressTimer: ReturnType | null = null; + #extraLongPressTimer: ReturnType | null = null; + #touchDidLongPress = false; + static LONG_PRESS_MS = 500; + static EXTRA_LONG_PRESS_MS = 1000; + static TOUCH_MOVE_THRESHOLD = 8; // px before considered a drag get x() { return this.#rect.x; @@ -485,6 +492,11 @@ export class FolkShape extends FolkElement { }; } + #clearLongPressTimers() { + if (this.#longPressTimer) { clearTimeout(this.#longPressTimer); this.#longPressTimer = null; } + if (this.#extraLongPressTimer) { clearTimeout(this.#extraLongPressTimer); this.#extraLongPressTimer = null; } + } + handleEvent(event: PointerEvent | KeyboardEvent | TouchEvent) { // In feed mode, suppress all drag/resize interactions if (this.closest('#canvas.feed-mode')) return; @@ -506,11 +518,11 @@ export class FolkShape extends FolkElement { // Two-finger gesture → cancel shape drag so canvas pan takes over if (event.touches.length >= 2) { - if (this.#isTouchDragging) { - this.#lastTouchPos = null; - this.#isTouchDragging = false; - this.#internals.states.delete("move"); - } + this.#clearLongPressTimers(); + this.#lastTouchPos = null; + this.#isTouchDragging = false; + this.#touchDidLongPress = false; + this.#internals.states.delete("move"); return; } @@ -526,16 +538,59 @@ export class FolkShape extends FolkElement { return; } + this.#touchStartPos = { x: touch.clientX, y: touch.clientY }; this.#lastTouchPos = { x: touch.clientX, y: touch.clientY }; - this.#isTouchDragging = true; - this.#internals.states.add("move"); + this.#isTouchDragging = false; + this.#touchDidLongPress = false; + + // Long press → select shape + this.#longPressTimer = setTimeout(() => { + this.#longPressTimer = null; + this.#touchDidLongPress = true; + // Stop any drag — switch to selected state + this.#isTouchDragging = false; + this.#internals.states.delete("move"); + navigator?.vibrate?.(30); + this.dispatchEvent(new CustomEvent("touch-select", { + bubbles: true, composed: true, + detail: { shapeId: this.id }, + })); + }, FolkShape.LONG_PRESS_MS); + + // Extra long press → context menu + this.#extraLongPressTimer = setTimeout(() => { + this.#extraLongPressTimer = null; + navigator?.vibrate?.(50); + const cmEvent = new MouseEvent("contextmenu", { + bubbles: true, cancelable: true, + clientX: touch.clientX, clientY: touch.clientY, + }); + this.dispatchEvent(cmEvent); + }, FolkShape.EXTRA_LONG_PRESS_MS); + this.focus(); return; } - if (event.type === "touchmove" && this.#isTouchDragging && event.touches.length === 1) { + if (event.type === "touchmove" && event.touches.length === 1) { const touch = event.touches[0]; - if (this.#lastTouchPos) { + + // Check if finger moved past threshold to start drag + if (!this.#isTouchDragging && this.#touchStartPos) { + const dx = touch.clientX - this.#touchStartPos.x; + const dy = touch.clientY - this.#touchStartPos.y; + if (Math.abs(dx) > FolkShape.TOUCH_MOVE_THRESHOLD || + Math.abs(dy) > FolkShape.TOUCH_MOVE_THRESHOLD) { + // Movement → cancel long press timers, begin drag + this.#clearLongPressTimers(); + if (!this.#touchDidLongPress) { + this.#isTouchDragging = true; + this.#internals.states.add("move"); + } + } + } + + if (this.#isTouchDragging && this.#lastTouchPos) { const zoom = (window.visualViewport?.scale ?? 1) * this.#getParentScale(); const moveDelta = { x: (touch.clientX - this.#lastTouchPos.x) / zoom, @@ -548,13 +603,18 @@ export class FolkShape extends FolkElement { this.#rect.y += moveDelta.y; this.requestUpdate(); this.#dispatchTransformEvent(); + } else { + this.#lastTouchPos = { x: touch.clientX, y: touch.clientY }; } return; } if (event.type === "touchend") { + this.#clearLongPressTimers(); this.#lastTouchPos = null; + this.#touchStartPos = null; this.#isTouchDragging = false; + this.#touchDidLongPress = false; this.#internals.states.delete("move"); return; } diff --git a/website/canvas.html b/website/canvas.html index caf15e7..08dd91e 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -3866,9 +3866,10 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest }); // Track selection for MI bridge — supports Shift/Ctrl+click multi-select + // On touch devices, first touch moves; selection comes from long-press (touch-select event) shape.addEventListener("pointerdown", (e) => { + if (e.pointerType === "touch") return; // handled by touch-select if (e.shiftKey || e.metaKey || e.ctrlKey) { - // Additive toggle if (selectedShapeIds.has(shape.id)) { selectedShapeIds.delete(shape.id); } else { @@ -3882,6 +3883,16 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest updateSelectionVisuals(); }); + // Mobile long-press → select shape + shape.addEventListener("touch-select", (e) => { + if (!selectedShapeIds.has(shape.id)) { + selectedShapeIds.clear(); + selectedShapeIds.add(shape.id); + } + selectedShapeId = shape.id; + updateSelectionVisuals(); + }); + // Close button — forget (fade) instead of remove shape.addEventListener("close", () => { const did = getLocalDID(); From 355d33768a2a5ceb51b12bf093b028973e05a412 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:59:04 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(mobile):=20responsive=20parity=20?= =?UTF-8?q?=E2=80=94=20touch=20targets,=20iOS=20zoom,=20viewport=20clampin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rNotes: always-visible add button on touch, 36px toolbar buttons with horizontal scroll, URL popover as bottom sheet on mobile, improved comment sidebar bottom panel with drag handle, larger footer buttons, slash menu viewport clamping and mobile-friendly item sizes, reduced code-textarea/image-preview heights. rTasks/rFiles: font-size 16px on inputs to prevent iOS Safari auto-zoom. Shell: .hover-reveal touch utility, 36px min-height on rapp-nav buttons. Co-Authored-By: Claude Opus 4.6 --- .../rfiles/components/folk-file-browser.ts | 1 + modules/rnotes/components/comment-panel.ts | 8 ++++ modules/rnotes/components/folk-notes-app.ts | 46 +++++++++++++++---- modules/rnotes/components/slash-command.ts | 6 ++- modules/rtasks/components/folk-tasks-board.ts | 2 + website/public/shell.css | 7 +++ 6 files changed, 60 insertions(+), 10 deletions(-) diff --git a/modules/rfiles/components/folk-file-browser.ts b/modules/rfiles/components/folk-file-browser.ts index 5adae03..fd05a5f 100644 --- a/modules/rfiles/components/folk-file-browser.ts +++ b/modules/rfiles/components/folk-file-browser.ts @@ -465,6 +465,7 @@ class FolkFileBrowser extends HTMLElement { .upload-row { flex-direction: column; align-items: stretch; } .file-card { padding: 10px; } .card-form-row { flex-direction: column; } + input[type="text"], select, textarea { font-size: 16px; } } diff --git a/modules/rnotes/components/comment-panel.ts b/modules/rnotes/components/comment-panel.ts index 1f34b1b..aaafd60 100644 --- a/modules/rnotes/components/comment-panel.ts +++ b/modules/rnotes/components/comment-panel.ts @@ -253,6 +253,14 @@ class NotesCommentPanel extends HTMLElement { .new-comment-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; } .first-message-text { word-break: break-word; overflow-wrap: anywhere; } .message-text { word-break: break-word; overflow-wrap: anywhere; } + @media (max-width: 480px) { + .panel { max-height: none; height: 100%; } + .thread-action { padding: 8px 10px; font-size: 12px; } + .reply-btn, .reply-cancel-btn { padding: 8px 16px; } + .reply-input { padding: 8px 10px; font-size: 14px; } + .emoji-pick { padding: 6px 8px; font-size: 18px; } + .new-comment-input { min-height: 44px; max-height: 100px; font-size: 14px; } + }
diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index d063d08..15c10b5 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -2130,8 +2130,10 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF popover.className = 'url-popover'; const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect(); - popover.style.left = `${anchorRect.left - hostRect.left}px`; - popover.style.top = `${anchorRect.bottom - hostRect.top + 4}px`; + if (window.innerWidth > 640) { + popover.style.left = `${anchorRect.left - hostRect.left}px`; + popover.style.top = `${anchorRect.bottom - hostRect.top + 4}px`; + } const input = document.createElement('input'); input.type = 'url'; @@ -3118,6 +3120,10 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } .sbt-notebook-header:hover .sbt-nb-add { opacity: 1; } .sbt-nb-add:hover { color: var(--rs-primary); background: var(--rs-bg-surface-raised); } + @media (pointer: coarse) { + .sbt-nb-add { opacity: 0.6; } + .sbt-nb-add:active { opacity: 1; color: var(--rs-primary); } + } .sbt-notes { padding-left: 20px; } .sbt-note { display: flex; align-items: center; gap: 8px; @@ -3201,11 +3207,16 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF @media (max-width: 480px) { .editor-with-comments { flex-direction: column; } .comment-sidebar.has-comments { - width: 100%; - border-left: none; - border-top: 1px solid var(--rs-border, #e5e7eb); - max-height: 250px; - overflow-y: auto; + width: 100%; border-left: none; + border-top: 2px solid var(--rs-border, #e5e7eb); + max-height: 250px; max-height: 40dvh; + min-height: 120px; overflow-y: auto; + border-radius: 12px 12px 0 0; padding-top: 4px; + } + .comment-sidebar.has-comments::before { + content: ''; display: block; width: 32px; height: 4px; + background: var(--rs-border-strong, #d1d5db); border-radius: 2px; + margin: 0 auto 4px; } } @@ -3521,13 +3532,17 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .mobile-sidebar-toggle { display: flex !important; } .editor-wrapper .editable-title { padding: 12px 14px 0; } .tiptap-container .tiptap { padding: 14px 16px; } + .sidebar-footer-btn { min-height: 36px; padding: 7px 12px; } } @media (max-width: 480px) { .rapp-nav__btn { padding: 5px 10px; font-size: 12px; } .editable-title { font-size: 18px; } .tiptap-container .tiptap { font-size: 14px; padding: 12px 14px; min-height: 200px; } - .editor-toolbar { padding: 3px 4px; gap: 1px; } - .toolbar-btn { width: 26px; height: 24px; } + .editor-toolbar { padding: 3px 4px; gap: 1px; overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; } + .toolbar-btn { width: 36px; height: 36px; } + .toolbar-sep { display: none; } + .code-textarea { min-height: 200px; } + .image-preview { max-height: 240px; } .note-actions-bar { flex-wrap: wrap; gap: 6px; } .note-action-btn { padding: 5px 10px; font-size: 11px; } } @@ -3570,6 +3585,13 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF border: 1px solid var(--rs-border); } .url-popover__btn--cancel:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); } + @media (max-width: 640px) { + .url-popover { + position: fixed; left: 8px !important; right: 8px !important; + top: auto !important; bottom: max(env(safe-area-inset-bottom), 8px); + min-width: 0; width: auto; border-radius: 12px 12px 0 0; + } + } /* ── Slash Menu ── */ .slash-menu { @@ -3604,6 +3626,12 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF font-size: 10px; color: var(--rs-text-muted); padding: 1px 6px; background: var(--rs-bg-surface-raised); border-radius: 3px; margin-left: auto; } + @media (max-width: 480px) { + .slash-menu { min-width: 200px; max-height: 260px; } + .slash-menu-item { padding: 10px 12px; } + .slash-menu-desc { display: none; } + .slash-menu-hint { display: none; } + } /* ── Code highlighting (lowlight) ── */ .tiptap-container .tiptap .hljs-keyword { color: #c792ea; } diff --git a/modules/rnotes/components/slash-command.ts b/modules/rnotes/components/slash-command.ts index 053383b..774569c 100644 --- a/modules/rnotes/components/slash-command.ts +++ b/modules/rnotes/components/slash-command.ts @@ -191,7 +191,11 @@ export function createSlashCommandPlugin(editor: Editor, shadowRoot: ShadowRoot) const shadowHost = shadowRoot.host as HTMLElement; const hostRect = shadowHost.getBoundingClientRect(); - menuEl.style.left = `${coords.left - hostRect.left}px`; + let left = coords.left - hostRect.left; + const menuWidth = 240; + const maxLeft = window.innerWidth - menuWidth - 8 - hostRect.left; + left = Math.max(4, Math.min(left, maxLeft)); + menuEl.style.left = `${left}px`; menuEl.style.top = `${coords.bottom - hostRect.top + 4}px`; } diff --git a/modules/rtasks/components/folk-tasks-board.ts b/modules/rtasks/components/folk-tasks-board.ts index 799c568..bebbea5 100644 --- a/modules/rtasks/components/folk-tasks-board.ts +++ b/modules/rtasks/components/folk-tasks-board.ts @@ -656,6 +656,8 @@ class FolkTasksBoard extends HTMLElement { .card { padding: 10px; } .card-title { font-size: 13px; } .card-meta { font-size: 10px; } + .create-form input, .create-form select, .create-form textarea { font-size: 16px; } + .create-form-actions button { min-height: 36px; padding: 6px 14px; } } diff --git a/website/public/shell.css b/website/public/shell.css index 08ee348..e54136c 100644 --- a/website/public/shell.css +++ b/website/public/shell.css @@ -443,6 +443,7 @@ body.rstack-sidebar-open #toolbar { body.rstack-sidebar-open rstack-user-dashboard { left: 0; } body.rstack-sidebar-open .rspace-iframe-wrap { left: 0; } body.rstack-sidebar-open #toolbar { left: 12px; } + .rapp-nav__btn, .rapp-nav__back { min-height: 36px; } } @media (max-width: 480px) { @@ -482,3 +483,9 @@ body.rstack-sidebar-open #toolbar { border-radius: 50%; animation: rspace-spin 0.8s linear infinite; } + +/* ── Touch-device utilities ── */ +@media (pointer: coarse) { + .hover-reveal { opacity: 0.5 !important; } + .hover-reveal:active { opacity: 1 !important; } +}