From 56ab259cd457d41c570ee9ca7fab79e1876882fd Mon Sep 17 00:00:00 2001 From: Andy Kopra Date: Wed, 14 Oct 2026 01:48:56 +0200 Subject: [PATCH] Shrink ignores what cannot be seen; blank lines at a block's end go Co-Authored-By: Claude Fable 5.1 --- client/js/page_model.js | 52 +++++++++++++++++++++----- client/js/session.js | 10 +++++ client/js/text_editor.js | 8 +++- client/js/text_layout.js | 20 ++++++++++ tests/test_end_to_end.py | 79 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 11 deletions(-) diff --git a/client/js/page_model.js b/client/js/page_model.js index 41b69b2..daa85c7 100644 --- a/client/js/page_model.js +++ b/client/js/page_model.js @@ -184,6 +184,17 @@ export function cleanText(received) { }; } +/** The paper's colour, in which a mark cannot be seen. */ +export const PAPER_WHITE = '#ffffff'; + +/** + * @param {string} color + * @returns {boolean} Whether a mark of this colour is invisible on the paper. + */ +export function isPaperWhite(color) { + return color.trim().toLowerCase() === PAPER_WHITE; +} + /** * The rectangle a text block takes up: its own, grown to its lines, for a * text rectangle; the lines' box for free text. @@ -550,20 +561,43 @@ export class Page { * @returns {Rectangle | null} Null if the page is blank. */ contentSheet() { - /** @type {Rectangle | null} */ - let box = this.strokeBounds ? { ...this.strokeBounds } : null; - for (const item of [...this.images, ...this.hides]) { + // What can be seen (Andy, 2026-10-14): a stroke or free text in the + // paper's white is left out (over an image it shows, but lies within + // the image's box, which counts); a text rectangle counts whole, its + // red frame being visible. The record that grows the page while + // writing (strokeBounds) is not used here, so a white stroke still + // gets room as it is written. + /** @type {{box: Rectangle | null}} A holder, since the closure's assignments are not seen by the type checker. */ + const found = { box: null }; + /** @param {number} left @param {number} top @param {number} width @param {number} height */ + const add = (left, top, width, height) => { + const box = found.box; if (!box) { - box = { left: item.x, top: item.y, width: item.width, height: item.height }; - continue; + found.box = { left, top, width, height }; + return; } - const right = Math.max(box.left + box.width, item.x + item.width); - const bottom = Math.max(box.top + box.height, item.y + item.height); - box.left = Math.min(box.left, item.x); - box.top = Math.min(box.top, item.y); + const right = Math.max(box.left + box.width, left + width); + const bottom = Math.max(box.top + box.height, top + height); + box.left = Math.min(box.left, left); + box.top = Math.min(box.top, top); box.width = right - box.left; box.height = bottom - box.top; + }; + for (const layer of this.layers.values()) { + for (const stroke of layer.strokes.values()) { + if (isPaperWhite(stroke.color)) continue; + const reach = stroke.width * 2; // As includeStroke reckons it. + const p = stroke.points; + for (let i = 0; i < p.length; i += POINT_SIZE) add(p[i] - reach, p[i + 1] - reach, 2 * reach, 2 * reach); + } + for (const block of layer.texts.values()) { + if (block.height === null && isPaperWhite(block.color)) continue; + const b = textBounds(block); + add(b.left, b.top, b.width, b.height); + } } + for (const item of [...this.images, ...this.hides]) add(item.x, item.y, item.width, item.height); + const box = found.box; if (!box) return null; // The greater of the two margins (Andy, 2026-10-08), exactly, the same // on all four sides (a grid made them uneven, Andy, 2026-10-08). diff --git a/client/js/session.js b/client/js/session.js index 16dbd8e..fdc2005 100644 --- a/client/js/session.js +++ b/client/js/session.js @@ -17,6 +17,7 @@ import { MAX_PAGE_HIDES, } from './page_model.js'; import { RelayConnection } from './relay_connection.js'; +import { trimTrailingBlankLines } from './text_layout.js'; /** @typedef {import('./crypto.js').Identity} Identity */ /** @typedef {import('./page_model.js').Layer} Layer */ @@ -525,6 +526,15 @@ export class Session { shrinkPage() { const page = this.page; if (!this.mayMakePages() || page.number === 0) return false; + // The blank lines at the end of one's own text blocks go first (Andy, + // 2026-10-14); another's blocks are theirs, trimmed when they finish + // typing. Each trim is a gesture of its own to undo; a block being + // typed in is left to the editor. + for (const block of this.ownLayer.texts.values()) { + if (!this.beginTextEdit(block.id, 'shrink')) continue; + if (trimTrailingBlankLines(block)) this.setText(block); + this.endTextEdit(block.id, 'shrink'); + } const sheet = page.contentSheet(); if (!sheet || (sheet.left === page.baseLeft && sheet.top === page.baseTop && sheet.width === page.baseWidth && sheet.height === page.baseHeight)) return false; diff --git a/client/js/text_editor.js b/client/js/text_editor.js index 3d9c62b..b01d424 100644 --- a/client/js/text_editor.js +++ b/client/js/text_editor.js @@ -14,7 +14,7 @@ import { randomId } from './crypto.js'; import { textBounds } from './page_model.js'; import { TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js'; import { HOLD_MS, HOLD_SLACK_PX } from './pen_input.js'; -import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, baselineOf, fontMetrics, layoutText } from './text_layout.js'; +import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, baselineOf, fontMetrics, layoutText, trimTrailingBlankLines } from './text_layout.js'; /** @typedef {import('./page_model.js').TextBlock} TextBlock */ /** @typedef {import('./page_model.js').TextFont} TextFont */ @@ -206,7 +206,10 @@ export class TextEditor { this.field.setSelectionRange(block.text.length, block.text.length); } - /** Finish editing. An empty block is removed. */ + /** + * Finish editing. The blank lines at the end go (Andy, 2026-10-14), + * and an empty block is removed. + */ stop() { const block = this.block; if (!block) return; @@ -215,6 +218,7 @@ export class TextEditor { this.field.hidden = true; this.mark.hidden = true; this.field.value = ''; + trimTrailingBlankLines(block); if (block.text.trim() === '') this.target.removeText(block.id); else this.target.setText(block); this.target.endEdit?.(block.id); diff --git a/client/js/text_layout.js b/client/js/text_layout.js index 15cb3cf..aab87d0 100644 --- a/client/js/text_layout.js +++ b/client/js/text_layout.js @@ -146,3 +146,23 @@ export function layoutText(text, width, size, family) { boxHeight: Math.ceil(size * TOP_GAP + lines.length * size * LINE_HEIGHT), }; } + +/** + * Remove the blank lines at the end of a block's text (Andy, 2026-10-14: + * they cannot be seen, and Shrink should keep no room for them) and lay + * the block out again. A text rectangle that is exactly as tall as its + * lines, which is what a box grown by typing is, shrinks back to the + * lines that remain; one dragged taller than its lines keeps its height, + * since that room was asked for. + * @param {import('./page_model.js').TextBlock} block + * @returns {boolean} Whether anything changed. + */ +export function trimTrailingBlankLines(block) { + const trimmed = block.text.replace(/(?:\n[ \t]*)+$/, ''); + if (trimmed === block.text) return false; + const grown = block.height !== null && block.height === Math.ceil(block.boxHeight); + block.text = trimmed; + Object.assign(block, layoutText(block.text, block.width, block.size, block.font)); + if (grown) block.height = Math.ceil(block.boxHeight); + return true; +} diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 29079d3..9400302 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -14,6 +14,7 @@ the second participant sees it at the end. import base64 import json +import math import os import pathlib import re @@ -1587,6 +1588,84 @@ class EndToEndTest(unittest.TestCase): firefox.run("document.getElementById('manual_close').click();") self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;")) + def test_shrink_ignores_the_invisible(self): + """Andy, 2026-10-14: what cannot be seen does not count for Shrink: + strokes and free text in the paper's white, and the blank lines at + the end of a text block, which go when its typing ends (a box + grown by them shrinks back) and, for one's own, at Shrink.""" + firefox = self.firefox + firefox.go(self.address("Late")) + self.enter_name("Shrinker", "start_session") + state = self.wait_until(lambda s: s["page"]["number"] == 1, "the page") + scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") + origin = state["origin"] + px = lambda x: (x - origin[0]) * scale + py = lambda y: (y - origin[1]) * scale + swatch = lambda i: firefox.run(f"document.querySelectorAll('#colors .swatch')[{i}].click();") + + # A black stroke, then a white stroke and white free text far to the + # right and below, where they would widen the shrunk page if counted. + firefox.drag("#page", px(500), py(500), px(900), py(520)) + self.wait_until(lambda s: len(s["layers"]["Shrinker"]) == 1 and s["layers"]["Shrinker"][0]["done"], "the black stroke") + swatch(4) + firefox.drag("#page", px(2000), py(1500), px(2500), py(1600)) + self.wait_until(lambda s: len(s["layers"]["Shrinker"]) == 2 and s["layers"]["Shrinker"][1]["done"], "the white stroke") + self.assertEqual(self.state()["layers"]["Shrinker"][1]["id"] and firefox.run( + "return [...window.kokkoja.session.ownLayer.strokes.values()][1].color;"), "#ffffff") + firefox.run("document.querySelector('[data-tool=text]').click();") + firefox.run(TEXT_GESTURE, px(2000) / state["page"]["width"] / scale, py(600) / state["page"]["height"] / scale, + px(2000) / state["page"]["width"] / scale, py(600) / state["page"]["height"] / scale) + firefox.type_into(".text_field", "unsichtbar") + # A small text rectangle, in black, grown by blank lines typed after + # the word: finishing removes them and the box shrinks back to its + # one line. Dragging it open finishes the white text first; the + # colour is chosen after, since a change restyles the block typed in. + fx = lambda x: px(x) / (state["page"]["width"] * scale) + fy = lambda y: py(y) / (state["page"]["height"] * scale) + firefox.run(TEXT_GESTURE, fx(500), fy(800), fx(1100), fy(830)) + swatch(0) + firefox.type_into(".text_field", "Antwort\n\n\n\n") + grown = self.wait_until(lambda s: len(s["texts"]["Shrinker"]) == 2 and s["texts"]["Shrinker"][1]["text"] == "Antwort\n\n\n\n", + "the box typed in") + box = grown["texts"]["Shrinker"][1] + self.assertGreater(box["height"], 300, "grown by the blank lines: five lines of about 70 units") + self.assertEqual(box["height"], math.ceil(box["boxHeight"])) + firefox.run("document.querySelector('[data-tool=pen]').click();") + trimmed = self.wait_until(lambda s: s["texts"]["Shrinker"][1]["text"] == "Antwort", "the blank lines gone") + box = trimmed["texts"]["Shrinker"][1] + self.assertEqual(box["height"], math.ceil(box["boxHeight"])) + self.assertLess(box["height"], 80, "back to one line") + self.assertEqual(trimmed["texts"]["Shrinker"][0]["text"], "unsichtbar") + self.assertEqual(firefox.run("return [...window.kokkoja.session.ownLayer.texts.values()][0].color;"), "#ffffff") + + # Shrink: the sheet holds the black stroke and the box with the + # margin, and nothing of the white marks. + before = self.state()["page"] + firefox.run("document.getElementById('zoom_shrink').click();") + shrunk = self.wait_until(lambda s: s["page"]["width"] < before["width"], "the shrunk page") + sheet = firefox.run("const p = window.kokkoja.session.page; return [p.baseLeft, p.baseTop, p.baseWidth, p.baseHeight];") + self.assertAlmostEqual(sheet[0], 500 - 14 - 50, delta=4) # the stroke's reach, then the margin + self.assertAlmostEqual(sheet[1], 500 - 14 - 50, delta=4) + self.assertAlmostEqual(sheet[0] + sheet[2], 1100 + 50, delta=4) # the box's right edge + self.assertAlmostEqual(sheet[1] + sheet[3], 800 + box["height"] + 50, delta=4) + + # A box of one's own that still has blank lines at its end, as one + # from a page made before the trimming would, is trimmed by Shrink + # itself, as one gesture to undo. Finishing typing trims now, so + # such a block is made by script here, the way a loaded page is. + firefox.run(""" + const s = window.kokkoja.session; + const b = [...s.ownLayer.texts.values()][1]; + b.text += '\\n\\n\\n'; + b.lines = [...b.lines, '', '', '']; + s.setText(b); + """) + self.wait_until(lambda s: s["texts"]["Shrinker"][1]["text"] == "Antwort\n\n\n", "the blank lines on the block") + firefox.run("document.getElementById('zoom_shrink').click();") + self.wait_until(lambda s: s["texts"]["Shrinker"][1]["text"] == "Antwort", "trimmed by Shrink") + firefox.run("document.getElementById('undo').click();") + self.wait_until(lambda s: s["texts"]["Shrinker"][1]["text"] == "Antwort\n\n\n", "the trim undone") + 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."""