350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
// @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<typeof setTimeout> | 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`;
|
|
}
|
|
}
|