feat(创建编辑): 支持画布文字输入、段落排版与多选编辑
build.yaml / build (push) Successful in 37s

This commit is contained in:
xiaoqidun committed 2026-09-15 21:13:27 +08:00
1 parent 21999afcc1
commit 875de1cea8
11 files changed
+962 -257

No files matched your search

+21 -20
View File
@@ -61,8 +61,9 @@
<button id="redoButton" class="button" type="button" disabled>重做</button>
<span>文件</span>
</div>
<div class="editor-group">
<div class="editor-group editor-objects">
<button id="selectObjectButton" class="button" type="button" aria-pressed="true">选择</button>
<button id="multiSelectButton" class="button" type="button" aria-pressed="false" title="多选对象">多选</button>
<button id="editObjectButton" class="button" type="button" aria-label="修改对象" disabled>修改</button>
<button id="copyObjectButton" class="button" type="button" aria-label="复制对象" disabled>复制</button>
<button id="deleteObjectButton" class="button" type="button" disabled>删除</button>
@@ -73,25 +74,36 @@
<option value="top">置顶</option>
<option value="bottom">置底</option>
</select>
<select id="objectAlign" class="order-select" aria-label="页面对齐" disabled>
<select id="objectAlign" class="order-select" aria-label="对象对齐" disabled>
<option value="" hidden>对齐</option>
<option value="left">左侧</option>
<option value="center">水平居中</option>
<option value="center">水平</option>
<option value="right">右侧</option>
<option value="top">顶部</option>
<option value="middle">垂直居中</option>
<option value="middle">垂直</option>
<option value="bottom">底部</option>
</select>
<span>对象</span>
</div>
<div class="editor-group">
<select id="textFont" aria-label="字体" title="字体" disabled></select>
<div class="text-font"><select id="textFont" aria-label="字体" title="字体" disabled></select><button id="textFontAdd" class="icon-button" type="button" title="添加字体" aria-label="添加字体">+</button></div>
<div class="form-unit text-size"><input id="textSize" type="number" min="1" step="any" required aria-label="字号" disabled><span>pt</span></div>
<input id="textColor" class="color-input" type="color" value="#000000" aria-label="文字颜色" title="文字颜色" disabled>
<span>文字</span>
</div>
<div class="editor-group editor-paragraph">
<select id="textAlign" aria-label="段落对齐" title="段落对齐" disabled>
<option value="left">左齐</option>
<option value="center">居中</option>
<option value="right">右齐</option>
<option value="justify">两端</option>
</select>
<button id="textWrap" class="button" type="button" aria-label="自动折行" aria-pressed="false" disabled>折行</button>
<label class="form-unit paragraph-height"><span>行距</span><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" aria-label="行距" title="行距,0为自动" disabled><span>pt</span></label>
<span>段落</span>
</div>
<div class="editor-group">
<button id="insertTextButton" class="button" type="button" aria-label="添加文字">文字</button>
<button id="insertTextButton" class="button" type="button" aria-label="添加文字" aria-pressed="false">文字</button>
<button id="insertImageButton" class="button" type="button" aria-label="添加图片">图片</button>
<button id="drawLineButton" class="button" type="button" aria-label="绘制直线" aria-pressed="false">直线</button>
<button id="drawRectangleButton" class="button" type="button" aria-label="绘制矩形" aria-pressed="false">矩形</button>
@@ -163,22 +175,11 @@
</div>
</form>
</dialog>
<dialog id="insertPanel" class="form-dialog" aria-label="添加文字">
<dialog id="insertPanel" class="form-dialog" aria-label="添加图片">
<form id="insertForm">
<div id="insertTextRow" class="form-row">
<label for="insertText">内容</label>
<textarea id="insertText" rows="3" required autofocus></textarea>
</div>
<div id="insertFontRow" class="form-row">
<label for="insertFont">字体</label>
<div class="form-font">
<select id="insertFont" required></select>
<button id="insertFontAdd" class="icon-button" type="button" title="添加字体" aria-label="添加字体">+</button>
</div>
</div>
<div id="insertImageRow" class="form-row" hidden>
<div class="form-row">
<label for="insertImage">图片</label>
<input id="insertImage" type="file" accept=".png,.jpg,.jpeg,image/png,image/jpeg" required>
<input id="insertImage" type="file" accept=".png,.jpg,.jpeg,image/png,image/jpeg" required autofocus>
</div>
<div class="form-actions">
<div id="insertStatus" class="form-status" role="status"></div>
+31 -6
View File
@@ -111,11 +111,27 @@ a:focus-visible {
}
.editor-file,
.editor-paragraph,
.editor-group:has(#textFont),
.editor-group:has(#shapeFill) {
grid-template-columns: repeat(2, max-content);
}
.editor-objects {
grid-template-columns: repeat(4, max-content);
}
.form-unit.paragraph-height {
grid-column: 1 / -1;
grid-template-columns: auto 58px auto;
gap: 4px;
}
.paragraph-height input {
width: 58px;
min-width: 0;
}
.editor-file #saveButton {
grid-row: 1 / 3;
height: 100%;
@@ -126,9 +142,8 @@ a:focus-visible {
}
#textFont {
grid-column: 1 / -1;
width: 128px;
min-width: 100%;
width: 100%;
min-width: 0;
}
.shape-paint,
@@ -268,7 +283,7 @@ button:not(:disabled):hover {
.new-icon {
position: relative;
width: 12px;
width: 15px;
height: 15px;
border: 1px solid currentColor;
}
@@ -338,12 +353,15 @@ button:not(:disabled):hover {
}
.page-control input,
.paragraph-height input,
.text-size input {
appearance: textfield;
}
.page-control input::-webkit-inner-spin-button,
.page-control input::-webkit-outer-spin-button,
.paragraph-height input::-webkit-inner-spin-button,
.paragraph-height input::-webkit-outer-spin-button,
.text-size input::-webkit-inner-spin-button,
.text-size input::-webkit-outer-spin-button {
-webkit-appearance: none;
@@ -451,6 +469,7 @@ button:not(:disabled):hover {
.form-row input[type="file"],
.form-row select,
.form-row textarea,
.paragraph-height input,
.text-size input {
width: 100%;
min-width: 0;
@@ -531,10 +550,12 @@ button:not(:disabled):hover {
color: var(--text-muted);
}
.form-font {
.text-font {
display: grid;
width: 160px;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
grid-column: 1 / -1;
gap: 4px;
}
.form-row input[aria-invalid="true"] {
@@ -1241,6 +1262,10 @@ button.outline-link:hover {
display: block;
}
.multi-selected .edit-handle {
display: none;
}
.edit-handle::before {
position: absolute;
inset: calc(-8px / var(--surface-scale));
+205 -101
View File
@@ -37,8 +37,9 @@ const state = {
editorInfo: null,
savedRevision: null,
insertObject: null,
insertFonts: [],
textFonts: [],
textFontID: null,
textDefaults: { type: "TextObject", size: 12 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0 },
ofdBytes: null,
fileName: "ofdgo.ofd",
openSeq: 0,
@@ -91,7 +92,12 @@ const el = {
copyObjectButton: document.querySelector("#copyObjectButton"),
objectOrder: document.querySelector("#objectOrder"),
objectAlign: document.querySelector("#objectAlign"),
multiSelectButton: document.querySelector("#multiSelectButton"),
textAlign: document.querySelector("#textAlign"),
textWrap: document.querySelector("#textWrap"),
textLineHeight: document.querySelector("#textLineHeight"),
textFont: document.querySelector("#textFont"),
textFontAdd: document.querySelector("#textFontAdd"),
textSize: document.querySelector("#textSize"),
textColor: document.querySelector("#textColor"),
drawLineButton: document.querySelector("#drawLineButton"),
@@ -130,12 +136,6 @@ const el = {
createCancel: document.querySelector("#createCancel"),
insertPanel: document.querySelector("#insertPanel"),
insertForm: document.querySelector("#insertForm"),
insertTextRow: document.querySelector("#insertTextRow"),
insertFontRow: document.querySelector("#insertFontRow"),
insertImageRow: document.querySelector("#insertImageRow"),
insertText: document.querySelector("#insertText"),
insertFont: document.querySelector("#insertFont"),
insertFontAdd: document.querySelector("#insertFontAdd"),
insertImage: document.querySelector("#insertImage"),
insertStatus: document.querySelector("#insertStatus"),
insertCancel: document.querySelector("#insertCancel"),
@@ -235,16 +235,20 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
busy: () => document.body.hasAttribute("aria-busy"),
rotation: () => state.rotation,
onSelect: updateObjectControls,
onTransform: (item, change) => changeDocument("ofdgoTransformObject", item,
onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item,
change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale),
onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height),
onDelete: (item) => changeDocument("ofdgoDeleteObject", item),
onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x, box.width, true, item.align || "", item.paragraphHeight || 0),
onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item),
onEdit: editCanvasObject,
drawStyle: shapeStyle,
onTool: updateDrawingControls,
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),
onCommitText: (item, value) => changeDocument("ofdgoUpdateText", item, value, null, item.size, null),
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),
onTextChange: () => {
const editing = canvasEditor.input;
setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision
@@ -255,7 +259,19 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
},
});
el.selectObjectButton.addEventListener("click", () => {
el.editorTools.addEventListener("pointerdown", (event) => {
if (canvasEditor.input && event.target.closest("button")) event.preventDefault();
});
// editorClick commits inline text before resolving the selection for a toolbar command.
function editorClick(button, action) {
button.addEventListener("click", async () => {
if (canvasEditor.input && !await canvasEditor.commitText()) return;
return action();
});
}
editorClick(el.selectObjectButton, () => {
if (document.body.hasAttribute("aria-busy")) {
return;
}
@@ -266,41 +282,61 @@ el.selectObjectButton.addEventListener("click", () => {
}
updateEditorTools();
});
el.deleteObjectButton.addEventListener("click", () => {
editorClick(el.deleteObjectButton, () => {
if (canvasEditor.selected) {
changeDocument("ofdgoDeleteObject", canvasEditor.selected);
canvasEditor.options.onDelete(canvasEditor.selected);
}
});
el.editObjectButton.addEventListener("click", () => {
editorClick(el.multiSelectButton, () => {
canvasEditor.multiple = !canvasEditor.multiple;
el.multiSelectButton.setAttribute("aria-pressed", String(canvasEditor.multiple));
});
editorClick(el.editObjectButton, () => {
if (canvasEditor.selected) {
openInsertPanel(canvasEditor.selected.type === "TextObject", canvasEditor.selected);
editCanvasObject(canvasEditor.selected);
}
});
el.textSize.addEventListener("change", () => changeTextStyle(false));
el.textSize.addEventListener("focus", () => updateObjectControls(canvasEditor.selected, true));
el.textSize.addEventListener("blur", () => updateObjectControls(canvasEditor.selected, true));
el.textColor.addEventListener("change", () => changeTextStyle(true));
el.textFont.addEventListener("change", () => {
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 (item?.type === "TextObject" && state.ready && !document.body.hasAttribute("aria-busy") && font && !font.embedded) {
return changeDocument("ofdgoUpdateText", item, item.text, fontManager.read(font), item.size, null);
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.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; });
for (const input of [el.textSize, el.shapeWidth]) {
for (const input of [el.textSize, el.shapeWidth, el.textLineHeight]) {
input.addEventListener("keydown", (event) => {
if (event.isComposing) return;
if (event.key === "Enter" || event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (event.repeat) return;
if (event.key === "Escape") {
updateObjectControls(canvasEditor.selected, true);
if (input === el.shapeWidth && canvasEditor.selected?.type !== "PathObject") {
input.value = input.defaultValue;
}
input.blur();
} else {
input.dispatchEvent(new Event("change"));
}
input.blur();
}
});
}
el.copyObjectButton.addEventListener("click", () => {
editorClick(el.copyObjectButton, () => {
const item = canvasEditor.selected;
if (!item) {
return;
@@ -325,13 +361,13 @@ el.objectAlign.addEventListener("change", () => {
const alignment = el.objectAlign.value;
el.objectAlign.value = "";
if (canvasEditor.selected && alignment) {
return changeDocument("ofdgoAlignObject", canvasEditor.selected, alignment);
return changeDocument(canvasEditor.selected.items ? "ofdgoAlignObjects" : "ofdgoAlignObject", canvasEditor.selected, alignment);
}
});
el.undoButton.addEventListener("click", () => changeDocument("ofdgoUndo"));
el.redoButton.addEventListener("click", () => changeDocument("ofdgoRedo"));
editorClick(el.undoButton, () => changeDocument("ofdgoUndo"));
editorClick(el.redoButton, () => changeDocument("ofdgoRedo"));
for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) {
button.addEventListener("click", () => {
editorClick(button, () => {
if (!state.editing || document.body.hasAttribute("aria-busy")) {
return;
}
@@ -345,15 +381,15 @@ for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButt
for (const input of [el.shapeFill, el.shapeFillColor, el.shapeStroke, el.shapeStrokeColor, el.shapeWidth]) {
input.addEventListener("change", changeShapeStyle);
}
el.addPageButton.addEventListener("click", () => {
editorClick(el.addPageButton, () => {
const page = currentPageInfo();
return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height);
});
el.copyPageButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "copy", state.pageIndex));
el.deletePageButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "delete", state.pageIndex));
el.movePagePrevButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1));
el.movePageNextButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex + 1));
el.pageSettingsButton.addEventListener("click", openPagePanel);
editorClick(el.copyPageButton, () => changeDocument("ofdgoChangePage", null, "copy", state.pageIndex));
editorClick(el.deletePageButton, () => changeDocument("ofdgoChangePage", null, "delete", state.pageIndex));
editorClick(el.movePagePrevButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1));
editorClick(el.movePageNextButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex + 1));
editorClick(el.pageSettingsButton, openPagePanel);
el.pageCancel.addEventListener("click", () => el.pagePanel.close());
el.pageWidth.addEventListener("input", updatePageDirection);
el.pageHeight.addEventListener("input", updatePageDirection);
@@ -374,12 +410,15 @@ el.ofdButton.addEventListener("click", openOFDFile);
el.newButton.addEventListener("click", openCreatePanel);
el.createCancel.addEventListener("click", () => el.createPanel.close());
el.createForm.addEventListener("submit", createDocument);
el.insertTextButton.addEventListener("click", () => openInsertPanel(true));
el.insertImageButton.addEventListener("click", () => openInsertPanel(false));
editorClick(el.insertTextButton, toggleTextTool);
editorClick(el.insertImageButton, () => openInsertPanel());
el.insertCancel.addEventListener("click", () => el.insertPanel.close());
el.insertPanel.addEventListener("close", () => { state.insertObject = null; });
el.insertForm.addEventListener("submit", insertObject);
el.insertFontAdd.addEventListener("click", () => openFontFile(el.fontInput));
el.textFontAdd.addEventListener("click", () => {
openFontFile(el.fontInput);
canvasEditor.commitText();
});
el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd"));
el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages"));
el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta"));
@@ -665,6 +704,8 @@ async function createDocument(event) {
}
state.editing = true;
canvasEditor.clear();
delete state.textDefaults.fontChoice;
updateTextFonts(null, true);
state.selectObjects = true;
setPan(false);
state.ofdBytes = null;
@@ -691,7 +732,7 @@ async function editCanvasObject(item) {
return;
}
if (item.type === "ImageObject") {
return openInsertPanel(false, item);
return openInsertPanel(item);
}
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input) {
return;
@@ -716,42 +757,70 @@ async function editCanvasObject(item) {
}
}
async function openInsertPanel(text, item = null) {
function openInsertPanel(item = null) {
if (!state.editing || document.body.hasAttribute("aria-busy")) {
return;
}
const openSeq = state.openSeq;
if (text && !item) {
await requestLocalFontsBeforeOpen();
}
if (openSeq !== state.openSeq) {
return;
}
el.insertForm.reset();
state.insertObject = item;
if (item && text) {
state.insertFonts = [];
el.insertText.value = item.text;
}
el.insertPanel.setAttribute("aria-label", item ? text ? "修改文字" : "替换图片" : text ? "添加文字" : "添加图片");
el.insertSubmit.textContent = item ? text ? "修改" : "替换" : "添加";
for (const [row, input] of [[el.insertTextRow, el.insertText], [el.insertFontRow, el.insertFont]]) {
row.hidden = !text;
input.disabled = !text;
}
el.insertImageRow.hidden = text;
el.insertImage.disabled = text;
updateInsertFonts();
el.insertPanel.setAttribute("aria-label", item ? "替换图片" : "添加图片");
el.insertSubmit.textContent = item ? "替换" : "添加";
el.insertStatus.textContent = "";
el.insertPanel.showModal();
(text ? el.insertText : el.insertImage).focus();
el.insertImage.focus();
}
function updateInsertFonts() {
const selected = state.insertFonts[Number(el.insertFont.value)];
state.insertFonts = editorFonts(state.insertObject?.type === "TextObject" ? state.insertObject : null);
setFontOptions(el.insertFont, state.insertFonts, selected);
el.insertSubmit.disabled = !el.insertTextRow.hidden && !state.insertFonts.length;
el.insertStatus.textContent = el.insertSubmit.disabled ? "尚未添加字体" : "";
// currentTextStyle returns the selected text style or the next text box's defaults.
function currentTextStyle() {
return canvasEditor.selected?.type === "TextObject" ? canvasEditor.selected : state.textDefaults;
}
// toggleTextTool places text directly on the page without creating empty document objects.
async function toggleTextTool() {
if (!state.editing || document.body.hasAttribute("aria-busy")) return;
if (canvasEditor.tool === "text") {
canvasEditor.setTool("");
return;
}
const openSeq = state.openSeq;
if (!state.textFonts.length) {
await requestLocalFontsBeforeOpen();
updateTextFonts(canvasEditor.selected, true);
}
if (openSeq !== state.openSeq) return;
const style = currentTextStyle();
state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap,
align: style.align || "left", paragraphHeight: style.paragraphHeight || 0,
fontChoice: state.textFonts[Number(el.textFont.value)] || state.textDefaults.fontChoice };
state.selectObjects = true;
setPan(false);
canvasEditor.setTool("text");
el.viewerPanel.focus({ preventScroll: true });
}
// beginCanvasText loads the chosen local font and starts an unsaved inline text box.
async function beginCanvasText(index, box, surface, page) {
const font = state.textDefaults.fontChoice || state.textFonts[Number(el.textFont.value)];
if (!font) {
setStatus("尚未添加字体");
el.textFontAdd.focus();
return;
}
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-draft", data);
await face.load();
if (openSeq !== state.openSeq || !surface.isConnected) return;
const node = document.createElement("div");
const item = { ...state.textDefaults, ...box, index, page, surface, node, text: "", draft: true, fontData: data };
canvasEditor.editText(item, face);
} catch (err) {
if (openSeq === state.openSeq) showError(err, false);
} finally {
if (openSeq === state.openSeq) setBusy(false);
}
}
function editorFonts(item) {
@@ -769,18 +838,21 @@ function setFontOptions(select, fonts, selected) {
const option = document.createElement("option");
option.value = String(index);
option.textContent = font.fullName || font.name;
option.selected = key(font) === key(selected);
select.append(option);
});
select.value = fonts.length ? String(Math.max(0, fonts.findIndex(font => key(font) === key(selected)))) : "";
}
function updateTextFonts(item, refresh = false) {
const text = item?.type === "TextObject";
if (refresh || state.textFonts[0]?.id !== (text ? `embedded:${item.font}` : undefined)) {
state.textFonts = text ? editorFonts(item) : [];
setFontOptions(el.textFont, state.textFonts, state.textFonts[0]);
const id = item?.type === "TextObject" && !item.draft ? item.font : null;
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);
setFontOptions(el.textFont, state.textFonts, selected || state.textFonts[0]);
if (!id) state.textDefaults.fontChoice = state.textFonts[Number(el.textFont.value)];
}
el.textFont.value = text ? "0" : "";
}
async function insertObject(event) {
@@ -789,7 +861,6 @@ async function insertObject(event) {
return;
}
let openSeq = state.openSeq;
const text = !el.insertTextRow.hidden;
const item = state.insertObject;
const index = item?.index ?? state.pageIndex;
const page = state.doc.pages[index];
@@ -798,23 +869,12 @@ async function insertObject(event) {
el.insertStatus.textContent = "";
setBusy(true);
try {
let data = null;
if (text) {
const font = state.insertFonts[Number(el.insertFont.value)];
if (!font.embedded) {
data = await fontManager.read(font);
}
} else {
data = new Uint8Array(await el.insertImage.files[0].arrayBuffer());
}
const data = new Uint8Array(await el.insertImage.files[0].arrayBuffer());
if (openSeq !== state.openSeq) {
return;
}
const value = item && data === null && el.insertText.value === item.text ? null : el.insertText.value;
const doc = item
? text ? await callWASM("ofdgoUpdateText", item.index, item.id, value, data, item.size, null)
: await callWASM("ofdgoReplaceImage", item.index, item.id, data)
: text ? await callWASM("ofdgoInsertText", index, el.insertText.value, data, x, y, 12 * 25.4 / 72, "#000000")
? await callWASM("ofdgoReplaceImage", item.index, item.id, data)
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2);
if (openSeq !== state.openSeq) {
return;
@@ -852,9 +912,28 @@ function textPoints(size) {
return Number((size * 72 / 25.4).toFixed(2));
}
// changeParagraph keeps layout in the library and only submits changed form values.
async function changeParagraph(wrap = currentTextStyle().wrap) {
const item = currentTextStyle();
if (document.body.hasAttribute("aria-busy") || item.draft) return;
if (!el.textLineHeight.checkValidity()) {
updateObjectControls(item, true);
return;
}
const points = Number(el.textLineHeight.value);
const height = points === textPoints(item.paragraphHeight || 0) ? item.paragraphHeight || 0 : points * 25.4 / 72;
if (item === state.textDefaults) {
Object.assign(item, { wrap: Boolean(wrap), align: el.textAlign.value, paragraphHeight: height });
updateObjectControls(canvasEditor.selected, true);
return;
}
await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height);
updateObjectControls(canvasEditor.selected, true);
}
async function changeTextStyle(color) {
const item = canvasEditor.selected;
if (item?.type !== "TextObject" || !state.ready || state.exporting) {
const item = currentTextStyle();
if (!state.ready || state.exporting || item.draft || document.body.hasAttribute("aria-busy")) {
return;
}
if (!color && !el.textSize.checkValidity()) {
@@ -862,8 +941,14 @@ async function changeTextStyle(color) {
updateObjectControls(item, true);
return;
}
const size = color || Number(el.textSize.value) === textPoints(item.size) ? item.size : Number(el.textSize.value) * 25.4 / 72;
const size = color || Number(el.textSize.value) === Number(el.textSize.defaultValue) ? item.size : Number(el.textSize.value) * 25.4 / 72;
const fill = color && el.textColor.value !== item.color ? el.textColor.value : null;
if (item === state.textDefaults) {
item.size = size;
if (fill !== null) item.color = fill;
updateObjectControls(canvasEditor.selected, true);
return;
}
if (size !== item.size || fill !== null) {
await changeDocument("ofdgoUpdateText", item, color ? null : item.text, null, size, fill);
}
@@ -903,6 +988,7 @@ async function changeShapeStyle() {
function updateDrawingControls() {
const tool = canvasEditor.tool;
el.insertTextButton.setAttribute("aria-pressed", String(tool === "text"));
const line = tool === "line" || !tool && canvasEditor.selected?.shape === "line";
if (line) {
el.shapeFill.checked = false;
@@ -924,6 +1010,8 @@ async function changeDocument(name, item, ...args) {
return;
}
let openSeq = state.openSeq;
const active = document.activeElement;
const toolbarFocus = el.editorTools.contains(active) ? active : null;
const previous = currentPageInfo();
const { scrollLeft, scrollTop } = el.viewerPanel;
setBusy(true);
@@ -940,14 +1028,14 @@ async function changeDocument(name, item, ...args) {
return true;
}
setEditorInfo(doc);
if (!item || name === "ofdgoDeleteObject") {
if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects") {
canvasEditor.clear();
}
if (name === "ofdgoCopyObject" || name === "ofdgoInsertShape") {
if (name === "ofdgoCopyObject" || name === "ofdgoInsertShape" || name === "ofdgoInsertText") {
canvasEditor.selectLast = true;
}
openSeq = ++state.openSeq;
if (item || name === "ofdgoInsertShape") {
if (item || name === "ofdgoInsertShape" || name === "ofdgoInsertText") {
await refreshEditorPage(doc, item ? item.index : args[0], openSeq);
} else {
const samePage = doc.pages.findIndex((page) => page.id === previous.id);
@@ -959,7 +1047,7 @@ async function changeDocument(name, item, ...args) {
keepPreview: true, previewScroll: keepScroll ? { scrollLeft, scrollTop } : null });
}
if (openSeq === state.openSeq) {
el.viewerPanel.focus({ preventScroll: true });
if (!toolbarFocus) el.viewerPanel.focus({ preventScroll: true });
return true;
}
} catch (err) {
@@ -973,6 +1061,7 @@ async function changeDocument(name, item, ...args) {
} finally {
if (openSeq === state.openSeq) {
setBusy(false);
if (toolbarFocus?.isConnected && !toolbarFocus.disabled) toolbarFocus.focus({ preventScroll: true });
}
}
}
@@ -1735,9 +1824,6 @@ async function changeFont(font, changes) {
function renderFontList() {
updateTextFonts(canvasEditor.selected, true);
if (el.insertPanel.open) {
updateInsertFonts();
}
el.fontList.replaceChildren();
const fonts = fontManager.records();
if (!fonts.length) {
@@ -2120,7 +2206,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value)
const label = archive ? "ZIP" : format.label;
const extension = archive ? "zip" : format.extension;
const mime = archive ? "application/zip" : format.mime;
const openSeq = state.openSeq;
let openSeq = state.openSeq;
const fileName = whole ? `${baseFileName()}.${extension}` : pageFileName(extension);
const pageIndex = state.pageIndex;
const dpi = exportFormatUsesDPI(format.value) ? currentImageDPI() : 0;
@@ -2135,6 +2221,12 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value)
if (openSeq !== state.openSeq) {
return;
}
if (canvasEditor.input) {
setBusy(false);
if (!await canvasEditor.commitText()) return;
openSeq = state.openSeq;
setBusy(true, `正在生成 ${label}`, null, whole ? STATUS.exporting : STATUS.pageExporting);
}
const result = saving ? await callWASM("ofdgoSaveDocument", file) : whole
? await callWASM("ofdgoExportDocument", format.value, dpi, indices, file)
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, file);
@@ -4033,16 +4125,28 @@ function updateEditorTools() {
}
function updateObjectControls(item, reset = false) {
const disabled = !item || !state.ready || state.exporting;
el.deleteObjectButton.disabled = el.copyObjectButton.disabled = el.objectAlign.disabled = disabled;
el.editObjectButton.disabled = disabled || item.type === "PathObject";
el.textFont.disabled = el.textSize.disabled = el.textColor.disabled = disabled || item.type !== "TextObject";
const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting;
el.deleteObjectButton.disabled = el.objectAlign.disabled = disabled;
el.copyObjectButton.disabled = disabled || Boolean(item?.items);
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.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)));
if (reset || document.activeElement !== el.textLineHeight) {
el.textLineHeight.value = text.paragraphHeight ? String(textPoints(text.paragraphHeight)) : "";
}
updateTextFonts(item);
if (reset || el.textSize.disabled || document.activeElement !== el.textSize) {
el.textSize.value = item?.type === "TextObject" ? textPoints(item.size) : "";
el.textSize.value = document.activeElement === el.textSize ? textPoints(text.size) : Math.round(textPoints(text.size));
el.textSize.defaultValue = el.textSize.value;
}
el.textSize.title = `字号 ${textPoints(text.size)} pt`;
if (reset || el.textColor.disabled || document.activeElement !== el.textColor) {
el.textColor.value = item?.type === "TextObject" ? item.color : "#000000";
el.textColor.value = text.color;
}
if (item?.type === "PathObject") {
el.shapeFill.checked = item.fill;
@@ -4054,7 +4158,7 @@ function updateObjectControls(item, reset = false) {
}
}
updateDrawingControls();
el.objectOrder.disabled = disabled || item.count <= 1;
el.objectOrder.disabled = disabled || Boolean(item.items) || item.count <= 1;
for (const option of el.objectOrder.options) {
option.disabled = disabled || (["up", "top"].includes(option.value) ? item.order === item.count - 1 : item.order === 0);
}
+153 -31
View File
@@ -30,6 +30,13 @@ export function objectTransform(box, page, dx, dy, corner = "") {
return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale };
}
// selectionBounds keeps same-page objects together without changing their relative positions.
export function selectionBounds(items) {
const x = Math.min(...items.map(item => item.x)), y = Math.min(...items.map(item => item.y));
return { x, y, width: Math.max(...items.map(item => item.x + item.width)) - x,
height: Math.max(...items.map(item => item.y + item.height)) - y };
}
// constrainedPoint applies drawing constraints before clipping the endpoint to the page.
export function constrainedPoint(from, to, page, shape, shift) {
let dx = to.x - from.x, dy = to.y - from.y;
@@ -95,6 +102,7 @@ export class CanvasEditor {
this.options = options;
this.enabled = false;
this.selected = null;
this.multiple = false;
this.nodes = new WeakMap();
this.pages = new WeakMap();
this.tool = "";
@@ -107,7 +115,7 @@ export class CanvasEditor {
viewer.addEventListener("keyup", (event) => this.modifierChange(event));
viewer.addEventListener("dblclick", () => {
const item = this.selected;
if (this.enabled && item && !this.input && !this.options.busy()) {
if (this.enabled && item && !item.items && !this.input && !this.options.busy()) {
this.options.onEdit(item);
}
});
@@ -136,6 +144,8 @@ export class CanvasEditor {
}
mount(index, page, surface) {
const selected = this.items().filter(item => item.index === index).map(item => item.id);
const selection = [];
this.pages.set(surface, { index, width: page.width, height: page.height });
const layer = document.createElement("div");
layer.className = "edit-layer";
@@ -152,7 +162,8 @@ export class CanvasEditor {
this.nodes.set(node, item);
node.addEventListener("focus", () => this.select(item));
const handles = object.shape === "line" ? ["start", "end"]
: object.shape ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] : ["nw", "ne", "sw", "se"];
: object.shape ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"]
: object.type === "TextObject" ? ["nw", "ne", "e", "se", "sw", "w"] : ["nw", "ne", "sw", "se"];
for (const corner of handles) {
const handle = document.createElement("span");
handle.className = `edit-handle edit-${corner}`;
@@ -161,11 +172,12 @@ export class CanvasEditor {
}
this.place(item);
layer.append(node);
if (this.selected?.index === index && this.selected.id === object.id) {
this.select(item);
if (selected.includes(object.id)) {
selection.push(item);
}
}
surface.append(layer);
if (selected.length) this.setSelection(selection);
if (this.selectLast && layer.lastElementChild) {
this.selectLast = false;
this.select(this.nodes.get(layer.lastElementChild));
@@ -173,6 +185,10 @@ 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;
}
if (item.shape) {
this.placeShape(item, { x: item.geometry.x + change.x, y: item.geometry.y + change.y,
width: item.geometry.width * change.scale, height: item.geometry.height * change.scale });
@@ -200,11 +216,34 @@ export class CanvasEditor {
}
}
select(item) {
this.selected?.node.classList.remove("selected");
this.selected = item;
item?.node.classList.add("selected");
this.options.onSelect(item);
// items returns the current selection without treating a group as a document object.
items() {
return this.selected?.items || (this.selected ? [this.selected] : []);
}
// setSelection updates object highlights and exposes one same-page selection to the toolbar.
setSelection(items) {
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 };
for (const item of items) {
item.node.classList.add("selected");
item.node.classList.toggle("multi-selected", items.length > 1);
item.node.setAttribute("aria-pressed", "true");
}
this.options.onSelect(this.selected);
}
select(item, add = false) {
if (!add || !item || this.selected && item.index !== this.selected.index) {
this.setSelection(item ? [item] : []);
return;
}
const items = this.items();
this.setSelection(items.includes(item) ? items.filter(member => member !== item) : [...items, item]);
}
clear() {
@@ -216,6 +255,7 @@ export class CanvasEditor {
start(event) {
if (this.input) {
if (event.target !== this.input.input) this.commitText();
return;
}
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
@@ -230,13 +270,23 @@ export class CanvasEditor {
event.preventDefault();
}
this.viewer.focus({ preventScroll: true });
this.select(node ? this.nodes.get(node) : null);
const target = node ? this.nodes.get(node) : null;
const additive = event.shiftKey || event.ctrlKey || event.metaKey || this.multiple;
if (!target) {
this.startMarquee(event, additive);
return;
}
if (additive && !event.target.dataset.corner) {
this.select(target, true);
return;
}
if (!this.items().includes(target)) this.select(target);
if (!node || this.options.busy()) {
return;
}
const item = this.selected;
this.drag = {
item, pointerID: event.pointerId, corner: event.target.dataset.corner || "",
item, pointerID: event.pointerId, corner: item.items ? "" : event.target.dataset.corner || "",
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
clientX: event.clientX, clientY: event.clientY,
};
@@ -259,11 +309,20 @@ export class CanvasEditor {
this.moveShape(event, drag);
return;
}
if (drag.marquee) {
this.moveMarquee(event, drag);
return;
}
if (!drag.change && !drag.box && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) {
return;
}
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation);
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation);
if (drag.item.type === "TextObject" && (drag.corner === "w" || drag.corner === "e")) {
drag.box = reshapeBox({ geometry: drag.item, page: drag.item.page }, to.x - from.x, 0, drag.corner, false);
Object.assign(drag.item.node.style, { left: `${drag.box.x * PX_PER_MM}px`, width: `${drag.box.width * PX_PER_MM}px` });
return;
}
if (drag.item.shape && drag.corner) {
drag.box = reshapeBox(drag.item, to.x - from.x, to.y - from.y, drag.corner, event.shiftKey);
this.placeShape(drag.item, drag.box || drag.item.geometry);
@@ -280,9 +339,25 @@ export class CanvasEditor {
return;
}
this.move(event);
if (drag.marquee) {
this.drag = null;
drag.preview.remove();
this.viewer.releasePointerCapture(drag.pointerID);
return;
}
if (drag.shape) {
this.drag = null;
this.viewer.releasePointerCapture(drag.pointerID);
if (drag.shape === "text") {
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation);
const x = Math.max(0, Math.min(from.x, drag.page.width - 1));
const y = Math.max(0, Math.min(from.y, drag.page.height - 1));
const box = drag.box || { x, y, width: Math.min(80, drag.page.width - x), height: 5 };
drag.preview.remove();
this.setTool("");
this.options.onDrawText(drag.page.index, box, drag.surface, drag.page);
return;
}
if (!drag.box) {
drag.preview.remove();
return;
@@ -292,13 +367,15 @@ export class CanvasEditor {
.finally(() => drag.preview.remove());
return;
}
if (drag.item.shape && drag.corner) {
if ((drag.item.shape || drag.item.type === "TextObject") && drag.corner && drag.box) {
this.drag = null;
this.viewer.releasePointerCapture(drag.pointerID);
const changed = drag.box && Object.keys(drag.box).some((key) => drag.box[key] !== drag.item.geometry[key]);
Promise.resolve(changed && this.options.onReshape(drag.item, drag.box)).finally(() => {
const geometry = drag.item.geometry || drag.item;
const changed = Object.keys(drag.box).some((key) => drag.box[key] !== geometry[key]);
const apply = drag.item.shape ? this.options.onReshape : this.options.onTextWidth;
Promise.resolve(changed && apply(drag.item, drag.box)).finally(() => {
drag.preview?.remove();
this.placeShape(drag.item, drag.item.geometry);
this.place(drag.item);
});
return;
}
@@ -313,6 +390,7 @@ export class CanvasEditor {
this.drag = null;
if (drag) {
drag.preview?.remove();
if (drag.marquee) this.setSelection(drag.before);
if (drag.item) {
this.place(drag.item);
}
@@ -322,6 +400,41 @@ export class CanvasEditor {
}
}
// startMarquee selects by page-space geometry, so rotation and zoom use the same hit tests.
startMarquee(event, additive) {
const surface = event.target.closest(".page-surface"), page = this.pages.get(surface);
if (!page) {
this.select(null);
return;
}
event.preventDefault();
const before = this.items();
const base = additive ? before.filter(item => item.index === page.index) : [];
this.setSelection(base);
this.drag = { marquee: true, page, surface, before, base,
...this.createPreview(surface, page, "rectangle", { fill: true, fillColor: "var(--accent-soft)", stroke: true, strokeColor: "var(--accent)", lineWidth: 0.2 }),
pointerID: event.pointerId, clientX: event.clientX, clientY: event.clientY,
rect: surface.getBoundingClientRect(), rotation: this.options.rotation() };
this.viewer.setPointerCapture(event.pointerId);
}
// moveMarquee includes intersecting objects while preserving an additive selection.
moveMarquee(event, drag) {
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation);
const to = constrainedPoint(from, pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation), drag.page, "rectangle", false);
const box = { x: Math.min(from.x, to.x), y: Math.min(from.y, to.y), width: Math.abs(to.x-from.x), height: Math.abs(to.y-from.y) };
paintShape(drag.node, "rectangle", box);
if (Math.hypot(event.clientX-drag.clientX, event.clientY-drag.clientY) < 3) return;
const items = [...drag.base];
for (const node of drag.surface.querySelectorAll(".edit-object")) {
const item = this.nodes.get(node);
if (!items.includes(item) && item.x <= box.x+box.width && item.x+item.width >= box.x && item.y <= box.y+box.height && item.y+item.height >= box.y) {
items.push(item);
}
}
this.setSelection(items);
}
startShape(event) {
const surface = event.target.closest(".page-surface");
const page = this.pages.get(surface);
@@ -336,7 +449,7 @@ export class CanvasEditor {
style.fill = false;
style.stroke = true;
}
this.drag = { shape, page, ...this.createPreview(surface, page, shape, style), style, pointerID: event.pointerId,
this.drag = { shape, page, surface, ...this.createPreview(surface, page, shape === "text" ? "rectangle" : shape, style), style, pointerID: event.pointerId,
rect: surface.getBoundingClientRect(), rotation: this.options.rotation(),
clientX: event.clientX, clientY: event.clientY };
this.viewer.setPointerCapture(event.pointerId);
@@ -362,15 +475,15 @@ export class CanvasEditor {
return { x: Math.max(0, Math.min(p.x, drag.page.width)), y: Math.max(0, Math.min(p.y, drag.page.height)) };
};
const from = point(drag.clientX, drag.clientY);
const to = constrainedPoint(from, pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation), drag.page, drag.shape, event.shiftKey);
const to = constrainedPoint(from, pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation), drag.page, drag.shape, event.shiftKey && drag.shape !== "text");
const x = Math.min(from.x, to.x), y = Math.min(from.y, to.y);
const width = Math.abs(to.x - from.x), height = Math.abs(to.y - from.y);
const width = Math.abs(to.x - from.x), height = drag.shape === "text" ? Math.max(5, Math.abs(to.y - from.y)) : Math.abs(to.y - from.y);
const valid = Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) >= 3
&& (drag.shape === "line" ? width > 0 || height > 0 : width > 0 && height > 0);
drag.box = valid ? drag.shape === "line"
? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y }
: { x, y, width, height } : null;
paintShape(drag.node, drag.shape, drag.shape === "line"
paintShape(drag.node, drag.shape === "text" ? "rectangle" : drag.shape, drag.shape === "line"
? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y } : { x, y, width, height });
}
@@ -384,7 +497,14 @@ export class CanvasEditor {
}
keyDown(event) {
if (event.defaultPrevented || event.target?.closest("input, textarea, select, [contenteditable]")) return;
this.modifierChange(event);
if (event.key === "Escape" && this.drag && !this.tool && !this.options.busy()) {
event.preventDefault();
event.stopPropagation();
this.cancel();
return;
}
if (event.key === "Escape" && this.tool && !this.options.busy()) {
event.preventDefault();
event.stopPropagation();
@@ -405,9 +525,9 @@ export class CanvasEditor {
this.drag ? this.cancel() : this.clear();
} else if (event.key === "Delete") {
this.options.onDelete(this.selected);
} else if (edit && !this.drag) {
} else if (edit && !this.drag && !this.selected.items) {
this.options.onEdit(this.selected);
} else if (!this.drag) {
} else if (!this.drag && directions[event.key]) {
const [x, y] = directions[event.key];
const [dx, dy] = [[x, y], [y, -x], [-x, -y], [-y, x]][this.options.rotation() / 90];
const step = event.shiftKey ? 10 : 1;
@@ -427,15 +547,16 @@ export class CanvasEditor {
const input = document.createElement("textarea");
input.className = "edit-text";
input.setAttribute("aria-label", "编辑文字");
input.wrap = "off";
input.wrap = item.wrap ? "soft" : "off";
input.spellcheck = false;
input.value = item.text;
Object.assign(input.style, {
left: `${item.x * PX_PER_MM}px`, top: `${item.y * PX_PER_MM}px`,
width: `${Math.max(item.width, Math.min(50, item.page.width - item.x)) * PX_PER_MM + 4}px`,
width: `${(item.wrap ? item.width : Math.max(item.width, Math.min(50, item.page.width - item.x))) * PX_PER_MM + 4}px`,
minHeight: `${item.height * PX_PER_MM + 4}px`,
fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`,
lineHeight: item.lineHeight ? `${item.lineHeight * PX_PER_MM}px` : "normal", color: item.color,
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 };
item.surface.append(input);
@@ -464,22 +585,21 @@ export class CanvasEditor {
});
resize();
input.focus({ preventScroll: true });
input.select();
if (!item.draft) input.select();
}
async commitText() {
const editing = this.input;
if (!editing || editing.input.readOnly) {
return;
}
if (editing.input.value === editing.item.text) {
if (!editing) return true;
if (editing.input.readOnly) return false;
if (editing.input.value === editing.item.text || editing.item.draft && !editing.input.value.trim()) {
this.closeText();
return;
return true;
}
editing.input.readOnly = true;
const saved = await this.options.onCommitText(editing.item, editing.input.value);
if (this.input !== editing) {
return;
return Boolean(saved);
}
if (saved) {
this.closeText();
@@ -487,6 +607,7 @@ export class CanvasEditor {
editing.input.readOnly = false;
editing.input.focus({ preventScroll: true });
}
return Boolean(saved);
}
closeText() {
@@ -495,6 +616,7 @@ export class CanvasEditor {
if (editing) {
editing.input.remove();
document.fonts.delete(editing.face);
if (editing.item.draft && this.selected === editing.item) this.select(null);
this.options.onTextChange();
}
}