diff --git a/client/index.html b/client/index.html index a6fe622..955bf39 100644 --- a/client/index.html +++ b/client/index.html @@ -45,7 +45,7 @@ + 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
@@ -64,9 +64,11 @@ aria-label="Text size in points" data-help="Text size in points.">
-
+
+ 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 +
diff --git a/client/js/main.js b/client/js/main.js index f87b415..1c45da1 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -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'); }); } diff --git a/client/js/pen_input.js b/client/js/pen_input.js index d7a1838..74b81cd 100644 --- a/client/js/pen_input.js +++ b/client/js/pen_input.js @@ -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 | 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?.(); diff --git a/client/js/renderer.js b/client/js/renderer.js index 231546e..66ba2b8 100644 --- a/client/js/renderer.js +++ b/client/js/renderer.js @@ -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; diff --git a/client/kokkoja.css b/client/kokkoja.css index 5dfa10e..ad96501 100644 --- a/client/kokkoja.css +++ b/client/kokkoja.css @@ -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; diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 6d91414..e2d62e3 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -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."""