Compare commits

...

2 commits

Author SHA1 Message Date
2e52f1be2c Merge pull request 'Show add-page indicator as a bar and palm-check its taps' (#8) from flat-pages-grid into main
Reviewed-on: #8
Reviewed-by: reudy <jakubadorfman@proton.me>
2026-09-27 21:38:30 +02:00
8295894b32 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>
2026-09-27 17:54:29 +00:00

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import DownloadIcon from '@lucide/svelte/icons/download';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
@ -118,6 +117,17 @@
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. */
function keepAnchor(anchor: string, fn: () => void) {
const before = doc.rectOf(anchor);
@ -227,6 +237,7 @@
| { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder }
| { 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: 'add'; pointerId: number; pointerType: string; startX: number; startY: number; slot: Slot }
| { kind: 'pinch' };
let gesture: Gesture | null = null;
@ -280,8 +291,10 @@
// Fingers ink only when enabled and no pen has been used; otherwise they pan/zoom.
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') {
const decision = palm.evaluate(e, fingerInks ? 'draw' : 'navigate');
const decision = palm.evaluate(e, fingerInks || bar ? 'draw' : 'navigate');
if (!decision.accept) return; // palm: ignore entirely
touches.set(e.pointerId, p);
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 inEditor = !!target.closest('.cm-editor');
// Pressing anywhere outside the text leaves the editor (preventDefault
@ -303,10 +324,10 @@
e.button === 1 ||
spaceDown ||
(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 (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') e.preventDefault();
capture(e.pointerId);
@ -375,6 +396,12 @@
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') {
eraseAt(e);
return;
@ -402,7 +429,9 @@
if (!gesture || gesture.pointerId !== e.pointerId) return;
const g = gesture;
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;
if (e.type === 'pointerup') doc.addStroke(g.pageId, g.rec.stroke);
} else if (g.kind === 'erase') {
@ -564,18 +593,17 @@
{/each}
{#each ghostSlots as slot (slot.anchor + slot.dir)}
{@const r = slotRect(slot)}
{@const r = barRect(slot)}
{#if r}
<!-- Pointer taps go through the palm-checked gesture; onclick only serves the keyboard. -->
<button
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"
title="Add page ({slot.dir})"
onclick={() => addPage(slot.anchor, slot.dir)}
>
<span class="plus" style="--icon: {Math.min(r.width, r.height) * 0.14}px">
<CirclePlusIcon size="100%" strokeWidth={1.25} />
</span>
</button>
onclick={(e) => e.detail === 0 && addPage(slot.anchor, slot.dir)}
></button>
{/if}
{/each}
@ -696,7 +724,49 @@
top: 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 {
position: absolute;
display: grid;
@ -709,7 +779,6 @@
padding: 0;
transition: background 0.12s, color 0.12s;
}
.ghost:hover,
.slot:hover {
background: var(--accent-soft);
color: var(--accent);
@ -720,7 +789,6 @@
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
.plus,
.arrow {
display: grid;
width: var(--icon);