feat(阅读编辑): 完善后台刷新、选取与文字编辑
build.yaml / build (push) Successful in 46s

This commit is contained in:
xiaoqidun committed 2026-10-27 15:08:25 +08:00
1 parent e67f329776
commit af8b64e748
4 files changed
+246 -109

No files matched your search

+50 -30
View File
@@ -42,7 +42,7 @@ let credentialRequest = null;
let documentDialogs = null; let documentDialogs = null;
let cancelPageTouch = null; let cancelPageTouch = null;
const workspace = { tabs: [], active: null, sequence: 0, nextID: 0, switching: false, closing: false, objectClipboard: null }; const workspace = { tabs: [], active: null, sequence: 0, nextID: 0, switching: false, closing: false, objectClipboard: null, textClipboard: null };
function createResourceManager() { function createResourceManager() {
return { kind: "Font", scope: "", views: new Map(), items: [], filteredItems: [], start: -1, end: -1, selected: null, references: [], referenceOffset: 0, targets: [], pick: null, busy: false, sequence: 0, url: null, fileAction: null }; return { kind: "Font", scope: "", views: new Map(), items: [], filteredItems: [], start: -1, end: -1, selected: null, references: [], referenceOffset: 0, targets: [], pick: null, busy: false, sequence: 0, url: null, fileAction: null };
@@ -125,6 +125,8 @@ function createDocumentState() {
searchMatches: [], searchMatches: [],
searchIndex: -1, searchIndex: -1,
searchRunning: false, searchRunning: false,
searchNext: 0,
searchPaused: false,
searchTruncated: false, searchTruncated: false,
navigationScroll: new Map(), navigationScroll: new Map(),
fontSyncPending: false, fontSyncPending: false,
@@ -703,7 +705,10 @@ el.editorTools.addEventListener("scroll", () => fontPicker.position());
const canvasEditor = new CanvasEditor(el.viewerPanel, { const canvasEditor = new CanvasEditor(el.viewerPanel, {
textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.saveAsButton, el.fontInput, document.querySelector("#textReflow")], textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.saveAsButton, el.fontInput, document.querySelector("#textReflow")],
keepTextFocus: () => fontPicker.open || el.closePanel.open || canvasEditor.input?.fontDialog, keepTextFocus: () => fontPicker.open || el.closePanel.open || canvasEditor.input?.fontDialog,
textClipboardScope: () => `${state.wasmSeq}:${state.fileName}:${state.doc?.documents?.[state.doc.documentIndex || 0]?.root}:${state.doc?.documentIndex || 0}:${state.doc?.documentVersion || ""}`, textClipboardScope: () => `${documentTab().id}:${state.wasmSeq}:${state.fileName}:${state.doc?.documents?.[state.doc.documentIndex || 0]?.root}:${state.doc?.documentIndex || 0}:${state.doc?.documentVersion || ""}`,
getTextClipboard: () => workspace.textClipboard,
setTextClipboard: clipboard => { workspace.textClipboard = clipboard; },
scrollBy: (x, y) => { el.viewerPanel.scrollLeft += x; pageView.scrollViewerBy(y); },
busy: () => document.body.hasAttribute("data-busy"), busy: () => document.body.hasAttribute("data-busy"),
rotation: () => state.rotation, rotation: () => state.rotation,
canInsert: (index) => pageCan("insert", index), canInsert: (index) => pageCan("insert", index),
@@ -2782,10 +2787,10 @@ function detachDocumentView() {
tab.view = { anchor: state.doc ? pageView.scaleAnchor() : null, navigation: el.navigationTabs.querySelector('[data-selected="true"]'), tab.view = { anchor: state.doc ? pageView.scaleAnchor() : null, navigation: el.navigationTabs.querySelector('[data-selected="true"]'),
navigationTop: el.navigationContent.scrollTop, metaTop: el.metaPanel.scrollTop, tool: canvasEditor.tool, navigationTop: el.navigationContent.scrollTop, metaTop: el.metaPanel.scrollTop, tool: canvasEditor.tool,
selection: selection ? {index: selection.index, ids: (selection.items || [selection]).map(item => item.id)} : canvasEditor.pendingSelection, selection: selection ? {index: selection.index, ids: (selection.items || [selection]).map(item => item.id)} : canvasEditor.pendingSelection,
richClipboard: canvasEditor.richClipboard, search: el.searchInput.value }; search: el.searchInput.value };
tab.view.searchStatus = state.searchRunning ? "搜索已暂停" : el.searchStatus.textContent; tab.view.searchStatus = state.searchRunning ? "搜索已暂停" : el.searchStatus.textContent;
if (state.searchRunning) { if (state.searchRunning) {
state.searchQuery = ""; state.searchPaused = true;
state.searchSeq++; state.searchSeq++;
state.searchRunning = false; state.searchRunning = false;
} }
@@ -2855,7 +2860,6 @@ function attachDocumentTab(tab) {
el.searchResults.replaceChildren(); el.searchResults.replaceChildren();
renderSearchResults(); renderSearchResults();
el.ofdInput.value = ""; el.ofdInput.value = "";
canvasEditor.richClipboard = tab.view?.richClipboard || null;
canvasEditor.pendingSelection = tab.view?.selection || null; canvasEditor.pendingSelection = tab.view?.selection || null;
documentDialogs?.setOwner(tab); documentDialogs?.setOwner(tab);
workspace.resourceView = resourceElements.Panel.open ? resourceManager : null; workspace.resourceView = resourceElements.Panel.open ? resourceManager : null;
@@ -2904,6 +2908,15 @@ async function activateDocumentTab(tab, closing = false) {
} }
} }
async function refreshDocumentTab(tab) {
tab.refreshPending = true;
updateWorkspaceDirty();
if (isForegroundDocument(tab)) {
advanceDocumentSequence();
await mountDocumentTab(tab);
}
}
async function mountDocumentTab(tab) { async function mountDocumentTab(tab) {
if (!isForegroundDocument(tab)) return; if (!isForegroundDocument(tab)) return;
const state = tab.state; const state = tab.state;
@@ -2933,11 +2946,19 @@ async function mountDocumentTab(tab) {
tab.configurePending = false; tab.configurePending = false;
} }
if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return; if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return;
if (tab.refreshPending) {
state.objectClipboardToken = state.styleClipboard = null;
state.pageSelection.clear();
state.pageSelectionAnchor = null;
state.documentSelection = null;
canvasEditor.pendingSelection = null;
}
await openDocument({ doc: state.wasmExited ? undefined : state.doc, pageIndex: state.pageIndex, fitMode: state.fitMode || "free", scale: state.scale, await openDocument({ doc: state.wasmExited ? undefined : state.doc, pageIndex: state.pageIndex, fitMode: state.fitMode || "free", scale: state.scale,
skipAutoFonts: !tab.initialLoad, keepSearch: true, keepCache: true, restoreTab: !tab.initialLoad, resetScroll: Boolean(tab.initialLoad), openSeq }); skipAutoFonts: !tab.initialLoad, keepSearch: !tab.refreshPending, keepCache: !tab.refreshPending, restoreTab: !tab.initialLoad, resetScroll: Boolean(tab.initialLoad), openSeq });
if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return; if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return;
tab.configurePending = false; tab.configurePending = false;
tab.initialLoad = false; tab.initialLoad = false;
tab.refreshPending = false;
if (view?.anchor) pageView.restoreScaleAnchor(view.anchor); if (view?.anchor) pageView.restoreScaleAnchor(view.anchor);
showNavigation(view?.navigation || el.pagesTab); showNavigation(view?.navigation || el.pagesTab);
el.navigationContent.scrollTop = view?.navigationTop || 0; el.navigationContent.scrollTop = view?.navigationTop || 0;
@@ -2957,6 +2978,7 @@ async function mountDocumentTab(tab) {
setStatus(tab.status); setStatus(tab.status);
setBusy(Boolean(tab.opening || tab.editTask || tab.state.exporting), tab.progress?.text || "", tab.progress?.percent ?? null, tab.status, tab); setBusy(Boolean(tab.opening || tab.editTask || tab.state.exporting), tab.progress?.text || "", tab.progress?.percent ?? null, tab.status, tab);
if (documentDialogs) await resumeDocumentPanels(tab); if (documentDialogs) await resumeDocumentPanels(tab);
if (isForegroundDocument(tab) && state.searchPaused && !tab.busy) searchDocument();
} }
async function releaseDocumentTab(tab) { async function releaseDocumentTab(tab) {
@@ -3266,11 +3288,9 @@ async function switchReadingVersion(id) {
state.pageCache.clear(); state.pageCache.clear();
state.pageIndex = 0; state.pageIndex = 0;
tab.view = null; tab.view = null;
tab.refreshPending = true; await refreshDocumentTab(tab);
updateWorkspaceDirty();
return true; return true;
} }
canvasEditor.richClipboard = null;
canvasEditor.clear(); canvasEditor.clear();
canvasEditor.setTool(""); canvasEditor.setTool("");
updateTextFonts(null, true); updateTextFonts(null, true);
@@ -3490,12 +3510,10 @@ async function switchDocument(index) {
state.pageCache.clear(); state.pageCache.clear();
state.pageIndex = 0; state.pageIndex = 0;
tab.view = null; tab.view = null;
tab.refreshPending = true; await refreshDocumentTab(tab);
updateWorkspaceDirty();
if (nativeHost) await nativeHost.release(source); if (nativeHost) await nativeHost.release(source);
return; return;
} }
canvasEditor.richClipboard = null;
canvasEditor.clear(); canvasEditor.clear();
canvasEditor.setTool(""); canvasEditor.setTool("");
updateTextFonts(null, true); updateTextFonts(null, true);
@@ -3598,7 +3616,7 @@ async function toggleEditor() {
state.conversionWarnings = []; state.conversionWarnings = [];
state.objectClipboardToken = state.styleClipboard = null; state.objectClipboardToken = state.styleClipboard = null;
state.pageCache.clear(); state.pageCache.clear();
tab.refreshPending = true; await refreshDocumentTab(tab);
return; return;
} }
openSeq = advanceDocumentSequence(); openSeq = advanceDocumentSequence();
@@ -3610,7 +3628,6 @@ async function toggleEditor() {
state.conversionWarnings = []; state.conversionWarnings = [];
state.objectClipboardToken = null; state.objectClipboardToken = null;
state.styleClipboard = null; state.styleClipboard = null;
canvasEditor.richClipboard = null;
canvasEditor.clear(); canvasEditor.clear();
pageView.setPan(false); pageView.setPan(false);
await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode, scale, await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode, scale,
@@ -3943,9 +3960,9 @@ async function editCanvasObject(item, point = null) {
await face.load(); await face.load();
if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return; if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return;
let rich = null; let rich = null;
if (item.richText) rich = await loadRichText(item, face, tab); if (item.richText) rich = await loadRichText(item, face, tab, data.bytes);
else if (!item.scoped && layoutKnown) { else if (!item.scoped && layoutKnown) {
try { rich = await loadRichText(item, face, tab); } catch {} try { rich = await loadRichText(item, face, tab, data.bytes); } catch {}
} }
if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return; if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return;
const source = rich || layoutKnown ? null : await callWASM("ofdgoPreviewText", item.index, item.id, item.text); const source = rich || layoutKnown ? null : await callWASM("ofdgoPreviewText", item.index, item.id, item.text);
@@ -4112,21 +4129,25 @@ function currentTextStyle() {
return canvasEditor.input?.style ? { ...text, ...canvasEditor.input.style } : text; return canvasEditor.input?.style ? { ...text, ...canvasEditor.input.style } : text;
} }
async function loadRichText(item, firstFace, tab = documentTab()) { async function loadRichText(item, firstFace, tab = documentTab(), firstData = null) {
const wasmSeq = tab.state.wasmSeq; const wasmSeq = tab.state.wasmSeq;
const callWASM = (name, ...args) => { const callWASM = (name, ...args) => {
if (tab.closed || wasmSeq !== tab.state.wasmSeq) throw new Error("文档已更改"); if (tab.closed || wasmSeq !== tab.state.wasmSeq) throw new Error("文档已更改");
return callTabWASM(tab, name, ...args); return callTabWASM(tab, name, ...args);
}; };
const model = item.richText || await callWASM("ofdgoGetRichText", item.index, item.id); const model = item.richText || await callWASM("ofdgoGetRichText", item.index, item.id);
const remaining = firstData ? { ...model, runs: model.runs.filter(run => run.format.font !== item.font) } : model;
const fontData = remaining.runs.length ? await callWASM("ofdgoRichTextFonts", JSON.stringify(remaining)) : [];
const resources = new Map(fontData.map(font => [font.key, font.data]));
if (firstData) resources.set(item.font, firstData);
const faces = new Map(); const faces = new Map();
for (const id of new Set(model.runs.map(run => run.format.font))) { for (const id of new Set(model.runs.map(run => run.format.font))) {
const data = id === item.font && firstFace ? null : (await callWASM("ofdgoEditorFont", id)).bytes; const data = id === item.font && firstFace ? null : resources.get(id);
const face = data ? await new FontFace(`ofdgo-rich-${id}`, data, fontFaceStyle(data)).load() : firstFace; const face = data ? await new FontFace(`ofdgo-rich-${crypto.randomUUID()}`, data, fontFaceStyle(data)).load() : firstFace;
if (tab.closed || wasmSeq !== tab.state.wasmSeq) throw new Error("文档已更改"); if (tab.closed || wasmSeq !== tab.state.wasmSeq) throw new Error("文档已更改");
faces.set(id, face); faces.set(id, face);
} }
return { model, faces }; return { model, faces, resources };
} }
async function ensureRichInput() { async function ensureRichInput() {
@@ -4482,8 +4503,7 @@ async function insertObject(event) {
pageCapabilities: doc.pageCapabilities, editWarnings: doc.editWarnings || []}; pageCapabilities: doc.pageCapabilities, editWarnings: doc.editWarnings || []};
state.dirty = state.packageDirty || state.encryptionDirty || doc.revision !== state.savedRevision; state.dirty = state.packageDirty || state.encryptionDirty || doc.revision !== state.savedRevision;
state.pageCache.clear(); state.pageCache.clear();
tab.refreshPending = true; await refreshDocumentTab(tab);
updateWorkspaceDirty();
return; return;
} }
el.insertPanel.close(); el.insertPanel.close();
@@ -4701,7 +4721,6 @@ async function changeDocument(name, item, ...args) {
const originalDocument = state.doc; const originalDocument = state.doc;
const before = editorLocation(), revision = state.editorInfo.revision; const before = editorLocation(), revision = state.editorInfo.revision;
const restoring = name === "ofdgoUndo" || name === "ofdgoRedo"; const restoring = name === "ofdgoUndo" || name === "ofdgoRedo";
if (restoring) canvasEditor.richClipboard = null;
const { scrollLeft, scrollTop } = el.viewerPanel; const { scrollLeft, scrollTop } = el.viewerPanel;
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments"; const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments";
const changingFont = name === "ofdgoChangeFontBinding"; const changingFont = name === "ofdgoChangeFontBinding";
@@ -4755,8 +4774,7 @@ async function changeDocument(name, item, ...args) {
pageCapabilities: doc.pageCapabilities, editWarnings: doc.editWarnings || []}; pageCapabilities: doc.pageCapabilities, editWarnings: doc.editWarnings || []};
tab.state.dirty = state.packageDirty || state.encryptionDirty || doc.revision !== state.savedRevision; tab.state.dirty = state.packageDirty || state.encryptionDirty || doc.revision !== state.savedRevision;
tab.state.pageCache.clear(); tab.state.pageCache.clear();
tab.refreshPending = true; await refreshDocumentTab(tab);
updateWorkspaceDirty();
} }
return; return;
} }
@@ -4789,7 +4807,6 @@ async function changeDocument(name, item, ...args) {
state.documentManagement.key = documentEntryKey(nextDocument); state.documentManagement.key = documentEntryKey(nextDocument);
} }
state.objectClipboardToken = state.styleClipboard = null; state.objectClipboardToken = state.styleClipboard = null;
canvasEditor.richClipboard = null;
state.pageSelection.clear(); state.pageSelection.clear();
state.pageSelectionAnchor = null; state.pageSelectionAnchor = null;
state.pageMultiSelect = false; state.pageMultiSelect = false;
@@ -10813,6 +10830,8 @@ function resetSearch(clearInput = true) {
for (const [id, request] of wasmRequests) if (request.tab === documentTab() && request.name === "ofdgoSearchPage") cancelOperation(id); for (const [id, request] of wasmRequests) if (request.tab === documentTab() && request.name === "ofdgoSearchPage") cancelOperation(id);
state.searchTruncated = false; state.searchTruncated = false;
state.searchRunning = false; state.searchRunning = false;
state.searchNext = 0;
state.searchPaused = false;
state.searchSeq += 1; state.searchSeq += 1;
state.searchQuery = ""; state.searchQuery = "";
state.searchMatches = []; state.searchMatches = [];
@@ -10868,11 +10887,11 @@ async function searchDocument() {
return; return;
} }
const query = el.searchInput.value.trim(); const query = el.searchInput.value.trim();
if (query && query === state.searchQuery) { if (query && query === state.searchQuery && (state.searchRunning || state.searchTruncated || state.searchNext >= state.doc.pageCount)) {
await selectSearchMatch(state.searchIndex + 1); await selectSearchMatch(state.searchIndex + 1);
return; return;
} }
resetSearch(false); if (query !== state.searchQuery) resetSearch(false);
if (!query) { if (!query) {
return; return;
} }
@@ -10882,13 +10901,15 @@ async function searchDocument() {
const pageCount = state.doc.pageCount; const pageCount = state.doc.pageCount;
const target = state; const target = state;
target.searchRunning = true; target.searchRunning = true;
target.searchPaused = false;
try { try {
for (let page = 0; page < pageCount; page += 1) { for (let page = target.searchNext; page < pageCount; page += 1) {
const matches = await callWASM("ofdgoSearchPage", page, query, SEARCH_MATCH_LIMIT - target.searchMatches.length + 1); const matches = await callWASM("ofdgoSearchPage", page, query, SEARCH_MATCH_LIMIT - target.searchMatches.length + 1);
if (target !== state || seq !== target.searchSeq || openSeq !== target.openSeq) { if (target !== state || seq !== target.searchSeq || openSeq !== target.openSeq) {
return; return;
} }
el.searchStatus.textContent = `正在搜索文字 · ${page + 1} / ${pageCount} 页`; el.searchStatus.textContent = `正在搜索文字 · ${page + 1} / ${pageCount} 页`;
target.searchNext = page + 1;
if (!matches?.length) { if (!matches?.length) {
continue; continue;
} }
@@ -10905,7 +10926,6 @@ async function searchDocument() {
} }
} catch (err) { } catch (err) {
if (target === state && seq === target.searchSeq && openSeq === target.openSeq) { if (target === state && seq === target.searchSeq && openSeq === target.openSeq) {
state.searchQuery = "";
el.searchStatus.textContent = "搜索失败"; el.searchStatus.textContent = "搜索失败";
showError(err, false); showError(err, false);
} }
+106 -60
View File
@@ -72,7 +72,7 @@ function sliceRuns(runs, start, end) {
} }
export class RichTextSession { export class RichTextSession {
constructor(editor, model, faces = new Map(), imports = []) { constructor(editor, model, faces = new Map(), imports = [], resources = new Map()) {
this.editor = editor; this.editor = editor;
this.editing = editor.input; this.editing = editor.input;
this.model = structuredClone(model); this.model = structuredClone(model);
@@ -81,11 +81,12 @@ export class RichTextSession {
this.faces = faces; this.faces = faces;
for (const face of faces.values()) document.fonts.add(face); for (const face of faces.values()) document.fonts.add(face);
this.imports = new Map(imports.map(font => [font.key, font.data])); this.imports = new Map(imports.map(font => [font.key, font.data]));
this.resources = resources;
this.choices = new Map(); this.choices = new Map();
if (this.editing.item.fontChoice && imports.length === 1) this.choices.set(imports[0].key, this.editing.item.fontChoice); if (this.editing.item.fontChoice && imports.length === 1) this.choices.set(imports[0].key, this.editing.item.fontChoice);
this.typing = { ...(model.runs[0]?.format || richTextModel(this.editing.item).runs[0].format) }; this.typing = { ...(model.runs[0]?.format || richTextModel(this.editing.item).runs[0].format) };
this.caret = [0, 0]; this.caret = [0, 0];
this.entries = [this.snapshot()]; this.entries = [{ typing: { ...this.typing }, caret: [...this.caret] }];
this.index = 0; this.index = 0;
this.selectionListener = () => { this.selectionListener = () => {
if (editor.input !== this.editing || this.rendering || this.editing.composing) return; if (editor.input !== this.editing || this.rendering || this.editing.composing) return;
@@ -122,15 +123,15 @@ export class RichTextSession {
const runs = sliceRuns(this.model.runs, start, end); const runs = sliceRuns(this.model.runs, start, end);
return Object.fromEntries(FORMAT_KEYS.map(key => [key, runs.every(run => run.format[key] === runs[0]?.format[key]) ? runs[0]?.format[key] : undefined])); return Object.fromEntries(FORMAT_KEYS.map(key => [key, runs.every(run => run.format[key] === runs[0]?.format[key]) ? runs[0]?.format[key] : undefined]));
} }
snapshot() { return { model: structuredClone(this.model), typing: { ...this.typing }, caret: [...this.caret] }; }
remember() { remember() {
this.selection(); this.selection();
this.entries[this.index].caret = [...this.caret]; this.entries[this.index].caret = [...this.caret];
} }
record() { record(change) {
const entry = this.snapshot(), old = this.entries[this.index]; const entry = { change, typing: { ...this.typing }, caret: [...this.caret] }, old = this.entries[this.index];
const changed = JSON.stringify(entry.model) !== JSON.stringify(old.model); const changed = change && JSON.stringify(change.before) !== JSON.stringify(change.after);
if (!changed && JSON.stringify(entry.typing) === JSON.stringify(old.typing)) return; if (!changed && JSON.stringify(entry.typing) === JSON.stringify(old.typing)) return;
if (!changed) entry.change = null;
this.entries.splice(++this.index, Infinity, entry); this.entries.splice(++this.index, Infinity, entry);
if (changed) this.preview(); if (changed) this.preview();
} }
@@ -138,8 +139,13 @@ export class RichTextSession {
if (this.editing.composing || this.editing.saving) return; if (this.editing.composing || this.editing.saving) return;
const entry = this.entries[this.index + direction]; const entry = this.entries[this.index + direction];
if (!entry) return; if (!entry) return;
const change = (direction < 0 ? this.entries[this.index] : entry).change;
if (change) {
const removed = direction < 0 ? change.after : change.before, added = direction < 0 ? change.before : change.after;
const end = change.start + removed.reduce((length, run) => length + run.text.length, 0);
this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, change.start), ...added, ...sliceRuns(this.model.runs, end, this.text.length)]);
}
this.index += direction; this.index += direction;
this.model = structuredClone(entry.model);
this.typing = { ...entry.typing }; this.typing = { ...entry.typing };
this.caret = [...entry.caret]; this.caret = [...entry.caret];
this.render(); this.render();
@@ -168,6 +174,7 @@ export class RichTextSession {
if (this.editing.composing || this.editing.saving) return; if (this.editing.composing || this.editing.saving) return;
this.remember(); this.remember();
const [start, end] = this.selection(); const [start, end] = this.selection();
const before = sliceRuns(this.model.runs, start, end);
this.caret = this.caret[0] > this.caret[1] ? [end, start] : [start, end]; this.caret = this.caret[0] > this.caret[1] ? [end, start] : [start, end];
if (start !== end) this.model.runs = normalizeRuns([ if (start !== end) this.model.runs = normalizeRuns([
...sliceRuns(this.model.runs, 0, start), ...sliceRuns(this.model.runs, 0, start),
@@ -175,18 +182,19 @@ export class RichTextSession {
...sliceRuns(this.model.runs, end, this.text.length), ...sliceRuns(this.model.runs, end, this.text.length),
]); ]);
this.typing = { ...this.typing, ...values }; this.typing = { ...this.typing, ...values };
this.record(); this.record({ start, before, after: sliceRuns(this.model.runs, start, end) });
this.render(); this.render();
this.editor.options.onTextChange(); this.editor.options.onTextChange();
} }
replace(runs) { replace(runs) {
this.remember(); this.remember();
const [start, end] = this.selection(); const [start, end] = this.selection();
const before = sliceRuns(this.model.runs, start, end);
this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, start), ...runs, ...sliceRuns(this.model.runs, end, this.text.length)]); this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, start), ...runs, ...sliceRuns(this.model.runs, end, this.text.length)]);
this.caret = [start + runs.reduce((length, run) => length + run.text.length, 0)]; this.caret = [start + runs.reduce((length, run) => length + run.text.length, 0)];
this.caret.push(this.caret[0]); this.caret.push(this.caret[0]);
if (runs.length) this.typing = { ...runs.at(-1).format }; if (runs.length) this.typing = { ...runs.at(-1).format };
this.record(); this.record({ start, before, after: sliceRuns(this.model.runs, start, this.caret[0]) });
this.render(); this.render();
this.editor.options.onTextChange(); this.editor.options.onTextChange();
} }
@@ -206,11 +214,12 @@ export class RichTextSession {
while (end > start && nextEnd > start && previous[end - 1] === value[nextEnd - 1]) { end--; nextEnd--; } while (end > start && nextEnd > start && previous[end - 1] === value[nextEnd - 1]) { end--; nextEnd--; }
if (end < previous.length && /[\uDC00-\uDFFF]/.test(previous[end])) { end++; nextEnd++; } if (end < previous.length && /[\uDC00-\uDFFF]/.test(previous[end])) { end++; nextEnd++; }
} }
const removed = sliceRuns(this.model.runs, start, end);
this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, start), this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, start),
{ text: value.slice(start, nextEnd), format: { ...this.typing } }, ...sliceRuns(this.model.runs, end, previous.length)]); { text: value.slice(start, nextEnd), format: { ...this.typing } }, ...sliceRuns(this.model.runs, end, previous.length)]);
this.caret = caret; this.caret = caret;
this.beforeCaret = null; this.beforeCaret = null;
this.record(); this.record({ start, before: removed, after: sliceRuns(this.model.runs, start, nextEnd) });
this.render(); this.render();
} }
copy(event, cut = false) { copy(event, cut = false) {
@@ -218,25 +227,41 @@ export class RichTextSession {
const [start, end] = this.selection(); const [start, end] = this.selection();
if (start === end || !event.clipboardData) return; if (start === end || !event.clipboardData) return;
const runs = sliceRuns(this.model.runs, start, end), text = runs.map(run => run.text).join(""); const runs = sliceRuns(this.model.runs, start, end), text = runs.map(run => run.text).join("");
this.editor.richClipboard = { token: crypto.randomUUID(), runs, text, scope: this.editor.options.textClipboardScope?.(), const keys = new Set(runs.map(run => run.format.font));
fonts: new Map(this.imports), faces: new Map(this.faces), choices: new Map(this.choices) }; const fonts = new Map([...keys].map(key => [key, this.imports.get(key) || this.resources.get(key)]).filter(([, data]) => data));
const clipboard = { token: crypto.randomUUID(), runs, text, scope: this.editor.options.textClipboardScope?.(),
fonts, faces: new Map([...this.faces].filter(([key]) => keys.has(key))), choices: new Map([...this.choices].filter(([key]) => keys.has(key))) };
if (this.editor.options.setTextClipboard) this.editor.options.setTextClipboard(clipboard);
else this.editor.richClipboard = clipboard;
event.preventDefault(); event.preventDefault();
event.clipboardData.setData("text/plain", text); event.clipboardData.setData("text/plain", text);
event.clipboardData.setData(RICH_CLIPBOARD, this.editor.richClipboard.token); event.clipboardData.setData(RICH_CLIPBOARD, clipboard.token);
if (cut) this.replace([]); if (cut) this.replace([]);
} }
paste(event) { async paste(event) {
event.preventDefault(); event.preventDefault();
if (this.editing.composing || this.editing.saving) return; if (this.editing.composing || this.editing.saving) return;
const text = event.clipboardData.getData("text/plain").replace(/\r\n?/g, "\n"); const text = event.clipboardData.getData("text/plain").replace(/\r\n?/g, "\n");
const copy = this.editor.richClipboard; const copy = this.editor.options.getTextClipboard ? this.editor.options.getTextClipboard() : this.editor.richClipboard;
const internal = copy && copy.scope === this.editor.options.textClipboardScope?.() && event.clipboardData.getData(RICH_CLIPBOARD) === copy.token && text === copy.text; const sameScope = copy?.scope === this.editor.options.textClipboardScope?.();
const internal = copy && (sameScope || copy.runs.every(run => copy.fonts.has(run.format.font)))
&& event.clipboardData.getData(RICH_CLIPBOARD) === copy.token && text === copy.text;
let runs;
if (internal) { if (internal) {
for (const [key, data] of copy.fonts) this.imports.set(key, data); const keys = new Map(copy.runs.map(run => [run.format.font, sameScope ? run.format.font : `temp-font-${crypto.randomUUID()}`]));
for (const [key, face] of copy.faces) { this.faces.set(key, face); document.fonts.add(face); } let faces = copy.faces;
this.choices = new Map([...copy.choices, ...(this.choices || [])]); if (!sameScope) {
const original = this.model.runs, caret = this.selection();
faces = new Map(await Promise.all([...copy.fonts].map(async ([key, data]) =>
[key, await new FontFace(`ofdgo-paste-${keys.get(key)}`, data, fontFaceStyle(data)).load()])));
if (this.editor.input !== this.editing || this.editing.composing || this.editing.saving || this.model.runs !== original || this.selection().join() !== caret.join()) return;
}
for (const [key, data] of copy.fonts) this.imports.set(keys.get(key), data);
for (const [key, face] of faces) { this.faces.set(keys.get(key), face); document.fonts.add(face); }
for (const [key, choice] of copy.choices) this.choices.set(keys.get(key), choice);
runs = copy.runs.map(run => ({ text: run.text, format: { ...run.format, font: keys.get(run.format.font) } }));
} }
this.replace(internal ? structuredClone(copy.runs) : [{ text, format: { ...this.typing } }]); this.replace(runs || [{ text, format: { ...this.typing } }]);
} }
setFont(face, data, key) { setFont(face, data, key) {
key ||= `temp-font-${crypto.randomUUID()}`; key ||= `temp-font-${crypto.randomUUID()}`;
@@ -247,35 +272,49 @@ export class RichTextSession {
} }
render() { render() {
this.rendering = true; this.rendering = true;
const input = this.editing.input, fragment = document.createDocumentFragment(), positions = []; const input = this.editing.input, lines = [[]], positions = [];
let paragraph = document.createElement("div"), offset = 0;
fragment.append(paragraph);
for (const run of this.model.runs) { for (const run of this.model.runs) {
const pieces = run.text.split("\n"), format = run.format; const pieces = run.text.split("\n");
for (let i = 0; i < pieces.length; i++) { for (let i = 0; i < pieces.length; i++) {
if (i) { paragraph = document.createElement("div"); fragment.append(paragraph); offset++; } if (i) lines.push([]);
const span = document.createElement("span"), node = document.createTextNode(pieces[i]); lines.at(-1).push({ text: pieces[i], format: run.format });
const face = this.faces.get(format.font) || this.editing.face;
Object.assign(span.style, { fontFamily: `"${face.family}"`, fontSize: `${format.size * PX_PER_MM}px`, color: format.color,
fontWeight: format.bold ? "700" : face.weight || "400", fontStyle: format.italic ? "italic" : face.style || "normal",
textDecoration: [format.underline && "underline", format.strikeout && "line-through"].filter(Boolean).join(" ") || "none",
textShadow: format.shadow ? "0.06em 0.06em 0 #808080" : "none", backgroundColor: format.background || "transparent" });
span.append(node); paragraph.append(span);
positions.push({ node, start: offset, end: offset + pieces[i].length });
offset += pieces[i].length;
} }
} }
if (!positions.length) { if (!lines[0].length) lines[0].push({ text: "", format: this.typing });
const node = document.createTextNode(""); paragraph.append(node); const previous = this.renderedLines || [];
positions.push({ node, start: 0, end: 0 }); let offset = 0;
} this.renderedLines = lines.map((parts, index) => {
const key = JSON.stringify(parts.map(part => [part.text, part.format, this.faces.get(part.format.font)?.family]));
let paragraph = previous[index]?.key === key && previous[index].node.parentNode === input ? previous[index].node : null;
if (!paragraph) {
paragraph = document.createElement("div");
for (const {text, format} of parts) {
const span = document.createElement("span"), face = this.faces.get(format.font) || this.editing.face;
Object.assign(span.style, { fontFamily: `"${face.family}"`, fontSize: `${format.size * PX_PER_MM}px`, color: format.color,
fontWeight: format.bold ? "700" : face.weight || "400", fontStyle: format.italic ? "italic" : face.style || "normal",
textDecoration: [format.underline && "underline", format.strikeout && "line-through"].filter(Boolean).join(" ") || "none",
textShadow: format.shadow ? "0.06em 0.06em 0 #808080" : "none", backgroundColor: format.background || "transparent" });
span.append(document.createTextNode(text));
paragraph.append(span);
}
if (!paragraph.textContent) {
paragraph.style.fontSize = paragraph.firstElementChild.style.fontSize;
paragraph.append(document.createElement("br"));
}
}
if (input.childNodes[index] !== paragraph) input.insertBefore(paragraph, input.childNodes[index] || null);
if (index) offset++;
for (const span of paragraph.children) {
if (span.nodeName !== "SPAN") continue;
const node = span.firstChild;
positions.push({ node, start: offset, end: offset + node.textContent.length });
offset += node.textContent.length;
}
return { key, node: paragraph };
});
while (input.childNodes.length > lines.length) input.lastChild.remove();
input.style.fontSize = this.model.runs.length ? "0px" : `${this.typing.size * PX_PER_MM}px`; input.style.fontSize = this.model.runs.length ? "0px" : `${this.typing.size * PX_PER_MM}px`;
input.style.caretColor = this.typing.color || "#000000"; input.style.caretColor = this.typing.color || "#000000";
for (const line of fragment.children) if (!line.textContent) {
line.style.fontSize = line.querySelector("span")?.style.fontSize || `${this.typing.size * PX_PER_MM}px`;
line.append(document.createElement("br"));
}
input.replaceChildren(fragment);
this.editor.fitTextInput(); this.editor.fitTextInput();
this.editing.range = null; this.editing.range = null;
input.focus({ preventScroll: true }); input.focus({ preventScroll: true });
@@ -1082,6 +1121,7 @@ export class CanvasEditor {
} }
if (drag.marquee) { if (drag.marquee) {
this.moveMarquee(event, drag); this.moveMarquee(event, drag);
this.autoScroll(drag);
return; 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) {
@@ -1140,12 +1180,12 @@ export class CanvasEditor {
scroll() { scroll() {
const drag = this.drag; const drag = this.drag;
if (!drag?.item || drag.corner || !drag.pointer) { if (!drag || !drag.item && !drag.marquee || drag.corner || !drag.pointer) {
this.cancel(); this.cancel();
return; return;
} }
drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation); drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item?.page || drag.page, drag.rotation);
drag.rect = drag.item.surface.getBoundingClientRect(); drag.rect = (drag.item?.surface || drag.surface).getBoundingClientRect();
this.move(drag.pointer); this.move(drag.pointer);
} }
@@ -1161,8 +1201,10 @@ export class CanvasEditor {
const elapsed = Math.min(32, time - (drag.scrollTime ?? time - 16)); const elapsed = Math.min(32, time - (drag.scrollTime ?? time - 16));
drag.scrollTime = time; drag.scrollTime = time;
const left = viewer.scrollLeft, top = viewer.scrollTop; const left = viewer.scrollLeft, top = viewer.scrollTop;
viewer.scrollLeft += speed(pointer.clientX, rect.left, viewer.clientWidth) * elapsed; const x = speed(pointer.clientX, rect.left, viewer.clientWidth) * elapsed;
viewer.scrollTop += speed(pointer.clientY, rect.top, viewer.clientHeight) * elapsed; const y = speed(pointer.clientY, rect.top, viewer.clientHeight) * elapsed;
if (this.options.scrollBy) this.options.scrollBy(x, y);
else { viewer.scrollLeft += x; viewer.scrollTop += y; }
if (viewer.scrollLeft !== left || viewer.scrollTop !== top) this.scroll(); if (viewer.scrollLeft !== left || viewer.scrollTop !== top) this.scroll();
else drag.scrollTime = null; else drag.scrollTime = null;
}); });
@@ -1393,7 +1435,7 @@ export class CanvasEditor {
} }
moveMarquee(event, drag) { moveMarquee(event, drag) {
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation); const from = drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation);
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation); const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation);
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) }; 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); paintShape(drag.node, "rectangle", box);
@@ -1622,7 +1664,7 @@ export class CanvasEditor {
}); });
this.input = { input, item, face, faces, frame, source: Boolean(source), fontChoice: item.fontChoice }; this.input = { input, item, face, faces, frame, source: Boolean(source), fontChoice: item.fontChoice };
const editing = this.input; const editing = this.input;
if (rich) editing.rich = new RichTextSession(this, rich.model, rich.faces, rich.fonts); if (rich) editing.rich = new RichTextSession(this, rich.model, rich.faces, rich.fonts, rich.resources);
if (source) { if (source) {
editing.selectionListener = () => this.paintSourceSelection(); editing.selectionListener = () => this.paintSourceSelection();
document.addEventListener("selectionchange", editing.selectionListener); document.addEventListener("selectionchange", editing.selectionListener);
@@ -1662,7 +1704,10 @@ export class CanvasEditor {
}); });
}); });
input.addEventListener("paste", event => { input.addEventListener("paste", event => {
if (editing.rich) { editing.rich.paste(event); return; } if (editing.rich) {
editing.rich.paste(event).catch(error => { if (this.input === editing) this.setTextError(error); });
return;
}
event.preventDefault(); event.preventDefault();
document.execCommand("insertText", false, event.clipboardData.getData("text/plain")); document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
}); });
@@ -1773,8 +1818,7 @@ export class CanvasEditor {
if (!editing || editing.rich || editing.composing || editing.saving) return false; if (!editing || editing.rich || editing.composing || editing.saving) return false;
const caret = editTextSelection(editing.input, editing.range) || [0, 0]; const caret = editTextSelection(editing.input, editing.range) || [0, 0];
const value = editTextValue(editing.input); const value = editTextValue(editing.input);
const session = new RichTextSession(this, rich.model, rich.faces, rich.fonts); const session = new RichTextSession(this, rich.model, rich.faces, rich.fonts, rich.resources);
const original = session.snapshot();
const format = { ...session.typing, ...editing.style }; const format = { ...session.typing, ...editing.style };
if (editing.fontData) { if (editing.fontData) {
const key = `temp-font-${crypto.randomUUID()}`; const key = `temp-font-${crypto.randomUUID()}`;
@@ -1785,15 +1829,17 @@ export class CanvasEditor {
} }
const history = editing.history; const history = editing.history;
const values = history ? history.entries.slice(0, history.index + 1) : [{ value: editing.item.text }, { value }]; const values = history ? history.entries.slice(0, history.index + 1) : [{ value: editing.item.text }, { value }];
const entries = [original];
for (const entry of values) { for (const entry of values) {
const snapshot = { model: { ...structuredClone(rich.model), runs: normalizeRuns([{ text: entry.value, format }]) }, const before = session.model.runs;
typing: { ...format }, caret: entry.caret || caret }; session.model.runs = normalizeRuns([{ text: entry.value, format }]);
if (JSON.stringify(snapshot.model) !== JSON.stringify(entries.at(-1).model)) entries.push(snapshot); session.typing = { ...format };
session.caret = entry.caret || caret;
session.record({ start: 0, before, after: session.model.runs });
} }
const current = { model: { ...structuredClone(rich.model), runs: normalizeRuns([{ text: value, format }]) }, typing: { ...format }, caret }; const before = session.model.runs;
if (JSON.stringify(current.model) !== JSON.stringify(entries.at(-1).model)) entries.push(current); session.model.runs = normalizeRuns([{ text: value, format }]);
Object.assign(session, { entries, index: entries.length - 1, model: current.model, typing: format, caret }); session.caret = caret;
session.record({ start: 0, before, after: session.model.runs });
editing.rich = session; editing.rich = session;
editing.source = false; editing.source = false;
document.removeEventListener("selectionchange", editing.selectionListener); document.removeEventListener("selectionchange", editing.selectionListener);
+35 -19
View File
@@ -5018,6 +5018,32 @@ func (e *businessEngine) getRichText(args []platformValue) (any, error) {
return e.browserRichText(value), nil return e.browserRichText(value), nil
} }
// richTextFonts 提供跨文档文字复制所需的独立字体数据
// 入参: args 富文本JSON
// 返回: any 字体集合, error 读取错误
func (e *businessEngine) richTextFonts(args []platformValue) (any, error) {
if e.currentEditor == nil {
return nil, fmt.Errorf("no document is being edited")
}
value, err := e.readRichText(args[0])
if err != nil {
return nil, err
}
fonts, err := e.currentEditor.RichTextFonts(value)
if err != nil {
return nil, err
}
result := make([]any, 0, len(fonts))
for key, data := range fonts {
_, data, _, _, err = ofdgo.FixFontDataAggressive(data, false, false)
if err != nil {
return nil, err
}
result = append(result, map[string]any{"key": key, "data": bytesToJS(data)})
}
return successResult(result), nil
}
// browserRichText 提供独立富文本快照和浏览器颜色,不改变库层样式 // browserRichText 提供独立富文本快照和浏览器颜色,不改变库层样式
// 入参: value 库层富文本 // 入参: value 库层富文本
// 返回: map[string]any 文字分段和段落选项 // 返回: map[string]any 文字分段和段落选项
@@ -5159,35 +5185,24 @@ func (e *businessEngine) importRichTextFonts(value *ofdgo.RichText, fonts platfo
if fonts.IsNull() || fonts.IsUndefined() { if fonts.IsNull() || fonts.IsUndefined() {
return nil return nil
} }
registered := make(map[string]string) data := make(map[string][]byte)
for i := 0; i < fonts.Length(); i++ { for i := 0; i < fonts.Length(); i++ {
entry := fonts.Index(i) entry := fonts.Index(i)
key := entry.Get("key").String() key := entry.Get("key").String()
if key == "" || registered[key] != "" { if key == "" || data[key] != nil {
return fmt.Errorf("invalid rich text font key") return fmt.Errorf("invalid rich text font key")
} }
used := false bytes, err := e.bytesFromJS(entry.Get("data"))
for _, run := range value.Runs {
used = used || run.Format.Font == key
}
if !used {
continue
}
data, err := e.bytesFromJS(entry.Get("data"))
if err != nil { if err != nil {
return err return err
} }
font, err := e.currentEditor.AddFont(FontFile{Data: data}, 0) data[key] = bytes
if err != nil {
return err
}
registered[key] = font
} }
for i := range value.Runs { imported, err := e.currentEditor.ImportRichText(*value, data)
if font := registered[value.Runs[i].Format.Font]; font != "" { if err != nil {
value.Runs[i].Format.Font = font return err
}
} }
*value = imported
return nil return nil
} }
@@ -7992,6 +8007,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
"ofdgoStyleText": e.styleText, "ofdgoStyleText": e.styleText,
"ofdgoFormatText": e.formatText, "ofdgoFormatText": e.formatText,
"ofdgoGetRichText": e.getRichText, "ofdgoGetRichText": e.getRichText,
"ofdgoRichTextFonts": e.richTextFonts,
"ofdgoSaveRichText": e.saveRichText, "ofdgoSaveRichText": e.saveRichText,
"ofdgoPreviewRichText": e.previewRichText, "ofdgoPreviewRichText": e.previewRichText,
"ofdgoCheckTextFont": e.checkTextFont, "ofdgoCheckTextFont": e.checkTextFont,
+55
View File
@@ -17,6 +17,8 @@ package ofdgo
import ( import (
"errors" "errors"
"fmt" "fmt"
"maps"
"slices"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -64,6 +66,59 @@ type TextFormatPatch struct {
Background *string `json:"background,omitempty"` Background *string `json:"background,omitempty"`
} }
// RichTextFonts 提取富文本实际引用的独立字体数据,供跨文档保存和复制使用
// 入参: value 富文本内容
// 返回: map[string][]byte 字体标识及数据, error 字体读取错误
func (e *Editor) RichTextFonts(value RichText) (map[string][]byte, error) {
fonts := make(map[string][]byte)
for _, run := range value.Runs {
if run.Text == "" || fonts[run.Format.Font] != nil {
continue
}
data, err := e.FontData(run.Format.Font)
if err != nil {
return nil, err
}
fonts[run.Format.Font] = data
}
return fonts, nil
}
// ImportRichText 注册富文本引用的外来字体并返回独立内容,失败时不保留新增资源
// 未提供数据的字体标识保留为当前文档引用,调用方可在外层事务中同时创建或更新对象
// 入参: value 富文本内容, fonts 外来字体标识及数据
// 返回: RichText 已映射字体标识的内容, error 字体注册错误
func (e *Editor) ImportRichText(value RichText, fonts map[string][]byte) (RichText, error) {
value = cloneEditorData(value)
used := make(map[string]bool)
for _, run := range value.Runs {
used[run.Format.Font] = true
}
err := e.Transaction(func(editor *Editor) error {
registered := make(map[string]string)
for _, key := range slices.Sorted(maps.Keys(fonts)) {
if !used[key] {
continue
}
id, err := editor.AddFont(FontFile{Data: fonts[key]}, 0)
if err != nil {
return err
}
registered[key] = id
}
for i := range value.Runs {
if id := registered[value.Runs[i].Format.Font]; id != "" {
value.Runs[i].Format.Font = id
}
}
return nil
})
if err != nil {
return RichText{}, err
}
return value, nil
}
// Text 返回包含显式换行的原文,不包含排版产生的软换行 // Text 返回包含显式换行的原文,不包含排版产生的软换行
// 返回: string 完整原文 // 返回: string 完整原文
func (v RichText) Text() string { func (v RichText) Text() string {