Show add-page indicator as a bar and palm-check its taps

The add-page ghost is now a thin bar along the active page's free edge
instead of a full page-sized block. Pointer taps on it go through the
stage gesture handling, so touches are evaluated by the palm rejector
with the same 'draw' policy as writing, including being cut off if the
pen shows up mid-tap. The native click only handles keyboard activation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agent 2026-09-27 17:54:29 +00:00
parent 138b797506
commit 8295894b32

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import DownloadIcon from '@lucide/svelte/icons/download'; import DownloadIcon from '@lucide/svelte/icons/download';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis'; import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'; import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
@ -118,6 +117,17 @@
return rectBeside(a, slot.dir, s); return rectBeside(a, slot.dir, s);
} }
/** Hit area of the add-page bar: a strip hugging the anchor's free edge. */
function barRect(slot: Slot): Rect | null {
const a = doc.rectOf(slot.anchor);
if (!a) return null;
const t = 20 / viewport.scale;
if (slot.dir === 'right') return { x: a.x + a.width, y: a.y, width: t, height: a.height };
if (slot.dir === 'left') return { x: a.x - t, y: a.y, width: t, height: a.height };
if (slot.dir === 'down') return { x: a.x, y: a.y + a.height, width: a.width, height: t };
return { x: a.x, y: a.y - t, width: a.width, height: t };
}
/** Run a tree change without the anchor page jumping on screen. */ /** Run a tree change without the anchor page jumping on screen. */
function keepAnchor(anchor: string, fn: () => void) { function keepAnchor(anchor: string, fn: () => void) {
const before = doc.rectOf(anchor); const before = doc.rectOf(anchor);
@ -227,6 +237,7 @@
| { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder } | { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder }
| { kind: 'erase'; pointerId: number; pointerType: string; hits: Map<string, Set<string>> } | { kind: 'erase'; pointerId: number; pointerType: string; hits: Map<string, Set<string>> }
| { kind: 'pan'; pointerId: number; lastX: number; lastY: number; startX: number; startY: number; moved: boolean; tap: boolean } | { kind: 'pan'; pointerId: number; lastX: number; lastY: number; startX: number; startY: number; moved: boolean; tap: boolean }
| { kind: 'add'; pointerId: number; pointerType: string; startX: number; startY: number; slot: Slot }
| { kind: 'pinch' }; | { kind: 'pinch' };
let gesture: Gesture | null = null; let gesture: Gesture | null = null;
@ -280,8 +291,10 @@
// Fingers ink only when enabled and no pen has been used; otherwise they pan/zoom. // Fingers ink only when enabled and no pen has been used; otherwise they pan/zoom.
const fingerInks = tools.inking && settings.data.fingerDraw && !palm.penSeen; const fingerInks = tools.inking && settings.data.fingerDraw && !palm.penSeen;
// Taps on an add-page bar get the same palm policy as writing.
const bar = target.closest<HTMLElement>('.ghost');
if (e.pointerType === 'touch') { if (e.pointerType === 'touch') {
const decision = palm.evaluate(e, fingerInks ? 'draw' : 'navigate'); const decision = palm.evaluate(e, fingerInks || bar ? 'draw' : 'navigate');
if (!decision.accept) return; // palm: ignore entirely if (!decision.accept) return; // palm: ignore entirely
touches.set(e.pointerId, p); touches.set(e.pointerId, p);
if (touches.size >= 2) { if (touches.size >= 2) {
@ -292,6 +305,14 @@
} }
} }
if (bar && e.button === 0) {
e.preventDefault();
const slot = { anchor: bar.dataset.anchor!, dir: bar.dataset.dir as Dir };
gesture = { kind: 'add', pointerId: e.pointerId, pointerType: e.pointerType, startX: p.x, startY: p.y, slot };
capture(e.pointerId);
return;
}
const onPage = pageAt(p.x, p.y); const onPage = pageAt(p.x, p.y);
const inEditor = !!target.closest('.cm-editor'); const inEditor = !!target.closest('.cm-editor');
// Pressing anywhere outside the text leaves the editor (preventDefault // Pressing anywhere outside the text leaves the editor (preventDefault
@ -303,10 +324,10 @@
e.button === 1 || e.button === 1 ||
spaceDown || spaceDown ||
(e.pointerType === 'touch' && (!fingerInks || !onPage)) || (e.pointerType === 'touch' && (!fingerInks || !onPage)) ||
(e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.ghost, .slot')); (e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.slot'));
if (wantsPan) { if (wantsPan) {
if (onPage && !inEditor) doc.activeId = onPage; if (onPage && !inEditor) doc.activeId = onPage;
gesture = { kind: 'pan', pointerId: e.pointerId, lastX: p.x, lastY: p.y, startX: p.x, startY: p.y, moved: false, tap: inEditor || !!target.closest('.ghost, .slot') }; gesture = { kind: 'pan', pointerId: e.pointerId, lastX: p.x, lastY: p.y, startX: p.x, startY: p.y, moved: false, tap: inEditor || !!target.closest('.slot') };
if (e.pointerType !== 'touch' || !gesture.tap) { if (e.pointerType !== 'touch' || !gesture.tap) {
if (e.pointerType !== 'touch') e.preventDefault(); if (e.pointerType !== 'touch') e.preventDefault();
capture(e.pointerId); capture(e.pointerId);
@ -375,6 +396,12 @@
return; return;
} }
if (gesture.kind === 'add') {
// Dragging off the bar is not a tap.
if (Math.hypot(p.x - gesture.startX, p.y - gesture.startY) > 12) gesture = null;
return;
}
if (gesture.kind === 'erase') { if (gesture.kind === 'erase') {
eraseAt(e); eraseAt(e);
return; return;
@ -402,7 +429,9 @@
if (!gesture || gesture.pointerId !== e.pointerId) return; if (!gesture || gesture.pointerId !== e.pointerId) return;
const g = gesture; const g = gesture;
gesture = null; gesture = null;
if (g.kind === 'draw') { if (g.kind === 'add') {
if (e.type === 'pointerup' && (g.pointerType !== 'touch' || palm.evaluate(e).accept)) addPage(g.slot.anchor, g.slot.dir);
} else if (g.kind === 'draw') {
live = null; live = null;
if (e.type === 'pointerup') doc.addStroke(g.pageId, g.rec.stroke); if (e.type === 'pointerup') doc.addStroke(g.pageId, g.rec.stroke);
} else if (g.kind === 'erase') { } else if (g.kind === 'erase') {
@ -564,18 +593,17 @@
{/each} {/each}
{#each ghostSlots as slot (slot.anchor + slot.dir)} {#each ghostSlots as slot (slot.anchor + slot.dir)}
{@const r = slotRect(slot)} {@const r = barRect(slot)}
{#if r} {#if r}
<!-- Pointer taps go through the palm-checked gesture; onclick only serves the keyboard. -->
<button <button
class="ghost" class="ghost"
data-anchor={slot.anchor}
data-dir={slot.dir}
style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px" style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px"
title="Add page ({slot.dir})" title="Add page ({slot.dir})"
onclick={() => addPage(slot.anchor, slot.dir)} onclick={(e) => e.detail === 0 && addPage(slot.anchor, slot.dir)}
> ></button>
<span class="plus" style="--icon: {Math.min(r.width, r.height) * 0.14}px">
<CirclePlusIcon size="100%" strokeWidth={1.25} />
</span>
</button>
{/if} {/if}
{/each} {/each}
@ -696,7 +724,49 @@
top: 0; top: 0;
transform-origin: 0 0; transform-origin: 0 0;
} }
.ghost, .ghost {
position: absolute;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
/* The visible bar: 4 screen px thick, 6 px off the page edge. */
.ghost::before {
content: '';
position: absolute;
background: var(--ghost-border);
border-radius: calc(2px / var(--s));
transition: background 0.12s;
}
.ghost[data-dir='left']::before,
.ghost[data-dir='right']::before {
top: 0;
bottom: 0;
width: calc(4px / var(--s));
}
.ghost[data-dir='up']::before,
.ghost[data-dir='down']::before {
left: 0;
right: 0;
height: calc(4px / var(--s));
}
.ghost[data-dir='right']::before {
left: calc(6px / var(--s));
}
.ghost[data-dir='left']::before {
right: calc(6px / var(--s));
}
.ghost[data-dir='down']::before {
top: calc(6px / var(--s));
}
.ghost[data-dir='up']::before {
bottom: calc(6px / var(--s));
}
.ghost:hover::before,
.ghost:focus-visible::before {
background: var(--accent);
}
.slot { .slot {
position: absolute; position: absolute;
display: grid; display: grid;
@ -709,7 +779,6 @@
padding: 0; padding: 0;
transition: background 0.12s, color 0.12s; transition: background 0.12s, color 0.12s;
} }
.ghost:hover,
.slot:hover { .slot:hover {
background: var(--accent-soft); background: var(--accent-soft);
color: var(--accent); color: var(--accent);
@ -720,7 +789,6 @@
color: var(--accent); color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 50%, transparent); border-color: color-mix(in srgb, var(--accent) 50%, transparent);
} }
.plus,
.arrow { .arrow {
display: grid; display: grid;
width: var(--icon); width: var(--icon);