// In-place label editing with a textarea laid over the element. import { isShape, normalizeElement, LABEL_WEIGHT, TEXT_WEIGHT, MAX_SIDE } from "/lib/model.mjs"; import { labelMaxWidth, labelCenterY, lineHeight, fontSizeOf, textLayout, neededHeight, arrowGeometry, } from "/lib/geometry.mjs"; import { autoSize } from "/lib/layout.mjs"; import { TYPE_NAMES } from "./announce.js"; export class LabelEditor { constructor(ed) { this.ed = ed; this.ta = null; ed.labels = this; ed.on("render", () => this.position()); } // fresh: the shape was just created, so it may widen to fit the text. // before: elements to use as the undo point (when the element was created just for this edit). open(id, { fresh = false, before = null } = {}) { const ed = this.ed; if (ed.editing) this.commit(); const el = ed.byId.get(id); if (!el || el.type === "pen") return; const ta = document.createElement("textarea"); ta.className = "label-editor"; ta.setAttribute("aria-label", el.type === "text" ? "Text" : `${TYPE_NAMES[el.type] || "Shape"} label`); ta.spellcheck = true; ta.value = el.text || ""; ed.layer.append(ta); this.ta = ta; ed.editing = { id, fresh, before: before || ed.elements, start: { x: el.x, y: el.y, w: el.w, h: el.h } }; ed.contentDirty = true; ta.addEventListener("input", () => this.input()); ta.addEventListener("keydown", (e) => this.keydown(e)); ta.addEventListener("blur", () => { if (this.ta === ta) this.commit({ refocus: false }); }); ed.requestRender(); this.position(); ta.focus({ preventScroll: true }); ta.setSelectionRange(ta.value.length, ta.value.length); ed.emit("editing", true); } input() { const ed = this.ed; const st = ed.editing; const el = st && ed.byId.get(st.id); if (!el) return this.close(); let next = { ...el, text: this.ta.value }; if (isShape(el)) next = this.grow(next, st); ed.replaceElement(normalizeElement(next) || next, { record: false, emit: false }); } // Grows the shape to fit its label (never below its size when editing started, never past the // size limit), keeping the center. grow(el, st) { const measure = this.ed.measure; let w = st.start.w; if (st.fresh) w = Math.max(w, autoSize(el.text, el.type, measure, el.size).w); const h = Math.min(MAX_SIDE, Math.max(st.start.h, Math.ceil(neededHeight({ ...el, w }, measure) / 10) * 10)); const cx = st.start.x + st.start.w / 2; const cy = st.start.y + st.start.h / 2; return { ...el, w, h, x: Math.round(cx - w / 2), y: Math.round(cy - h / 2) }; } keydown(e) { e.stopPropagation(); if (e.isComposing || e.keyCode === 229) return; const ed = this.ed; if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey)) { e.preventDefault(); this.commit(); return; } if (e.key === "Tab") { const id = ed.editing.id; const el = ed.byId.get(id); // Only a shape has a next step to add. Anywhere else Tab moves focus on as usual, and the // blur saves the edit. if (!el || !isShape(el)) return; e.preventDefault(); this.commit(); if (e.shiftKey) ed.addSibling(id); else ed.addConnected(id, ed.lastDirection || "right"); } } position() { const ed = this.ed; const st = ed.editing; const ta = this.ta; const el = st && ta && ed.byId.get(st.id); if (!el) return; const z = ed.view.zoom; const text = ta.value || " "; let fs; let weight; let left; let width; let cy = null; let top = null; let align = "center"; let wrap = true; let color; let background = "transparent"; if (isShape(el)) { fs = fontSizeOf(el); weight = LABEL_WEIGHT; width = labelMaxWidth(el); left = el.x + el.w / 2 - width / 2; cy = labelCenterY(el); color = el.fill === "solid" ? ed.paint(el.color, "onSolid") : ed.paint("gray", "ink"); } else if (el.type === "text") { const l = textLayout({ ...el, text }, ed.measure); fs = l.fs; weight = TEXT_WEIGHT; width = l.w + fs; left = el.x; top = el.y; align = "left"; wrap = false; color = ed.paint(el.color, "ink"); } else { const g = arrowGeometry(el, ed.byId); if (!g) return; fs = fontSizeOf(el); weight = TEXT_WEIGHT; const widest = Math.max(...text.split("\n").map((s) => ed.measure(s, fs, weight))); width = widest + fs * 2; left = g.mid.x - width / 2; cy = g.mid.y; wrap = false; color = ed.paint(el.color, "ink"); background = ed.theme.bg; } const lh = lineHeight(fs); Object.assign(ta.style, { width: `${width * z}px`, font: `${weight} ${fs * z}px/${lh * z}px ${ed.theme.font}`, textAlign: align, color, background, height: "0px", }); ta.classList.toggle("no-wrap", !wrap); const h = Math.max(ta.scrollHeight, lh * z); ta.style.height = `${h}px`; const s = ed.toScreen(left, top ?? cy); ta.style.left = `${s.x}px`; ta.style.top = `${top !== null ? s.y : s.y - h / 2}px`; } // Ends editing and records one undo step. Empty text elements are removed. commit({ refocus = true } = {}) { const ed = this.ed; const st = ed.editing; if (!st) return; const ta = this.ta; this.ta = null; ed.editing = null; ta.remove(); const el = ed.byId.get(st.id); if (el) { const text = String(el.text || "").replace(/\s+$/, ""); if (el.type === "text" && !text.trim()) ed.setElements(ed.elements.filter((e) => e.id !== el.id), { record: false, emit: false }); else if (text !== el.text) ed.replaceElement({ ...el, text }, { record: false, emit: false }); } if (ed.elements !== st.before && JSON.stringify(ed.elements) !== JSON.stringify(st.before)) ed.pushHistory(st.before); ed.contentDirty = true; ed.requestRender(); ed.emit("editing", false); ed.emit("change"); ed.emit("idle"); if (refocus) ed.focusCanvas(); } // Drops the editor without recording anything (used when a different drawing is loaded). close() { const ed = this.ed; if (this.ta) this.ta.remove(); this.ta = null; ed.editing = null; ed.contentDirty = true; ed.requestRender(); ed.emit("editing", false); } }