UNPKG

js-draw

Version:

Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.

283 lines (279 loc) 12.4 kB
import TextComponent from '../components/TextComponent.mjs'; import EditorImage from '../image/EditorImage.mjs'; import { Rect2, Mat33, Vec2, Color4 } from '@js-draw/math'; import { PointerDevice } from '../Pointer.mjs'; import { EditorEventType } from '../types.mjs'; import BaseTool from './BaseTool.mjs'; import Erase from '../commands/Erase.mjs'; import uniteCommands from '../commands/uniteCommands.mjs'; import { ReactiveValue } from '../util/ReactiveValue.mjs'; const overlayCSSClass = 'textEditorOverlay'; /** A tool that allows users to enter and edit text. */ export default class TextTool extends BaseTool { constructor(editor, description, localizationTable) { super(editor.notifier, description); this.editor = editor; this.localizationTable = localizationTable; this.textInputElem = null; this.textMeasuringCtx = null; this.removeExistingCommand = null; const editorFonts = editor.getCurrentSettings().text?.fonts ?? []; this.textStyleValue = ReactiveValue.fromInitialValue({ size: 32, fontFamily: editorFonts.length > 0 ? editorFonts[0] : 'sans-serif', renderingStyle: { fill: Color4.purple, }, }); this.textStyleValue.onUpdateAndNow(() => { this.textStyle = this.textStyleValue.get(); this.updateTextInput(); this.editor.notifier.dispatch(EditorEventType.ToolUpdated, { kind: EditorEventType.ToolUpdated, tool: this, }); }); this.contentTransform = ReactiveValue.fromInitialValue(Mat33.identity); this.textEditOverlay = document.createElement('div'); this.textEditOverlay.classList.add(overlayCSSClass); this.editor.addStyleSheet(` .${overlayCSSClass} textarea { background-color: rgba(0, 0, 0, 0); white-space: pre; overflow: hidden; padding: 0; margin: 0; border: none; padding: 0; min-width: 100px; min-height: 1.1em; } `); this.anchorControl = this.editor.anchorElementToCanvas(this.textEditOverlay, this.contentTransform); } initTextMeasuringCanvas() { this.textMeasuringCtx ??= document.createElement('canvas').getContext('2d'); } getTextAscent(text, style) { this.initTextMeasuringCanvas(); if (this.textMeasuringCtx) { this.textMeasuringCtx.textBaseline = 'alphabetic'; TextComponent.applyTextStyles(this.textMeasuringCtx, style); const measurement = this.textMeasuringCtx.measureText(text); return measurement.fontBoundingBoxAscent ?? measurement.actualBoundingBoxAscent; } // Estimate return (style.size * 2) / 3; } // Take input from this' textInputElem and add it to the EditorImage. // If [removeInput], the HTML input element is removed. Otherwise, its value // is cleared. flushInput(removeInput = true) { if (!this.textInputElem) return; // Determine the scroll first -- removing the input (and other DOM changes) // also change the scroll. const scrollingRegion = this.textEditOverlay.parentElement; const containerScroll = Vec2.of(scrollingRegion?.scrollLeft ?? 0, scrollingRegion?.scrollTop ?? 0); const content = this.textInputElem.value.trimEnd(); this.textInputElem.value = ''; if (removeInput) { // In some browsers, .remove() triggers a .blur event (synchronously). // Clear this.textInputElem before removal const input = this.textInputElem; this.textInputElem = null; input.remove(); } if (content !== '') { // When the text is long, it can cause its container to scroll so that the // editing caret is in view. // So that the text added to the document is in the same position as the text // shown in the editor, account for this scroll when computing the transform: const scrollCorrectionScreen = containerScroll.times(-1); // Uses .transformVec3 to avoid also translating the scroll correction (treating // it as a point): const scrollCorrectionCanvas = this.editor.viewport.screenToCanvasTransform.transformVec3(scrollCorrectionScreen); const scrollTransform = Mat33.translation(scrollCorrectionCanvas); const textComponent = TextComponent.fromLines(content.split('\n'), scrollTransform.rightMul(this.contentTransform.get()), this.textStyle); const action = EditorImage.addComponent(textComponent); if (this.removeExistingCommand) { // Unapply so that `removeExistingCommand` can be added to the undo stack. this.removeExistingCommand.unapply(this.editor); this.editor.dispatch(uniteCommands([this.removeExistingCommand, action])); this.removeExistingCommand = null; } else { this.editor.dispatch(action); } } } updateTextInput() { if (!this.textInputElem) { return; } this.textInputElem.placeholder = this.localizationTable.enterTextToInsert; this.textInputElem.style.fontFamily = this.textStyle.fontFamily; this.textInputElem.style.fontStyle = this.textStyle.fontStyle ?? ''; this.textInputElem.style.fontVariant = this.textStyle.fontVariant ?? ''; this.textInputElem.style.fontWeight = this.textStyle.fontWeight ?? ''; this.textInputElem.style.fontSize = `${this.textStyle.size}px`; this.textInputElem.style.color = this.textStyle.renderingStyle.fill.toHexString(); this.textInputElem.style.margin = '0'; this.textInputElem.style.width = `${this.textInputElem.scrollWidth}px`; this.textInputElem.style.height = `${this.textInputElem.scrollHeight}px`; // Get the ascent based on the font, using a string of characters // that is tall in most fonts. const tallText = 'Testing!'; const ascent = this.getTextAscent(tallText, this.textStyle); const vertAdjust = ascent; this.textInputElem.style.transform = `translate(0, ${-vertAdjust}px)`; this.textInputElem.style.transformOrigin = 'top left'; // Match the line height of default rendered text. const lineHeight = Math.floor(this.textStyle.size); this.textInputElem.style.lineHeight = `${lineHeight}px`; } startTextInput(textCanvasPos, initialText) { this.flushInput(); this.textInputElem = document.createElement('textarea'); this.textInputElem.value = initialText; this.textInputElem.style.display = 'inline-block'; const textTargetPosition = this.editor.viewport.roundPoint(textCanvasPos); const textRotation = -this.editor.viewport.getRotationAngle(); const textScale = Vec2.of(1, 1).times(this.editor.viewport.getSizeOfPixelOnCanvas()); this.contentTransform.set( // Scale, then rotate, then translate: Mat33.translation(textTargetPosition) .rightMul(Mat33.zRotation(textRotation)) .rightMul(Mat33.scaling2D(textScale))); this.updateTextInput(); // Update the input size/position/etc. after the placeHolder has had time to appear. setTimeout(() => this.updateTextInput(), 0); this.textInputElem.oninput = () => { if (this.textInputElem) { this.textInputElem.style.width = `${this.textInputElem.scrollWidth}px`; this.textInputElem.style.height = `${this.textInputElem.scrollHeight}px`; } }; this.textInputElem.onblur = () => { const input = this.textInputElem; // Delay removing the input -- flushInput may be called within a blur() // event handler const removeInput = false; this.flushInput(removeInput); this.textInputElem = null; if (input) { input.classList.add('-hiding'); } setTimeout(() => { input?.remove(); }, 0); }; this.textInputElem.onkeyup = (evt) => { // In certain input modes, the <enter> key is used to select characters. // When in this mode, prevent <enter> from submitting: if (evt.isComposing) return; if (evt.key === 'Enter' && !evt.shiftKey) { this.flushInput(); this.editor.focus(); } else if (evt.key === 'Escape') { // Cancel input. this.textInputElem?.remove(); this.textInputElem = null; this.editor.focus(); this.removeExistingCommand?.unapply(this.editor); this.removeExistingCommand = null; } }; this.textEditOverlay.replaceChildren(this.textInputElem); setTimeout(() => this.textInputElem?.focus(), 0); } setEnabled(enabled) { super.setEnabled(enabled); if (!this.isEnabled()) { this.flushInput(); } this.textEditOverlay.style.display = enabled ? 'block' : 'none'; } onPointerDown({ current, allPointers }) { if (current.device === PointerDevice.Eraser) { return false; } if (allPointers.length === 1) { // Are we clicking on a text node? const canvasPos = current.canvasPos; const halfTestRegionSize = Vec2.of(4, 4).times(this.editor.viewport.getSizeOfPixelOnCanvas()); const testRegion = Rect2.fromCorners(canvasPos.minus(halfTestRegionSize), canvasPos.plus(halfTestRegionSize)); const targetNodes = this.editor.image.getComponentsIntersecting(testRegion); let targetTextNodes = targetNodes.filter((node) => node instanceof TextComponent); // Don't try to edit text nodes that contain the viewport (this allows us // to zoom in on text nodes and add text on top of them.) const visibleRect = this.editor.viewport.visibleRect; targetTextNodes = targetTextNodes.filter((node) => !node.getBBox().containsRect(visibleRect)); // End any TextNodes we're currently editing. this.flushInput(); if (targetTextNodes.length > 0) { const targetNode = targetTextNodes[targetTextNodes.length - 1]; this.setTextStyle(targetNode.getTextStyle()); // Create and temporarily apply removeExistingCommand. this.removeExistingCommand = new Erase([targetNode]); this.removeExistingCommand.apply(this.editor); this.startTextInput(targetNode.getBaselinePos(), targetNode.getText()); this.contentTransform.set(targetNode.getTransform()); this.updateTextInput(); } else { this.removeExistingCommand = null; this.startTextInput(current.canvasPos, ''); } return true; } return false; } onGestureCancel() { this.flushInput(); this.editor.focus(); } setFontFamily(fontFamily) { if (fontFamily !== this.textStyle.fontFamily) { this.textStyleValue.set({ ...this.textStyle, fontFamily: fontFamily, }); } } setColor(color) { if (!color.eq(this.textStyle.renderingStyle.fill)) { this.textStyleValue.set({ ...this.textStyle, renderingStyle: { ...this.textStyle.renderingStyle, fill: color, }, }); } } setFontSize(size) { if (size !== this.textStyle.size) { this.textStyleValue.set({ ...this.textStyle, size, }); } } getTextStyle() { return this.textStyle; } getStyleValue() { return this.textStyleValue; } setTextStyle(style) { this.textStyleValue.set(style); } // @internal onDestroy() { super.onDestroy(); this.anchorControl.remove(); } }