// @ts-check /** * Editing a text block: a real text field laid over the page, so that * umlauts, dead keys and selection work as they do everywhere. Moving a * block and resizing a text rectangle are done by a hold of the pen, as * for images (main.js), not by handles here (2026-10-08). * * While a block is being edited the renderer leaves it out, and the field * shows it; everyone else sees it drawn, updated at every keystroke. */ import { codeAtEnd } from './character_codes.js'; 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, trimTrailingBlankLines } from './text_layout.js'; /** @typedef {import('./page_model.js').TextBlock} TextBlock */ /** @typedef {import('./page_model.js').TextFont} TextFont */ /** @typedef {import('./renderer.js').Renderer} Renderer */ /** A block without a width is shown at least this wide, in page units. */ const LEAST_FIELD_WIDTH = 300; export class TextEditor { /** * @param {HTMLElement} pageElement * @param {Renderer} renderer * @param {object} target * @param {(block: TextBlock) => void} target.setText The block changed. * @param {(id: string) => void} target.removeText The block was left empty. * @param {(id: string) => void} [target.beginEdit] Editing of the block begins: one gesture until endEdit. * @param {(id: string) => void} [target.endEdit] The editing ended. * @param {(clientX: number, clientY: number) => boolean} [target.beginHold] * The pen rested on the field for HOLD_MS: a hold, as on the page * (Andy, 2026-10-09: the box being typed in can be moved without * stopping). Then the pen's movement goes to holdMove, and holdEnd * when it lifts. * @param {(clientX: number, clientY: number) => void} [target.holdMove] * @param {() => void} [target.holdEnd] */ constructor(pageElement, renderer, target) { this.pageElement = pageElement; this.renderer = renderer; this.target = target; /** @type {TextBlock | null} */ this.block = null; this.field = document.createElement('textarea'); this.field.className = 'text_field'; this.field.spellcheck = false; this.field.hidden = true; /** True while a character code is being replaced in the field. */ this.replacing = false; this.field.addEventListener('input', () => { if (this.replacing) return; this.replaceCode(); this.changed(); }); this.watchForHold(); this.field.addEventListener('keydown', (event) => { if (event.key === 'Escape') { event.preventDefault(); this.stop(); } }); /** Free text's corner mark while it is being edited (the renderer leaves the block out then). */ this.mark = document.createElement('div'); this.mark.className = 'text_mark'; this.mark.hidden = true; /** Shows a rectangle while it is being dragged open. */ this.preview = document.createElement('div'); this.preview.className = 'rect_preview'; this.preview.hidden = true; this.pageElement.append(this.field, this.mark, this.preview); } /** * A press that rests on the field becomes a hold that moves the block, * as a hold on the page would; a press that moves at once selects text * as in any field. */ watchForHold() { const field = this.field; /** @type {ReturnType | null} */ let timer = null; let holding = false; let pointer = -1; let pressX = 0; let pressY = 0; const cancel = () => { if (timer !== null) clearTimeout(timer); timer = null; }; field.addEventListener('pointerdown', (event) => { if (!this.target.beginHold || (event.pointerType === 'mouse' && event.button !== 0)) return; pointer = event.pointerId; pressX = event.clientX; pressY = event.clientY; cancel(); timer = setTimeout(() => { timer = null; if (!this.target.beginHold?.(pressX, pressY)) return; holding = true; field.setPointerCapture(pointer); }, HOLD_MS); }); field.addEventListener('pointermove', (event) => { if (event.pointerId !== pointer) return; if (holding) { event.preventDefault(); this.target.holdMove?.(event.clientX, event.clientY); } else if (timer !== null && Math.hypot(event.clientX - pressX, event.clientY - pressY) > HOLD_SLACK_PX) { cancel(); } }); const finish = (/** @type {PointerEvent} */ event) => { if (event.pointerId !== pointer) return; cancel(); pointer = -1; if (holding) { holding = false; this.target.holdEnd?.(); field.focus(); } }; field.addEventListener('pointerup', finish); field.addEventListener('pointercancel', finish); } /** * Show the rectangle being dragged open, from (x0, y0) to (x1, y1). * @param {number} x0 * @param {number} y0 * @param {number} x1 * @param {number} y1 */ showPreview(x0, y0, x1, y1) { const scale = this.renderer.cssPixelsPerUnit; const page = this.renderer.page; this.preview.hidden = false; this.preview.style.left = `${(Math.min(x0, x1) - page.left) * scale}px`; this.preview.style.top = `${(Math.min(y0, y1) - page.top) * scale}px`; this.preview.style.width = `${Math.abs(x1 - x0) * scale}px`; this.preview.style.height = `${Math.abs(y1 - y0) * scale}px`; } hidePreview() { this.preview.hidden = true; } /** @returns {boolean} */ get editing() { return this.block !== null; } /** * Start a new block: free text, or a text rectangle with a width and a * height. * @param {number} x * @param {number} y * @param {number | null} width * @param {number | null} height * @param {{color: string, size: number, font: TextFont}} style */ begin(x, y, width, height, style) { this.stop(); /** @type {TextBlock} */ const block = { id: randomId(), x: Math.round(x), y: Math.round(y), width, height: width === null ? null : height, text: '', lines: [''], size: style.size, font: style.font, color: style.color, boxWidth: 0, boxHeight: 0, }; Object.assign(block, layoutText('', width, style.size, style.font)); this.edit(block); } /** * Give the block being edited another colour, size or font. * @param {Partial<{color: string, size: number, font: TextFont}>} style */ restyle(style) { if (!this.block) return; Object.assign(this.block, style); this.changed(); } /** * Edit a block of ours. * @param {TextBlock} block */ edit(block) { this.stop(); this.target.beginEdit?.(block.id); this.block = block; this.renderer.editingText = block.id; this.field.value = block.text; this.field.hidden = false; this.layout(); this.target.setText(block); // Redrawn without this block. this.field.focus(); // The cursor at the end of the text (Andy, 2026-10-09). this.field.setSelectionRange(block.text.length, block.text.length); } /** * 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; this.block = null; this.renderer.editingText = null; 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); } /** * The page was replaced under the editor: leave off without sending * anything, since the block already reached the page it belonged to at * every keystroke, and that page is being kept. Finishing normally * would put the block on the new page. * @returns {TextBlock | null} The block that was being edited. */ discard() { const block = this.block; if (!block) return null; this.block = null; this.renderer.editingText = null; this.field.hidden = true; this.mark.hidden = true; this.field.value = ''; return block; } /** * The text before the caret ends with one of Klammertext's character * codes (character_codes.js): replace it by the character it stands * for. Done through the browser's own editing command so that its * undo history survives (assigning the field's value would discard * it); the command fires an input event of its own, ignored here. */ replaceCode() { const field = this.field; const caret = field.selectionStart; if (caret !== field.selectionEnd) return; const code = codeAtEnd(field.value.slice(0, caret)); if (!code) return; field.setSelectionRange(caret - code.length, caret); this.replacing = true; try { if (!document.execCommand('insertText', false, code.character)) { field.setRangeText(code.character, caret - code.length, caret, 'end'); } } finally { this.replacing = false; } } /** The text, position or width changed: lay it out and tell the page. */ changed() { const block = this.block; if (!block) return; block.text = this.field.value; Object.assign(block, layoutText(block.text, block.width, block.size, block.font)); // A text rectangle grows downward by lines when the text needs more. if (block.height !== null && block.boxHeight > block.height) block.height = Math.ceil(block.boxHeight); this.layout(); this.target.setText(block); } /** Place the field over the block, at the present zoom. */ layout() { const block = this.block; if (!block) return; const scale = this.renderer.cssPixelsPerUnit; const page = this.renderer.page; // Everything on whole device pixels, placed as the canvas places the // drawn block (renderer.js drawText): the same pixels in both, so that // nothing moves when editing ends (Andy, 2026-10-09). const dpr = window.devicePixelRatio || 1; /** @param {number} px CSS pixels. @returns {number} The nearest whole device pixel, in CSS pixels. */ const whole = (px) => Math.round(px * dpr) / dpr; const left = whole((block.x - page.left) * scale); const top = whole((block.y - page.top) * scale); const isRect = block.height !== null; const fieldWidth = (block.width ?? Math.max(LEAST_FIELD_WIDTH, block.boxWidth + block.size)) * scale; const style = this.field.style; this.field.classList.toggle('rect', isRect); style.left = `${left}px`; style.top = `${top}px`; // The text's left edge and its first baseline, where the canvas draws // them; the lines a whole number of pixels apart. const textLeft = whole((block.x + block.size * LEFT_GAP - page.left) * scale); const firstBaseline = whole((block.y + baselineOf(block.size, block.font, 0) - page.top) * scale); const lineHeight = whole(block.size * LINE_HEIGHT * scale); const { ascent, descent } = fontMetrics(block.size, block.font); // In a line box the baseline lies half the leading and the ascent down. const baselineInLine = (lineHeight - (ascent + descent) * scale) / 2 + ascent * scale; // A box's field is exactly the box, on whole pixels, and draws the // frame as an outline inside its edge, which takes no room, at the // width the canvas draws it. const frame = Math.max(1, Math.round(TEXT_FRAME_WIDTH * scale * dpr)) / dpr; style.outlineWidth = isRect ? `${frame}px` : '0'; style.outlineOffset = `${-frame}px`; const padTop = firstBaseline - top - baselineInLine; style.paddingTop = `${padTop}px`; style.paddingLeft = `${textLeft - left}px`; // Room below the last line, or the field scrolls by a few pixels to // keep the cursor in view and the text jumps. const slack = (LINE_HEIGHT - 1) / 2 * block.size * scale + 1; style.paddingBottom = `${slack}px`; style.font = `${block.size * scale}px ${block.font}`; style.lineHeight = `${lineHeight}px`; style.color = block.color; style.whiteSpace = block.width === null ? 'pre' : 'pre-wrap'; if (isRect) { const bounds = textBounds(block); // The canvas strokes the far lines on the pixel at the edge itself, // so the field reaches one pixel past it. style.boxSizing = 'border-box'; style.width = `${whole((bounds.left + bounds.width - page.left) * scale) - left + 1 / dpr}px`; style.height = `${Math.max(block.lines.length * lineHeight + padTop + slack, whole((bounds.top + bounds.height - page.top) * scale) - top + 1 / dpr)}px`; } else { style.boxSizing = 'content-box'; // A little wider than the block, so that the field never wraps a // line earlier than the layout did. style.width = `${fieldWidth - (textLeft - left) + 2}px`; style.height = `${block.lines.length * lineHeight}px`; } this.field.scrollTop = 0; this.mark.hidden = isRect; this.mark.style.left = `${left}px`; this.mark.style.top = `${top}px`; this.mark.style.width = `${whole(TEXT_MARK * scale)}px`; this.mark.style.height = `${whole(TEXT_MARK * scale)}px`; this.mark.style.borderWidth = `${Math.max(1, Math.round(TEXT_FRAME_WIDTH * scale * dpr)) / dpr}px`; } }