Shrink ignores what cannot be seen; blank lines at a block's end go
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
bb41047e85
commit
56ab259cd4
5 files changed
+158
-11
No files matched your search
+43
-9
@@ -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
|
* The rectangle a text block takes up: its own, grown to its lines, for a
|
||||||
* text rectangle; the lines' box for free text.
|
* text rectangle; the lines' box for free text.
|
||||||
@@ -550,20 +561,43 @@ export class Page {
|
|||||||
* @returns {Rectangle | null} Null if the page is blank.
|
* @returns {Rectangle | null} Null if the page is blank.
|
||||||
*/
|
*/
|
||||||
contentSheet() {
|
contentSheet() {
|
||||||
/** @type {Rectangle | null} */
|
// What can be seen (Andy, 2026-10-14): a stroke or free text in the
|
||||||
let box = this.strokeBounds ? { ...this.strokeBounds } : null;
|
// paper's white is left out (over an image it shows, but lies within
|
||||||
for (const item of [...this.images, ...this.hides]) {
|
// 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) {
|
if (!box) {
|
||||||
box = { left: item.x, top: item.y, width: item.width, height: item.height };
|
found.box = { left, top, width, height };
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
const right = Math.max(box.left + box.width, item.x + item.width);
|
const right = Math.max(box.left + box.width, left + width);
|
||||||
const bottom = Math.max(box.top + box.height, item.y + item.height);
|
const bottom = Math.max(box.top + box.height, top + height);
|
||||||
box.left = Math.min(box.left, item.x);
|
box.left = Math.min(box.left, left);
|
||||||
box.top = Math.min(box.top, item.y);
|
box.top = Math.min(box.top, top);
|
||||||
box.width = right - box.left;
|
box.width = right - box.left;
|
||||||
box.height = bottom - box.top;
|
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;
|
if (!box) return null;
|
||||||
// The greater of the two margins (Andy, 2026-10-08), exactly, the same
|
// 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).
|
// on all four sides (a grid made them uneven, Andy, 2026-10-08).
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
MAX_PAGE_HIDES,
|
MAX_PAGE_HIDES,
|
||||||
} from './page_model.js';
|
} from './page_model.js';
|
||||||
import { RelayConnection } from './relay_connection.js';
|
import { RelayConnection } from './relay_connection.js';
|
||||||
|
import { trimTrailingBlankLines } from './text_layout.js';
|
||||||
|
|
||||||
/** @typedef {import('./crypto.js').Identity} Identity */
|
/** @typedef {import('./crypto.js').Identity} Identity */
|
||||||
/** @typedef {import('./page_model.js').Layer} Layer */
|
/** @typedef {import('./page_model.js').Layer} Layer */
|
||||||
@@ -525,6 +526,15 @@ export class Session {
|
|||||||
shrinkPage() {
|
shrinkPage() {
|
||||||
const page = this.page;
|
const page = this.page;
|
||||||
if (!this.mayMakePages() || page.number === 0) return false;
|
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();
|
const sheet = page.contentSheet();
|
||||||
if (!sheet || (sheet.left === page.baseLeft && sheet.top === page.baseTop &&
|
if (!sheet || (sheet.left === page.baseLeft && sheet.top === page.baseTop &&
|
||||||
sheet.width === page.baseWidth && sheet.height === page.baseHeight)) return false;
|
sheet.width === page.baseWidth && sheet.height === page.baseHeight)) return false;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { randomId } from './crypto.js';
|
|||||||
import { textBounds } from './page_model.js';
|
import { textBounds } from './page_model.js';
|
||||||
import { TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
|
import { TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
|
||||||
import { HOLD_MS, HOLD_SLACK_PX } from './pen_input.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').TextBlock} TextBlock */
|
||||||
/** @typedef {import('./page_model.js').TextFont} TextFont */
|
/** @typedef {import('./page_model.js').TextFont} TextFont */
|
||||||
@@ -206,7 +206,10 @@ export class TextEditor {
|
|||||||
this.field.setSelectionRange(block.text.length, block.text.length);
|
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() {
|
stop() {
|
||||||
const block = this.block;
|
const block = this.block;
|
||||||
if (!block) return;
|
if (!block) return;
|
||||||
@@ -215,6 +218,7 @@ export class TextEditor {
|
|||||||
this.field.hidden = true;
|
this.field.hidden = true;
|
||||||
this.mark.hidden = true;
|
this.mark.hidden = true;
|
||||||
this.field.value = '';
|
this.field.value = '';
|
||||||
|
trimTrailingBlankLines(block);
|
||||||
if (block.text.trim() === '') this.target.removeText(block.id);
|
if (block.text.trim() === '') this.target.removeText(block.id);
|
||||||
else this.target.setText(block);
|
else this.target.setText(block);
|
||||||
this.target.endEdit?.(block.id);
|
this.target.endEdit?.(block.id);
|
||||||
|
|||||||
@@ -146,3 +146,23 @@ export function layoutText(text, width, size, family) {
|
|||||||
boxHeight: Math.ceil(size * TOP_GAP + lines.length * size * LINE_HEIGHT),
|
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;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ the second participant sees it at the end.
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
@@ -1587,6 +1588,84 @@ class EndToEndTest(unittest.TestCase):
|
|||||||
firefox.run("document.getElementById('manual_close').click();")
|
firefox.run("document.getElementById('manual_close').click();")
|
||||||
self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;"))
|
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):
|
def test_text_being_typed_stays_on_the_page_left(self):
|
||||||
"""Andy, 2026-10-06: a guest was typing when the host pressed New,
|
"""Andy, 2026-10-06: a guest was typing when the host pressed New,
|
||||||
and the text reappeared on the new page."""
|
and the text reappeared on the new page."""
|
||||||
|
|||||||
Reference in new issue
Block a user