feat(创建编辑): WebUI新增OFD创建与可视化编辑功能
build.yaml / build (push) Successful in 35s

This commit is contained in:
xiaoqidun committed 2026-09-15 14:24:19 +08:00
1 parent dcf1ae8b3a
commit aec10e93a4
13 files changed
+1669 -317

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ import (
// FS WebUI嵌入静态文件
//
//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.sw.js ofdgo.wasm ofdgo.wasm.js wasm_exec.js
//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.wasm ofdgo_edit.js ofdgo_font.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js
var FS embed.FS
// Checksum 计算WebUI嵌入资源校验值
+69
View File
@@ -17,6 +17,7 @@
<input id="fontDirectoryInput" class="file-input" type="file" webkitdirectory multiple>
<div class="tool-group">
<button id="ofdButton" class="icon-button" type="button" title="打开" aria-label="打开" aria-keyshortcuts="Control+O Meta+O"><span class="open-icon" aria-hidden="true"></span></button>
<button id="newButton" class="icon-button" type="button" title="新建" aria-label="新建文档"><span class="new-icon" aria-hidden="true"></span></button>
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">&#9776;</button>
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">&#9432;</button>
</div>
@@ -53,6 +54,74 @@
<button id="exportButton" class="button" type="button" data-short="多" title="导出文档" aria-label="导出文档" aria-haspopup="dialog" aria-controls="exportPanel" disabled>文档</button>
</div>
</header>
<div id="editorTools" class="editor-toolbar" role="toolbar" aria-label="创作工具" hidden>
<div class="editor-group">
<button id="selectObjectButton" class="button" type="button" aria-pressed="true">选择</button>
<button id="editTextButton" class="button" type="button" aria-label="修改文字" disabled>修改</button>
<button id="deleteObjectButton" class="button" type="button" disabled>删除</button>
<span>对象</span>
</div>
<div class="editor-group">
<button id="insertTextButton" class="button" type="button" aria-label="添加文字">文字</button>
<button id="insertImageButton" class="button" type="button" aria-label="添加图片">图片</button>
<span>插入</span>
</div>
<div class="editor-group">
<button id="saveButton" class="button" type="button" aria-label="保存OFD">保存</button>
<button id="undoButton" class="button" type="button" disabled>撤销</button>
<button id="redoButton" class="button" type="button" disabled>重做</button>
<span>文件</span>
</div>
</div>
<dialog id="createPanel" class="form-dialog" aria-label="新建文档">
<form id="createForm">
<div class="form-row">
<label for="createName">名称</label>
<input id="createName" type="text" value="未命名" required autofocus>
</div>
<div class="form-row">
<label for="createWidth">宽度</label>
<div class="form-unit"><input id="createWidth" type="number" value="210" min="1" step="any" required><span>mm</span></div>
</div>
<div class="form-row">
<label for="createHeight">高度</label>
<div class="form-unit"><input id="createHeight" type="number" value="297" min="1" step="any" required><span>mm</span></div>
</div>
<div class="form-actions">
<div id="createStatus" class="form-status" role="status"></div>
<button id="createCancel" class="button" type="button">取消</button>
<button class="button" type="submit">新建</button>
</div>
</form>
</dialog>
<dialog id="insertPanel" class="form-dialog" aria-label="添加文字">
<form id="insertForm">
<div id="insertTextRow" class="form-row">
<label for="insertText">内容</label>
<input id="insertText" type="text" required autofocus>
</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="insertSizeRow" class="form-row">
<label for="insertSize">字号</label>
<div class="form-unit"><input id="insertSize" type="number" value="12" min="1" step="any" required><span>pt</span></div>
</div>
<div id="insertImageRow" class="form-row" hidden>
<label for="insertImage">图片</label>
<input id="insertImage" type="file" accept=".png,.jpg,.jpeg,image/png,image/jpeg" required>
</div>
<div class="form-actions">
<div id="insertStatus" class="form-status" role="status"></div>
<button id="insertCancel" class="button" type="button">取消</button>
<button id="insertSubmit" class="button" type="submit">添加</button>
</div>
</form>
</dialog>
<dialog id="exportPanel" class="form-dialog" aria-label="导出文档">
<form id="exportForm">
<div class="form-row">
+168 -1
View File
@@ -77,6 +77,48 @@ a:focus-visible {
overflow: hidden;
}
.app:has(.editor-toolbar:not([hidden])) {
grid-template-rows: auto auto minmax(0, 1fr) auto;
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 8px 12px 6px;
border-bottom: 1px solid var(--line);
background: var(--panel);
font-size: 14px;
}
.editor-toolbar[hidden] {
display: none;
}
.editor-group {
display: grid;
grid-auto-columns: max-content;
column-gap: 6px;
row-gap: 4px;
padding-right: 12px;
border-right: 1px solid var(--line);
}
.editor-group:last-child {
border-right: 0;
}
.editor-group > button {
grid-row: 1;
}
.editor-group > span {
grid-column: 1 / -1;
text-align: center;
font-size: 12px;
color: var(--text-muted);
}
.app-bar {
display: flex;
align-items: center;
@@ -186,6 +228,29 @@ button:not(:disabled):hover {
content: "";
}
.new-icon {
position: relative;
width: 12px;
height: 15px;
border: 1px solid currentColor;
}
.new-icon::before,
.new-icon::after {
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 1px;
background: currentColor;
content: "";
transform: translate(-50%, -50%);
}
.new-icon::after {
transform: translate(-50%, -50%) rotate(90deg);
}
.toolbar-space {
flex: 1 1 auto;
}
@@ -325,7 +390,10 @@ button:not(:disabled):hover {
outline-offset: 2px;
}
.form-row input[type="text"] {
.form-row input[type="text"],
.form-row input[type="number"],
.form-row input[type="file"],
.form-row select {
width: 100%;
min-width: 0;
height: var(--control-height);
@@ -336,6 +404,28 @@ button:not(:disabled):hover {
color: #1f2428;
}
.form-row input[type="file"] {
height: auto;
padding: 4px;
}
.form-unit {
display: grid;
grid-template-columns: minmax(0, 1fr) 2em;
align-items: center;
gap: 8px;
}
.form-unit > span {
color: var(--text-muted);
}
.form-font {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.form-row input[aria-invalid="true"] {
border-color: #b14343;
}
@@ -813,6 +903,7 @@ button.outline-link:hover {
.meta-list > [hidden],
.navigation-tabs[hidden],
.form-row[hidden],
.tool-group[hidden],
.page-list[hidden],
.page-frame[hidden],
.progress-panel > [hidden],
@@ -937,6 +1028,82 @@ button.outline-link:hover {
transform-origin: 0 0;
}
.edit-layer {
display: none;
position: absolute;
inset: 0;
pointer-events: none;
}
.editing-select .edit-layer {
display: block;
}
.editing-select .text-run > span {
pointer-events: none;
}
.edit-object {
position: absolute;
pointer-events: auto;
touch-action: none;
user-select: none;
cursor: move;
}
.edit-object:hover,
.edit-object.selected {
outline: calc(1px / var(--surface-scale)) solid var(--accent);
}
.edit-object.selected {
background: rgba(15, 123, 104, 0.04);
}
.edit-handle {
display: none;
position: absolute;
width: calc(9px / var(--surface-scale));
height: calc(9px / var(--surface-scale));
border: calc(1px / var(--surface-scale)) solid var(--accent);
background: #fff;
transform: translate(-50%, -50%);
}
.selected .edit-handle {
display: block;
}
.edit-handle::before {
position: absolute;
inset: calc(-8px / var(--surface-scale));
content: "";
}
.edit-nw {
left: 0;
top: 0;
cursor: nwse-resize;
}
.edit-ne {
left: 100%;
top: 0;
cursor: nesw-resize;
}
.edit-sw {
left: 0;
top: 100%;
cursor: nesw-resize;
}
.edit-se {
left: 100%;
top: 100%;
cursor: nwse-resize;
}
.text-layer {
position: absolute;
inset: 0;
+413 -274
View File
File diff suppressed because it is too large. Load diff
+204
View File
@@ -0,0 +1,204 @@
const PX_PER_MM = 96 / 25.4;
export function pagePoint(x, y, rect, page, rotation) {
const u = (x - rect.left) / rect.width;
const v = (y - rect.top) / rect.height;
const points = [[u, v], [v, 1 - u], [1 - u, 1 - v], [1 - v, u]];
const point = points[rotation / 90];
return { x: point[0] * page.width, y: point[1] * page.height };
}
export function objectTransform(box, page, dx, dy, corner = "") {
if (!corner) {
return {
x: Math.max(-box.x, Math.min(dx, Math.max(-box.x, page.width - box.x - box.width))),
y: Math.max(-box.y, Math.min(dy, Math.max(-box.y, page.height - box.y - box.height))),
scale: 1,
};
}
const left = corner.includes("w");
const top = corner.includes("n");
const x = left ? -dx : dx;
const y = top ? -dy : dy;
const limit = Math.min(
(left ? box.x + box.width : page.width - box.x) / box.width,
(top ? box.y + box.height : page.height - box.y) / box.height,
);
const scale = Math.max(Math.min(1 / Math.min(box.width, box.height), limit), Math.min(limit,
1 + (x * box.width + y * box.height) / (box.width ** 2 + box.height ** 2)));
return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale };
}
export class CanvasEditor {
constructor(viewer, options) {
this.viewer = viewer;
this.options = options;
this.enabled = false;
this.selected = null;
this.nodes = new WeakMap();
viewer.addEventListener("pointerdown", (event) => this.start(event));
viewer.addEventListener("pointermove", (event) => this.move(event));
viewer.addEventListener("pointerup", (event) => this.end(event));
viewer.addEventListener("pointercancel", () => this.cancel());
viewer.addEventListener("lostpointercapture", () => this.cancel());
viewer.addEventListener("keydown", (event) => this.keyDown(event));
viewer.addEventListener("dblclick", () => {
const item = this.selected;
if (this.enabled && item && !item.image && !this.options.busy()) {
this.options.onEdit(item);
}
});
viewer.addEventListener("scroll", () => this.cancel());
window.addEventListener("blur", () => this.cancel());
window.addEventListener("resize", () => this.cancel());
}
setEnabled(enabled) {
this.enabled = enabled;
this.viewer.classList.toggle("editing-select", enabled);
if (!enabled) {
this.clear();
}
}
mount(index, page, surface) {
const layer = document.createElement("div");
layer.className = "edit-layer";
for (const object of page.objects) {
const node = document.createElement("div");
node.className = "edit-object";
node.tabIndex = 0;
node.setAttribute("role", "button");
node.setAttribute("aria-label", object.image ? "图片对象" : "文字对象");
const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface };
this.nodes.set(node, item);
node.addEventListener("focus", () => this.select(item));
this.place(item);
if (object.image) {
for (const corner of ["nw", "ne", "sw", "se"]) {
const handle = document.createElement("span");
handle.className = `edit-handle edit-${corner}`;
handle.dataset.corner = corner;
node.append(handle);
}
}
layer.append(node);
if (this.selected?.index === index && this.selected.id === object.id) {
this.select(item);
}
}
surface.append(layer);
if (this.selectLast && layer.lastElementChild) {
this.selectLast = false;
this.select(this.nodes.get(layer.lastElementChild));
}
}
place(item, change = { x: 0, y: 0, scale: 1 }) {
Object.assign(item.node.style, {
left: `${(item.x + change.x) * PX_PER_MM}px`, top: `${(item.y + change.y) * PX_PER_MM}px`,
width: `${item.width * change.scale * PX_PER_MM}px`, height: `${item.height * change.scale * PX_PER_MM}px`,
});
}
select(item) {
this.selected?.node.classList.remove("selected");
this.selected = item;
item?.node.classList.add("selected");
this.options.onSelect(item);
}
clear() {
this.cancel();
this.selectLast = false;
this.select(null);
}
start(event) {
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
return;
}
const node = event.target.closest(".edit-object");
if (!node) {
this.select(null);
return;
}
event.preventDefault();
this.select(this.nodes.get(node));
this.viewer.focus({ preventScroll: true });
const item = this.selected;
this.drag = {
item, pointerID: event.pointerId, corner: event.target.dataset.corner || "",
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
clientX: event.clientX, clientY: event.clientY,
};
this.viewer.setPointerCapture(event.pointerId);
}
move(event) {
const drag = this.drag;
if (!drag || drag.pointerID !== event.pointerId) {
return;
}
event.preventDefault();
if (!drag.change && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) {
return;
}
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation);
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation);
drag.change = objectTransform(drag.item, drag.item.page, to.x - from.x, to.y - from.y, drag.corner);
this.place(drag.item, drag.change);
}
end(event) {
const drag = this.drag;
if (!drag || drag.pointerID !== event.pointerId) {
return;
}
this.move(event);
this.cancel();
if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) {
this.options.onTransform(drag.item, drag.change);
}
}
cancel() {
const drag = this.drag;
this.drag = null;
if (drag) {
this.place(drag.item);
if (this.viewer.hasPointerCapture(drag.pointerID)) {
this.viewer.releasePointerCapture(drag.pointerID);
}
}
}
keyDown(event) {
if (!this.enabled || !this.selected || this.options.busy() || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) {
return;
}
const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const edit = (event.key === "Enter" || event.key === "F2") && !this.selected.image;
if (event.key !== "Escape" && event.key !== "Delete" && !edit && !directions[event.key]) {
return;
}
event.preventDefault();
event.stopPropagation();
if (event.key === "Escape") {
this.drag ? this.cancel() : this.clear();
} else if (event.key === "Delete") {
this.options.onDelete(this.selected);
} else if (edit && !this.drag) {
this.options.onEdit(this.selected);
} else if (!this.drag) {
const [x, y] = directions[event.key];
const [dx, dy] = [[x, y], [y, -x], [-x, -y], [-y, x]][this.options.rotation() / 90];
const step = event.shiftKey ? 10 : 1;
const change = objectTransform(this.selected, this.selected.page,
dx * step, dy * step);
if (change.x || change.y) {
this.options.onTransform(this.selected, change);
}
}
}
}
+240
View File
@@ -0,0 +1,240 @@
const DATABASE = "ofdgo";
export class FontManager {
constructor({ onChange, onPermissionChange }) {
this.userFonts = [];
this.localFonts = [];
this.catalog = [];
this.catalogLoaded = false;
this.permission = "prompt";
this.sequence = 0;
this.database = null;
this.onPermissionChange = onPermissionChange;
this.channel = typeof BroadcastChannel === "function" ? new BroadcastChannel(DATABASE) : null;
if (this.channel) {
this.channel.onmessage = onChange;
}
}
openDatabase() {
if (!this.database) {
this.database = new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE, 1);
request.onupgradeneeded = () => {
const store = request.result.createObjectStore("fonts", { keyPath: "id", autoIncrement: true });
store.createIndex("checksum", "checksum", { unique: true });
};
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => {
db.close();
this.database = null;
};
resolve(db);
};
request.onerror = () => reject(request.error);
}).catch((err) => {
this.database = null;
throw err;
});
}
return this.database;
}
async transaction(mode, action) {
const db = await this.openDatabase();
return new Promise((resolve, reject) => {
const tx = db.transaction("fonts", mode);
let request;
tx.oncomplete = () => {
if (mode === "readwrite") {
this.channel?.postMessage(null);
}
resolve(request?.result);
};
tx.onabort = () => reject(tx.error || new Error("字体保存失败"));
try {
request = action(tx.objectStore("fonts"));
} catch (err) {
tx.abort();
reject(err);
}
});
}
listStored() {
return this.transaction("readonly", (store) => store.getAll());
}
async store(fonts) {
const records = new Map();
for (const font of fonts) {
const digest = await crypto.subtle.digest("SHA-256", font.data);
const checksum = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
if (!records.has(checksum)) {
records.set(checksum, {
name: font.name,
data: new Blob([font.data]),
enabled: font.enabled,
checksum,
});
}
}
await this.transaction("readwrite", (store) => {
for (const font of records.values()) {
const request = store.index("checksum").getKey(font.checksum);
request.onsuccess = () => {
if (request.result === undefined) {
store.add(font);
}
};
}
});
}
updateStored(id, changes) {
return this.transaction("readwrite", (store) => {
const request = store.get(id);
request.onsuccess = () => {
if (request.result) {
store.put({ ...request.result, ...changes });
}
};
});
}
deleteStored(id) {
return this.transaction("readwrite", (store) => store.delete(id));
}
async queryLocal() {
try {
const available = await window.queryLocalFonts();
this.catalog = available;
this.catalogLoaded = true;
this.permission = "granted";
this.onPermissionChange();
return available;
} catch (err) {
if (err && err.name === "NotAllowedError") {
this.permission = "denied";
this.onPermissionChange();
}
throw err;
}
}
async files(fonts) {
const result = [];
for (const font of fonts) {
if (!font.enabled) {
continue;
}
result.push({ name: font.name, data: await this.read(font) });
}
return result;
}
async read(font) {
if (font.blob) {
const blob = await font.blob();
return new Uint8Array(await blob.arrayBuffer());
}
if (font.data instanceof Blob) {
font.data = new Uint8Array(await font.data.arrayBuffer());
}
return font.data;
}
async add(fonts) {
let saved = true;
try {
await this.store(fonts);
} catch {
saved = false;
this.userFonts.push(...fonts);
}
const changed = saved ? await this.restore() : true;
if (saved && navigator.storage?.persist) {
navigator.storage.persist().catch(() => false);
}
return { saved, changed };
}
async change(font, changes) {
if (font.source === "stored") {
if (changes) {
await this.updateStored(font.id, changes);
} else {
await this.deleteStored(font.id);
}
await this.restore();
} else if (changes) {
Object.assign(font, changes);
} else {
this.localFonts = this.localFonts.filter((item) => item.id !== font.id);
this.userFonts = this.userFonts.filter((item) => item.id !== font.id);
}
}
async restore() {
const stored = await this.listStored();
const previous = new Map(this.userFonts.map((font) => [font.id, font]));
const fonts = stored.map((font) => ({
...font,
data: previous.get(font.id)?.checksum === font.checksum ? previous.get(font.id).data : font.data,
source: "stored",
}));
fonts.push(...this.userFonts.filter((font) => font.source === "upload"));
if (fonts.length === this.userFonts.length && fonts.every((font, index) => {
const old = this.userFonts[index];
return font.id === old.id && font.name === old.name && font.enabled === old.enabled && font.checksum === old.checksum;
})) {
return false;
}
this.userFonts = fonts;
return true;
}
record(name, data, source) {
this.sequence += 1;
return {
id: `${source}-${this.sequence}`,
name: name || "font.ttf",
data,
enabled: true,
source,
};
}
records() {
return [...this.localFonts, ...this.userFonts];
}
localName(font) {
const name = font.fullName || font.family || font.postscriptName || "local-font";
return `${name}.ttf`;
}
canReadLocal() {
return typeof window.queryLocalFonts === "function";
}
async refreshPermission() {
if (!this.canReadLocal() || !navigator.permissions?.query) {
this.onPermissionChange();
return;
}
try {
const permission = await navigator.permissions.query({ name: "local-fonts" });
this.permission = permission.state;
permission.onchange = () => {
this.permission = permission.state;
this.onPermissionChange();
};
} catch {
this.permission = "prompt";
}
this.onPermissionChange();
}
}
@@ -7,7 +7,7 @@ self.onmessage = ({ data }) => {
exports.get(data.id)?.abort();
return;
}
if (data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment") {
if (data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument") {
exports.set(data.id, new AbortController());
}
pending = pending.then(() => handleMessage(data));
@@ -1,6 +1,6 @@
const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`;
const META_CACHE = `${CACHE_PREFIX}meta`;
const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.sw.js", "ofdgo.wasm", "ofdgo.wasm.js", "wasm_exec.js"]
const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"]
.map((path) => new URL(path, self.registration.scope).href);
const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href;