feat(编辑能力): 增强图文编辑能力,优化界面与保存可靠性
build.yaml / build (push) Successful in 42s

This commit is contained in:
xiaoqidun committed 2026-09-24 11:58:40 +08:00
1 parent 0508029387
commit d7cd3d03b5
20 files changed
+1158 -137

No files matched your search

+26
View File
@@ -184,6 +184,31 @@
<div class="form-row"><span>组合</span><div class="form-format"><button id="groupObjectsButton" class="button" type="button">组合</button><button id="ungroupObjectButton" class="button" type="button">解组</button></div></div>
<div class="form-row"><span>格式</span><div class="form-format"><button id="copyStyleButton" class="button" type="button" aria-label="复制格式">复制</button><button id="pasteStyleButton" class="button" type="button" aria-label="粘贴格式">粘贴</button></div></div>
<div class="form-row"><label for="objectOpacity">透明</label><div class="form-unit"><input id="objectOpacity" type="number" min="0" max="100" step="any" aria-label="透明度" autofocus><span>%</span></div></div>
<details id="objectGradientFields" hidden>
<summary>渐变</summary>
<div class="form-pair">
<div class="form-row"><label for="gradientTarget">作用</label><select id="gradientTarget"><option value="fill">填充</option><option value="stroke">描边</option></select></div>
<div class="form-row"><label for="gradientKind">类型</label><select id="gradientKind"><option value="keep">保持</option><option value="linear">线性</option><option value="radial">径向</option></select></div>
</div>
<div id="gradientControls" hidden>
<div class="form-row"><label for="gradientAngle">角度</label><div class="form-unit"><input id="gradientAngle" type="number" step="any" value="0"><span>°</span></div></div>
<div class="gradient-stop-row gradient-stop-head"><span>颜色</span><span>位置%</span><span>透明%</span><span></span></div>
<div id="gradientStops"></div>
<button id="gradientAdd" class="small-button" type="button" title="添加色标" aria-label="添加色标">+</button>
</div>
</details>
<details id="objectPatternFields" hidden>
<summary>图案</summary>
<div class="form-pair">
<div class="form-row"><label for="patternWidth">宽度</label><div class="form-unit"><input id="patternWidth" type="number" min="0" step="any"><span>mm</span></div></div>
<div class="form-row"><label for="patternHeight">高度</label><div class="form-unit"><input id="patternHeight" type="number" min="0" step="any"><span>mm</span></div></div>
</div>
<div class="form-pair">
<div class="form-row"><label for="patternXStep">横距</label><div class="form-unit"><input id="patternXStep" type="number" min="0" step="any"><span>mm</span></div></div>
<div class="form-row"><label for="patternYStep">纵距</label><div class="form-unit"><input id="patternYStep" type="number" min="0" step="any"><span>mm</span></div></div>
</div>
<div class="form-row"><label for="patternCTM">矩阵</label><input id="patternCTM" type="text" spellcheck="false" autocomplete="off"></div>
</details>
<div id="objectStrokeFields">
<div class="form-row"><label for="objectDash">线型</label><select id="objectDash"><option value="mixed" hidden>混合</option><option value="solid">实线</option><option value="dash">虚线</option><option value="dot">点线</option><option value="dashdot">点划</option><option value="custom">自定</option></select></div>
<div id="objectDashRow" class="form-row" hidden><label for="objectDashPattern">间隔</label><div class="form-unit"><input id="objectDashPattern" type="text" autocomplete="off" spellcheck="false"><span>mm</span></div></div>
@@ -267,6 +292,7 @@
<label class="form-row"><span>标题</span><input id="infoTitle" type="text" autofocus></label>
<label class="form-row"><span>主题</span><input id="infoSubject" type="text"></label>
<label class="form-row"><span>作者</span><input id="infoAuthor" type="text"></label>
<details class="info-custom"><summary>扩展</summary><div id="infoCustomRows"></div><button id="infoCustomAdd" class="small-button" type="button" title="添加字段" aria-label="添加字段">+</button></details>
<div class="form-actions"><div id="infoStatus" class="form-status" role="status"></div><button id="infoCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div>
</form>
</dialog>
+52
View File
@@ -774,6 +774,8 @@ button:not(:disabled):hover {
outline-offset: 2px;
}
.info-custom-row input,
.gradient-stop-row input,
.form-row input[type="text"],
.form-row input[type="password"],
.form-row input[type="number"],
@@ -798,6 +800,56 @@ button:not(:disabled):hover {
resize: vertical;
}
.form-dialog details {
margin-bottom: 12px;
}
.form-dialog details > summary {
cursor: pointer;
}
.form-dialog details[open] > summary {
margin-bottom: 12px;
}
#infoCustomRows {
max-height: 240px;
overflow-y: auto;
}
.info-custom-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) var(--control-height);
gap: 6px;
margin: 6px 0;
}
#infoCustomRows > :first-child {
margin-top: 0;
}
.gradient-stop-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) minmax(0, 1fr) var(--control-height);
gap: 6px;
margin: 6px 0;
align-items: center;
}
.gradient-stop-head {
font-size: 12px;
}
#gradientStops {
max-height: 180px;
overflow-y: auto;
}
#infoCustomRows :focus-visible,
#gradientStops :focus-visible {
outline-offset: -2px;
}
.color-input {
width: calc(2em + 22px);
height: var(--control-height);
+113 -3
View File
@@ -213,6 +213,8 @@ const el = {
infoPanel: document.querySelector("#infoPanel"),
infoForm: document.querySelector("#infoForm"),
infoTitle: document.querySelector("#infoTitle"),
infoCustomRows: document.querySelector("#infoCustomRows"),
infoCustomAdd: document.querySelector("#infoCustomAdd"),
infoAuthor: document.querySelector("#infoAuthor"),
infoSubject: document.querySelector("#infoSubject"),
infoCancel: document.querySelector("#infoCancel"),
@@ -286,6 +288,19 @@ const el = {
groupObjectsButton: document.querySelector("#groupObjectsButton"),
ungroupObjectButton: document.querySelector("#ungroupObjectButton"),
objectOpacity: document.querySelector("#objectOpacity"),
objectGradientFields: document.querySelector("#objectGradientFields"),
gradientTarget: document.querySelector("#gradientTarget"),
gradientKind: document.querySelector("#gradientKind"),
gradientControls: document.querySelector("#gradientControls"),
gradientAngle: document.querySelector("#gradientAngle"),
gradientStops: document.querySelector("#gradientStops"),
gradientAdd: document.querySelector("#gradientAdd"),
objectPatternFields: document.querySelector("#objectPatternFields"),
patternWidth: document.querySelector("#patternWidth"),
patternHeight: document.querySelector("#patternHeight"),
patternXStep: document.querySelector("#patternXStep"),
patternYStep: document.querySelector("#patternYStep"),
patternCTM: document.querySelector("#patternCTM"),
objectStrokeFields: document.querySelector("#objectStrokeFields"),
objectDash: document.querySelector("#objectDash"),
objectDashRow: document.querySelector("#objectDashRow"),
@@ -590,14 +605,41 @@ editorClick(el.paragraphButton, () => {
el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close());
editorClick(el.infoButton, () => {
for (const key of ["Title", "Author", "Subject"]) el[`info${key}`].value = state.doc[key.toLowerCase()] || "";
el.infoCustomRows.replaceChildren();
for (const [index, field] of (state.doc.customData || []).entries()) addCustomDataRow(field, index);
el.infoStatus.textContent = "";
el.infoPanel.showModal();
});
el.infoCancel.addEventListener("click", () => el.infoPanel.close());
el.infoCustomAdd.addEventListener("click", () => addCustomDataRow());
el.infoForm.addEventListener("submit", async event => {
event.preventDefault();
if (await changeDocument("ofdgoUpdateInfo", null, el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value)) el.infoPanel.close();
const fields = [...el.infoCustomRows.children].map(row => ({Name: row.children[0].value, Value: row.children[1].value, Source: row.sourceIndex}));
if (await changeDocument("ofdgoUpdateInfo", null, el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value, JSON.stringify(fields))) el.infoPanel.close();
});
function addCustomDataRow(field = {Name: "", Value: ""}, sourceIndex) {
const row = document.createElement("div");
row.sourceIndex = sourceIndex;
row.className = "info-custom-row";
for (const [key, label] of [["Name", "名称"], ["Value", "内容"]]) {
const input = document.createElement("input");
input.type = "text";
input.value = field[key];
input.setAttribute("aria-label", label);
input.placeholder = label;
row.append(input);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "small-button";
remove.textContent = "×";
remove.title = "删除字段";
remove.setAttribute("aria-label", "删除字段");
remove.addEventListener("click", () => row.remove());
row.append(remove);
el.infoCustomRows.append(row);
}
el.annotationClose.addEventListener("click", () => el.annotationNote.close());
el.annotationNote.addEventListener("close", () => { state.annotationEdit = null; });
el.penTool.addEventListener("change", () => {
@@ -947,12 +989,22 @@ el.objectBoundsForm.addEventListener("submit", async event => {
if (saved) el.objectBoundsPanel.close();
});
el.objectStyleCancel.addEventListener("click", () => el.objectStylePanel.close());
el.gradientKind.addEventListener("change", () => {
el.gradientControls.hidden = el.gradientKind.value === "keep";
el.gradientAngle.disabled = el.gradientKind.value !== "linear";
for (const input of el.gradientStops.querySelectorAll("input")) input.disabled = el.gradientKind.value === "keep";
});
el.gradientAdd.addEventListener("click", () => addGradientStop(50, "#808080", 0));
el.objectDash.addEventListener("change", () => {
el.objectDashRow.hidden = el.objectDash.value !== "custom";
el.objectDashPattern.required = !el.objectDashRow.hidden;
});
el.objectStyleForm.addEventListener("submit", async event => {
event.preventDefault();
if (!el.objectGradientFields.hidden && el.gradientKind.value !== "keep" && el.gradientStops.children.length < 2) {
el.objectStyleStatus.textContent = "至少保留两个色标";
return;
}
const style = readObjectStyle();
const item = canvasEditor.selected;
if (await changeDocument("ofdgoStyleObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, style)) el.objectStylePanel.close();
@@ -1390,6 +1442,21 @@ function openObjectStyle() {
el.copyStyleButton.disabled = items.length !== 1 || !canCopyStyle(items[0]);
el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canCopyStyle(item));
const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null;
const pattern = items.length === 1 && !items[0].scoped ? items[0].fillPattern : null;
el.objectGradientFields.hidden = items.length !== 1 || items[0].scoped || items[0].type !== "PathObject" || !items[0].paintSize || !canEditObject(items[0], "paint");
el.gradientKind.value = "keep";
el.gradientTarget.value = "fill";
el.gradientControls.hidden = true;
el.gradientAngle.value = "0";
el.gradientAngle.disabled = true;
el.gradientStops.replaceChildren();
addGradientStop(0, "#000000", 0);
addGradientStop(100, "#ffffff", 0);
el.objectPatternFields.hidden = !pattern;
for (const [input, key] of [[el.patternWidth, "width"], [el.patternHeight, "height"], [el.patternXStep, "xStep"], [el.patternYStep, "yStep"], [el.patternCTM, "ctm"]]) {
input.value = pattern?.[key] ?? "";
input.disabled = !pattern;
}
const alpha = items.some(item => item.alphaMixed) ? null : shared("alpha", 255), dash = shared("dashPattern", "");
el.objectOpacity.value = alpha === null ? "" : String(Math.round((1 - alpha / 255) * 10000) / 100);
el.objectOpacity.placeholder = alpha === null ? "混合" : "";
@@ -1401,13 +1468,32 @@ function openObjectStyle() {
el.objectDashOffset.value = shared("dashOffset", 0) ?? "";
el.objectCap.value = shared("cap", "") || (shared("cap", "") === null ? "mixed" : "Butt");
el.objectJoin.value = shared("join", "") || (shared("join", "") === null ? "mixed" : "Miter");
state.styleOriginal = Object.fromEntries(["objectOpacity", "objectDash", "objectDashPattern", "objectDashOffset", "objectCap", "objectJoin"].map(key => [key, el[key].value]));
state.styleOriginal = Object.fromEntries(["objectOpacity", "objectDash", "objectDashPattern", "objectDashOffset", "objectCap", "objectJoin", "patternWidth", "patternHeight", "patternXStep", "patternYStep", "patternCTM"].map(key => [key, el[key].value]));
el.objectStyleStatus.textContent = "";
el.objectStylePanel.showModal();
}
function readObjectStyle() {
const style = {}, changed = key => el[key].value !== state.styleOriginal[key];
if (!el.objectGradientFields.hidden && el.gradientKind.value !== "keep") {
const [width, height] = canvasEditor.selected.paintSize;
const Segment = [...el.gradientStops.children].map(row => ({Position: Number(row.children[1].value) / 100, Color: {Value: row.children[0].value, Alpha: Math.round(255 * (1 - Number(row.children[2].value) / 100))}})).sort((a, b) => a.Position - b.Position);
const angle = Number(el.gradientAngle.value) * Math.PI / 180, x = Math.cos(angle), y = Math.sin(angle);
const length = Math.abs(width * x) + Math.abs(height * y);
const paint = el.gradientKind.value === "linear"
? {AxialShd: {StartPoint: `${width / 2 - x * length / 2} ${height / 2 - y * length / 2}`, EndPoint: `${width / 2 + x * length / 2} ${height / 2 + y * length / 2}`, Extend: "3", Segment}}
: {RadialShd: {StartPoint: `${width / 2} ${height / 2}`, EndPoint: `${width / 2} ${height / 2}`, EndRadius: Math.max(width, height) / 2, Extend: "3", Segment}};
const target = el.gradientTarget.value;
style[`${target}Paint`] = JSON.stringify(paint);
style[target] = true;
}
if (!el.objectPatternFields.hidden) {
const pattern = {};
for (const [input, key] of [["patternWidth", "Width"], ["patternHeight", "Height"], ["patternXStep", "XStep"], ["patternYStep", "YStep"], ["patternCTM", "CTM"]]) {
if (changed(input)) pattern[key] = key === "CTM" ? el[input].value.trim() : Number(el[input].value);
}
if (Object.keys(pattern).length) style.patternStyle = JSON.stringify(pattern);
}
if (changed("objectOpacity") && el.objectOpacity.value !== "") style.alpha = Math.round((1 - Number(el.objectOpacity.value) / 100) * 255);
if (!el.objectStrokeFields.hidden) {
if (changed("objectDash") || changed("objectDashPattern")) {
@@ -1421,6 +1507,30 @@ function readObjectStyle() {
return style;
}
function addGradientStop(position, color, transparency) {
const row = document.createElement("div");
row.className = "gradient-stop-row";
for (const [type, value, label] of [["color", color, "色标颜色"], ["number", position, "色标位置"], ["number", transparency, "色标透明度"]]) {
const input = document.createElement("input");
input.type = type;
input.value = value;
input.disabled = el.gradientKind.value === "keep";
input.setAttribute("aria-label", label);
input.title = label;
if (type === "number") { input.min = "0"; input.max = "100"; input.step = "any"; input.required = true; }
row.append(input);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "small-button";
remove.textContent = "×";
remove.title = "删除色标";
remove.setAttribute("aria-label", "删除色标");
remove.addEventListener("click", () => row.remove());
row.append(remove);
el.gradientStops.append(row);
}
function openOutlinePanel(action) {
state.outlineAction = action;
let outline = null, items = state.doc.outlines || [];
@@ -2439,7 +2549,7 @@ async function changeDocument(name, item, ...args) {
}
setEditorInfo(doc);
if (name === "ofdgoUpdateInfo") {
Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject });
Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject, customData: doc.customData });
renderMeta();
updateControls();
rememberEditorView(before, revision);