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:
commit
95c80d04c9
39 files changed
+9905
No files matched your search
@@ -0,0 +1,229 @@
|
||||
// @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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user