diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index be4f67e..25f6e16 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -171,11 +171,6 @@ a:focus-visible { opacity: 1; } -.editor-group:has(#shapeFill) .editor-field { - grid-template-columns: 2em 100px; - justify-content: end; -} - .editor-group:has(#textFont) { grid-template-columns: 100px 54px; } @@ -1277,7 +1272,8 @@ button.outline-link:hover { } .edit-object:hover, -.edit-object.selected { +.edit-object.selected, +.edit-selection { outline: calc(1px / var(--surface-scale)) solid var(--accent); } @@ -1301,7 +1297,8 @@ button.outline-link:hover { transform: translate(-50%, -50%); } -.selected .edit-handle { +.selected .edit-handle, +.edit-selection .edit-handle { display: block; } @@ -1309,6 +1306,11 @@ button.outline-link:hover { display: none; } +.edit-selection .edit-handle { + pointer-events: auto; + touch-action: none; +} + .edit-handle::before { position: absolute; inset: calc(-8px / var(--surface-scale)); diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 5a549a7..6ac8b59 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -233,6 +233,7 @@ const fontManager = new FontManager({ }); const canvasEditor = new CanvasEditor(el.viewerPanel, { + fontControls: [el.textFont, el.textFontAdd], busy: () => document.body.hasAttribute("aria-busy"), rotation: () => state.rotation, onSelect: updateObjectControls, @@ -247,14 +248,15 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape, box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth), onDrawText: beginCanvasText, - onCommitText: (item, value) => item.draft - ? changeDocument("ofdgoInsertText", null, item.index, value, item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight) - : changeDocument("ofdgoUpdateText", item, value, null, item.size, null), + onCommitText: (item, value, fontData) => item.draft + ? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight) + : changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, null), onTextChange: () => { const editing = canvasEditor.input; setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision - || Boolean(editing && editing.input.value !== editing.item.text))); + || Boolean(editing && (editing.input.value !== editing.item.text || editing.fontData)))); if (!editing) { + updateTextFonts(canvasEditor.selected, true); syncSelection(); } }, @@ -304,19 +306,7 @@ el.textColor.addEventListener("change", () => changeTextStyle(true)); editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap)); el.textAlign.addEventListener("change", () => changeParagraph()); el.textLineHeight.addEventListener("change", () => changeParagraph()); -el.textFont.addEventListener("change", async () => { - const item = canvasEditor.selected; - const font = state.textFonts[Number(el.textFont.value)]; - if (!state.ready || document.body.hasAttribute("aria-busy") || !font || item?.draft) return; - if (item?.type === "TextObject") { - if (!font.embedded) { - await changeDocument("ofdgoUpdateText", item, item.text, fontManager.read(font), item.size, null); - updateTextFonts(canvasEditor.selected, true); - } - } else { - state.textDefaults.fontChoice = font; - } -}); +el.textFont.addEventListener("change", changeTextFont); el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; }); for (const input of [el.textSize, el.shapeWidth, el.textLineHeight]) { input.addEventListener("keydown", (event) => { @@ -418,10 +408,7 @@ editorClick(el.insertImageButton, () => openInsertPanel()); el.insertCancel.addEventListener("click", () => el.insertPanel.close()); el.insertPanel.addEventListener("close", () => { state.insertObject = null; }); el.insertForm.addEventListener("submit", insertObject); -el.textFontAdd.addEventListener("click", () => { - openFontFile(el.fontInput); - canvasEditor.commitText(); -}); +el.textFontAdd.addEventListener("click", () => openFontFile(el.fontInput)); el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd")); el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages")); el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta")); @@ -851,13 +838,51 @@ function updateTextFonts(item, refresh = false) { if (refresh || state.textFontID !== id) { state.textFontID = id; state.textFonts = editorFonts(id ? item : null); - const selected = id ? state.textFonts[0] : state.textDefaults.fontChoice; - if (!id && selected?.embedded) state.textFonts.unshift(selected); + const selected = canvasEditor.input?.fontChoice || (id ? state.textFonts[0] : state.textDefaults.fontChoice); + if (selected && (canvasEditor.input || selected.embedded) + && !state.textFonts.some(font => (font.id || font.postscriptName) === (selected.id || selected.postscriptName))) { + state.textFonts.unshift(selected); + } setFontOptions(el.textFont, state.textFonts, selected || state.textFonts[0]); if (!id) state.textDefaults.fontChoice = state.textFonts[Number(el.textFont.value)]; } } +// changeTextFont preserves uncommitted text while loading a replacement font. +async function changeTextFont() { + const item = canvasEditor.selected; + const font = state.textFonts[Number(el.textFont.value)]; + if (!state.ready || document.body.hasAttribute("aria-busy") || !font) return; + const editing = canvasEditor.input; + if (editing) { + const openSeq = state.openSeq; + setBusy(true); + try { + const data = font.embedded ? (await callWASM("ofdgoEditorFont", font.id.slice(9))).bytes : await fontManager.read(font); + const face = new FontFace("ofdgo-edit-font", data); + await face.load(); + if (openSeq !== state.openSeq || canvasEditor.input !== editing) return; + editing.fontChoice = font; + if (item.draft) state.textDefaults.fontChoice = font; + canvasEditor.setTextFont(face, data); + } catch (err) { + if (openSeq === state.openSeq) { + updateTextFonts(canvasEditor.selected, true); + showError(err, false); + } + } finally { + if (openSeq === state.openSeq) setBusy(false); + } + } else if (item?.type === "TextObject") { + if (!font.embedded) { + await changeDocument("ofdgoUpdateText", item, item.text, fontManager.read(font), item.size, null); + updateTextFonts(canvasEditor.selected, true); + } + } else { + state.textDefaults.fontChoice = font; + } +} + async function insertObject(event) { event.preventDefault(); if (!state.editing || document.body.hasAttribute("aria-busy") || el.insertSubmit.disabled) { @@ -1779,6 +1804,10 @@ async function applyFontChange(options = {}) { if (!state.doc) { return; } + if (state.editing) { + setStatus(pageStatus(state.pageIndex, state.doc.pageCount)); + return; + } await openDocument({ pageIndex: state.pageIndex, fitMode: state.fitMode, @@ -4135,8 +4164,9 @@ function updateObjectControls(item, reset = false) { el.editObjectButton.disabled = disabled || Boolean(item.items) || item.type === "PathObject"; el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting; const text = item?.type === "TextObject" ? item : state.textDefaults; - el.textFont.disabled = el.textSize.disabled = el.textColor.disabled = !state.editing || !state.ready || state.exporting || Boolean(item?.draft || item?.items); - el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting || Boolean(item?.draft); + el.textFont.disabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items); + el.textSize.disabled = el.textColor.disabled = el.textFont.disabled || Boolean(item?.draft); + el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting; el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSize.disabled; el.textAlign.value = text.align || "left"; el.textWrap.setAttribute("aria-pressed", String(Boolean(text.wrap))); diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index ab52201..87d2c42 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -28,6 +28,29 @@ export function selectionBounds(items) { height: Math.max(...items.map(item => item.y + item.height)) - y }; } +// alignmentGuides marks the nearest edges or centers without changing object coordinates. +function alignmentGuides(box, targets, tolerance) { + const lines = []; + for (const [axis, size, cross, span] of [["x", "width", "y", "height"], ["y", "height", "x", "width"]]) { + let distance = Infinity, line; + for (const target of targets) { + for (const anchor of [0, 0.5, 1]) { + const position = target[axis] + target[size] * anchor; + for (const edge of [0, 0.5, 1]) { + const delta = Math.abs(box[axis] + box[size] * edge - position); + if (delta > tolerance[axis] || delta >= distance) continue; + distance = delta; + const from = Math.min(box[cross], target[cross]); + const to = Math.max(box[cross] + box[span], target[cross] + target[span]); + line = axis === "x" ? `M${position} ${from}V${to}` : `M${from} ${position}H${to}`; + } + } + } + if (line) lines.push(line); + } + return lines.join(""); +} + // constrainedPoint preserves shape constraints without changing off-page geometry. export function constrainedPoint(from, to, shape, shift) { let dx = to.x - from.x, dy = to.y - from.y; @@ -170,11 +193,14 @@ export class CanvasEditor { place(item, change = { x: 0, y: 0, scale: 1 }) { if (item.items) { - for (const member of item.items) this.place(member, change); - return; + for (const member of item.items) this.place(member, { + x: change.x + (member.x - item.x) * (change.scale - 1), + y: change.y + (member.y - item.y) * (change.scale - 1), scale: change.scale, + }); } if (item.shape) { - this.placeShape(item, { x: item.geometry.x + change.x, y: item.geometry.y + change.y, + this.placeShape(item, { x: item.geometry.x + change.x + (item.geometry.x - item.x) * (change.scale - 1), + y: item.geometry.y + change.y + (item.geometry.y - item.y) * (change.scale - 1), width: item.geometry.width * change.scale, height: item.geometry.height * change.scale }); return; } @@ -207,12 +233,27 @@ export class CanvasEditor { // setSelection updates object highlights and exposes one same-page selection to the toolbar. setSelection(items) { + if (this.selected?.items) this.selected.node.remove(); for (const item of this.items()) { item.node.classList.remove("selected", "multi-selected"); item.node.setAttribute("aria-pressed", "false"); } this.selected = items.length < 2 ? items[0] || null : { ...selectionBounds(items), items, id: items.map(item => item.id), index: items[0].index, page: items[0].page, surface: items[0].surface }; + if (items.length > 1) { + const node = document.createElement("div"); + node.className = "edit-layer edit-selection"; + for (const corner of ["nw", "ne", "sw", "se"]) { + const handle = document.createElement("span"); + handle.className = `edit-handle edit-${corner}`; + handle.dataset.corner = corner; + node.append(handle); + } + this.selected.node = node; + this.nodes.set(node, this.selected); + this.place(this.selected); + this.selected.surface.append(node); + } for (const item of items) { item.node.classList.add("selected"); item.node.classList.toggle("multi-selected", items.length > 1); @@ -249,7 +290,7 @@ export class CanvasEditor { this.startShape(event); return; } - const node = event.target.closest(".edit-object"); + const node = event.target.closest(".edit-object, .edit-selection"); if (node) { event.preventDefault(); } @@ -264,16 +305,22 @@ export class CanvasEditor { this.select(target, true); return; } - if (!this.items().includes(target)) this.select(target); + if (target !== this.selected && !this.items().includes(target)) this.select(target); if (!node || this.options.busy()) { return; } const item = this.selected; this.drag = { - item, pointerID: event.pointerId, corner: item.items ? "" : event.target.dataset.corner || "", + item, pointerID: event.pointerId, corner: !item.items || target === item ? event.target.dataset.corner || "" : "", rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(), clientX: event.clientX, clientY: event.clientY, }; + if (!this.drag.corner) { + const selected = this.items(); + this.drag.targets = [...item.surface.querySelectorAll(".edit-object")] + .map(node => this.nodes.get(node)).filter(member => !selected.includes(member)); + this.drag.targets.push({ x: 0, y: 0, ...item.page }); + } if (item.shape === "line" && this.drag.corner) { Object.assign(this.drag, this.createPreview(item.surface, item.page, item.shape, { fill: false, stroke: true, strokeColor: "var(--accent)", lineWidth: item.lineWidth, @@ -315,6 +362,24 @@ export class CanvasEditor { } drag.change = objectTransform(drag.item, to.x - from.x, to.y - from.y, drag.corner); this.place(drag.item, drag.change); + if (!drag.corner) this.showGuides(drag); + } + + // showGuides keeps the visual threshold constant through page zoom and rotation. + showGuides(drag) { + const { item, rect, rotation, change } = drag; + const path = alignmentGuides({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, { + x: 4 * item.page.width / (rotation % 180 ? rect.height : rect.width), + y: 4 * item.page.height / (rotation % 180 ? rect.width : rect.height), + }); + if (!drag.guides && path) { + drag.guides = this.createPreview(item.surface, item.page, "path", { + fill: false, stroke: true, strokeColor: "var(--accent)", lineWidth: 1, + }); + drag.guides.node.setAttribute("vector-effect", "non-scaling-stroke"); + drag.guides.node.setAttribute("stroke-dasharray", "4 3"); + } + drag.guides?.node.setAttribute("d", path); } end(event) { @@ -374,6 +439,7 @@ export class CanvasEditor { this.drag = null; if (drag) { drag.preview?.remove(); + drag.guides?.preview.remove(); if (drag.marquee) this.setSelection(drag.before); if (drag.item) { this.place(drag.item); @@ -554,17 +620,15 @@ export class CanvasEditor { lineHeight: item.paragraphHeight || item.lineHeight ? `${(item.paragraphHeight || item.lineHeight) * PX_PER_MM}px` : "normal", color: item.color, textAlign: item.align || "left", }); - this.input = { input, item, face }; + this.input = { input, item, face, fontChoice: item.fontChoice }; item.surface.append(input); - const resize = () => { - input.style.height = "0px"; - input.style.height = `${input.scrollHeight + 2}px`; - }; input.addEventListener("input", () => { - resize(); + this.resizeText(); this.options.onTextChange(); }); - input.addEventListener("blur", () => this.commitText()); + input.addEventListener("blur", (event) => { + if (!this.options.fontControls?.includes(event.relatedTarget)) this.commitText(); + }); input.addEventListener("keydown", (event) => { event.stopPropagation(); if (event.isComposing || input.readOnly) { @@ -579,21 +643,41 @@ export class CanvasEditor { this.commitText(); } }); - resize(); + this.resizeText(); input.focus({ preventScroll: true }); if (!item.draft) input.select(); } + // resizeText follows the draft's current font and content without changing document geometry. + resizeText() { + const { input } = this.input; + input.style.height = "0px"; + input.style.height = `${input.scrollHeight + 2}px`; + } + + // setTextFont changes only the inline buffer until text and font are committed together. + setTextFont(face, data) { + const editing = this.input; + document.fonts.add(face); + document.fonts.delete(editing.face); + editing.face = face; + editing.fontData = data; + editing.input.style.fontFamily = `"${face.family}"`; + this.resizeText(); + this.options.onTextChange(); + editing.input.focus({ preventScroll: true }); + } + async commitText() { const editing = this.input; if (!editing) return true; if (editing.input.readOnly) return false; - if (editing.input.value === editing.item.text || editing.item.draft && !editing.input.value.trim()) { + if (editing.input.value === editing.item.text && !editing.fontData || editing.item.draft && !editing.input.value.trim()) { this.closeText(); return true; } editing.input.readOnly = true; - const saved = await this.options.onCommitText(editing.item, editing.input.value); + const saved = await this.options.onCommitText(editing.item, editing.input.value, editing.fontData); if (this.input !== editing) { return Boolean(saved); }