Arrange tool, and a hold that is easier to find

An Arrange tool marks the corners of every image, Hide and own text
rectangle in blue and moves or resizes them by a plain drag, a press
elsewhere doing nothing.  The hold that does the same with any tool is
shorter (450 ms), with more slack, and shows all four corners.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-14 00:01:45 +02:00
1 parent 94a3c1ba0e
commit 8ecf3e94a6
6 files changed
+260 -25

No files matched your search

+5 -3
View File
@@ -45,7 +45,7 @@
<button type="button" data-tool="pen" aria-pressed="true"
data-help="Write on the page. Only your own writing can be erased.">Pen</button>
<button type="button" data-tool="text" aria-pressed="false"
data-help="Type on the page. Tap where the text should begin, or drag open a rectangle: a red box, white inside, in which the text wraps and which grows downward as you type. Tap your own text to change it; rest the pen on it to move it, or near a corner of a box to resize it.">Text</button>
data-help="Type on the page. Tap where the text should begin, or drag open a rectangle: a red box, white inside, in which the text wraps and which grows downward as you type. Tap your own text to change it; rest the pen on it to move it, or near a corner of a box to resize it, or use Arrange.">Text</button>
</div>
<div id="settings" class="settings">
<div class="group" id="colors" role="radiogroup" aria-label="Color">
@@ -64,9 +64,11 @@
aria-label="Text size in points" data-help="Text size in points.">
</div>
</div>
<div class="group after_settings" role="radiogroup" aria-label="Hide and Eraser">
<div class="group after_settings" role="radiogroup" aria-label="Hide, Arrange and Eraser">
<button type="button" data-tool="hide" aria-pressed="false" hidden
data-help="Hide part of the page for everyone: drag open a rectangle, and what lies under it is covered in white, marked by blue corners. Rest the pen on it to move it, or near a corner to resize it; a tap of the eraser removes it.">Hide</button>
data-help="Hide part of the page for everyone: drag open a rectangle, and what lies under it is covered in white, marked by blue corners. Rest the pen on it to move it, or near a corner to resize it, or use Arrange; a tap of the eraser removes it.">Hide</button>
<button type="button" data-tool="arrange" aria-pressed="false"
data-help="Move and resize: the corners of every image, Hide and text box of yours are marked in blue. Drag inside one to move it, drag a corner to resize it (an image keeps its shape). A press elsewhere does nothing. With any other tool, resting the pen on one of them for half a second does the same.">Arrange</button>
<button type="button" data-tool="eraser" aria-pressed="false"
data-help="Erase your own strokes and text by moving over them. A tap on an image, or on a Hide rectangle, removes it, if you are the host or a co-host (or added the image yourself). Two quick taps on empty paper, away from anything the eraser would remove, undo your latest mark; again, and the one before.">Eraser</button>
</div>
+40 -5
View File
@@ -460,7 +460,7 @@ async function enterSession(invitationText, name, notebook) {
showInvitationCode(config.site ? input('invitation').value : '');
const isHost = identity.id === invitation.hostId;
let tool = /** @type {'pen' | 'eraser' | 'text' | 'hide'} */ ('pen');
let tool = /** @type {'pen' | 'eraser' | 'text' | 'hide' | 'arrange'} */ ('pen');
let color = DEFAULT_COLOR;
let width = WIDTHS[1];
let textSize = TEXT_SIZE;
@@ -510,6 +510,7 @@ async function enterSession(invitationText, name, notebook) {
onTextChanged: (layerId) => {
syncLayers();
renderer.redrawLayer(layerId);
if (layerId === session.identity.id) refreshArrangeCues();
writer.changed();
},
onPageReplaced: (oldPage, newPage) => {
@@ -529,11 +530,13 @@ async function enterSession(invitationText, name, notebook) {
syncLayers();
showParticipants(session, renderer);
showPageNumber();
refreshArrangeCues();
},
onImagesChanged: () => {
log(`Page ${session.page.number}: ${session.page.images.length} images, ${session.page.hides.length} hides`);
renderer.showImages();
renderer.drawHides();
refreshArrangeCues();
writer.writeNow();
},
onPageResized: () => {
@@ -552,6 +555,7 @@ async function enterSession(invitationText, name, notebook) {
(session.isHost ? `; guests allowed: ${session.permitted.size}` : ''));
showHostTools();
showParticipants(session, renderer);
refreshArrangeCues();
},
onParticipantsChanged: () => {
syncLayers();
@@ -781,6 +785,32 @@ async function enterSession(invitationText, name, notebook) {
return block ? textBounds(block) : null;
}
/**
* With the Arrange tool chosen, mark what a press can move or resize:
* the images and Hides (for the host and co-hosts) and one's own text
* rectangles; free text can be moved but has no corners. Called
* whenever any of them, or the right, may have changed.
*/
function refreshArrangeCues() {
if (tool !== 'arrange') {
renderer.setArrangeCues([]);
return;
}
const page = session.page;
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide'}[]} */
const cues = [];
if (session.mayMakePages()) {
for (const im of page.images) cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height } });
}
for (const block of session.ownLayer.texts.values()) {
if (block.height !== null) cues.push({ kind: 'text', rect: textBounds(block) });
}
if (session.mayMakePages()) {
for (const h of page.hides) cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height } });
}
renderer.setArrangeCues(cues);
}
/** @type {import('./pen_input.js').PenTarget} */
const penTarget = {
toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY),
@@ -871,6 +901,7 @@ async function enterSession(invitationText, name, notebook) {
if (textEditor?.block?.id === block.id) textEditor.layout();
}
renderer.showGrip(currentHeldRect(held), held.corner);
refreshArrangeCues();
},
holdEnd: () => {
if (!held) return;
@@ -881,6 +912,7 @@ async function enterSession(invitationText, name, notebook) {
if (held.kind === 'hide') session.endHideEdit();
held = null;
renderer.showGrip(null);
refreshArrangeCues();
},
eraseAlong: (x0, y0, x1, y1) => {
textEditor?.stop();
@@ -979,10 +1011,12 @@ async function enterSession(invitationText, name, notebook) {
if (value !== tool) trace(`Tool: ${value}`);
tool = value;
// Only the chosen tool's settings show: the pen's colours and line
// widths, the text's colours, size and font, nothing for the eraser.
element('colors').hidden = value === 'eraser' || value === 'hide';
// widths, the text's colours, size and font, nothing for the
// eraser, Hide or Arrange.
element('colors').hidden = value === 'eraser' || value === 'hide' || value === 'arrange';
element('widths').hidden = value !== 'pen';
element('text_style').hidden = value !== 'text';
refreshArrangeCues();
},
setColor: (value) => {
color = value;
@@ -1249,7 +1283,7 @@ function press(group, chosen) {
* @param {number} options.width Initial width.
* @param {number} options.textSize
* @param {import('./page_model.js').TextFont} options.textFont
* @param {(tool: 'pen' | 'eraser' | 'text' | 'hide') => void} options.setTool
* @param {(tool: 'pen' | 'eraser' | 'text' | 'hide' | 'arrange') => void} options.setTool
* @param {(color: string) => void} options.setColor
* @param {(width: number) => void} options.setWidth
* @param {(size: number) => void} options.setTextSize
@@ -1265,7 +1299,8 @@ function buildToolbar(options) {
other.setAttribute('aria-pressed', String(other === button));
}
const chosen = button.getAttribute('data-tool');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide' : 'pen');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide'
: chosen === 'arrange' ? 'arrange' : 'pen');
});
}
+23 -6
View File
@@ -45,7 +45,7 @@ const focusSettleOn = !new URLSearchParams(location.search).has('no_focus_settle
* @typedef {object} PenTarget
* @property {(clientX: number, clientY: number) => [number, number]} toPage
* Convert a window position to page units.
* @property {() => 'pen' | 'eraser' | 'text' | 'hide'} tool The tool selected in the toolbar.
* @property {() => 'pen' | 'eraser' | 'text' | 'hide' | 'arrange'} tool The tool selected in the toolbar.
* @property {() => boolean} canWrite False while there is no page to write on.
* @property {() => void} beginStroke
* @property {(points: number[]) => void} addPoints
@@ -85,12 +85,14 @@ const ZOOM_DEAD_ZONE = 4;
/**
* A press that rests this long becomes a hold, which moves or scales the
* image under it (Andy's teacher, 2026-10-08; 600 ms so that a careful
* start of a stroke is not one). Moving farther than HOLD_SLACK_PX
* before then makes it a stroke after all.
* image, text block or Hide under it (Andy, 2026-10-08; 600 ms until
* 2026-10-13, when the hold was made easier to find: shorter, and with
* more slack for a pen held in the air). Longer than a tap, shorter than
* a pause in writing (the start of a stroke is not one). Moving farther
* than HOLD_SLACK_PX before that is a stroke, or erasing, as before.
*/
export const HOLD_MS = 600;
export const HOLD_SLACK_PX = 6;
export const HOLD_MS = 450;
export const HOLD_SLACK_PX = 10;
/**
* What the input device is delivering, for judging pen support.
@@ -121,6 +123,8 @@ export function attachPenInput(surface, target) {
/** @type {ReturnType<typeof setTimeout> | null} */
let holdTimer = null;
let holding = false;
/** With the Arrange tool: the press took hold at once, or found nothing and does nothing. */
let arranging = false;
let pressX = 0;
let pressY = 0;
/** @type {PointerEvent | null} The eraser's press, not yet erased with. */
@@ -246,6 +250,15 @@ export function attachPenInput(surface, target) {
erasedTexts = 0;
holding = false;
eraseWaiting = null;
// The Arrange tool holds at once: a press on an image, a text block
// or a Hide moves it, on a corner resizes it, and a press elsewhere
// does nothing (Andy, 2026-10-13).
if (tool === 'arrange' && !erasing) {
arranging = true;
holding = target.beginHold?.(event.clientX, event.clientY) ?? false;
trace(holding ? 'arrange: took hold' : 'arrange: nothing under the pointer');
return;
}
// Resting the pointer, with any tool, may become a hold on an image.
if (target.beginHold) {
pressX = event.clientX;
@@ -281,6 +294,7 @@ export function attachPenInput(surface, target) {
target.holdMove?.(event.clientX, event.clientY);
return;
}
if (arranging) return;
if (holdTimer !== null && Math.hypot(event.clientX - pressX, event.clientY - pressY) > HOLD_SLACK_PX) {
cancelHoldTimer();
eraseFromPress();
@@ -315,7 +329,10 @@ export function attachPenInput(surface, target) {
cancelHoldTimer();
if (holding) {
holding = false;
arranging = false;
target.holdEnd?.();
} else if (arranging) {
arranging = false;
} else if (zooming) {
zooming = false;
target.endZoom?.();
+73 -9
View File
@@ -107,6 +107,19 @@ function strokeCrisp(context, polylines, color, width) {
context.restore();
}
/**
* Four handle marks, one for each corner of an overlay (the grip and the
* Arrange cues), placed by their class.
* @returns {HTMLElement[]}
*/
function cornerHandles() {
return ['nw', 'ne', 'sw', 'se'].map((corner) => {
const handle = document.createElement('span');
handle.className = `handle ${corner}`;
return handle;
});
}
/**
* Draw the corners of a rectangle as short L-shaped marks.
* @param {CanvasRenderingContext2D} context
@@ -221,13 +234,30 @@ export class Renderer {
* @type {{element: HTMLImageElement, data: Uint8Array | null, hash: string}[]}
*/
this.imageElements = [];
/** The green cue around an image being handled by hand. */
/**
* The green cue around an image, text block or Hide being handled by
* hand: its outline and its four corners, the corner being dragged
* marked (all four since 2026-10-13, so that a hold shows where the
* corners are).
*/
this.grip = document.createElement('div');
this.grip.className = 'image_grip';
this.grip.hidden = true;
this.grip.append(...cornerHandles());
this.pageElement.append(this.grip);
/** @type {{rect: import('./page_model.js').Rectangle, corner: [number, number] | null} | null} */
this.gripShown = null;
/**
* The blue cues of the Arrange tool: the corners of every image, text
* rectangle and Hide that can be moved or resized, and an outline for
* images, whose edges may be invisible on the white page (Andy,
* 2026-10-13: figures on a white ground).
* @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide'}[]}
*/
this.arrangeCues = [];
this.cueElements = document.createElement('div');
this.cueElements.className = 'arrange_cues';
this.pageElement.append(this.cueElements);
/** Canvas pixels per page unit. */
this.scale = 1;
@@ -501,19 +531,52 @@ export class Renderer {
const shown = this.gripShown;
this.grip.hidden = !shown;
if (!shown) return;
const page = this.page;
const style = this.grip.style;
style.left = `${(shown.rect.left - page.left) / page.width * 100}%`;
style.top = `${(shown.rect.top - page.top) / page.height * 100}%`;
style.width = `${shown.rect.width / page.width * 100}%`;
style.height = `${shown.rect.height / page.height * 100}%`;
this.placeOverlay(this.grip, shown.rect);
this.grip.classList.toggle('corner', shown.corner !== null);
if (shown.corner) {
style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%');
style.setProperty('--corner-y', shown.corner[1] ? '100%' : '0%');
this.grip.style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%');
this.grip.style.setProperty('--corner-y', shown.corner[1] ? '100%' : '0%');
}
}
/**
* Place an overlay element over a rectangle of the page, in percentages
* of the page, so that it keeps its place at any zoom.
* @param {HTMLElement} el
* @param {import('./page_model.js').Rectangle} rect Page units.
*/
placeOverlay(el, rect) {
const page = this.page;
el.style.left = `${(rect.left - page.left) / page.width * 100}%`;
el.style.top = `${(rect.top - page.top) / page.height * 100}%`;
el.style.width = `${rect.width / page.width * 100}%`;
el.style.height = `${rect.height / page.height * 100}%`;
}
/**
* Show the Arrange tool's cues on these rectangles, or none.
* @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide'}[]} cues
*/
setArrangeCues(cues) {
this.arrangeCues = cues;
this.cueElements.replaceChildren();
for (const cue of cues) {
const el = document.createElement('div');
el.className = `arrange_cue ${cue.kind}`;
el.append(...cornerHandles());
this.cueElements.append(el);
}
this.layoutCues();
}
layoutCues() {
const elements = this.cueElements.children;
this.arrangeCues.forEach((cue, i) => {
const el = /** @type {HTMLElement} */ (elements[i]);
if (el) this.placeOverlay(el, cue.rect);
});
}
/** @returns {number} CORNER_REACH_PX in page units at the present zoom. */
cornerReach() {
return CORNER_REACH_PX / this.cssPixelsPerUnit;
@@ -573,6 +636,7 @@ export class Renderer {
this.pageElement.style.height = `${this.page.height / unitsPerCssPixel}px`;
this.layoutImages();
this.layoutGrip();
this.layoutCues();
// When the page grew to the left or upward, keep what is in view in view.
if (this.shownLeft !== null && this.page.left < this.shownLeft) {
desk.scrollLeft += (this.shownLeft - this.page.left) / unitsPerCssPixel;
+26 -2
View File
@@ -277,19 +277,43 @@ h2 {
box-sizing: border-box;
border: 2px solid #2e7d32;
pointer-events: none;
--handle-color: #2e7d32;
}
/* The four corners of a held object, and the corner being dragged, larger. */
.handle {
position: absolute;
width: 10px;
height: 10px;
transform: translate(-50%, -50%);
background: var(--handle-color);
}
.handle.nw { left: 0; top: 0; }
.handle.ne { left: 100%; top: 0; }
.handle.sw { left: 0; top: 100%; }
.handle.se { left: 100%; top: 100%; }
.image_grip.corner::after {
content: "";
position: absolute;
left: var(--corner-x);
top: var(--corner-y);
width: 12px;
height: 12px;
width: 16px;
height: 16px;
transform: translate(-50%, -50%);
background: #2e7d32;
}
/* The Arrange tool's cues: blue corners on everything that can be moved
or resized, and an outline on images, whose edges may not show. */
.arrange_cues { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
.arrange_cue {
position: absolute;
box-sizing: border-box;
--handle-color: #1e88e5;
}
.arrange_cue.image { border: 1px solid #1e88e5; }
.page {
position: relative;
flex: none;
+93
View File
@@ -1391,6 +1391,99 @@ class EndToEndTest(unittest.TestCase):
self.wait_until(lambda s: s["texts"].get("Typist") and s["texts"]["Typist"][0]["text"] == shown,
"the onlooker to see the same text")
def test_arrange_tool(self):
"""Andy, 2026-10-13: with the Arrange tool, blue corners mark every
image, Hide and own text rectangle, a drag inside one moves it and
a drag on a corner resizes it, at once, no hold; a press elsewhere
does nothing. A guest without the right sees only their own text."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
self.enter_name("Arranger", "start_session")
invitation = firefox.run("return document.getElementById('invitation').value;")
guest_tab = firefox.new_tab()
firefox.go(self.address("Student", join=invitation))
self.enter_name("Watcher", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the watcher")
firefox.switch_to(host_tab)
cues = lambda: firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => c.className.split(' ')[1]);")
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
rect = lambda state, i: [state["images"][i][k] for k in ("x", "y", "width", "height")]
firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False)
placed = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["ready"], "the image")
self.assertEqual(cues(), [], "no cues with the pen")
choose("arrange")
self.assertEqual(cues(), ["image"])
self.assertTrue(firefox.run("return document.getElementById('colors').hidden;"))
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
origin = placed["origin"]
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
# A drag inside the image moves it at once, and draws nothing.
before = rect(placed, 0)
firefox.drag("#page", px(before[0] + 150), py(before[1] + 100), px(before[0] + 150) + 200, py(before[1] + 100) + 100)
moved = self.wait_until(lambda s: s["images"][0]["x"] != before[0], "the image moved")
self.assertEqual(rect(moved, 0)[2:], before[2:], "the same size")
self.assertAlmostEqual(moved["images"][0]["x"], before[0] + 200 / scale, delta=2)
self.assertAlmostEqual(moved["images"][0]["y"], before[1] + 100 / scale, delta=2)
self.assertEqual(moved["layers"]["Arranger"], [], "no stroke")
# A drag on its bottom-right corner scales it, the shape kept, the
# top-left corner fixed.
now = rect(moved, 0)
firefox.drag("#page", px(now[0] + now[2]) - 3, py(now[1] + now[3]) - 3, px(now[0] + now[2]) + 150, py(now[1] + now[3]) - 3)
scaled = self.wait_until(lambda s: s["images"][0]["width"] != now[2], "the image scaled")
self.assertEqual(rect(scaled, 0)[:2], now[:2], "the top-left corner stays")
self.assertAlmostEqual(scaled["images"][0]["width"] / scaled["images"][0]["height"], 300 / 200, places=2)
self.assertGreater(scaled["images"][0]["width"], now[2] + 100 / scale)
# A drag on empty paper does nothing at all.
firefox.drag("#page", px(100), py(1800), px(600), py(1900))
time.sleep(0.5)
after = self.state()
self.assertEqual(after["layers"]["Arranger"], [])
self.assertEqual(rect(after, 0), rect(scaled, 0))
# A text rectangle of one's own and a Hide get cues too; a corner
# drag widens the text box, a drag inside moves the Hide.
choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.3, 0.7)
firefox.type_into(".text_field", "Box")
choose("hide")
firefox.run(TEXT_GESTURE, 0.5, 0.6, 0.7, 0.7)
with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and s["texts"]["Arranger"] and s["texts"]["Arranger"][0]["text"] == "Box",
"the box and the Hide")
choose("arrange")
self.assertEqual(sorted(cues()), ["hide", "image", "text"])
box = with_all["texts"]["Arranger"][0]
firefox.drag("#page", px(box["x"] + box["width"]) - 3, py(box["y"] + box["height"]) - 3,
px(box["x"] + box["width"]) + 100, py(box["y"] + box["height"]) + 40)
widened = self.wait_until(lambda s: s["texts"]["Arranger"][0]["width"] > box["width"] + 80 / scale, "the box widened")
self.assertEqual((widened["texts"]["Arranger"][0]["x"], widened["texts"]["Arranger"][0]["y"]), (box["x"], box["y"]))
hide = with_all["hides"][0]
firefox.drag("#page", px(hide[0] + hide[2] / 2), py(hide[1] + hide[3] / 2),
px(hide[0] + hide[2] / 2) - 100, py(hide[1] + hide[3] / 2) + 50)
hide_moved = self.wait_until(lambda s: s["hides"][0][0] != hide[0], "the Hide moved")
self.assertEqual(hide_moved["hides"][0][2:], hide[2:])
self.assertEqual(len(cues()), 3)
# Undo takes the Hide's move back, as it does a hold's.
firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide back by Undo")
# The watcher, without the right, is shown only their own text.
firefox.switch_to(guest_tab)
self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher")
choose("arrange")
self.assertEqual(cues(), [])
choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9)
firefox.type_into(".text_field", "Mine")
choose("arrange")
self.assertEqual(cues(), ["text"])
def test_text_being_typed_stays_on_the_page_left(self):
"""Andy, 2026-10-06: a guest was typing when the host pressed New,
and the text reappeared on the new page."""