Kokkoja: first public assembly

Assembled from the development repository by tools/make_release.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-06 15:58:02 +02:00
commit 95c80d04c9
39 files changed
+9905

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
// @ts-check
/**
* Lays out the text of a text block into lines, measured in page units.
*
* The writer's client does this and sends the lines along with the text,
* so that everyone shows the same line breaks even where fonts differ a
* little. A block with a width wraps at it; a block without one breaks
* lines only where the writer pressed Enter.
*/
/** Line height as a multiple of the font size. */
export const LINE_HEIGHT = 1.25;
/**
* Room between a block's top edge and its first line, as a multiple of
* the font size (Andy, 2026-10-03: the text should not sit on the edge).
*/
export const TOP_GAP = 0.3;
/**
* Room between a block's left edge (its corner square) and the text, as a
* multiple of the font size: about half a character's width (Andy,
* 2026-10-03).
*/
export const LEFT_GAP = 0.25;
/**
* @param {number} size Font size in page units.
* @param {string} family
* @returns {string} The CSS font for a canvas whose units are page units.
*/
export function fontOf(size, family) {
return `${size}px ${family}`;
}
/** @type {CanvasRenderingContext2D | null} */
let measurer = null;
/**
* @param {string} text
* @param {number} size
* @param {string} family
* @returns {number} The text's width in page units.
*/
export function textWidth(text, size, family) {
if (!measurer) {
measurer = /** @type {CanvasRenderingContext2D} */ (document.createElement('canvas').getContext('2d'));
}
measurer.font = fontOf(size, family);
return measurer.measureText(text).width;
}
/**
* Break one paragraph into lines no wider than `width`. Breaks come at
* spaces; a word wider than the width is broken where it must be.
* @param {string} paragraph
* @param {number} width
* @param {number} size
* @param {string} family
* @returns {string[]}
*/
function wrap(paragraph, width, size, family) {
/** @type {string[]} */
const lines = [];
let line = '';
for (const word of paragraph.split(' ')) {
const tried = line === '' ? word : `${line} ${word}`;
if (textWidth(tried, size, family) <= width ||
line === '' && textWidth(word, size, family) <= width) {
line = tried;
continue;
}
if (line !== '') lines.push(line);
// The word alone: fits, or is broken by characters.
line = '';
for (const character of word) {
if (line !== '' && textWidth(line + character, size, family) > width) {
lines.push(line);
line = '';
}
line += character;
}
}
lines.push(line);
return lines;
}
/**
* @param {string} text What the writer typed, with its own line breaks.
* @param {number | null} width Wrap at this width, or null for no wrapping.
* @param {number} size
* @param {string} family
* @returns {{lines: string[], boxWidth: number, boxHeight: number}}
* The lines as shown, and the rectangle they take up.
*/
export function layoutText(text, width, size, family) {
/** @type {string[]} */
const lines = [];
const gap = size * LEFT_GAP;
for (const paragraph of text.split('\n')) {
if (width === null) lines.push(paragraph);
else lines.push(...wrap(paragraph, Math.max(size, width - gap), size, family));
}
const widest = Math.max(0, ...lines.map((line) => textWidth(line, size, family)));
return {
lines,
boxWidth: width ?? Math.ceil(widest + gap),
boxHeight: Math.ceil(size * TOP_GAP + lines.length * size * LINE_HEIGHT),
};
}