mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
This commit is contained in:
1 parent
21999afcc1
commit
875de1cea8
11 files changed
+959
-254
No files matched your search
+21
-20
@@ -61,8 +61,9 @@
|
|||||||
<button id="redoButton" class="button" type="button" disabled>重做</button>
|
<button id="redoButton" class="button" type="button" disabled>重做</button>
|
||||||
<span>文件</span>
|
<span>文件</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-group">
|
<div class="editor-group editor-objects">
|
||||||
<button id="selectObjectButton" class="button" type="button" aria-pressed="true">选择</button>
|
<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="editObjectButton" class="button" type="button" aria-label="修改对象" disabled>修改</button>
|
||||||
<button id="copyObjectButton" 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>
|
<button id="deleteObjectButton" class="button" type="button" disabled>删除</button>
|
||||||
@@ -73,25 +74,36 @@
|
|||||||
<option value="top">置顶</option>
|
<option value="top">置顶</option>
|
||||||
<option value="bottom">置底</option>
|
<option value="bottom">置底</option>
|
||||||
</select>
|
</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="" hidden>对齐</option>
|
||||||
<option value="left">左侧</option>
|
<option value="left">左侧</option>
|
||||||
<option value="center">水平居中</option>
|
<option value="center">水平</option>
|
||||||
<option value="right">右侧</option>
|
<option value="right">右侧</option>
|
||||||
<option value="top">顶部</option>
|
<option value="top">顶部</option>
|
||||||
<option value="middle">垂直居中</option>
|
<option value="middle">垂直</option>
|
||||||
<option value="bottom">底部</option>
|
<option value="bottom">底部</option>
|
||||||
</select>
|
</select>
|
||||||
<span>对象</span>
|
<span>对象</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-group">
|
<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>
|
<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>
|
<input id="textColor" class="color-input" type="color" value="#000000" aria-label="文字颜色" title="文字颜色" disabled>
|
||||||
<span>文字</span>
|
<span>文字</span>
|
||||||
</div>
|
</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">
|
<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="insertImageButton" class="button" type="button" aria-label="添加图片">图片</button>
|
||||||
<button id="drawLineButton" class="button" type="button" aria-label="绘制直线" aria-pressed="false">直线</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>
|
<button id="drawRectangleButton" class="button" type="button" aria-label="绘制矩形" aria-pressed="false">矩形</button>
|
||||||
@@ -163,22 +175,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="insertPanel" class="form-dialog" aria-label="添加文字">
|
<dialog id="insertPanel" class="form-dialog" aria-label="添加图片">
|
||||||
<form id="insertForm">
|
<form id="insertForm">
|
||||||
<div id="insertTextRow" class="form-row">
|
<div 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>
|
|
||||||
<label for="insertImage">图片</label>
|
<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>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<div id="insertStatus" class="form-status" role="status"></div>
|
<div id="insertStatus" class="form-status" role="status"></div>
|
||||||
|
|||||||
+31
-6
@@ -111,11 +111,27 @@ a:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-file,
|
.editor-file,
|
||||||
|
.editor-paragraph,
|
||||||
.editor-group:has(#textFont),
|
.editor-group:has(#textFont),
|
||||||
.editor-group:has(#shapeFill) {
|
.editor-group:has(#shapeFill) {
|
||||||
grid-template-columns: repeat(2, max-content);
|
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 {
|
.editor-file #saveButton {
|
||||||
grid-row: 1 / 3;
|
grid-row: 1 / 3;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -126,9 +142,8 @@ a:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#textFont {
|
#textFont {
|
||||||
grid-column: 1 / -1;
|
width: 100%;
|
||||||
width: 128px;
|
min-width: 0;
|
||||||
min-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shape-paint,
|
.shape-paint,
|
||||||
@@ -268,7 +283,7 @@ button:not(:disabled):hover {
|
|||||||
|
|
||||||
.new-icon {
|
.new-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 12px;
|
width: 15px;
|
||||||
height: 15px;
|
height: 15px;
|
||||||
border: 1px solid currentColor;
|
border: 1px solid currentColor;
|
||||||
}
|
}
|
||||||
@@ -338,12 +353,15 @@ button:not(:disabled):hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-control input,
|
.page-control input,
|
||||||
|
.paragraph-height input,
|
||||||
.text-size input {
|
.text-size input {
|
||||||
appearance: textfield;
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-control input::-webkit-inner-spin-button,
|
.page-control input::-webkit-inner-spin-button,
|
||||||
.page-control input::-webkit-outer-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-inner-spin-button,
|
||||||
.text-size input::-webkit-outer-spin-button {
|
.text-size input::-webkit-outer-spin-button {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
@@ -451,6 +469,7 @@ button:not(:disabled):hover {
|
|||||||
.form-row input[type="file"],
|
.form-row input[type="file"],
|
||||||
.form-row select,
|
.form-row select,
|
||||||
.form-row textarea,
|
.form-row textarea,
|
||||||
|
.paragraph-height input,
|
||||||
.text-size input {
|
.text-size input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -531,10 +550,12 @@ button:not(:disabled):hover {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-font {
|
.text-font {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
width: 160px;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 8px;
|
grid-column: 1 / -1;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row input[aria-invalid="true"] {
|
.form-row input[aria-invalid="true"] {
|
||||||
@@ -1241,6 +1262,10 @@ button.outline-link:hover {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.multi-selected .edit-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-handle::before {
|
.edit-handle::before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: calc(-8px / var(--surface-scale));
|
inset: calc(-8px / var(--surface-scale));
|
||||||
|
|||||||
+205
-101
@@ -37,8 +37,9 @@ const state = {
|
|||||||
editorInfo: null,
|
editorInfo: null,
|
||||||
savedRevision: null,
|
savedRevision: null,
|
||||||
insertObject: null,
|
insertObject: null,
|
||||||
insertFonts: [],
|
|
||||||
textFonts: [],
|
textFonts: [],
|
||||||
|
textFontID: null,
|
||||||
|
textDefaults: { type: "TextObject", size: 12 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0 },
|
||||||
ofdBytes: null,
|
ofdBytes: null,
|
||||||
fileName: "ofdgo.ofd",
|
fileName: "ofdgo.ofd",
|
||||||
openSeq: 0,
|
openSeq: 0,
|
||||||
@@ -91,7 +92,12 @@ const el = {
|
|||||||
copyObjectButton: document.querySelector("#copyObjectButton"),
|
copyObjectButton: document.querySelector("#copyObjectButton"),
|
||||||
objectOrder: document.querySelector("#objectOrder"),
|
objectOrder: document.querySelector("#objectOrder"),
|
||||||
objectAlign: document.querySelector("#objectAlign"),
|
objectAlign: document.querySelector("#objectAlign"),
|
||||||
|
multiSelectButton: document.querySelector("#multiSelectButton"),
|
||||||
|
textAlign: document.querySelector("#textAlign"),
|
||||||
|
textWrap: document.querySelector("#textWrap"),
|
||||||
|
textLineHeight: document.querySelector("#textLineHeight"),
|
||||||
textFont: document.querySelector("#textFont"),
|
textFont: document.querySelector("#textFont"),
|
||||||
|
textFontAdd: document.querySelector("#textFontAdd"),
|
||||||
textSize: document.querySelector("#textSize"),
|
textSize: document.querySelector("#textSize"),
|
||||||
textColor: document.querySelector("#textColor"),
|
textColor: document.querySelector("#textColor"),
|
||||||
drawLineButton: document.querySelector("#drawLineButton"),
|
drawLineButton: document.querySelector("#drawLineButton"),
|
||||||
@@ -130,12 +136,6 @@ const el = {
|
|||||||
createCancel: document.querySelector("#createCancel"),
|
createCancel: document.querySelector("#createCancel"),
|
||||||
insertPanel: document.querySelector("#insertPanel"),
|
insertPanel: document.querySelector("#insertPanel"),
|
||||||
insertForm: document.querySelector("#insertForm"),
|
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"),
|
insertImage: document.querySelector("#insertImage"),
|
||||||
insertStatus: document.querySelector("#insertStatus"),
|
insertStatus: document.querySelector("#insertStatus"),
|
||||||
insertCancel: document.querySelector("#insertCancel"),
|
insertCancel: document.querySelector("#insertCancel"),
|
||||||
@@ -235,16 +235,20 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
|||||||
busy: () => document.body.hasAttribute("aria-busy"),
|
busy: () => document.body.hasAttribute("aria-busy"),
|
||||||
rotation: () => state.rotation,
|
rotation: () => state.rotation,
|
||||||
onSelect: updateObjectControls,
|
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),
|
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),
|
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,
|
onEdit: editCanvasObject,
|
||||||
drawStyle: shapeStyle,
|
drawStyle: shapeStyle,
|
||||||
onTool: updateDrawingControls,
|
onTool: updateDrawingControls,
|
||||||
onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape,
|
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),
|
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: () => {
|
onTextChange: () => {
|
||||||
const editing = canvasEditor.input;
|
const editing = canvasEditor.input;
|
||||||
setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision
|
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")) {
|
if (document.body.hasAttribute("aria-busy")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -266,41 +282,61 @@ el.selectObjectButton.addEventListener("click", () => {
|
|||||||
}
|
}
|
||||||
updateEditorTools();
|
updateEditorTools();
|
||||||
});
|
});
|
||||||
el.deleteObjectButton.addEventListener("click", () => {
|
editorClick(el.deleteObjectButton, () => {
|
||||||
if (canvasEditor.selected) {
|
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) {
|
if (canvasEditor.selected) {
|
||||||
openInsertPanel(canvasEditor.selected.type === "TextObject", canvasEditor.selected);
|
editCanvasObject(canvasEditor.selected);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
el.textSize.addEventListener("change", () => changeTextStyle(false));
|
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.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 item = canvasEditor.selected;
|
||||||
const font = state.textFonts[Number(el.textFont.value)];
|
const font = state.textFonts[Number(el.textFont.value)];
|
||||||
if (item?.type === "TextObject" && state.ready && !document.body.hasAttribute("aria-busy") && font && !font.embedded) {
|
if (!state.ready || document.body.hasAttribute("aria-busy") || !font || item?.draft) return;
|
||||||
return changeDocument("ofdgoUpdateText", item, item.text, fontManager.read(font), item.size, null);
|
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; });
|
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) => {
|
input.addEventListener("keydown", (event) => {
|
||||||
|
if (event.isComposing) return;
|
||||||
if (event.key === "Enter" || event.key === "Escape") {
|
if (event.key === "Enter" || event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.repeat) return;
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
updateObjectControls(canvasEditor.selected, true);
|
updateObjectControls(canvasEditor.selected, true);
|
||||||
if (input === el.shapeWidth && canvasEditor.selected?.type !== "PathObject") {
|
if (input === el.shapeWidth && canvasEditor.selected?.type !== "PathObject") {
|
||||||
input.value = input.defaultValue;
|
input.value = input.defaultValue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
input.blur();
|
input.blur();
|
||||||
|
} else {
|
||||||
|
input.dispatchEvent(new Event("change"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
el.copyObjectButton.addEventListener("click", () => {
|
editorClick(el.copyObjectButton, () => {
|
||||||
const item = canvasEditor.selected;
|
const item = canvasEditor.selected;
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
@@ -325,13 +361,13 @@ el.objectAlign.addEventListener("change", () => {
|
|||||||
const alignment = el.objectAlign.value;
|
const alignment = el.objectAlign.value;
|
||||||
el.objectAlign.value = "";
|
el.objectAlign.value = "";
|
||||||
if (canvasEditor.selected && alignment) {
|
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"));
|
editorClick(el.undoButton, () => changeDocument("ofdgoUndo"));
|
||||||
el.redoButton.addEventListener("click", () => changeDocument("ofdgoRedo"));
|
editorClick(el.redoButton, () => changeDocument("ofdgoRedo"));
|
||||||
for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) {
|
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")) {
|
if (!state.editing || document.body.hasAttribute("aria-busy")) {
|
||||||
return;
|
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]) {
|
for (const input of [el.shapeFill, el.shapeFillColor, el.shapeStroke, el.shapeStrokeColor, el.shapeWidth]) {
|
||||||
input.addEventListener("change", changeShapeStyle);
|
input.addEventListener("change", changeShapeStyle);
|
||||||
}
|
}
|
||||||
el.addPageButton.addEventListener("click", () => {
|
editorClick(el.addPageButton, () => {
|
||||||
const page = currentPageInfo();
|
const page = currentPageInfo();
|
||||||
return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height);
|
return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height);
|
||||||
});
|
});
|
||||||
el.copyPageButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "copy", state.pageIndex));
|
editorClick(el.copyPageButton, () => changeDocument("ofdgoChangePage", null, "copy", state.pageIndex));
|
||||||
el.deletePageButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "delete", state.pageIndex));
|
editorClick(el.deletePageButton, () => changeDocument("ofdgoChangePage", null, "delete", state.pageIndex));
|
||||||
el.movePagePrevButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1));
|
editorClick(el.movePagePrevButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1));
|
||||||
el.movePageNextButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex + 1));
|
editorClick(el.movePageNextButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex + 1));
|
||||||
el.pageSettingsButton.addEventListener("click", openPagePanel);
|
editorClick(el.pageSettingsButton, openPagePanel);
|
||||||
el.pageCancel.addEventListener("click", () => el.pagePanel.close());
|
el.pageCancel.addEventListener("click", () => el.pagePanel.close());
|
||||||
el.pageWidth.addEventListener("input", updatePageDirection);
|
el.pageWidth.addEventListener("input", updatePageDirection);
|
||||||
el.pageHeight.addEventListener("input", updatePageDirection);
|
el.pageHeight.addEventListener("input", updatePageDirection);
|
||||||
@@ -374,12 +410,15 @@ el.ofdButton.addEventListener("click", openOFDFile);
|
|||||||
el.newButton.addEventListener("click", openCreatePanel);
|
el.newButton.addEventListener("click", openCreatePanel);
|
||||||
el.createCancel.addEventListener("click", () => el.createPanel.close());
|
el.createCancel.addEventListener("click", () => el.createPanel.close());
|
||||||
el.createForm.addEventListener("submit", createDocument);
|
el.createForm.addEventListener("submit", createDocument);
|
||||||
el.insertTextButton.addEventListener("click", () => openInsertPanel(true));
|
editorClick(el.insertTextButton, toggleTextTool);
|
||||||
el.insertImageButton.addEventListener("click", () => openInsertPanel(false));
|
editorClick(el.insertImageButton, () => openInsertPanel());
|
||||||
el.insertCancel.addEventListener("click", () => el.insertPanel.close());
|
el.insertCancel.addEventListener("click", () => el.insertPanel.close());
|
||||||
el.insertPanel.addEventListener("close", () => { state.insertObject = null; });
|
el.insertPanel.addEventListener("close", () => { state.insertObject = null; });
|
||||||
el.insertForm.addEventListener("submit", insertObject);
|
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.saveButton.addEventListener("click", () => exportFile(true, null, "ofd"));
|
||||||
el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages"));
|
el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages"));
|
||||||
el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta"));
|
el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta"));
|
||||||
@@ -665,6 +704,8 @@ async function createDocument(event) {
|
|||||||
}
|
}
|
||||||
state.editing = true;
|
state.editing = true;
|
||||||
canvasEditor.clear();
|
canvasEditor.clear();
|
||||||
|
delete state.textDefaults.fontChoice;
|
||||||
|
updateTextFonts(null, true);
|
||||||
state.selectObjects = true;
|
state.selectObjects = true;
|
||||||
setPan(false);
|
setPan(false);
|
||||||
state.ofdBytes = null;
|
state.ofdBytes = null;
|
||||||
@@ -691,7 +732,7 @@ async function editCanvasObject(item) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.type === "ImageObject") {
|
if (item.type === "ImageObject") {
|
||||||
return openInsertPanel(false, item);
|
return openInsertPanel(item);
|
||||||
}
|
}
|
||||||
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input) {
|
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input) {
|
||||||
return;
|
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")) {
|
if (!state.editing || document.body.hasAttribute("aria-busy")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const openSeq = state.openSeq;
|
|
||||||
if (text && !item) {
|
|
||||||
await requestLocalFontsBeforeOpen();
|
|
||||||
}
|
|
||||||
if (openSeq !== state.openSeq) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.insertForm.reset();
|
el.insertForm.reset();
|
||||||
state.insertObject = item;
|
state.insertObject = item;
|
||||||
if (item && text) {
|
el.insertPanel.setAttribute("aria-label", item ? "替换图片" : "添加图片");
|
||||||
state.insertFonts = [];
|
el.insertSubmit.textContent = item ? "替换" : "添加";
|
||||||
el.insertText.value = item.text;
|
el.insertStatus.textContent = "";
|
||||||
}
|
|
||||||
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.showModal();
|
el.insertPanel.showModal();
|
||||||
(text ? el.insertText : el.insertImage).focus();
|
el.insertImage.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateInsertFonts() {
|
// currentTextStyle returns the selected text style or the next text box's defaults.
|
||||||
const selected = state.insertFonts[Number(el.insertFont.value)];
|
function currentTextStyle() {
|
||||||
state.insertFonts = editorFonts(state.insertObject?.type === "TextObject" ? state.insertObject : null);
|
return canvasEditor.selected?.type === "TextObject" ? canvasEditor.selected : state.textDefaults;
|
||||||
setFontOptions(el.insertFont, state.insertFonts, selected);
|
}
|
||||||
el.insertSubmit.disabled = !el.insertTextRow.hidden && !state.insertFonts.length;
|
|
||||||
el.insertStatus.textContent = el.insertSubmit.disabled ? "尚未添加字体" : "";
|
// 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) {
|
function editorFonts(item) {
|
||||||
@@ -769,18 +838,21 @@ function setFontOptions(select, fonts, selected) {
|
|||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = String(index);
|
option.value = String(index);
|
||||||
option.textContent = font.fullName || font.name;
|
option.textContent = font.fullName || font.name;
|
||||||
option.selected = key(font) === key(selected);
|
|
||||||
select.append(option);
|
select.append(option);
|
||||||
});
|
});
|
||||||
|
select.value = fonts.length ? String(Math.max(0, fonts.findIndex(font => key(font) === key(selected)))) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTextFonts(item, refresh = false) {
|
function updateTextFonts(item, refresh = false) {
|
||||||
const text = item?.type === "TextObject";
|
const id = item?.type === "TextObject" && !item.draft ? item.font : null;
|
||||||
if (refresh || state.textFonts[0]?.id !== (text ? `embedded:${item.font}` : undefined)) {
|
if (refresh || state.textFontID !== id) {
|
||||||
state.textFonts = text ? editorFonts(item) : [];
|
state.textFontID = id;
|
||||||
setFontOptions(el.textFont, state.textFonts, state.textFonts[0]);
|
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) {
|
async function insertObject(event) {
|
||||||
@@ -789,7 +861,6 @@ async function insertObject(event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let openSeq = state.openSeq;
|
let openSeq = state.openSeq;
|
||||||
const text = !el.insertTextRow.hidden;
|
|
||||||
const item = state.insertObject;
|
const item = state.insertObject;
|
||||||
const index = item?.index ?? state.pageIndex;
|
const index = item?.index ?? state.pageIndex;
|
||||||
const page = state.doc.pages[index];
|
const page = state.doc.pages[index];
|
||||||
@@ -798,23 +869,12 @@ async function insertObject(event) {
|
|||||||
el.insertStatus.textContent = "";
|
el.insertStatus.textContent = "";
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
let data = null;
|
const data = new Uint8Array(await el.insertImage.files[0].arrayBuffer());
|
||||||
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());
|
|
||||||
}
|
|
||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const value = item && data === null && el.insertText.value === item.text ? null : el.insertText.value;
|
|
||||||
const doc = item
|
const doc = item
|
||||||
? text ? await callWASM("ofdgoUpdateText", item.index, item.id, value, data, item.size, null)
|
? await callWASM("ofdgoReplaceImage", item.index, item.id, data)
|
||||||
: 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("ofdgoInsertImage", index, data, x, y, page.width - x * 2);
|
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2);
|
||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
return;
|
return;
|
||||||
@@ -852,9 +912,28 @@ function textPoints(size) {
|
|||||||
return Number((size * 72 / 25.4).toFixed(2));
|
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) {
|
async function changeTextStyle(color) {
|
||||||
const item = canvasEditor.selected;
|
const item = currentTextStyle();
|
||||||
if (item?.type !== "TextObject" || !state.ready || state.exporting) {
|
if (!state.ready || state.exporting || item.draft || document.body.hasAttribute("aria-busy")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!color && !el.textSize.checkValidity()) {
|
if (!color && !el.textSize.checkValidity()) {
|
||||||
@@ -862,8 +941,14 @@ async function changeTextStyle(color) {
|
|||||||
updateObjectControls(item, true);
|
updateObjectControls(item, true);
|
||||||
return;
|
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;
|
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) {
|
if (size !== item.size || fill !== null) {
|
||||||
await changeDocument("ofdgoUpdateText", item, color ? null : item.text, null, size, fill);
|
await changeDocument("ofdgoUpdateText", item, color ? null : item.text, null, size, fill);
|
||||||
}
|
}
|
||||||
@@ -903,6 +988,7 @@ async function changeShapeStyle() {
|
|||||||
|
|
||||||
function updateDrawingControls() {
|
function updateDrawingControls() {
|
||||||
const tool = canvasEditor.tool;
|
const tool = canvasEditor.tool;
|
||||||
|
el.insertTextButton.setAttribute("aria-pressed", String(tool === "text"));
|
||||||
const line = tool === "line" || !tool && canvasEditor.selected?.shape === "line";
|
const line = tool === "line" || !tool && canvasEditor.selected?.shape === "line";
|
||||||
if (line) {
|
if (line) {
|
||||||
el.shapeFill.checked = false;
|
el.shapeFill.checked = false;
|
||||||
@@ -924,6 +1010,8 @@ async function changeDocument(name, item, ...args) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let openSeq = state.openSeq;
|
let openSeq = state.openSeq;
|
||||||
|
const active = document.activeElement;
|
||||||
|
const toolbarFocus = el.editorTools.contains(active) ? active : null;
|
||||||
const previous = currentPageInfo();
|
const previous = currentPageInfo();
|
||||||
const { scrollLeft, scrollTop } = el.viewerPanel;
|
const { scrollLeft, scrollTop } = el.viewerPanel;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -940,14 +1028,14 @@ async function changeDocument(name, item, ...args) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
setEditorInfo(doc);
|
setEditorInfo(doc);
|
||||||
if (!item || name === "ofdgoDeleteObject") {
|
if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects") {
|
||||||
canvasEditor.clear();
|
canvasEditor.clear();
|
||||||
}
|
}
|
||||||
if (name === "ofdgoCopyObject" || name === "ofdgoInsertShape") {
|
if (name === "ofdgoCopyObject" || name === "ofdgoInsertShape" || name === "ofdgoInsertText") {
|
||||||
canvasEditor.selectLast = true;
|
canvasEditor.selectLast = true;
|
||||||
}
|
}
|
||||||
openSeq = ++state.openSeq;
|
openSeq = ++state.openSeq;
|
||||||
if (item || name === "ofdgoInsertShape") {
|
if (item || name === "ofdgoInsertShape" || name === "ofdgoInsertText") {
|
||||||
await refreshEditorPage(doc, item ? item.index : args[0], openSeq);
|
await refreshEditorPage(doc, item ? item.index : args[0], openSeq);
|
||||||
} else {
|
} else {
|
||||||
const samePage = doc.pages.findIndex((page) => page.id === previous.id);
|
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 });
|
keepPreview: true, previewScroll: keepScroll ? { scrollLeft, scrollTop } : null });
|
||||||
}
|
}
|
||||||
if (openSeq === state.openSeq) {
|
if (openSeq === state.openSeq) {
|
||||||
el.viewerPanel.focus({ preventScroll: true });
|
if (!toolbarFocus) el.viewerPanel.focus({ preventScroll: true });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -973,6 +1061,7 @@ async function changeDocument(name, item, ...args) {
|
|||||||
} finally {
|
} finally {
|
||||||
if (openSeq === state.openSeq) {
|
if (openSeq === state.openSeq) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
if (toolbarFocus?.isConnected && !toolbarFocus.disabled) toolbarFocus.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1735,9 +1824,6 @@ async function changeFont(font, changes) {
|
|||||||
|
|
||||||
function renderFontList() {
|
function renderFontList() {
|
||||||
updateTextFonts(canvasEditor.selected, true);
|
updateTextFonts(canvasEditor.selected, true);
|
||||||
if (el.insertPanel.open) {
|
|
||||||
updateInsertFonts();
|
|
||||||
}
|
|
||||||
el.fontList.replaceChildren();
|
el.fontList.replaceChildren();
|
||||||
const fonts = fontManager.records();
|
const fonts = fontManager.records();
|
||||||
if (!fonts.length) {
|
if (!fonts.length) {
|
||||||
@@ -2120,7 +2206,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value)
|
|||||||
const label = archive ? "ZIP" : format.label;
|
const label = archive ? "ZIP" : format.label;
|
||||||
const extension = archive ? "zip" : format.extension;
|
const extension = archive ? "zip" : format.extension;
|
||||||
const mime = archive ? "application/zip" : format.mime;
|
const mime = archive ? "application/zip" : format.mime;
|
||||||
const openSeq = state.openSeq;
|
let openSeq = state.openSeq;
|
||||||
const fileName = whole ? `${baseFileName()}.${extension}` : pageFileName(extension);
|
const fileName = whole ? `${baseFileName()}.${extension}` : pageFileName(extension);
|
||||||
const pageIndex = state.pageIndex;
|
const pageIndex = state.pageIndex;
|
||||||
const dpi = exportFormatUsesDPI(format.value) ? currentImageDPI() : 0;
|
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) {
|
if (openSeq !== state.openSeq) {
|
||||||
return;
|
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
|
const result = saving ? await callWASM("ofdgoSaveDocument", file) : whole
|
||||||
? await callWASM("ofdgoExportDocument", format.value, dpi, indices, file)
|
? await callWASM("ofdgoExportDocument", format.value, dpi, indices, file)
|
||||||
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, file);
|
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, file);
|
||||||
@@ -4033,16 +4125,28 @@ function updateEditorTools() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateObjectControls(item, reset = false) {
|
function updateObjectControls(item, reset = false) {
|
||||||
const disabled = !item || !state.ready || state.exporting;
|
const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting;
|
||||||
el.deleteObjectButton.disabled = el.copyObjectButton.disabled = el.objectAlign.disabled = disabled;
|
el.deleteObjectButton.disabled = el.objectAlign.disabled = disabled;
|
||||||
el.editObjectButton.disabled = disabled || item.type === "PathObject";
|
el.copyObjectButton.disabled = disabled || Boolean(item?.items);
|
||||||
el.textFont.disabled = el.textSize.disabled = el.textColor.disabled = disabled || item.type !== "TextObject";
|
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);
|
updateTextFonts(item);
|
||||||
if (reset || el.textSize.disabled || document.activeElement !== el.textSize) {
|
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) {
|
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") {
|
if (item?.type === "PathObject") {
|
||||||
el.shapeFill.checked = item.fill;
|
el.shapeFill.checked = item.fill;
|
||||||
@@ -4054,7 +4158,7 @@ function updateObjectControls(item, reset = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateDrawingControls();
|
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) {
|
for (const option of el.objectOrder.options) {
|
||||||
option.disabled = disabled || (["up", "top"].includes(option.value) ? item.order === item.count - 1 : item.order === 0);
|
option.disabled = disabled || (["up", "top"].includes(option.value) ? item.order === item.count - 1 : item.order === 0);
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-31
@@ -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 };
|
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.
|
// constrainedPoint applies drawing constraints before clipping the endpoint to the page.
|
||||||
export function constrainedPoint(from, to, page, shape, shift) {
|
export function constrainedPoint(from, to, page, shape, shift) {
|
||||||
let dx = to.x - from.x, dy = to.y - from.y;
|
let dx = to.x - from.x, dy = to.y - from.y;
|
||||||
@@ -95,6 +102,7 @@ export class CanvasEditor {
|
|||||||
this.options = options;
|
this.options = options;
|
||||||
this.enabled = false;
|
this.enabled = false;
|
||||||
this.selected = null;
|
this.selected = null;
|
||||||
|
this.multiple = false;
|
||||||
this.nodes = new WeakMap();
|
this.nodes = new WeakMap();
|
||||||
this.pages = new WeakMap();
|
this.pages = new WeakMap();
|
||||||
this.tool = "";
|
this.tool = "";
|
||||||
@@ -107,7 +115,7 @@ export class CanvasEditor {
|
|||||||
viewer.addEventListener("keyup", (event) => this.modifierChange(event));
|
viewer.addEventListener("keyup", (event) => this.modifierChange(event));
|
||||||
viewer.addEventListener("dblclick", () => {
|
viewer.addEventListener("dblclick", () => {
|
||||||
const item = this.selected;
|
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);
|
this.options.onEdit(item);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -136,6 +144,8 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mount(index, page, surface) {
|
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 });
|
this.pages.set(surface, { index, width: page.width, height: page.height });
|
||||||
const layer = document.createElement("div");
|
const layer = document.createElement("div");
|
||||||
layer.className = "edit-layer";
|
layer.className = "edit-layer";
|
||||||
@@ -152,7 +162,8 @@ export class CanvasEditor {
|
|||||||
this.nodes.set(node, item);
|
this.nodes.set(node, item);
|
||||||
node.addEventListener("focus", () => this.select(item));
|
node.addEventListener("focus", () => this.select(item));
|
||||||
const handles = object.shape === "line" ? ["start", "end"]
|
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) {
|
for (const corner of handles) {
|
||||||
const handle = document.createElement("span");
|
const handle = document.createElement("span");
|
||||||
handle.className = `edit-handle edit-${corner}`;
|
handle.className = `edit-handle edit-${corner}`;
|
||||||
@@ -161,11 +172,12 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
this.place(item);
|
this.place(item);
|
||||||
layer.append(node);
|
layer.append(node);
|
||||||
if (this.selected?.index === index && this.selected.id === object.id) {
|
if (selected.includes(object.id)) {
|
||||||
this.select(item);
|
selection.push(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
surface.append(layer);
|
surface.append(layer);
|
||||||
|
if (selected.length) this.setSelection(selection);
|
||||||
if (this.selectLast && layer.lastElementChild) {
|
if (this.selectLast && layer.lastElementChild) {
|
||||||
this.selectLast = false;
|
this.selectLast = false;
|
||||||
this.select(this.nodes.get(layer.lastElementChild));
|
this.select(this.nodes.get(layer.lastElementChild));
|
||||||
@@ -173,6 +185,10 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
place(item, change = { x: 0, y: 0, scale: 1 }) {
|
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) {
|
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, y: item.geometry.y + change.y,
|
||||||
width: item.geometry.width * change.scale, height: item.geometry.height * change.scale });
|
width: item.geometry.width * change.scale, height: item.geometry.height * change.scale });
|
||||||
@@ -200,11 +216,34 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
select(item) {
|
// items returns the current selection without treating a group as a document object.
|
||||||
this.selected?.node.classList.remove("selected");
|
items() {
|
||||||
this.selected = item;
|
return this.selected?.items || (this.selected ? [this.selected] : []);
|
||||||
item?.node.classList.add("selected");
|
}
|
||||||
this.options.onSelect(item);
|
|
||||||
|
// 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() {
|
clear() {
|
||||||
@@ -216,6 +255,7 @@ export class CanvasEditor {
|
|||||||
|
|
||||||
start(event) {
|
start(event) {
|
||||||
if (this.input) {
|
if (this.input) {
|
||||||
|
if (event.target !== this.input.input) this.commitText();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
|
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
|
||||||
@@ -230,13 +270,23 @@ export class CanvasEditor {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
this.viewer.focus({ preventScroll: true });
|
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()) {
|
if (!node || this.options.busy()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item = this.selected;
|
const item = this.selected;
|
||||||
this.drag = {
|
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(),
|
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
||||||
clientX: event.clientX, clientY: event.clientY,
|
clientX: event.clientX, clientY: event.clientY,
|
||||||
};
|
};
|
||||||
@@ -259,11 +309,20 @@ export class CanvasEditor {
|
|||||||
this.moveShape(event, drag);
|
this.moveShape(event, drag);
|
||||||
return;
|
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) {
|
if (!drag.change && !drag.box && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation);
|
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);
|
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) {
|
if (drag.item.shape && drag.corner) {
|
||||||
drag.box = reshapeBox(drag.item, to.x - from.x, to.y - from.y, drag.corner, event.shiftKey);
|
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);
|
this.placeShape(drag.item, drag.box || drag.item.geometry);
|
||||||
@@ -280,9 +339,25 @@ export class CanvasEditor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.move(event);
|
this.move(event);
|
||||||
|
if (drag.marquee) {
|
||||||
|
this.drag = null;
|
||||||
|
drag.preview.remove();
|
||||||
|
this.viewer.releasePointerCapture(drag.pointerID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (drag.shape) {
|
if (drag.shape) {
|
||||||
this.drag = null;
|
this.drag = null;
|
||||||
this.viewer.releasePointerCapture(drag.pointerID);
|
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) {
|
if (!drag.box) {
|
||||||
drag.preview.remove();
|
drag.preview.remove();
|
||||||
return;
|
return;
|
||||||
@@ -292,13 +367,15 @@ export class CanvasEditor {
|
|||||||
.finally(() => drag.preview.remove());
|
.finally(() => drag.preview.remove());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (drag.item.shape && drag.corner) {
|
if ((drag.item.shape || drag.item.type === "TextObject") && drag.corner && drag.box) {
|
||||||
this.drag = null;
|
this.drag = null;
|
||||||
this.viewer.releasePointerCapture(drag.pointerID);
|
this.viewer.releasePointerCapture(drag.pointerID);
|
||||||
const changed = drag.box && Object.keys(drag.box).some((key) => drag.box[key] !== drag.item.geometry[key]);
|
const geometry = drag.item.geometry || drag.item;
|
||||||
Promise.resolve(changed && this.options.onReshape(drag.item, drag.box)).finally(() => {
|
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();
|
drag.preview?.remove();
|
||||||
this.placeShape(drag.item, drag.item.geometry);
|
this.place(drag.item);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -313,6 +390,7 @@ export class CanvasEditor {
|
|||||||
this.drag = null;
|
this.drag = null;
|
||||||
if (drag) {
|
if (drag) {
|
||||||
drag.preview?.remove();
|
drag.preview?.remove();
|
||||||
|
if (drag.marquee) this.setSelection(drag.before);
|
||||||
if (drag.item) {
|
if (drag.item) {
|
||||||
this.place(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) {
|
startShape(event) {
|
||||||
const surface = event.target.closest(".page-surface");
|
const surface = event.target.closest(".page-surface");
|
||||||
const page = this.pages.get(surface);
|
const page = this.pages.get(surface);
|
||||||
@@ -336,7 +449,7 @@ export class CanvasEditor {
|
|||||||
style.fill = false;
|
style.fill = false;
|
||||||
style.stroke = true;
|
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(),
|
rect: surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
||||||
clientX: event.clientX, clientY: event.clientY };
|
clientX: event.clientX, clientY: event.clientY };
|
||||||
this.viewer.setPointerCapture(event.pointerId);
|
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)) };
|
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 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 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
|
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.shape === "line" ? width > 0 || height > 0 : width > 0 && height > 0);
|
||||||
drag.box = valid ? drag.shape === "line"
|
drag.box = valid ? drag.shape === "line"
|
||||||
? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y }
|
? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y }
|
||||||
: { x, y, width, height } : null;
|
: { 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 });
|
? { 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) {
|
keyDown(event) {
|
||||||
|
if (event.defaultPrevented || event.target?.closest("input, textarea, select, [contenteditable]")) return;
|
||||||
this.modifierChange(event);
|
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()) {
|
if (event.key === "Escape" && this.tool && !this.options.busy()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -405,9 +525,9 @@ export class CanvasEditor {
|
|||||||
this.drag ? this.cancel() : this.clear();
|
this.drag ? this.cancel() : this.clear();
|
||||||
} else if (event.key === "Delete") {
|
} else if (event.key === "Delete") {
|
||||||
this.options.onDelete(this.selected);
|
this.options.onDelete(this.selected);
|
||||||
} else if (edit && !this.drag) {
|
} else if (edit && !this.drag && !this.selected.items) {
|
||||||
this.options.onEdit(this.selected);
|
this.options.onEdit(this.selected);
|
||||||
} else if (!this.drag) {
|
} else if (!this.drag && directions[event.key]) {
|
||||||
const [x, y] = 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 [dx, dy] = [[x, y], [y, -x], [-x, -y], [-y, x]][this.options.rotation() / 90];
|
||||||
const step = event.shiftKey ? 10 : 1;
|
const step = event.shiftKey ? 10 : 1;
|
||||||
@@ -427,15 +547,16 @@ export class CanvasEditor {
|
|||||||
const input = document.createElement("textarea");
|
const input = document.createElement("textarea");
|
||||||
input.className = "edit-text";
|
input.className = "edit-text";
|
||||||
input.setAttribute("aria-label", "编辑文字");
|
input.setAttribute("aria-label", "编辑文字");
|
||||||
input.wrap = "off";
|
input.wrap = item.wrap ? "soft" : "off";
|
||||||
input.spellcheck = false;
|
input.spellcheck = false;
|
||||||
input.value = item.text;
|
input.value = item.text;
|
||||||
Object.assign(input.style, {
|
Object.assign(input.style, {
|
||||||
left: `${item.x * PX_PER_MM}px`, top: `${item.y * PX_PER_MM}px`,
|
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`,
|
minHeight: `${item.height * PX_PER_MM + 4}px`,
|
||||||
fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}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 };
|
this.input = { input, item, face };
|
||||||
item.surface.append(input);
|
item.surface.append(input);
|
||||||
@@ -464,22 +585,21 @@ export class CanvasEditor {
|
|||||||
});
|
});
|
||||||
resize();
|
resize();
|
||||||
input.focus({ preventScroll: true });
|
input.focus({ preventScroll: true });
|
||||||
input.select();
|
if (!item.draft) input.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
async commitText() {
|
async commitText() {
|
||||||
const editing = this.input;
|
const editing = this.input;
|
||||||
if (!editing || editing.input.readOnly) {
|
if (!editing) return true;
|
||||||
return;
|
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) {
|
|
||||||
this.closeText();
|
this.closeText();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
editing.input.readOnly = 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);
|
||||||
if (this.input !== editing) {
|
if (this.input !== editing) {
|
||||||
return;
|
return Boolean(saved);
|
||||||
}
|
}
|
||||||
if (saved) {
|
if (saved) {
|
||||||
this.closeText();
|
this.closeText();
|
||||||
@@ -487,6 +607,7 @@ export class CanvasEditor {
|
|||||||
editing.input.readOnly = false;
|
editing.input.readOnly = false;
|
||||||
editing.input.focus({ preventScroll: true });
|
editing.input.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
return Boolean(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeText() {
|
closeText() {
|
||||||
@@ -495,6 +616,7 @@ export class CanvasEditor {
|
|||||||
if (editing) {
|
if (editing) {
|
||||||
editing.input.remove();
|
editing.input.remove();
|
||||||
document.fonts.delete(editing.face);
|
document.fonts.delete(editing.face);
|
||||||
|
if (editing.item.draft && this.selected === editing.item) this.select(null);
|
||||||
this.options.onTextChange();
|
this.options.onTextChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module github.com/xiaoqidun/ofdgo
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-text/typesetting v0.3.4
|
||||||
github.com/tdewolff/canvas v0.0.0-20260913163248-dd4999d1c76a
|
github.com/tdewolff/canvas v0.0.0-20260913163248-dd4999d1c76a
|
||||||
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
|
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
|
||||||
github.com/xiaoqidun/jbig2 v1.0.0
|
github.com/xiaoqidun/jbig2 v1.0.0
|
||||||
@@ -23,7 +24,6 @@ require (
|
|||||||
github.com/benoitkugler/textlayout v0.3.2 // indirect
|
github.com/benoitkugler/textlayout v0.3.2 // indirect
|
||||||
github.com/benoitkugler/textprocessing v0.0.6 // indirect
|
github.com/benoitkugler/textprocessing v0.0.6 // indirect
|
||||||
github.com/go-fonts/latin-modern v0.3.3 // indirect
|
github.com/go-fonts/latin-modern v0.3.3 // indirect
|
||||||
github.com/go-text/typesetting v0.3.4 // indirect
|
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/kolesa-team/go-webp v1.0.5 // indirect
|
github.com/kolesa-team/go-webp v1.0.5 // indirect
|
||||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
|
|||||||
+109
-7
@@ -109,6 +109,10 @@ func RunWASM() {
|
|||||||
registerCallback("ofdgoAlignObject", alignObject)
|
registerCallback("ofdgoAlignObject", alignObject)
|
||||||
registerCallback("ofdgoTransformObject", transformObject)
|
registerCallback("ofdgoTransformObject", transformObject)
|
||||||
registerCallback("ofdgoReshapeObject", reshapeObject)
|
registerCallback("ofdgoReshapeObject", reshapeObject)
|
||||||
|
registerCallback("ofdgoLayoutText", layoutText)
|
||||||
|
registerCallback("ofdgoTransformObjects", transformObjects)
|
||||||
|
registerCallback("ofdgoAlignObjects", alignObjects)
|
||||||
|
registerCallback("ofdgoDeleteObjects", deleteObjects)
|
||||||
registerCallback("ofdgoCopyObject", copyObject)
|
registerCallback("ofdgoCopyObject", copyObject)
|
||||||
registerCallback("ofdgoMoveObject", moveObject)
|
registerCallback("ofdgoMoveObject", moveObject)
|
||||||
registerCallback("ofdgoDeleteObject", deleteObject)
|
registerCallback("ofdgoDeleteObject", deleteObject)
|
||||||
@@ -574,9 +578,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
textBoxes := make(map[string]ofdgo.Box)
|
textBoxes := make(map[string]ofdgo.Box)
|
||||||
textValues := make(map[string]string)
|
|
||||||
for _, run := range text.Runs {
|
for _, run := range text.Runs {
|
||||||
textValues[run.ID] += run.Text
|
|
||||||
for _, box := range run.Boxes {
|
for _, box := range run.Boxes {
|
||||||
if box.W <= 0 || box.H <= 0 {
|
if box.W <= 0 || box.H <= 0 {
|
||||||
continue
|
continue
|
||||||
@@ -636,7 +638,20 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
|||||||
item["lineWidth"] = path.LineWidth * editorPathScale(path)
|
item["lineWidth"] = path.LineWidth * editorPathScale(path)
|
||||||
}
|
}
|
||||||
if object.Type == "TextObject" {
|
if object.Type == "TextObject" {
|
||||||
item["text"], item["font"], item["fontName"], item["size"] = textValues[id], object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size
|
source, err := currentEditor.Object(index, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value, layout := source.TextObject.TextLayout()
|
||||||
|
item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size
|
||||||
|
item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight
|
||||||
|
if layout.Wrap {
|
||||||
|
frame, err := ofdgo.ParseBox(object.TextObject.Boundary)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item["x"], item["y"], item["width"], item["height"] = frame.X, frame.Y, frame.W, math.Max(object.TextObject.Size, box.Y+box.H-frame.Y)
|
||||||
|
}
|
||||||
if codes := object.TextObject.TextCode; len(codes) > 1 {
|
if codes := object.TextObject.TextCode; len(codes) > 1 {
|
||||||
first, _ := strconv.ParseFloat(codes[0].Y, 64)
|
first, _ := strconv.ParseFloat(codes[0].Y, 64)
|
||||||
second, _ := strconv.ParseFloat(codes[1].Y, 64)
|
second, _ := strconv.ParseFloat(codes[1].Y, 64)
|
||||||
@@ -932,7 +947,8 @@ func updateText(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
object.TextObject.Size = args[4].Float()
|
object.TextObject.Size = args[4].Float()
|
||||||
if err := currentEditor.LayoutText(&object.TextObject, args[2].String(), 0); err != nil {
|
_, layout := object.TextObject.TextLayout()
|
||||||
|
if err := currentEditor.LayoutText(&object.TextObject, args[2].String(), layout); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1077,7 +1093,7 @@ func updatePathStyle(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// insertText 使用选定字体创建文字对象
|
// insertText 使用选定字体创建文字对象
|
||||||
// 入参: args 页码、内容、字体数据、横纵坐标、字号和颜色
|
// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐和行距
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
func insertText(args []js.Value) (any, error) {
|
func insertText(args []js.Value) (any, error) {
|
||||||
if currentEditor == nil {
|
if currentEditor == nil {
|
||||||
@@ -1101,12 +1117,16 @@ func insertText(args []js.Value) (any, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
x, y := args[3].Float(), args[4].Float()
|
x, y := args[3].Float(), args[4].Float()
|
||||||
box = ofdgo.Box{X: x, Y: y, W: box.W - x, H: box.H - y}
|
width := args[7].Float()
|
||||||
|
if width == 0 {
|
||||||
|
width = 80
|
||||||
|
}
|
||||||
|
box = ofdgo.Box{X: x, Y: y, W: math.Min(width, box.W-x), H: box.H - y}
|
||||||
object := ofdgo.GraphicObject{Type: "TextObject", TextObject: ofdgo.TextObject{
|
object := ofdgo.GraphicObject{Type: "TextObject", TextObject: ofdgo.TextObject{
|
||||||
Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H),
|
Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H),
|
||||||
Font: fontID, Size: args[5].Float(),
|
Font: fontID, Size: args[5].Float(),
|
||||||
}}
|
}}
|
||||||
if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), 0); err != nil {
|
if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float()}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := setTextColor(&object.TextObject, args[6].String()); err != nil {
|
if err := setTextColor(&object.TextObject, args[6].String()); err != nil {
|
||||||
@@ -1118,6 +1138,88 @@ func insertText(args []js.Value) (any, error) {
|
|||||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// layoutText 调整文字框和段落排版,保留软换行前的原文。
|
||||||
|
// 入参: args 页码、对象标识、左侧坐标、宽度、折行、对齐和行距
|
||||||
|
// 返回: any 文档信息, error 错误信息
|
||||||
|
func layoutText(args []js.Value) (any, error) {
|
||||||
|
if currentEditor == nil {
|
||||||
|
return nil, fmt.Errorf("no document is being created")
|
||||||
|
}
|
||||||
|
page, id := args[0].Int(), args[1].String()
|
||||||
|
object, err := currentEditor.Object(page, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if object.Type != "TextObject" {
|
||||||
|
return nil, fmt.Errorf("object is not text")
|
||||||
|
}
|
||||||
|
box, err := ofdgo.ParseBox(object.TextObject.Boundary)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !args[2].IsNull() {
|
||||||
|
box.X, box.W = args[2].Float(), args[3].Float()
|
||||||
|
}
|
||||||
|
object.TextObject.Boundary = fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H)
|
||||||
|
value, _ := object.TextObject.TextLayout()
|
||||||
|
if err := currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float()}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
revision := currentEditor.Revision()
|
||||||
|
if err := currentEditor.UpdateObject(page, id, object); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if currentEditor.Revision() == revision {
|
||||||
|
return editorSummary(), nil
|
||||||
|
}
|
||||||
|
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectedIDs 读取多选对象标识,交由库统一校验。
|
||||||
|
func selectedIDs(value js.Value) []string {
|
||||||
|
ids := make([]string, value.Length())
|
||||||
|
for i := range ids {
|
||||||
|
ids[i] = value.Index(i).String()
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// transformObjects 统一移动或缩放选区,生成一次撤销记录。
|
||||||
|
func transformObjects(args []js.Value) (any, error) {
|
||||||
|
return changeObjects(func() error {
|
||||||
|
return currentEditor.TransformObjects(args[0].Int(), selectedIDs(args[1]), args[2].Float(), args[3].Float(), args[4].Float())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// alignObjects 对齐选区中的对象。
|
||||||
|
func alignObjects(args []js.Value) (any, error) {
|
||||||
|
return changeObjects(func() error {
|
||||||
|
return currentEditor.AlignObjects(args[0].Int(), selectedIDs(args[1]), args[2].String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteObjects 一次删除选区中的对象。
|
||||||
|
func deleteObjects(args []js.Value) (any, error) {
|
||||||
|
return changeObjects(func() error {
|
||||||
|
return currentEditor.DeleteObjects(args[0].Int(), selectedIDs(args[1]))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeObjects 提交库层批量操作,无修改时复用当前预览。
|
||||||
|
func changeObjects(apply func() error) (any, error) {
|
||||||
|
if currentEditor == nil {
|
||||||
|
return nil, fmt.Errorf("no document is being created")
|
||||||
|
}
|
||||||
|
revision := currentEditor.Revision()
|
||||||
|
if err := apply(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if currentEditor.Revision() == revision {
|
||||||
|
return editorSummary(), nil
|
||||||
|
}
|
||||||
|
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||||
|
}
|
||||||
|
|
||||||
// insertImage 按原始比例创建图片对象
|
// insertImage 按原始比例创建图片对象
|
||||||
// 入参: args 页码、图片数据、横纵坐标和宽度
|
// 入参: args 页码、图片数据、横纵坐标和宽度
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
|
|||||||
+57
-85
@@ -105,6 +105,11 @@ func (e *Editor) Page(index int) (*PageContent, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result.ID = page.ID
|
result.ID = page.ID
|
||||||
|
for i, layer := range page.Content.Layer {
|
||||||
|
for j, object := range layer.Objects {
|
||||||
|
result.Content.Layer[i].Objects[j].TextObject.layout = object.TextObject.layout
|
||||||
|
}
|
||||||
|
}
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,6 +598,7 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
|
|||||||
// 入参: object 对象内容
|
// 入参: object 对象内容
|
||||||
// 返回: GraphicObject 独立副本, error 错误信息
|
// 返回: GraphicObject 独立副本, error 错误信息
|
||||||
func cloneEditorObject(object GraphicObject) (GraphicObject, error) {
|
func cloneEditorObject(object GraphicObject) (GraphicObject, error) {
|
||||||
|
layout := object.TextObject.layout
|
||||||
data, err := encodeOFDXML(func(x *ofdXML) { x.object(object, true) })
|
data, err := encodeOFDXML(func(x *ofdXML) { x.object(object, true) })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return GraphicObject{}, err
|
return GraphicObject{}, err
|
||||||
@@ -610,6 +616,7 @@ func cloneEditorObject(object GraphicObject) (GraphicObject, error) {
|
|||||||
if err := xml.Unmarshal(data, target); err != nil {
|
if err := xml.Unmarshal(data, target); err != nil {
|
||||||
return GraphicObject{}, err
|
return GraphicObject{}, err
|
||||||
}
|
}
|
||||||
|
object.TextObject.layout = layout
|
||||||
return object, nil
|
return object, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,14 +628,14 @@ func (e *Editor) AddText(page int, box Box, value, fontID string, size float64)
|
|||||||
Boundary: fmt.Sprintf("%s %s %s %s", ofdNumber(box.X), ofdNumber(box.Y), ofdNumber(box.W), ofdNumber(box.H)),
|
Boundary: fmt.Sprintf("%s %s %s %s", ofdNumber(box.X), ofdNumber(box.Y), ofdNumber(box.W), ofdNumber(box.H)),
|
||||||
Font: fontID, Size: size,
|
Font: fontID, Size: size,
|
||||||
}}
|
}}
|
||||||
if err := e.LayoutText(&object.TextObject, value, 0); err != nil {
|
if err := e.LayoutText(&object.TextObject, value, TextLayout{}); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return e.AddObject(page, object)
|
return e.AddObject(page, object)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateText 按AddText规则重排横向文字,保留对象标识、顺序、边界及绘制属性
|
// UpdateText 按现有段落选项重排横向文字,保留对象标识、顺序、边界及绘制属性
|
||||||
// 自定义文字定位使用UpdateObject,不进行段落排版和复杂文字塑形
|
// 自定义文字定位使用UpdateObject,不进行复杂文字塑形
|
||||||
// 入参: page 页面索引, id 文字对象标识, value 原文, fontID 字体资源标识, size 字号
|
// 入参: page 页面索引, id 文字对象标识, value 原文, fontID 字体资源标识, size 字号
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) UpdateText(page int, id, value, fontID string, size float64) error {
|
func (e *Editor) UpdateText(page int, id, value, fontID string, size float64) error {
|
||||||
@@ -641,7 +648,8 @@ func (e *Editor) UpdateText(page int, id, value, fontID string, size float64) er
|
|||||||
return fmt.Errorf("object %q is not text", id)
|
return fmt.Errorf("object %q is not text", id)
|
||||||
}
|
}
|
||||||
object.TextObject.Font, object.TextObject.Size = fontID, size
|
object.TextObject.Font, object.TextObject.Size = fontID, size
|
||||||
if err := e.LayoutText(&object.TextObject, value, 0); err != nil {
|
_, layout := object.TextObject.TextLayout()
|
||||||
|
if err := e.LayoutText(&object.TextObject, value, layout); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
object, err = e.prepareObject(id, object)
|
object, err = e.prepareObject(id, object)
|
||||||
@@ -656,16 +664,11 @@ func (e *Editor) UpdateText(page int, id, value, fontID string, size float64) er
|
|||||||
// 入参: page 页面索引, id 对象标识, dx 横向位移, dy 纵向位移, scale 正缩放比例
|
// 入参: page 页面索引, id 对象标识, dx 横向位移, dy 纵向位移, scale 正缩放比例
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) TransformObject(page int, id string, dx, dy, scale float64) error {
|
func (e *Editor) TransformObject(page int, id string, dx, dy, scale float64) error {
|
||||||
if !finite(dx) || !finite(dy) || !finite(scale) || scale <= 0 {
|
return e.TransformObjects(page, []string{id}, dx, dy, scale)
|
||||||
return fmt.Errorf("transform requires finite offsets and a positive finite scale")
|
|
||||||
}
|
|
||||||
object, err := e.Object(page, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if dx == 0 && dy == 0 && scale == 1 {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// transformEditorObject 变换独立对象副本,保留绘制属性和编辑中的段落信息。
|
||||||
|
func transformEditorObject(object GraphicObject, dx, dy, scale float64) (GraphicObject, error) {
|
||||||
var boundary, ctm *string
|
var boundary, ctm *string
|
||||||
switch object.Type {
|
switch object.Type {
|
||||||
case "TextObject":
|
case "TextObject":
|
||||||
@@ -673,6 +676,11 @@ func (e *Editor) TransformObject(page int, id string, dx, dy, scale float64) err
|
|||||||
boundary, ctm = &obj.Boundary, &obj.CTM
|
boundary, ctm = &obj.Boundary, &obj.CTM
|
||||||
if scale != 1 {
|
if scale != 1 {
|
||||||
obj.Size *= scale
|
obj.Size *= scale
|
||||||
|
if obj.layout != nil {
|
||||||
|
layout := *obj.layout
|
||||||
|
layout.options.LineHeight *= scale
|
||||||
|
obj.layout = &layout
|
||||||
|
}
|
||||||
if obj.LineWidth == 0 && obj.Stroke != nil && *obj.Stroke {
|
if obj.LineWidth == 0 && obj.Stroke != nil && *obj.Stroke {
|
||||||
obj.LineWidth = defaultPathLineWidth
|
obj.LineWidth = defaultPathLineWidth
|
||||||
}
|
}
|
||||||
@@ -691,7 +699,7 @@ func (e *Editor) TransformObject(page int, id string, dx, dy, scale float64) err
|
|||||||
}
|
}
|
||||||
box, err := ParseBox(*boundary)
|
box, err := ParseBox(*boundary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return GraphicObject{}, err
|
||||||
}
|
}
|
||||||
*boundary = fmt.Sprintf("%s %s %s %s", ofdNumber(box.X*scale+dx), ofdNumber(box.Y*scale+dy), ofdNumber(box.W*scale), ofdNumber(box.H*scale))
|
*boundary = fmt.Sprintf("%s %s %s %s", ofdNumber(box.X*scale+dx), ofdNumber(box.Y*scale+dy), ofdNumber(box.W*scale), ofdNumber(box.H*scale))
|
||||||
if scale != 1 && (*ctm != "" || object.Type != "TextObject") {
|
if scale != 1 && (*ctm != "" || object.Type != "TextObject") {
|
||||||
@@ -701,7 +709,7 @@ func (e *Editor) TransformObject(page int, id string, dx, dy, scale float64) err
|
|||||||
}
|
}
|
||||||
*ctm = fmt.Sprintf("%s %s %s %s %s %s", ofdNumber(m.a), ofdNumber(m.b), ofdNumber(m.c), ofdNumber(m.d), ofdNumber(m.e*scale), ofdNumber(m.f*scale))
|
*ctm = fmt.Sprintf("%s %s %s %s %s %s", ofdNumber(m.a), ofdNumber(m.b), ofdNumber(m.c), ofdNumber(m.d), ofdNumber(m.e*scale), ofdNumber(m.f*scale))
|
||||||
}
|
}
|
||||||
return e.UpdateObject(page, id, object)
|
return object, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceImage 替换图片资源,保留对象位置、尺寸、变换和绘制顺序
|
// ReplaceImage 替换图片资源,保留对象位置、尺寸、变换和绘制顺序
|
||||||
@@ -726,59 +734,7 @@ func (e *Editor) ReplaceImage(page int, id string, data []byte) error {
|
|||||||
// 入参: page 页面索引, id 对象标识, alignment 为left、center、right、top、middle或bottom
|
// 入参: page 页面索引, id 对象标识, alignment 为left、center、right、top、middle或bottom
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) AlignObject(page int, id, alignment string) error {
|
func (e *Editor) AlignObject(page int, id, alignment string) error {
|
||||||
if !slices.Contains([]string{"left", "center", "right", "top", "middle", "bottom"}, alignment) {
|
return e.AlignObjects(page, []string{id}, alignment)
|
||||||
return fmt.Errorf("invalid alignment %q", alignment)
|
|
||||||
}
|
|
||||||
object, err := e.Object(page, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
content := &e.pages[page]
|
|
||||||
pageBox, _ := ParseBox(content.Area.PhysicalBox)
|
|
||||||
var box Box
|
|
||||||
switch object.Type {
|
|
||||||
case "TextObject":
|
|
||||||
reader, err := e.Reader()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer reader.Close()
|
|
||||||
renderer := NewRenderer(reader)
|
|
||||||
text, err := renderer.PageText(content)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, run := range text.Runs {
|
|
||||||
if run.ID == id {
|
|
||||||
for _, bounds := range run.Boxes {
|
|
||||||
box = unionTextBox(box, bounds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "PathObject":
|
|
||||||
box, _ = ParseBox(object.PathObject.Boundary)
|
|
||||||
case "ImageObject":
|
|
||||||
box, _ = ParseBox(object.ImageObject.Boundary)
|
|
||||||
}
|
|
||||||
if box.W <= 0 || box.H <= 0 {
|
|
||||||
return fmt.Errorf("object %q has no visible bounds", id)
|
|
||||||
}
|
|
||||||
dx, dy := 0.0, 0.0
|
|
||||||
switch alignment {
|
|
||||||
case "left":
|
|
||||||
dx = -box.X
|
|
||||||
case "center":
|
|
||||||
dx = (pageBox.W-box.W)/2 - box.X
|
|
||||||
case "right":
|
|
||||||
dx = pageBox.W - box.W - box.X
|
|
||||||
case "top":
|
|
||||||
dy = -box.Y
|
|
||||||
case "middle":
|
|
||||||
dy = (pageBox.H-box.H)/2 - box.Y
|
|
||||||
case "bottom":
|
|
||||||
dy = pageBox.H - box.H - box.Y
|
|
||||||
}
|
|
||||||
return e.TransformObject(page, id, dx, dy, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// scaleTextNumbers 缩放已校验的文字定位数值,保留g重复编码
|
// scaleTextNumbers 缩放已校验的文字定位数值,保留g重复编码
|
||||||
@@ -796,11 +752,11 @@ func scaleTextNumbers(value string, scale float64) string {
|
|||||||
return strings.Join(fields, " ")
|
return strings.Join(fields, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// LayoutText 按显式换行和嵌入字体度量重排横向文字,保留绘制属性
|
// LayoutText 按嵌入字体度量和段落选项重排横向文字,保留绘制属性,不进行复杂文字塑形
|
||||||
// 不自动折行或进行复杂文字塑形,不修改文档,通过AddObject或UpdateObject提交
|
// 不修改文档,通过AddObject或UpdateObject提交;选项仅供当前编辑过程使用,保存为标准文字定位
|
||||||
// 入参: obj 文字对象, value 原文, lineHeight 基线间距,0使用字体度量,单位为毫米
|
// 入参: obj 文字对象, value 原文, options 段落排版选项
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) LayoutText(obj *TextObject, value string, lineHeight float64) error {
|
func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) error {
|
||||||
if obj.ReadDirection != 0 || obj.CharDirection != 0 {
|
if obj.ReadDirection != 0 || obj.CharDirection != 0 {
|
||||||
return fmt.Errorf("automatic text layout requires horizontal text")
|
return fmt.Errorf("automatic text layout requires horizontal text")
|
||||||
}
|
}
|
||||||
@@ -815,9 +771,21 @@ func (e *Editor) LayoutText(obj *TextObject, value string, lineHeight float64) e
|
|||||||
if !utf8.ValidString(value) || strings.Contains(value, "\t") || strings.Trim(value, "\n") == "" {
|
if !utf8.ValidString(value) || strings.Contains(value, "\t") || strings.Trim(value, "\n") == "" {
|
||||||
return fmt.Errorf("text must contain UTF-8 characters without tabs")
|
return fmt.Errorf("text must contain UTF-8 characters without tabs")
|
||||||
}
|
}
|
||||||
|
lineHeight := options.LineHeight
|
||||||
if !finite(lineHeight) || lineHeight < 0 {
|
if !finite(lineHeight) || lineHeight < 0 {
|
||||||
return fmt.Errorf("line height must be finite and nonnegative")
|
return fmt.Errorf("line height must be finite and nonnegative")
|
||||||
}
|
}
|
||||||
|
if !slices.Contains([]string{"", "left", "center", "right", "justify"}, options.Align) {
|
||||||
|
return fmt.Errorf("invalid text alignment %q", options.Align)
|
||||||
|
}
|
||||||
|
var width float64
|
||||||
|
if options.Wrap || options.Align != "" && options.Align != "left" {
|
||||||
|
box, err := creationBox(obj.Boundary)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
width = box.W
|
||||||
|
}
|
||||||
hScale := obj.HScale
|
hScale := obj.HScale
|
||||||
if hScale == 0 {
|
if hScale == 0 {
|
||||||
hScale = 1
|
hScale = 1
|
||||||
@@ -827,31 +795,35 @@ func (e *Editor) LayoutText(obj *TextObject, value string, lineHeight float64) e
|
|||||||
if lineHeight == 0 {
|
if lineHeight == 0 {
|
||||||
lineHeight = math.Max(obj.Size, float64(int(ascender)-int(descender)+int(gap))*unit)
|
lineHeight = math.Max(obj.Size, float64(int(ascender)-int(descender)+int(gap))*unit)
|
||||||
}
|
}
|
||||||
lines := strings.Split(value, "\n")
|
var codes []TextCode
|
||||||
codes := make([]TextCode, len(lines))
|
for _, paragraph := range strings.Split(value, "\n") {
|
||||||
for line, value := range lines {
|
runes := []rune(paragraph)
|
||||||
runes := []rune(value)
|
advances := make([]float64, len(runes))
|
||||||
deltas := make([]string, 0, max(0, len(runes)-1))
|
|
||||||
for i, char := range runes {
|
for i, char := range runes {
|
||||||
glyph := sfnt.GlyphIndex(char)
|
glyph := sfnt.GlyphIndex(char)
|
||||||
if glyph == 0 {
|
if glyph == 0 {
|
||||||
return fmt.Errorf("font does not contain U+%04X", char)
|
return fmt.Errorf("font does not contain U+%04X", char)
|
||||||
}
|
}
|
||||||
if i+1 < len(runes) {
|
advances[i] = float64(sfnt.GlyphAdvance(glyph)) * unit * hScale
|
||||||
advance := float64(sfnt.GlyphAdvance(glyph)) * unit * hScale
|
if !finite(advances[i]) {
|
||||||
if !finite(advance) {
|
|
||||||
return fmt.Errorf("text advance exceeds finite range")
|
return fmt.Errorf("text advance exceeds finite range")
|
||||||
}
|
}
|
||||||
deltas = append(deltas, ofdNumber(advance))
|
|
||||||
}
|
}
|
||||||
|
lines := breakTextLines(runes, advances, width, options.Wrap)
|
||||||
|
for i, line := range lines {
|
||||||
|
x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width, options.Align, i+1 < len(lines))
|
||||||
|
y := float64(ascender)*unit + float64(len(codes))*lineHeight
|
||||||
|
if !finite(x) || !finite(y) {
|
||||||
|
return fmt.Errorf("text position exceeds finite range")
|
||||||
}
|
}
|
||||||
y := float64(ascender)*unit + float64(line)*lineHeight
|
codes = append(codes, TextCode{X: ofdNumber(x), Y: ofdNumber(y), DeltaX: deltas, Value: escapeOFDText(string(runes[line[0]:line[1]]))})
|
||||||
if !finite(y) {
|
|
||||||
return fmt.Errorf("text baseline exceeds finite range")
|
|
||||||
}
|
}
|
||||||
codes[line] = TextCode{X: "0", Y: ofdNumber(y), DeltaX: strings.Join(deltas, " "), Value: escapeOFDText(value)}
|
|
||||||
}
|
}
|
||||||
obj.TextCode = codes
|
obj.TextCode = codes
|
||||||
|
obj.layout = nil
|
||||||
|
if options != (TextLayout{}) {
|
||||||
|
obj.layout = &textLayout{value: value, options: options}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package ofdgo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateObjects 原子替换同页对象,按各对象ID定位,全部校验通过后提交一次历史记录。
|
||||||
|
// 入参: page 页面索引, objects 新对象内容,ID不得重复
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) UpdateObjects(page int, objects []GraphicObject) error {
|
||||||
|
ids := make([]string, len(objects))
|
||||||
|
for i, object := range objects {
|
||||||
|
ids[i] = editorObjectID(object)
|
||||||
|
}
|
||||||
|
before, indexes, err := e.selectedObjects(page, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
after := make([]GraphicObject, len(objects))
|
||||||
|
for i, object := range objects {
|
||||||
|
after[i], err = e.prepareObject(ids[i], object)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reflect.DeepEqual(before, after) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
apply := func(e *Editor, objects []GraphicObject) {
|
||||||
|
for i, index := range indexes {
|
||||||
|
e.pages[page].Content.Layer[0].Objects[index] = objects[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply(e, after)
|
||||||
|
if change := e.recordChange(); change != nil {
|
||||||
|
change.undo = func(e *Editor) { apply(e, before) }
|
||||||
|
change.redo = func(e *Editor) { apply(e, after) }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransformObjects 同页对象以页面原点等比缩放后统一平移,保持相对位置,一次撤销恢复全部。
|
||||||
|
// 入参: page 页面索引, ids 对象标识, dx、dy 位移, scale 正缩放比例
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) TransformObjects(page int, ids []string, dx, dy, scale float64) error {
|
||||||
|
if !finite(dx) || !finite(dy) || !finite(scale) || scale <= 0 {
|
||||||
|
return fmt.Errorf("transform requires finite offsets and a positive finite scale")
|
||||||
|
}
|
||||||
|
objects, _, err := e.selectedObjects(page, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dx == 0 && dy == 0 && scale == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i, object := range objects {
|
||||||
|
object, err = cloneEditorObject(object)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
objects[i], err = transformEditorObject(object, dx, dy, scale)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e.UpdateObjects(page, objects)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlignObjects 单对象对齐页面,多对象相互对齐至选区边界,文字采用实际字形范围。
|
||||||
|
// 入参: page 页面索引, ids 对象标识, alignment 为left、center、right、top、middle或bottom
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) AlignObjects(page int, ids []string, alignment string) error {
|
||||||
|
if !slices.Contains([]string{"left", "center", "right", "top", "middle", "bottom"}, alignment) {
|
||||||
|
return fmt.Errorf("invalid alignment %q", alignment)
|
||||||
|
}
|
||||||
|
objects, _, err := e.selectedObjects(page, ids)
|
||||||
|
if err != nil || len(objects) == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
boxes, err := e.objectBounds(page, objects)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target, _ := ParseBox(e.pages[page].Area.PhysicalBox)
|
||||||
|
if len(objects) > 1 {
|
||||||
|
target = Box{}
|
||||||
|
for _, box := range boxes {
|
||||||
|
target = unionTextBox(target, box)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, box := range boxes {
|
||||||
|
dx, dy := 0.0, 0.0
|
||||||
|
switch alignment {
|
||||||
|
case "left":
|
||||||
|
dx = target.X - box.X
|
||||||
|
case "center":
|
||||||
|
dx = target.X + (target.W-box.W)/2 - box.X
|
||||||
|
case "right":
|
||||||
|
dx = target.X + target.W - box.W - box.X
|
||||||
|
case "top":
|
||||||
|
dy = target.Y - box.Y
|
||||||
|
case "middle":
|
||||||
|
dy = target.Y + (target.H-box.H)/2 - box.Y
|
||||||
|
case "bottom":
|
||||||
|
dy = target.Y + target.H - box.H - box.Y
|
||||||
|
}
|
||||||
|
objects[i], err = cloneEditorObject(objects[i])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
objects[i], err = transformEditorObject(objects[i], dx, dy, 1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e.UpdateObjects(page, objects)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteObjects 原子删除同页对象,保留其余对象顺序,一次撤销恢复全部。
|
||||||
|
// 入参: page 页面索引, ids 对象标识
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) DeleteObjects(page int, ids []string) error {
|
||||||
|
_, indexes, err := e.selectedObjects(page, ids)
|
||||||
|
if err != nil || len(indexes) == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
before := e.pages[page].Content.Layer[0].Objects
|
||||||
|
after := slices.Clone(before)
|
||||||
|
slices.Sort(indexes)
|
||||||
|
for _, index := range slices.Backward(indexes) {
|
||||||
|
after = slices.Delete(after, index, index+1)
|
||||||
|
}
|
||||||
|
e.pages[page].Content.Layer[0].Objects = slices.Clone(after)
|
||||||
|
if change := e.recordChange(); change != nil {
|
||||||
|
change.undo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects = slices.Clone(before) }
|
||||||
|
change.redo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects = slices.Clone(after) }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectedObjects 校验同页选择,返回内部只读对象与对应索引。
|
||||||
|
func (e *Editor) selectedObjects(page int, ids []string) ([]GraphicObject, []int, error) {
|
||||||
|
if _, err := e.page(page); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
objects := make([]GraphicObject, len(ids))
|
||||||
|
indexes := make([]int, len(ids))
|
||||||
|
seen := make(map[string]bool, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
if seen[id] {
|
||||||
|
return nil, nil, fmt.Errorf("duplicate object %q", id)
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
layer, index, err := e.findObject(page, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
objects[i], indexes[i] = layer.Objects[index], index
|
||||||
|
}
|
||||||
|
return objects, indexes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorObjectID 获取可创作对象的标准标识。
|
||||||
|
func editorObjectID(object GraphicObject) string {
|
||||||
|
switch object.Type {
|
||||||
|
case "TextObject":
|
||||||
|
return object.TextObject.ID
|
||||||
|
case "PathObject":
|
||||||
|
return object.PathObject.ID
|
||||||
|
case "ImageObject":
|
||||||
|
return object.ImageObject.ID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectBounds 批量获取对齐范围,每页只度量一次文字。
|
||||||
|
func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error) {
|
||||||
|
textBoxes := make(map[string]Box)
|
||||||
|
if slices.ContainsFunc(objects, func(o GraphicObject) bool { return o.Type == "TextObject" }) {
|
||||||
|
reader, err := e.Reader()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
text, err := NewRenderer(reader).PageText(&e.pages[page])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, run := range text.Runs {
|
||||||
|
for _, box := range run.Boxes {
|
||||||
|
textBoxes[run.ID] = unionTextBox(textBoxes[run.ID], box)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boxes := make([]Box, len(objects))
|
||||||
|
for i, object := range objects {
|
||||||
|
switch object.Type {
|
||||||
|
case "TextObject":
|
||||||
|
boxes[i] = textBoxes[object.TextObject.ID]
|
||||||
|
case "PathObject":
|
||||||
|
boxes[i], _ = ParseBox(object.PathObject.Boundary)
|
||||||
|
case "ImageObject":
|
||||||
|
boxes[i], _ = ParseBox(object.ImageObject.Boundary)
|
||||||
|
}
|
||||||
|
if boxes[i].W <= 0 || boxes[i].H <= 0 {
|
||||||
|
return nil, fmt.Errorf("object %q has no visible bounds", editorObjectID(object))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return boxes, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package ofdgo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-text/typesetting/segmenter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TextLayout 横向段落选项,Wrap按Boundary宽度折行,Align为left、center、right或justify。
|
||||||
|
// LineHeight为毫米单位的基线间距,0使用字体度量;零值保持显式换行和左对齐。
|
||||||
|
type TextLayout struct {
|
||||||
|
Wrap bool
|
||||||
|
Align string
|
||||||
|
LineHeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染。
|
||||||
|
type textLayout struct {
|
||||||
|
value string
|
||||||
|
options TextLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextLayout 获取最近一次排版的原文与选项,区分软换行和显式换行。
|
||||||
|
// 信息仅在当前编辑过程保留,读取OFD时返回定位后的文字和默认选项。
|
||||||
|
// 返回: string 原文, TextLayout 排版选项
|
||||||
|
func (obj TextObject) TextLayout() (string, TextLayout) {
|
||||||
|
if obj.layout != nil {
|
||||||
|
return obj.layout.value, obj.layout.options
|
||||||
|
}
|
||||||
|
return obj.Text(), TextLayout{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// breakTextLines 优先使用Unicode断行机会,过长词仅在字素边界折行,不丢弃空白。
|
||||||
|
func breakTextLines(runes []rune, advances []float64, width float64, wrap bool) [][2]int {
|
||||||
|
if !wrap || len(runes) == 0 {
|
||||||
|
return [][2]int{{0, len(runes)}}
|
||||||
|
}
|
||||||
|
var seg segmenter.Segmenter
|
||||||
|
seg.Init(runes)
|
||||||
|
breaks := make([]bool, len(runes)+1)
|
||||||
|
for it := seg.LineIterator(); it.Next(); {
|
||||||
|
line := it.Line()
|
||||||
|
breaks[line.Offset+len(line.Text)] = true
|
||||||
|
}
|
||||||
|
var ends []int
|
||||||
|
for it := seg.GraphemeIterator(); it.Next(); {
|
||||||
|
cluster := it.Grapheme()
|
||||||
|
ends = append(ends, cluster.Offset+len(cluster.Text))
|
||||||
|
}
|
||||||
|
sums := make([]float64, len(runes)+1)
|
||||||
|
for i, advance := range advances {
|
||||||
|
sums[i+1] = sums[i] + advance
|
||||||
|
}
|
||||||
|
var lines [][2]int
|
||||||
|
for start, cursor := 0, 0; start < len(runes); {
|
||||||
|
end, legal, next := start, start, cursor
|
||||||
|
for next < len(ends) {
|
||||||
|
candidate := ends[next]
|
||||||
|
visible := candidate
|
||||||
|
for visible > start && runes[visible-1] == ' ' {
|
||||||
|
visible--
|
||||||
|
}
|
||||||
|
if sums[visible]-sums[start] > width && end > start {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end = candidate
|
||||||
|
next++
|
||||||
|
if breaks[end] {
|
||||||
|
legal = end
|
||||||
|
}
|
||||||
|
if sums[visible]-sums[start] > width {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end < len(runes) && legal > start {
|
||||||
|
end = legal
|
||||||
|
}
|
||||||
|
lines = append(lines, [2]int{start, end})
|
||||||
|
start = end
|
||||||
|
for cursor < len(ends) && ends[cursor] <= start {
|
||||||
|
cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐。
|
||||||
|
func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool) (float64, string) {
|
||||||
|
end := len(runes)
|
||||||
|
for end > 0 && runes[end-1] == ' ' {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
length := 0.0
|
||||||
|
for _, advance := range advances[:end] {
|
||||||
|
length += advance
|
||||||
|
}
|
||||||
|
x, extra := 0.0, max(0, width-length)
|
||||||
|
if alignment == "center" {
|
||||||
|
x = extra / 2
|
||||||
|
} else if alignment == "right" {
|
||||||
|
x = extra
|
||||||
|
}
|
||||||
|
gaps := make(map[int]bool)
|
||||||
|
if alignment == "justify" && justify && extra > 0 {
|
||||||
|
var seg segmenter.Segmenter
|
||||||
|
seg.Init(runes[:end])
|
||||||
|
for it := seg.LineIterator(); it.Next(); {
|
||||||
|
line := it.Line()
|
||||||
|
at := line.Offset + len(line.Text)
|
||||||
|
if at < end {
|
||||||
|
gaps[at-1] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deltas := make([]string, max(0, len(runes)-1))
|
||||||
|
for i := range deltas {
|
||||||
|
advance := advances[i]
|
||||||
|
if gaps[i] {
|
||||||
|
advance += extra / float64(len(gaps))
|
||||||
|
}
|
||||||
|
deltas[i] = ofdNumber(advance)
|
||||||
|
}
|
||||||
|
return x, strings.Join(deltas, " ")
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@ type TextObject struct {
|
|||||||
TextCode []TextCode `xml:"TextCode"`
|
TextCode []TextCode `xml:"TextCode"`
|
||||||
Clips *Clips `xml:"Clips"`
|
Clips *Clips `xml:"Clips"`
|
||||||
Actions []Action `xml:"Actions>Action"`
|
Actions []Action `xml:"Actions>Action"`
|
||||||
|
layout *textLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
// FillColor 填充颜色
|
// FillColor 填充颜色
|
||||||
|
|||||||
+17
-3
@@ -25,7 +25,7 @@ import (
|
|||||||
"github.com/tdewolff/font"
|
"github.com/tdewolff/font"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Text 获取文字原文,同一行起点处的基线变化以换行分隔,字形索引以占位字符表示
|
// Text 获取定位后的文字,横向换行以换行符分隔,字形索引以占位字符表示
|
||||||
// 返回: string 原文
|
// 返回: string 原文
|
||||||
func (obj TextObject) Text() string {
|
func (obj TextObject) Text() string {
|
||||||
var text strings.Builder
|
var text strings.Builder
|
||||||
@@ -42,7 +42,7 @@ func (obj TextObject) Text() string {
|
|||||||
return text.String()
|
return text.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// textCodeLineBreak 判断横向文本是否回到行首并更换基线
|
// textCodeLineBreak 判断横向文字的行首回退,或有足够行距且水平范围重叠的对齐段落
|
||||||
// 入参: index 文本编码索引
|
// 入参: index 文本编码索引
|
||||||
// 返回: bool 是否换行
|
// 返回: bool 是否换行
|
||||||
func (obj TextObject) textCodeLineBreak(index int) bool {
|
func (obj TextObject) textCodeLineBreak(index int) bool {
|
||||||
@@ -54,7 +54,21 @@ func (obj TextObject) textCodeLineBreak(index int) bool {
|
|||||||
start, es := strconv.ParseFloat(obj.TextCode[0].X, 64)
|
start, es := strconv.ParseFloat(obj.TextCode[0].X, 64)
|
||||||
y, ey := strconv.ParseFloat(code.Y, 64)
|
y, ey := strconv.ParseFloat(code.Y, 64)
|
||||||
prev, ep := strconv.ParseFloat(previous.Y, 64)
|
prev, ep := strconv.ParseFloat(previous.Y, 64)
|
||||||
return ex == nil && es == nil && ey == nil && ep == nil && x == start && y != prev
|
if ex != nil || es != nil || ey != nil || ep != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if x == start && y != prev {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
left, err := strconv.ParseFloat(previous.X, 64)
|
||||||
|
if err != nil || obj.Size <= 0 || y-prev < obj.Size/2 || previous.DeltaY != "" || code.DeltaY != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
right := left + obj.Size
|
||||||
|
for _, dx := range previous.GetDeltaX() {
|
||||||
|
right += dx
|
||||||
|
}
|
||||||
|
return x <= right
|
||||||
}
|
}
|
||||||
|
|
||||||
// textGlyph 绘制字形
|
// textGlyph 绘制字形
|
||||||
|
|||||||
Reference in new issue
Block a user