// @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, with * handles for moving the block and changing its width. * * 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 { randomId } from './crypto.js'; import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, layoutText } 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. */ 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; this.field.addEventListener('input', () => this.changed()); this.field.addEventListener('keydown', (event) => { if (event.key === 'Escape') { event.preventDefault(); this.stop(); } }); this.corner = this.makeHandle('text_handle corner', 'Move the text'); this.edge = this.makeHandle('text_edge', 'Move the text'); this.end = this.makeHandle('text_handle end', 'Change the width'); /** Shows the top edge while it is being dragged out. */ this.preview = document.createElement('div'); this.preview.className = 'text_preview'; this.preview.hidden = true; this.pageElement.append(this.field, this.edge, this.corner, this.end, this.preview); } /** * Show where the top edge of a new block would run, from (x0, y0) to * (x1, y0), while the pointer is dragging. * @param {number} x0 * @param {number} y0 * @param {number} x1 */ showPreview(x0, y0, x1) { 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 = `${(y0 - page.top) * scale}px`; this.preview.style.width = `${Math.abs(x1 - x0) * scale}px`; } hidePreview() { this.preview.hidden = true; } /** * @param {string} className * @param {string} help * @returns {HTMLElement} */ makeHandle(className, help) { const handle = document.createElement('div'); handle.className = className; handle.hidden = true; handle.setAttribute('data-help', help); const widening = className.includes('end'); handle.addEventListener('pointerdown', (event) => { if (!this.block) return; event.preventDefault(); event.stopPropagation(); handle.setPointerCapture(event.pointerId); const [startX, startY] = this.renderer.pageFromClient(event.clientX, event.clientY); const { x, y, width } = this.block; const move = (/** @type {PointerEvent} */ e) => { if (!this.block) return; const [nowX, nowY] = this.renderer.pageFromClient(e.clientX, e.clientY); if (widening) { this.block.width = Math.max(100, Math.round((width ?? 0) + nowX - startX)); } else { this.block.x = Math.round(x + nowX - startX); this.block.y = Math.round(y + nowY - startY); } this.changed(); }; const finish = () => { handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', finish); handle.removeEventListener('pointercancel', finish); this.field.focus(); }; handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', finish); handle.addEventListener('pointercancel', finish); }); return handle; } /** @returns {boolean} */ get editing() { return this.block !== null; } /** * Start a new block. * @param {number} x * @param {number} y * @param {number | null} width * @param {{color: string, size: number, font: TextFont}} style */ begin(x, y, width, style) { this.stop(); /** @type {TextBlock} */ const block = { id: randomId(), x: Math.round(x), y: Math.round(y), width, 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.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(); } /** Finish editing. 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.field.value = ''; for (const handle of [this.corner, this.edge, this.end]) handle.hidden = true; if (block.text.trim() === '') this.target.removeText(block.id); else this.target.setText(block); } /** 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)); this.layout(); this.target.setText(block); } /** Place the field and the handles 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; const left = (block.x - page.left) * scale; const top = (block.y - page.top) * scale; const fieldWidth = (block.width ?? Math.max(LEAST_FIELD_WIDTH, block.boxWidth + block.size)) * scale; const style = this.field.style; style.left = `${left}px`; style.top = `${top}px`; // A little wider than the block, so that the field never wraps a line // earlier than the layout did. style.width = `${fieldWidth - block.size * LEFT_GAP * scale + 2}px`; style.paddingTop = `${block.size * TOP_GAP * scale}px`; style.paddingLeft = `${block.size * LEFT_GAP * scale}px`; style.font = `${block.size * scale}px ${block.font}`; style.lineHeight = String(LINE_HEIGHT); style.color = block.color; style.whiteSpace = block.width === null ? 'pre' : 'pre-wrap'; // Exactly as tall as the lines: no spare line below the text. style.height = `${block.lines.length * block.size * LINE_HEIGHT * scale}px`; this.corner.hidden = false; this.corner.style.left = `${left}px`; this.corner.style.top = `${top}px`; const hasWidth = block.width !== null; this.edge.hidden = !hasWidth; this.end.hidden = !hasWidth; if (hasWidth) { this.edge.style.left = `${left}px`; this.edge.style.top = `${top}px`; this.edge.style.width = `${fieldWidth}px`; this.end.style.left = `${left + fieldWidth}px`; this.end.style.top = `${top}px`; } } }