mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
12158 lines
537 KiB
JavaScript
12158 lines
537 KiB
JavaScript
import { SVG_NAMESPACE, CanvasEditor, canEditObject, editTextValue, richTextModel, fontFaceStyle, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js";
|
||
import { FontManager, FontPicker } from "./ofdgo_font.js";
|
||
import { nativeHost, createEngineWorker, documentBytes, saveFilePicker, directoryPicker } from "./ofdgo_host.js";
|
||
|
||
const MM_TO_PX = 96 / 25.4;
|
||
const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)");
|
||
const DEFAULT_IMAGE_DPI = 300;
|
||
const PAGE_CACHE_LIMIT = 16;
|
||
const PAGE_CACHE_BYTES = 32 * 1024 * 1024;
|
||
const SCROLL_SEGMENT_SIZE = 1000000;
|
||
const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects";
|
||
const STATUS = {
|
||
ready: "选择 OFD、PDF 文件",
|
||
opening: "正在打开文档",
|
||
engine: "正在准备引擎",
|
||
recovering: "正在恢复引擎",
|
||
fonts: "正在匹配字体",
|
||
exporting: "正在导出文档",
|
||
pageExporting: "正在导出单页",
|
||
};
|
||
const wasmRequests = new Map();
|
||
const metaContents = new WeakMap();
|
||
const batch = { items: [], formats: [], running: false, canceled: false, worker: null, requests: new Map(), sequence: 0, activeID: 0, progressTime: 0, progress: null };
|
||
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Compression", "FontRow", "FontMode", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
|
||
const fontBinding = { font: null, choices: [], sequence: 0 };
|
||
const fontBindingElements = Object.fromEntries(["Panel", "Form", "Title", "Name", "SourceRow", "Source", "File", "Hint", "Status", "Cancel", "Submit"].map(name => [name, document.querySelector(`#fontBinding${name}`)]));
|
||
const resourceElements = Object.fromEntries(["ManageButton", "Panel", "Form", "Kind", "List", "Empty", "Reference", "ReferenceGroup", "Actions", "Add", "Preview", "Export", "Rename", "Replace", "Delete", "Locate", "Close", "Binding", "BindingActions", "Page", "Object", "Pick", "Bind", "Unbind", "Viewer", "Status", "File"].map(name => [name, document.querySelector(`#resource${name}`)]));
|
||
const resourceManager = { items: [], selected: null, references: [], referenceOffset: 0, targets: [], pick: null, busy: false, sequence: 0, url: null, fileAction: null };
|
||
const settingsElements = Object.fromEntries(["Button", "Panel", "Form", "Title", "Kind", "Context", "Content", "Actions", "Status"].map(name => [name, document.querySelector(`#settings${name}`)]));
|
||
const settings = { sequence: 0, busy: false, page: 0, scope: "document", object: "", path: [], selected: -1, data: null, actions: [], drafts: new Map(), view: "", baseline: {}, suspended: false, invoice: false };
|
||
const invoicePanel = { tab: "basic", entries: [], formats: [], drafts: new Map(), key: "", result: null };
|
||
const invoiceDocuments = new WeakMap();
|
||
const invoiceReferenceStandard = "http://www.ofdspec.org/2016";
|
||
|
||
let wasmPromise = null;
|
||
let remoteDownload = null;
|
||
let wasmWorker = null;
|
||
let wasmRequestID = 0;
|
||
let wasmRecoveryTimer = 0;
|
||
let textMeasure = null;
|
||
let credentialRequest = null;
|
||
let cancelPageTouch = null;
|
||
|
||
const state = {
|
||
conversionWarnings: [],
|
||
composite: null,
|
||
annotationEdit: null,
|
||
annotationCreate: null,
|
||
ready: false,
|
||
wasmExited: false,
|
||
wasmSeq: 0,
|
||
wasmRecovering: false,
|
||
wasmRecoveries: 0,
|
||
exporting: false,
|
||
signing: false,
|
||
signCanceled: false,
|
||
exportRequestID: 0,
|
||
editing: false,
|
||
selectObjects: true,
|
||
dirty: false,
|
||
editorInfo: null,
|
||
editorViews: new Map(),
|
||
savedRevision: null,
|
||
packageDirty: false,
|
||
encryptionDirty: false,
|
||
insertObject: null,
|
||
importPageCount: 0,
|
||
importDocumentCount: 0,
|
||
importDocumentMode: false,
|
||
importEncrypted: false,
|
||
importing: false,
|
||
imageImporting: false,
|
||
createInsert: false,
|
||
createPackage: false,
|
||
createDocumentAt: null,
|
||
documentManagement: null,
|
||
versionManagement: null,
|
||
infoDocumentIndex: null,
|
||
pageSelection: new Set(),
|
||
pageSelectionAnchor: null,
|
||
pageMultiSelect: false,
|
||
pageTouchPointer: null,
|
||
outlineSelection: null,
|
||
outlineSeq: 0,
|
||
openActionEnabled: false,
|
||
openActionDocument: false,
|
||
openActionPage: null,
|
||
openActionTask: null,
|
||
openActionFrame: 0,
|
||
outlineAction: "add",
|
||
styleOriginal: null,
|
||
objectClipboard: null,
|
||
styleClipboard: null,
|
||
outlineExpanded: new Map(),
|
||
textFonts: [],
|
||
textFontID: null,
|
||
textDefaults: { type: "TextObject", size: 16 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 },
|
||
ofdBytes: null,
|
||
fileName: "ofdgo.ofd",
|
||
openSeq: 0,
|
||
pageSeq: 0,
|
||
searchSeq: 0,
|
||
searchQuery: "",
|
||
searchMatches: [],
|
||
searchIndex: -1,
|
||
navigationScroll: new Map(),
|
||
fontSyncPending: false,
|
||
fontCatalogLoading: false,
|
||
fontFacesLoading: false,
|
||
fontRenderPending: false,
|
||
fontSyncing: false,
|
||
doc: null,
|
||
pageIndex: 0,
|
||
scale: 1,
|
||
rotation: 0,
|
||
panMode: false,
|
||
pan: null,
|
||
fitMode: "width",
|
||
continuous: false,
|
||
renderAnnotations: true,
|
||
renderBackend: "canvas",
|
||
renderMode: "svg",
|
||
renderDPI: DEFAULT_IMAGE_DPI,
|
||
pageCache: new Map(),
|
||
mediaPlayers: new Map(),
|
||
svgFonts: new Map(),
|
||
svgImages: new Map(),
|
||
selectedPages: new Set(),
|
||
documentSelection: null,
|
||
pageInFlight: new Map(),
|
||
pageRenderQueue: [],
|
||
pageRenderRunning: false,
|
||
pageObserver: null,
|
||
visiblePages: new Set(),
|
||
scrollFrame: 0,
|
||
pageWindow: null,
|
||
thumbnailWindow: null,
|
||
thumbnailFrame: 0,
|
||
pageDragIndex: null,
|
||
pageListCurrent: null,
|
||
thumbnailInFlight: new Set(),
|
||
thumbnailObserver: null,
|
||
visibleThumbnails: new Set(),
|
||
exportFormats: [],
|
||
exportPages: null,
|
||
exportBackdrop: false,
|
||
encryptionChanged: false,
|
||
showPages: !COMPACT_LAYOUT.matches,
|
||
showMeta: !COMPACT_LAYOUT.matches,
|
||
};
|
||
|
||
const el = {
|
||
signButton: document.querySelector("#signButton"),
|
||
signPanel: document.querySelector("#signPanel"),
|
||
signForm: document.querySelector("#signForm"),
|
||
signCertificate: document.querySelector("#signCertificate"),
|
||
signKey: document.querySelector("#signKey"),
|
||
signKeyPassword: document.querySelector("#signKeyPassword"),
|
||
signRoots: document.querySelector("#signRoots"),
|
||
signIntermediates: document.querySelector("#signIntermediates"),
|
||
signSeal: document.querySelector("#signSeal"),
|
||
signPlacementFields: document.querySelector("#signPlacementFields"),
|
||
signPlacement: document.querySelector("#signPlacement"),
|
||
signPages: document.querySelector("#signPages"),
|
||
signX: document.querySelector("#signX"),
|
||
signY: document.querySelector("#signY"),
|
||
signWidth: document.querySelector("#signWidth"),
|
||
signHeight: document.querySelector("#signHeight"),
|
||
signMode: document.querySelector("#signMode"),
|
||
signLock: document.querySelector("#signLock"),
|
||
signStatus: document.querySelector("#signStatus"),
|
||
signCancel: document.querySelector("#signCancel"),
|
||
verifyButton: document.querySelector("#verifyButton"),
|
||
verifyPanel: document.querySelector("#verifyPanel"),
|
||
verifyForm: document.querySelector("#verifyForm"),
|
||
verifyRoots: document.querySelector("#verifyRoots"),
|
||
verifyCerts: document.querySelector("#verifyCerts"),
|
||
verifyTimeRoots: document.querySelector("#verifyTimeRoots"),
|
||
verifyTokens: document.querySelector("#verifyTokens"),
|
||
verifyCRLs: document.querySelector("#verifyCRLs"),
|
||
verifyOCSP: document.querySelector("#verifyOCSP"),
|
||
verifyRequireTime: document.querySelector("#verifyRequireTime"),
|
||
verifyRequireRevocation: document.querySelector("#verifyRequireRevocation"),
|
||
verifyStatus: document.querySelector("#verifyStatus"),
|
||
verifyCancel: document.querySelector("#verifyCancel"),
|
||
securityPanel: document.querySelector("#securityPanel"),
|
||
securityEncryption: document.querySelector("#securityEncryption"),
|
||
securityMethod: document.querySelector("#securityMethod"),
|
||
securityMethodRow: document.querySelector("#securityMethodRow"),
|
||
securityScheme: document.querySelector("#securityScheme"),
|
||
securitySchemeRow: document.querySelector("#securitySchemeRow"),
|
||
encryptSaveButton: document.querySelector("#encryptSaveButton"),
|
||
credentialsPanel: document.querySelector("#credentialsPanel"),
|
||
credentialsType: document.querySelector("#credentialsType"),
|
||
credentialsTypeRow: document.querySelector("#credentialsTypeRow"),
|
||
credentialsUserRow: document.querySelector("#credentialsUserRow"),
|
||
credentialsPasswordRow: document.querySelector("#credentialsPasswordRow"),
|
||
credentialsCertificateRow: document.querySelector("#credentialsCertificateRow"),
|
||
credentialsKeyRow: document.querySelector("#credentialsKeyRow"),
|
||
credentialsCertificate: document.querySelector("#credentialsCertificate"),
|
||
credentialsKey: document.querySelector("#credentialsKey"),
|
||
credentialsKeyPassword: document.querySelector("#credentialsKeyPassword"),
|
||
credentialsKeyPasswordRow: document.querySelector("#credentialsKeyPasswordRow"),
|
||
credentialsForm: document.querySelector("#credentialsForm"),
|
||
credentialsUser: document.querySelector("#credentialsUser"),
|
||
credentialsPassword: document.querySelector("#credentialsPassword"),
|
||
credentialsStatus: document.querySelector("#credentialsStatus"),
|
||
credentialsCancel: document.querySelector("#credentialsCancel"),
|
||
encryptionPanel: document.querySelector("#encryptionPanel"),
|
||
encryptionForm: document.querySelector("#encryptionForm"),
|
||
encryptionKeep: document.querySelector("#encryptionKeep"),
|
||
encryptionCurrentRow: document.querySelector("#encryptionCurrentRow"),
|
||
encryptionCurrent: document.querySelector("#encryptionCurrent"),
|
||
encryptionChange: document.querySelector("#encryptionChange"),
|
||
encryptionCancel: document.querySelector("#encryptionCancel"),
|
||
encryptionSubmit: document.querySelector("#encryptionSubmit"),
|
||
encryptionStatus: document.querySelector("#encryptionStatus"),
|
||
encryptionType: document.querySelector("#encryptionType"),
|
||
encryptionUserRow: document.querySelector("#encryptionUserRow"),
|
||
encryptionPasswordRow: document.querySelector("#encryptionPasswordRow"),
|
||
encryptionConfirmRow: document.querySelector("#encryptionConfirmRow"),
|
||
encryptionCertificatesRow: document.querySelector("#encryptionCertificatesRow"),
|
||
encryptionCertificates: document.querySelector("#encryptionCertificates"),
|
||
encryptionUser: document.querySelector("#encryptionUser"),
|
||
encryptionPassword: document.querySelector("#encryptionPassword"),
|
||
encryptionConfirm: document.querySelector("#encryptionConfirm"),
|
||
ofdInput: document.querySelector("#ofdInput"),
|
||
ofdButton: document.querySelector("#ofdButton"),
|
||
newButton: document.querySelector("#newButton"),
|
||
editButton: document.querySelector("#editButton"),
|
||
editNotice: document.querySelector("#editNotice"),
|
||
editorTools: document.querySelector("#editorTools"),
|
||
selectObjectButton: document.querySelector("#selectObjectButton"),
|
||
deleteObjectButton: document.querySelector("#deleteObjectButton"),
|
||
editObjectButton: document.querySelector("#editObjectButton"),
|
||
compositeBackButton: document.querySelector("#compositeBackButton"),
|
||
copyObjectButton: document.querySelector("#copyObjectButton"),
|
||
objectOrder: document.querySelector("#objectOrder"),
|
||
objectAlign: document.querySelector("#objectAlign"),
|
||
objectDistribute: document.querySelector("#objectDistribute"),
|
||
objectRotate: document.querySelector("#objectRotate"),
|
||
objectFlip: document.querySelector("#objectFlip"),
|
||
imageFit: document.querySelector("#imageFit"),
|
||
cropImageButton: document.querySelector("#cropImageButton"),
|
||
saveImageButton: document.querySelector("#saveImageButton"),
|
||
resetCropButton: document.querySelector("#resetCropButton"),
|
||
multiSelectButton: document.querySelector("#multiSelectButton"),
|
||
textAlign: document.querySelector("#textAlign"),
|
||
textWrap: document.querySelector("#textWrap"),
|
||
textLineHeight: document.querySelector("#textLineHeight"),
|
||
textSpacing: document.querySelector("#textSpacing"),
|
||
textFont: document.querySelector("#textFont"),
|
||
textFontToggle: document.querySelector("#textFontToggle"),
|
||
textFontList: document.querySelector("#textFontList"),
|
||
textFontAdd: document.querySelector("#textFontAdd"),
|
||
paragraphButton: document.querySelector("#paragraphButton"),
|
||
infoButton: document.querySelector("#infoButton"),
|
||
documentSelect: document.querySelector("#documentSelect"),
|
||
documentActions: document.querySelector("#documentActions"),
|
||
documentManageButton: document.querySelector("#documentManageButton"),
|
||
invoiceButton: document.querySelector("#invoiceButton"),
|
||
documentPanel: document.querySelector("#documentPanel"),
|
||
documentForm: document.querySelector("#documentForm"),
|
||
documentList: document.querySelector("#documentList"),
|
||
documentStatus: document.querySelector("#documentStatus"),
|
||
documentClose: document.querySelector("#documentClose"),
|
||
documentRow: document.querySelector("#documentRow"),
|
||
documentAddButton: document.querySelector("#documentAddButton"),
|
||
documentImportButton: document.querySelector("#documentImportButton"),
|
||
documentDeleteButton: document.querySelector("#documentDeleteButton"),
|
||
documentUpButton: document.querySelector("#documentUpButton"),
|
||
documentDownButton: document.querySelector("#documentDownButton"),
|
||
versionManageButton: document.querySelector("#versionManageButton"),
|
||
versionTitle: document.querySelector("#versionTitle"),
|
||
versionNameRow: document.querySelector("#versionNameRow"),
|
||
versionActions: document.querySelector("#versionActions"),
|
||
versionPanel: document.querySelector("#versionPanel"),
|
||
versionForm: document.querySelector("#versionForm"),
|
||
versionNotice: document.querySelector("#versionNotice"),
|
||
versionList: document.querySelector("#versionList"),
|
||
versionName: document.querySelector("#versionName"),
|
||
versionStatus: document.querySelector("#versionStatus"),
|
||
versionAdd: document.querySelector("#versionAdd"),
|
||
versionRename: document.querySelector("#versionRename"),
|
||
versionDefault: document.querySelector("#versionDefault"),
|
||
versionDelete: document.querySelector("#versionDelete"),
|
||
versionSelect: document.querySelector("#versionSelect"),
|
||
versionExport: document.querySelector("#versionExport"),
|
||
versionClose: document.querySelector("#versionClose"),
|
||
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"),
|
||
infoStatus: document.querySelector("#infoStatus"),
|
||
paragraphPanel: document.querySelector("#paragraphPanel"),
|
||
paragraphForm: document.querySelector("#paragraphForm"),
|
||
paragraphCancel: document.querySelector("#paragraphCancel"),
|
||
paragraphStatus: document.querySelector("#paragraphStatus"),
|
||
leftIndent: document.querySelector("#leftIndent"),
|
||
rightIndent: document.querySelector("#rightIndent"),
|
||
firstLineIndent: document.querySelector("#firstLineIndent"),
|
||
importPanel: document.querySelector("#importPanel"),
|
||
importForm: document.querySelector("#importForm"),
|
||
importFile: document.querySelector("#importFile"),
|
||
importRange: document.querySelector("#importRange"),
|
||
importPagesRow: document.querySelector("#importPagesRow"),
|
||
importPages: document.querySelector("#importPages"),
|
||
importPagesLabel: document.querySelector("#importPagesLabel"),
|
||
importDocuments: document.querySelector("#importDocuments"),
|
||
importPosition: document.querySelector("#importPosition"),
|
||
importPositionRow: document.querySelector("#importPositionRow"),
|
||
importOutlinesRow: document.querySelector("#importOutlinesRow"),
|
||
importOutlines: document.querySelector("#importOutlines"),
|
||
importStatus: document.querySelector("#importStatus"),
|
||
importCancel: document.querySelector("#importCancel"),
|
||
importSubmit: document.querySelector("#importSubmit"),
|
||
textSize: document.querySelector("#textSize"),
|
||
textColor: document.querySelector("#textColor"),
|
||
drawLineButton: document.querySelector("#drawLineButton"),
|
||
arrowTool: document.querySelector("#arrowTool"),
|
||
drawRectangleButton: document.querySelector("#drawRectangleButton"),
|
||
drawEllipseButton: document.querySelector("#drawEllipseButton"),
|
||
eraseButton: document.querySelector("#eraseButton"),
|
||
eraseMode: document.querySelector("#eraseMode"),
|
||
shapeFill: document.querySelector("#shapeFill"),
|
||
shapeFillColor: document.querySelector("#shapeFillColor"),
|
||
shapeStroke: document.querySelector("#shapeStroke"),
|
||
shapeStrokeColor: document.querySelector("#shapeStrokeColor"),
|
||
shapeWidth: document.querySelector("#shapeWidth"),
|
||
undoButton: document.querySelector("#undoButton"),
|
||
redoButton: document.querySelector("#redoButton"),
|
||
insertTextButton: document.querySelector("#insertTextButton"),
|
||
insertImageButton: document.querySelector("#insertImageButton"),
|
||
addPageButton: document.querySelector("#addPageButton"),
|
||
copyPageButton: document.querySelector("#copyPageButton"),
|
||
deletePageButton: document.querySelector("#deletePageButton"),
|
||
movePagePrevButton: document.querySelector("#movePagePrevButton"),
|
||
movePageNextButton: document.querySelector("#movePageNextButton"),
|
||
pageSettingsButton: document.querySelector("#pageSettingsButton"),
|
||
batchPagesButton: document.querySelector("#batchPagesButton"),
|
||
batchPagesPanel: document.querySelector("#batchPagesPanel"),
|
||
batchPagesForm: document.querySelector("#batchPagesForm"),
|
||
batchPageAction: document.querySelector("#batchPageAction"),
|
||
batchPageRange: document.querySelector("#batchPageRange"),
|
||
batchPagePositionRow: document.querySelector("#batchPagePositionRow"),
|
||
batchPagePosition: document.querySelector("#batchPagePosition"),
|
||
batchPagesStatus: document.querySelector("#batchPagesStatus"),
|
||
batchPagesCancel: document.querySelector("#batchPagesCancel"),
|
||
batchPagesSubmit: document.querySelector("#batchPagesSubmit"),
|
||
objectStyleButton: document.querySelector("#objectStyleButton"),
|
||
copyStyleButton: document.querySelector("#copyStyleButton"),
|
||
pasteStyleButton: document.querySelector("#pasteStyleButton"),
|
||
objectBoundsButton: document.querySelector("#objectBoundsButton"),
|
||
objectBoundsPanel: document.querySelector("#objectBoundsPanel"),
|
||
objectBoundsForm: document.querySelector("#objectBoundsForm"),
|
||
objectBoundsCancel: document.querySelector("#objectBoundsCancel"),
|
||
objectBoundsStatus: document.querySelector("#objectBoundsStatus"),
|
||
objectX: document.querySelector("#objectX"),
|
||
objectY: document.querySelector("#objectY"),
|
||
objectWidth: document.querySelector("#objectWidth"),
|
||
objectHeight: document.querySelector("#objectHeight"),
|
||
objectAspect: document.querySelector("#objectAspect"),
|
||
objectStylePanel: document.querySelector("#objectStylePanel"),
|
||
objectStyleForm: document.querySelector("#objectStyleForm"),
|
||
groupObjectsButton: document.querySelector("#groupObjectsButton"),
|
||
ungroupObjectButton: document.querySelector("#ungroupObjectButton"),
|
||
objectOpacity: document.querySelector("#objectOpacity"),
|
||
objectLinkFields: document.querySelector("#objectLinkFields"),
|
||
objectLinkKind: document.querySelector("#objectLinkKind"),
|
||
objectLinkAddress: document.querySelector("#objectLinkAddress"),
|
||
objectLinkPage: document.querySelector("#objectLinkPage"),
|
||
objectLinkAddressRow: document.querySelector("#objectLinkAddressRow"),
|
||
objectLinkPageRow: document.querySelector("#objectLinkPageRow"),
|
||
objectGradientFields: document.querySelector("#objectGradientFields"),
|
||
gradientTarget: document.querySelector("#gradientTarget"),
|
||
objectBorderFields: document.querySelector("#objectBorderFields"),
|
||
borderEnabled: document.querySelector("#borderEnabled"),
|
||
borderWidth: document.querySelector("#borderWidth"),
|
||
borderColor: document.querySelector("#borderColor"),
|
||
borderColorOriginal: document.querySelector("#borderColorOriginal"),
|
||
borderHorizontal: document.querySelector("#borderHorizontal"),
|
||
borderVertical: document.querySelector("#borderVertical"),
|
||
patternTarget: document.querySelector("#patternTarget"),
|
||
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"),
|
||
objectDashPattern: document.querySelector("#objectDashPattern"),
|
||
objectDashOffset: document.querySelector("#objectDashOffset"),
|
||
objectCap: document.querySelector("#objectCap"),
|
||
objectJoin: document.querySelector("#objectJoin"),
|
||
objectStyleStatus: document.querySelector("#objectStyleStatus"),
|
||
objectStyleCancel: document.querySelector("#objectStyleCancel"),
|
||
outlinePanel: document.querySelector("#outlinePanel"),
|
||
outlineForm: document.querySelector("#outlineForm"),
|
||
outlineTitle: document.querySelector("#outlineTitle"),
|
||
outlineParent: document.querySelector("#outlineParent"),
|
||
outlinePosition: document.querySelector("#outlinePosition"),
|
||
outlinePositionRow: document.querySelector("#outlinePositionRow"),
|
||
outlinePage: document.querySelector("#outlinePage"),
|
||
outlineStatus: document.querySelector("#outlineStatus"),
|
||
outlineCancel: document.querySelector("#outlineCancel"),
|
||
outlineSubmit: document.querySelector("#outlineSubmit"),
|
||
pagePanel: document.querySelector("#pagePanel"),
|
||
pageForm: document.querySelector("#pageForm"),
|
||
pagePortrait: document.querySelector("#pagePortrait"),
|
||
pageLandscape: document.querySelector("#pageLandscape"),
|
||
pageWidth: document.querySelector("#pageWidth"),
|
||
pageRangeRow: document.querySelector("#pageRangeRow"),
|
||
pageRange: document.querySelector("#pageRange"),
|
||
pageHeight: document.querySelector("#pageHeight"),
|
||
pageStatus: document.querySelector("#pageStatus"),
|
||
pageCancel: document.querySelector("#pageCancel"),
|
||
saveButton: document.querySelector("#saveButton"),
|
||
createPanel: document.querySelector("#createPanel"),
|
||
createMode: document.querySelector("#createMode"),
|
||
createImages: document.querySelector("#createImages"),
|
||
createSize: document.querySelector("#createSize"),
|
||
createMargin: document.querySelector("#createMargin"),
|
||
createPosition: document.querySelector("#createPosition"),
|
||
createSubmit: document.querySelector("#createSubmit"),
|
||
createForm: document.querySelector("#createForm"),
|
||
createName: document.querySelector("#createName"),
|
||
createWidth: document.querySelector("#createWidth"),
|
||
createHeight: document.querySelector("#createHeight"),
|
||
createStatus: document.querySelector("#createStatus"),
|
||
createCancel: document.querySelector("#createCancel"),
|
||
insertPanel: document.querySelector("#insertPanel"),
|
||
insertForm: document.querySelector("#insertForm"),
|
||
insertImage: document.querySelector("#insertImage"),
|
||
insertFitRow: document.querySelector("#insertFitRow"),
|
||
insertFit: document.querySelector("#insertFit"),
|
||
insertStatus: document.querySelector("#insertStatus"),
|
||
insertCancel: document.querySelector("#insertCancel"),
|
||
insertSubmit: document.querySelector("#insertSubmit"),
|
||
fontInput: document.querySelector("#fontInput"),
|
||
fontDirectoryInput: document.querySelector("#fontDirectoryInput"),
|
||
togglePagesButton: document.querySelector("#togglePagesButton"),
|
||
toggleMetaButton: document.querySelector("#toggleMetaButton"),
|
||
fontAddButton: document.querySelector("#fontAddButton"),
|
||
fontDirectoryButton: document.querySelector("#fontDirectoryButton"),
|
||
localFontButton: document.querySelector("#localFontButton"),
|
||
prevButton: document.querySelector("#prevButton"),
|
||
nextButton: document.querySelector("#nextButton"),
|
||
pageControl: document.querySelector(".page-control"),
|
||
pageInput: document.querySelector("#pageInput"),
|
||
pageTotal: document.querySelector("#pageTotal"),
|
||
zoomOutButton: document.querySelector("#zoomOutButton"),
|
||
zoomInButton: document.querySelector("#zoomInButton"),
|
||
zoomLabel: document.querySelector("#zoomLabel"),
|
||
fitButton: document.querySelector("#fitButton"),
|
||
fitHeightButton: document.querySelector("#fitHeightButton"),
|
||
rotateButton: document.querySelector("#rotateButton"),
|
||
panButton: document.querySelector("#panButton"),
|
||
continuousButton: document.querySelector("#continuousButton"),
|
||
annotationButton: document.querySelector("#annotationButton"),
|
||
imageDPI: document.querySelector("#imageDPI"),
|
||
outputCompression: document.querySelector("#outputCompression"),
|
||
compressionValue: document.querySelector("#compressionValue"),
|
||
dpiValue: document.querySelector("#dpiValue"),
|
||
formatValue: document.querySelector("#formatValue"),
|
||
exportFormat: document.querySelector("#exportFormat"),
|
||
exportPageButton: document.querySelector("#exportPageButton"),
|
||
exportButton: document.querySelector("#exportButton"),
|
||
exportPanel: document.querySelector("#exportPanel"),
|
||
exportTitle: document.querySelector("#exportTitle"),
|
||
exportForm: document.querySelector("#exportForm"),
|
||
exportAll: document.querySelector("#exportAll"),
|
||
exportSpecified: document.querySelector("#exportSpecified"),
|
||
exportRangeRow: document.querySelector("#exportRangeRow"),
|
||
exportRange: document.querySelector("#exportRange"),
|
||
exportRangeStatus: document.querySelector("#exportRangeStatus"),
|
||
exportCancel: document.querySelector("#exportCancel"),
|
||
exportSubmit: document.querySelector("#exportSubmit"),
|
||
emptyState: document.querySelector("#emptyState"),
|
||
progressPanel: document.querySelector("#progressPanel"),
|
||
progressLabel: document.querySelector("#progressLabel"),
|
||
cancelExportButton: document.querySelector("#cancelExportButton"),
|
||
progressBar: document.querySelector("#progressBar"),
|
||
pageFrame: document.querySelector("#pageFrame"),
|
||
viewerPanel: document.querySelector(".viewer-panel"),
|
||
svgHost: document.querySelector("#svgHost"),
|
||
pageListPanel: document.querySelector(".page-list-panel"),
|
||
pageListTitle: document.querySelector("#pageListTitle"),
|
||
navigationTabs: document.querySelector("#navigationTabs"),
|
||
pageSelectionTools: document.querySelector("#pageSelectionTools"),
|
||
pageSelectionCount: document.querySelector("#pageSelectionCount"),
|
||
selectAllPages: document.querySelector("#selectAllPages"),
|
||
finishPageSelection: document.querySelector("#finishPageSelection"),
|
||
navigationContent: document.querySelector("#navigationContent"),
|
||
pagesTab: document.querySelector("#pagesTab"),
|
||
annotationsTab: document.querySelector("#annotationsTab"),
|
||
annotationsPanel: document.querySelector("#annotationsPanel"),
|
||
annotationsFilter: document.querySelector("#annotationsFilter"),
|
||
annotationsKind: document.querySelector("#annotationsKind"),
|
||
annotationsStatus: document.querySelector("#annotationsStatus"),
|
||
annotationsList: document.querySelector("#annotationsList"),
|
||
outlinesTab: document.querySelector("#outlinesTab"),
|
||
pageList: document.querySelector("#pageList"),
|
||
outlineList: document.querySelector("#outlineList"),
|
||
searchTab: document.querySelector("#searchTab"),
|
||
searchPanel: document.querySelector("#searchPanel"),
|
||
searchForm: document.querySelector("#searchForm"),
|
||
searchInput: document.querySelector("#searchInput"),
|
||
searchStatus: document.querySelector("#searchStatus"),
|
||
searchCount: document.querySelector("#searchCount"),
|
||
searchPrev: document.querySelector("#searchPrev"),
|
||
searchNext: document.querySelector("#searchNext"),
|
||
searchResults: document.querySelector("#searchResults"),
|
||
metaPanel: document.querySelector(".meta-panel"),
|
||
appPanel: document.querySelector("#appPanel"),
|
||
renderVectorButton: document.querySelector("#renderVectorButton"),
|
||
renderRasterButton: document.querySelector("#renderRasterButton"),
|
||
offlineStatus: document.querySelector("#offlineStatus"),
|
||
refreshAppButton: document.querySelector("#refreshAppButton"),
|
||
metaFile: document.querySelector("#metaFile"),
|
||
metaTitle: document.querySelector("#metaTitle"),
|
||
conversionNotice: document.querySelector("#conversionNotice"),
|
||
conversionWarnings: document.querySelector("#conversionWarnings"),
|
||
metaSubject: document.querySelector("#metaSubject"),
|
||
metaAuthor: document.querySelector("#metaAuthor"),
|
||
metaCreationDate: document.querySelector("#metaCreationDate"),
|
||
metaModDate: document.querySelector("#metaModDate"),
|
||
metaCreator: document.querySelector("#metaCreator"),
|
||
metaType: document.querySelector("#metaType"),
|
||
metaVersion: document.querySelector("#metaVersion"),
|
||
metaSignatures: document.querySelector("#metaSignatures"),
|
||
metaFonts: document.querySelector("#metaFonts"),
|
||
attachmentDialog: document.querySelector("#attachmentDialog"),
|
||
attachmentForm: document.querySelector("#attachmentForm"),
|
||
attachmentTitle: document.querySelector("#attachmentTitle"),
|
||
attachmentFile: document.querySelector("#attachmentFile"),
|
||
attachmentFileRow: document.querySelector("#attachmentFileRow"),
|
||
attachmentName: document.querySelector("#attachmentName"),
|
||
attachmentNameRow: document.querySelector("#attachmentNameRow"),
|
||
attachmentStatus: document.querySelector("#attachmentStatus"),
|
||
attachmentCancel: document.querySelector("#attachmentCancel"),
|
||
attachmentSubmit: document.querySelector("#attachmentSubmit"),
|
||
signaturePanel: document.querySelector("#signaturePanel"),
|
||
structurePanel: document.querySelector("#structurePanel"),
|
||
structureList: document.querySelector("#structureList"),
|
||
structureSummary: document.querySelector("#structureSummary"),
|
||
structureManage: document.querySelector("#structureManage"),
|
||
structureDialog: document.querySelector("#structureDialog"),
|
||
structureForm: document.querySelector("#structureForm"),
|
||
structureEntry: document.querySelector("#structureEntry"),
|
||
structureNodeSelect: document.querySelector("#structureNodeSelect"),
|
||
structureAction: document.querySelector("#structureAction"),
|
||
structureFields: document.querySelector("#structureFields"),
|
||
structureType: document.querySelector("#structureType"),
|
||
structureName: document.querySelector("#structureName"),
|
||
structureNamespace: document.querySelector("#structureNamespace"),
|
||
structureText: document.querySelector("#structureText"),
|
||
structureAttributes: document.querySelector("#structureAttributes"),
|
||
structureBinding: document.querySelector("#structureBinding"),
|
||
structureReferences: document.querySelector("#structureReferences"),
|
||
structureStatus: document.querySelector("#structureStatus"),
|
||
structureSubmit: document.querySelector("#structureSubmit"),
|
||
signatureSummary: document.querySelector("#signatureSummary"),
|
||
signatureList: document.querySelector("#signatureList"),
|
||
annotationNote: document.querySelector("#annotationNote"),
|
||
annotationTool: document.querySelector("#annotationTool"),
|
||
penTool: document.querySelector("#penTool"),
|
||
annotationCreate: document.querySelector("#annotationCreate"),
|
||
annotationCreateStatus: document.querySelector("#annotationCreateStatus"),
|
||
objectPicker: document.querySelector("#objectPicker"),
|
||
objectPickerList: document.querySelector("#objectPickerList"),
|
||
annotationForm: document.querySelector("#annotationForm"),
|
||
annotationFields: document.querySelector("#annotationFields"),
|
||
annotationRemark: document.querySelector("#annotationRemark"),
|
||
annotationCreator: document.querySelector("#annotationCreator"),
|
||
annotationStatus: document.querySelector("#annotationStatus"),
|
||
annotationClose: document.querySelector("#annotationClose"),
|
||
annotationSubmit: document.querySelector("#annotationSubmit"),
|
||
annotationDetail: document.querySelector("#annotationDetail"),
|
||
docFontList: document.querySelector("#docFontList"),
|
||
docFontSummary: document.querySelector("#docFontSummary"),
|
||
availableFontSummary: document.querySelector("#availableFontSummary"),
|
||
fontPermissionHint: document.querySelector("#fontPermissionHint"),
|
||
fontList: document.querySelector("#fontList"),
|
||
statusText: document.querySelector("#statusText"),
|
||
};
|
||
|
||
const fontManager = new FontManager({
|
||
host: nativeHost,
|
||
onChange: scheduleFontSync,
|
||
onPermissionChange: updateFontPermissionHint,
|
||
});
|
||
const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts);
|
||
const textFormatButtons = [...document.querySelectorAll("[data-text-format]")];
|
||
const textBackground = document.querySelector("#textBackground");
|
||
const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Content", "Old", "Font", "Text", "TextRow", "Image", "ImageRow", "Author", "LinkKind", "Address", "AddressRow", "Target", "TargetRow", "X", "Y", "Width", "Height", "Angle", "Opacity", "Tile", "Placement", "PlacementLabel", "Pick", "Scope", "Pages"].map(key => [key, document.querySelector(`#annotation${key}`)]));
|
||
el.editorTools.addEventListener("scroll", () => fontPicker.position());
|
||
|
||
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.fontInput, document.querySelector("#textReflow")],
|
||
keepTextFocus: () => fontPicker.open || canvasEditor.input?.fontDialog,
|
||
textClipboardScope: () => `${state.wasmSeq}:${state.fileName}:${state.doc?.documents?.[state.doc.documentIndex || 0]?.root}:${state.doc?.documentIndex || 0}:${state.doc?.documentVersion || ""}`,
|
||
busy: () => document.body.hasAttribute("data-busy"),
|
||
rotation: () => state.rotation,
|
||
canInsert: (index) => pageCan("insert", index),
|
||
onSelect: (item, previous) => {
|
||
updateObjectControls(item);
|
||
const reason = objectEditReason(item), previousReason = objectEditReason(previous);
|
||
if (state.editing && state.doc && !document.body.hasAttribute("data-busy")
|
||
&& (reason || previousReason && el.statusText.textContent === previousReason)) {
|
||
setStatus(reason || pageStatus(state.pageIndex, state.doc.pageCount));
|
||
}
|
||
},
|
||
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),
|
||
onNudgeChange: () => {
|
||
updatePendingChanges();
|
||
el.undoButton.disabled = !state.ready || state.exporting || !state.editorInfo?.canUndo && !canvasEditor.nudgeChanged();
|
||
},
|
||
onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height, Boolean(item.oriented)),
|
||
onResize: (item, box) => changeDocument("ofdgoResizeObjects", { ...item, id: [item.id] }, box.x, box.y, box.width, box.height),
|
||
onTextWidth: (item, box) => item.richText
|
||
? saveRichText(item, { ...item.richText, layout: { ...item.richText.layout, wrap: true } }, [], { ...item, frameWidth: box.width, frameOffset: box.x - item.x })
|
||
: changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(item)),
|
||
onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height),
|
||
onCropChange: () => {
|
||
updatePendingChanges();
|
||
updateObjectControls(canvasEditor.selected);
|
||
syncSelection();
|
||
if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0);
|
||
},
|
||
onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item),
|
||
onErase: (items, box, points) => {
|
||
const blocked = items.find(item => !canEditObject(item, box ? "arrange" : "delete"));
|
||
if (blocked) { setStatus(objectEditReason(blocked) || "对象暂不可擦除"); return false; }
|
||
const item = { index: items[0].index, id: items.map(item => item.id), scoped: items.every(item => item.scoped) };
|
||
if (points) return changeDocument("ofdgoEraseObjectsPath", item, points);
|
||
return box ? changeDocument("ofdgoEraseObjects", item, box.x, box.y, box.width, box.height)
|
||
: changeDocument("ofdgoDeleteObjects", item);
|
||
},
|
||
onEdit: editCanvasObject,
|
||
onPick: openObjectPicker,
|
||
onExitScope: () => {
|
||
if (!state.composite) return false;
|
||
exitCompositeScope();
|
||
return true;
|
||
},
|
||
drawStyle: shapeStyle,
|
||
onTool: () => {
|
||
updateDrawingControls();
|
||
const entry = state.annotationCreate;
|
||
if (entry?.picking && canvasEditor.tool !== "annotation:watermark") {
|
||
queueMicrotask(() => {
|
||
if (state.annotationCreate === entry && entry.picking) {
|
||
entry.picking = false;
|
||
if (!state.editing || entry.openSeq !== state.openSeq || canvasEditor.tool) {
|
||
state.annotationCreate = null;
|
||
return;
|
||
}
|
||
el.annotationCreate.showModal();
|
||
}
|
||
});
|
||
}
|
||
},
|
||
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),
|
||
onDrawText: beginCanvasText,
|
||
onDrawAnnotation: placeAnnotation,
|
||
onDrawInk: (index, points, style, pressure) => changeDocument("ofdgoInsertInk", null, index, JSON.stringify(points), style.lineWidth, pressure, style.strokeColor),
|
||
onPreviewText: (item, value, font, size, reflow, color = null) => item.selectionIDs
|
||
? callWASM("ofdgoPreviewTextSelection", item.index, item.selectionIDs, value, font || null, size, color, !!reflow)
|
||
: callWASM("ofdgoPreviewText", item.index, item.id, value, font || null, size, !!reflow),
|
||
onCommitRichText: saveRichText,
|
||
onPreviewRichText: validateRichText,
|
||
onTextError: (message, previous) => {
|
||
if (message) setStatus(message);
|
||
else if (el.statusText.textContent === previous) setStatus(state.doc ? pageStatus(state.pageIndex, state.doc.pageCount) : STATUS.ready);
|
||
},
|
||
onCommitText: (item, value, fontData, color = null, reflow = false) => item.selectionIDs
|
||
? changeDocument("ofdgoUpdateTextSelection", item, value, fontData || null, canvasEditor.input?.style?.size ?? null, color, reflow)
|
||
: item.draft
|
||
? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing, ...textIndents(item))
|
||
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, color, reflow),
|
||
onTextChange: () => {
|
||
const editing = canvasEditor.input;
|
||
updatePendingChanges();
|
||
if (editing?.rich || editing?.source) updateObjectControls(canvasEditor.selected);
|
||
if (editing?.source) updateTextFonts(canvasEditor.selected, true);
|
||
if (!editing) {
|
||
updateObjectControls(canvasEditor.selected);
|
||
updateTextFonts(canvasEditor.selected, true);
|
||
syncSelection();
|
||
if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0);
|
||
}
|
||
},
|
||
});
|
||
|
||
function updatePendingChanges() {
|
||
setDirty(state.packageDirty || state.encryptionDirty || Boolean(state.editorInfo) && (state.editorInfo.revision !== state.savedRevision
|
||
|| canvasEditor.textChanged() || canvasEditor.cropChanged() || canvasEditor.nudgeChanged()));
|
||
}
|
||
|
||
el.documentSelect.addEventListener("change", () => switchDocument(Number(el.documentSelect.value)));
|
||
|
||
el.editorTools.addEventListener("pointerdown", (event) => {
|
||
if ((canvasEditor.input || canvasEditor.nudge) && event.target.closest("button")) event.preventDefault();
|
||
});
|
||
|
||
function editorClick(button, action) {
|
||
button.addEventListener("click", async () => {
|
||
if ((canvasEditor.nudge || canvasEditor.nudgeCommit) && !await canvasEditor.commitNudge()) return;
|
||
if (canvasEditor.input && !await canvasEditor.commitText()) return;
|
||
if (canvasEditor.crop && !await canvasEditor.commitCrop()) return;
|
||
return action();
|
||
});
|
||
}
|
||
|
||
editorClick(el.selectObjectButton, () => {
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
state.selectObjects = !state.selectObjects || state.panMode || Boolean(canvasEditor.tool);
|
||
canvasEditor.setTool("");
|
||
if (state.selectObjects && state.panMode) {
|
||
togglePan();
|
||
}
|
||
updateEditorTools();
|
||
});
|
||
editorClick(el.deleteObjectButton, () => {
|
||
if (canvasEditor.selected) {
|
||
canvasEditor.options.onDelete(canvasEditor.selected);
|
||
}
|
||
});
|
||
editorClick(el.multiSelectButton, () => {
|
||
canvasEditor.multiple = !canvasEditor.multiple;
|
||
el.multiSelectButton.setAttribute("data-pressed", String(canvasEditor.multiple));
|
||
});
|
||
editorClick(el.compositeBackButton, exitCompositeScope);
|
||
editorClick(el.editObjectButton, () => {
|
||
if (canvasEditor.selected) {
|
||
editCanvasObject(canvasEditor.selected);
|
||
}
|
||
});
|
||
el.textSize.addEventListener("change", () => changeTextStyle(false));
|
||
el.textColor.addEventListener("change", () => changeTextStyle(true));
|
||
for (const button of textFormatButtons) button.addEventListener("click", () => changeRangeFormat(button.dataset.textFormat));
|
||
textBackground.addEventListener("change", () => changeRangeFormat("background", textBackground.value));
|
||
editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap));
|
||
el.textAlign.addEventListener("change", () => changeParagraph());
|
||
el.textLineHeight.addEventListener("change", () => changeParagraph());
|
||
editorClick(el.paragraphButton, () => {
|
||
const item = currentTextStyle();
|
||
for (const key of ["leftIndent", "rightIndent", "firstLineIndent"]) el[key].value = item[key] || 0;
|
||
el.paragraphStatus.textContent = "";
|
||
el.paragraphPanel.showModal();
|
||
});
|
||
el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close());
|
||
editorClick(el.documentAddButton, () => openCreatePanel(false, true));
|
||
editorClick(el.documentImportButton, () => openImportPanel(true));
|
||
editorClick(el.documentManageButton, openDocumentManagement);
|
||
resourceElements.ManageButton.addEventListener("click", openResourceManagement);
|
||
resourceElements.Close.addEventListener("click", () => resourceElements.Panel.close());
|
||
resourceElements.Form.addEventListener("submit", event => event.preventDefault());
|
||
resourceElements.Panel.addEventListener("close", () => {
|
||
resourceManager.sequence++;
|
||
if (resourceManager.requestID) wasmWorker?.postMessage({type: "cancel", id: resourceManager.requestID});
|
||
releaseResourcePreview();
|
||
});
|
||
resourceElements.Kind.addEventListener("change", () => resourceTask(renderResourceList));
|
||
resourceElements.Reference.addEventListener("change", () => resourceTask(syncResourceReference));
|
||
resourceElements.ReferenceGroup.addEventListener("change", () => resourceTask(() => loadManagedReferences(Number(resourceElements.ReferenceGroup.value) * 100)));
|
||
resourceElements.Page.addEventListener("change", () => resourceTask(() => loadResourceTargets("")));
|
||
resourceElements.Object.addEventListener("change", updateResourceControls);
|
||
resourceElements.Pick.addEventListener("click", () => resourceTask(beginResourcePick));
|
||
resourceElements.Preview.addEventListener("click", () => resourceTask(previewManagedResource));
|
||
resourceElements.Export.addEventListener("click", () => {
|
||
const item = resourceManager.selected;
|
||
if (item) downloadResource({ id: JSON.stringify([item.kind, item.id]), fileName: resourceFileName(item) }, "ofdgoExportResource", "资源");
|
||
});
|
||
resourceElements.Locate.addEventListener("click", () => resourceTask(locateManagedResource));
|
||
resourceElements.Add.addEventListener("click", () => {
|
||
if (!state.editing) return;
|
||
const kind = resourceElements.Kind.value;
|
||
if (kind === "Font") openFontFile(el.fontInput);
|
||
else if (kind === "Image") openInsertPanel();
|
||
else if (kind === "Attachment") openAttachmentDialog("add");
|
||
else if (kind === "ColorSpace" || kind === "DrawParam") openResourceSettings(kind, "");
|
||
else chooseResourceFile("add");
|
||
});
|
||
resourceElements.Replace.addEventListener("click", async () => {
|
||
const item = resourceManager.selected;
|
||
if (!state.editing || !item) return;
|
||
if (item.kind === "ColorSpace" || item.kind === "DrawParam") {
|
||
await openResourceSettings(item.kind, item.id);
|
||
} else if (item.kind === "Font") {
|
||
const font = state.doc.fonts?.find(font => font.id === item.id) || {id: item.id, fontName: item.name, embedded: Boolean(item.file)};
|
||
await openFontBinding(font);
|
||
if (!fontBindingElements.Panel.open) await resourceTask(loadManagedResources);
|
||
} else chooseResourceFile("replace");
|
||
});
|
||
resourceElements.Rename.addEventListener("click", () => {
|
||
const item = resourceManager.selected;
|
||
if (item?.kind === "Attachment") openAttachmentDialog("rename", item);
|
||
});
|
||
resourceElements.Delete.addEventListener("click", () => resourceTask(async () => {
|
||
const item = resourceManager.selected;
|
||
if (!item) return;
|
||
const message = item.kind === "Attachment" ? `删除附件“${item.name}”?相关附件链接将一并移除。` : `删除资源“${item.name}”?`;
|
||
if (window.confirm(message)) await changeManagedResource("delete", item);
|
||
}));
|
||
for (const [button, action] of [[resourceElements.Bind, "bind"], [resourceElements.Unbind, "unbind"]]) {
|
||
button.addEventListener("click", () => resourceTask(() => changeManagedResource(action, resourceManager.selected)));
|
||
}
|
||
resourceElements.File.addEventListener("change", () => resourceTask(async () => {
|
||
const request = resourceManager.fileAction, file = resourceElements.File.files[0];
|
||
if (!file || !request || request.sequence !== state.openSeq || !state.editing) return;
|
||
await changeManagedResource(request.action, request.item, file);
|
||
}));
|
||
for (const dialog of [fontBindingElements.Panel, el.attachmentDialog]) {
|
||
dialog.addEventListener("close", () => { if (resourceElements.Panel.open) resourceTask(loadManagedResources); });
|
||
}
|
||
editorClick(el.versionManageButton, openVersionManagement);
|
||
el.versionClose.addEventListener("click", () => el.versionPanel.close());
|
||
el.versionPanel.addEventListener("cancel", event => { if (el.versionForm.inert) event.preventDefault(); });
|
||
el.versionPanel.addEventListener("close", () => {
|
||
if (!el.versionPanel.open) {
|
||
state.versionManagement = null;
|
||
el.versionList.replaceChildren();
|
||
el.versionName.value = el.versionStatus.textContent = "";
|
||
}
|
||
});
|
||
el.versionForm.addEventListener("submit", event => event.preventDefault());
|
||
el.versionName.addEventListener("input", updateVersionControls);
|
||
for (const [button, action] of [[el.versionAdd, "add"], [el.versionRename, "rename"], [el.versionDefault, "default"], [el.versionDelete, "delete"], [el.versionSelect, "select"]]) {
|
||
button.addEventListener("click", () => changeVersion(action));
|
||
}
|
||
el.versionList.addEventListener("keydown", event => {
|
||
const manager = state.versionManagement;
|
||
if (!manager?.entries || manager.loading || document.body.hasAttribute("data-busy")) return;
|
||
let index = manager.entries.findIndex(entry => entry.id === manager.id);
|
||
if (event.key === "ArrowUp") index--;
|
||
else if (event.key === "ArrowDown") index++;
|
||
else if (event.key === "Home") index = 0;
|
||
else if (event.key === "End") index = manager.entries.length - 1;
|
||
else return;
|
||
event.preventDefault();
|
||
selectManagedVersion(manager.entries[Math.max(0, Math.min(index, manager.entries.length - 1))].id, true);
|
||
});
|
||
el.documentClose.addEventListener("click", () => el.documentPanel.close());
|
||
el.documentPanel.addEventListener("cancel", event => { if (el.documentForm.inert) event.preventDefault(); });
|
||
el.documentPanel.addEventListener("close", () => {
|
||
if (!el.documentPanel.open) {
|
||
state.documentManagement = null;
|
||
el.documentList.replaceChildren();
|
||
el.documentStatus.textContent = "";
|
||
}
|
||
});
|
||
el.documentList.addEventListener("keydown", event => {
|
||
if (!state.documentManagement || !["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
|
||
event.preventDefault();
|
||
const count = state.doc.documents.length, index = state.documentManagement.index;
|
||
selectManagedDocument(event.key === "Home" ? 0 : event.key === "End" ? count - 1
|
||
: Math.max(0, Math.min(count - 1, index + (event.key === "ArrowUp" ? -1 : 1))), true);
|
||
});
|
||
editorClick(el.documentDeleteButton, () => {
|
||
const index = managedDocumentIndex(), entry = state.doc.documents[index];
|
||
if (window.confirm(`删除文档“${entry.title || "未命名"}”?`)) return changeDocument("ofdgoChangeDocument", null, "delete", index);
|
||
});
|
||
editorClick(el.documentUpButton, () => {
|
||
const index = managedDocumentIndex();
|
||
return changeDocument("ofdgoChangeDocument", null, "move", index, index - 1);
|
||
});
|
||
editorClick(el.documentDownButton, () => {
|
||
const index = managedDocumentIndex();
|
||
return changeDocument("ofdgoChangeDocument", null, "move", index, index + 1);
|
||
});
|
||
editorClick(el.infoButton, () => {
|
||
state.infoDocumentIndex = managedDocumentIndex();
|
||
const info = state.documentManagement?.entries?.[state.infoDocumentIndex] || state.doc;
|
||
for (const key of ["Title", "Author", "Subject"]) el[`info${key}`].value = info[key.toLowerCase()] || "";
|
||
el.infoCustomRows.replaceChildren();
|
||
for (const [index, field] of (info.customData || []).entries()) addCustomDataRow(field, index);
|
||
el.infoStatus.textContent = "";
|
||
el.infoPanel.showModal();
|
||
});
|
||
el.infoCancel.addEventListener("click", () => el.infoPanel.close());
|
||
el.infoPanel.addEventListener("cancel", event => { if (el.infoForm.inert) event.preventDefault(); });
|
||
el.infoPanel.addEventListener("close", () => { if (!el.infoPanel.open) state.infoDocumentIndex = null; });
|
||
el.infoCustomAdd.addEventListener("click", () => addCustomDataRow());
|
||
el.infoForm.addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
const fields = [...el.infoCustomRows.children].map(row => ({Name: row.children[0].value, Value: row.children[1].value, Source: row.sourceIndex}));
|
||
const args = [el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value, JSON.stringify(fields)];
|
||
if (state.infoDocumentIndex !== null) args.push(state.infoDocumentIndex);
|
||
if (await changeDocument("ofdgoUpdateInfo", null, ...args)) 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.placeholder = label;
|
||
row.append(input);
|
||
}
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.className = "icon-button";
|
||
remove.textContent = "×";
|
||
remove.title = "删除字段";
|
||
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.objectPicker.addEventListener("close", () => { if (!el.objectPicker.open) el.objectPickerList.replaceChildren(); });
|
||
el.penTool.addEventListener("change", () => {
|
||
const tool = el.penTool.value;
|
||
el.penTool.value = "";
|
||
canvasEditor.select(null);
|
||
toggleDrawingTool(tool);
|
||
});
|
||
el.annotationTool.addEventListener("change", async () => {
|
||
const kind = el.annotationTool.value;
|
||
el.annotationTool.value = "";
|
||
if (kind === "objects") { openObjectPicker(); return; }
|
||
let selected = canvasEditor.selected;
|
||
if (state.composite?.key.startsWith("annotation:")) {
|
||
const id = state.composite.key.split("/")[0];
|
||
selected = { ...state.pageCache.get(state.composite.index)?.objects.find(item => item.id === id), index: state.composite.index, id, type: "Annotation" };
|
||
}
|
||
if (kind === "details" || kind === "replace") {
|
||
if (selected?.type !== "Annotation") { setStatus("请先选择注解"); return; }
|
||
try {
|
||
const info = await callWASM("ofdgoReadAnnotation", selected.index, selected.id);
|
||
if (kind === "details") editAnnotationDetails({ ...selected, ...info });
|
||
else openAnnotationCreate(kind, selected.index, null, selected, info);
|
||
} catch (error) { showError(error, false); }
|
||
return;
|
||
}
|
||
if (kind === "link" && selected?.annotationType === "Link") {
|
||
try {
|
||
const info = await callWASM("ofdgoReadAnnotation", selected.index, selected.id);
|
||
openAnnotationCreate(kind, selected.index, selected, selected, info);
|
||
} catch (error) { showError(error, false); }
|
||
} else {
|
||
resetCompositeScope();
|
||
updateControls();
|
||
if (kind === "watermark") openAnnotationCreate(kind, state.pageIndex);
|
||
else {
|
||
canvasEditor.select(null);
|
||
toggleDrawingTool(`annotation:${kind}`);
|
||
}
|
||
}
|
||
});
|
||
annotationFields.CreateCancel.addEventListener("click", () => el.annotationCreate.close());
|
||
el.annotationCreate.addEventListener("close", () => {
|
||
if (!state.annotationCreate?.picking) state.annotationCreate = null;
|
||
});
|
||
annotationFields.Content.addEventListener("change", updateAnnotationFields);
|
||
annotationFields.Height.addEventListener("input", () => { state.annotationCreate.height = annotationFields.Height.value; });
|
||
annotationFields.LinkKind.addEventListener("change", updateAnnotationFields);
|
||
annotationFields.Old.addEventListener("change", () => { annotationFields.Text.value = annotationFields.Old.value; });
|
||
annotationFields.Tile.addEventListener("change", () => {
|
||
annotationFields.Placement.value = "page";
|
||
state.annotationCreate.area = null;
|
||
updateAnnotationFields();
|
||
});
|
||
annotationFields.Placement.addEventListener("change", () => {
|
||
if (annotationFields.Placement.value === "custom") pickWatermarkArea();
|
||
else state.annotationCreate.area = null;
|
||
});
|
||
annotationFields.Pick.addEventListener("click", pickWatermarkArea);
|
||
annotationFields.Scope.addEventListener("change", updateAnnotationFields);
|
||
annotationFields.CreateForm.addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
const entry = state.annotationCreate;
|
||
if (!entry || !annotationFields.CreateForm.reportValidity() || document.body.hasAttribute("data-busy")) return;
|
||
const openSeq = state.openSeq;
|
||
const value = key => annotationFields[key].value;
|
||
const image = entry.kind === "stamp" || entry.kind === "watermark" && value("Content") === "image";
|
||
let font = null, data = null;
|
||
try {
|
||
if (image) data = new Uint8Array(await annotationFields.Image.files[0].arrayBuffer());
|
||
if (entry.kind === "watermark" && !image || entry.kind === "replace" && value("Font") === "current") {
|
||
const choice = state.textDefaults.fontChoice || state.textFonts[Number(fontPicker.value)];
|
||
if (!choice || choice.disabled) throw new Error("请先选择可用字体");
|
||
font = await readTextFont(choice);
|
||
}
|
||
if (openSeq !== state.openSeq || state.annotationCreate !== entry) return;
|
||
const options = { kind: entry.kind, id: entry.item?.id.replace("annotation:", "") || "", text: value("Text"), old: value("Old"),
|
||
creator: value("Author"), pages: entry.kind === "watermark" && value("Scope") !== "custom"
|
||
? value("Scope") === "all" ? `1-${state.doc.pageCount}` : String(entry.index + 1) : value("Pages"),
|
||
uri: value("Address"), target: value("LinkKind") === "page" ? Number(value("Target")) : 0,
|
||
x: Number(value("X")), y: Number(value("Y")), width: entry.kind === "note" ? 5 : Number(value("Width")), height: entry.kind === "note" ? 5 : Number(value("Height")),
|
||
angle: Number(value("Angle")), alpha: Math.round((100 - Number(value("Opacity"))) * 255 / 100), tile: value("Tile") === "tile",
|
||
area: entry.kind === "watermark" && value("Placement") === "custom" ? entry.area : null,
|
||
size: state.textDefaults.size, color: state.textDefaults.color };
|
||
if (entry.kind === "link" && entry.item) {
|
||
options.base = entry.info?.linkBase || "";
|
||
options.keep = value("LinkKind") === "keep" || value("LinkKind") === entry.info?.linkKind
|
||
&& (value("LinkKind") === "uri" ? value("Address") === entry.info.linkURI : Number(value("Target")) === entry.info.linkPage);
|
||
}
|
||
if (await changeDocument("ofdgoWriteAnnotation", null, entry.index, JSON.stringify(options), font, data)) el.annotationCreate.close();
|
||
} catch (error) { el.annotationCreateStatus.textContent = error.message; }
|
||
});
|
||
el.annotationForm.addEventListener("submit", async event => {
|
||
if (!state.annotationEdit) return;
|
||
event.preventDefault();
|
||
const fromSummary = state.annotationEdit.fromSummary;
|
||
if (await changeAnnotations(state.annotationEdit, "update", el.annotationRemark.value, el.annotationCreator.value)) {
|
||
el.annotationNote.close();
|
||
if (fromSummary) showNavigation(el.annotationsTab);
|
||
canvasEditor.focus();
|
||
}
|
||
});
|
||
el.paragraphForm.addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
if (document.body.hasAttribute("data-busy") || !el.paragraphForm.reportValidity()) return;
|
||
const item = currentTextStyle();
|
||
const indents = Object.fromEntries(["leftIndent", "rightIndent", "firstLineIndent"].map(key => [key, Number(el[key].value)]));
|
||
if (item === state.textDefaults) {
|
||
Object.assign(item, indents);
|
||
} else {
|
||
if (!confirmTextReflow(item)) return;
|
||
const saved = item.richText ? await saveRichText(item, { ...item.richText, layout: { ...item.richText.layout, ...indents } })
|
||
: await changeDocument("ofdgoLayoutText", item, null, null, Boolean(item.wrap), item.align || "left",
|
||
item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(indents));
|
||
if (!saved) return;
|
||
}
|
||
el.paragraphPanel.close();
|
||
});
|
||
el.textSpacing.addEventListener("change", () => changeParagraph());
|
||
el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; });
|
||
for (const input of [el.textSize, el.shapeWidth, el.textLineHeight, el.textSpacing]) {
|
||
input.addEventListener("keydown", (event) => {
|
||
if (event.isComposing) return;
|
||
if (event.key === "Enter" || event.key === "Escape") {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.repeat) return;
|
||
if (event.key === "Escape") {
|
||
updateObjectControls(canvasEditor.selected, true);
|
||
if (input === el.shapeWidth && canvasEditor.selected?.type !== "PathObject") {
|
||
input.value = input.defaultValue;
|
||
}
|
||
input.blur();
|
||
} else {
|
||
input.dispatchEvent(new Event("change"));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
editorClick(el.copyObjectButton, () => {
|
||
const item = canvasEditor.selected;
|
||
if (!item) {
|
||
return;
|
||
}
|
||
return changeDocument("ofdgoCopyObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, 3, 3);
|
||
});
|
||
el.objectOrder.addEventListener("change", () => {
|
||
const action = el.objectOrder.value;
|
||
el.objectOrder.value = "";
|
||
const item = canvasEditor.selected;
|
||
if (!item || !action) {
|
||
return;
|
||
}
|
||
return changeDocument("ofdgoOrderObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, action);
|
||
});
|
||
el.objectAlign.addEventListener("change", () => {
|
||
const alignment = el.objectAlign.value;
|
||
el.objectAlign.value = "";
|
||
if (canvasEditor.selected && alignment) {
|
||
return changeDocument(canvasEditor.selected.items ? "ofdgoAlignObjects" : "ofdgoAlignObject", canvasEditor.selected, alignment);
|
||
}
|
||
});
|
||
el.objectDistribute.addEventListener("change", () => {
|
||
const axis = el.objectDistribute.value;
|
||
el.objectDistribute.value = "";
|
||
if (axis && canvasEditor.items().length >= 3) {
|
||
return changeDocument("ofdgoDistributeObjects", canvasEditor.selected, axis);
|
||
}
|
||
});
|
||
for (const [select, method] of [[el.objectRotate, "ofdgoRotateObjects"], [el.objectFlip, "ofdgoFlipObjects"]]) {
|
||
select.addEventListener("change", () => {
|
||
const value = select.value, item = canvasEditor.selected;
|
||
select.value = "";
|
||
if (!item || !value || canvasEditor.crop) return;
|
||
return changeDocument(method, { ...item, id: canvasEditor.items().map(member => member.id) },
|
||
select === el.objectRotate ? Number(value) : value);
|
||
});
|
||
}
|
||
el.cropImageButton.addEventListener("click", () => {
|
||
if (document.body.hasAttribute("data-busy")) return;
|
||
if (canvasEditor.crop) return canvasEditor.commitCrop();
|
||
const item = canvasEditor.selected;
|
||
if (item?.type === "ImageObject") return startImageCrop(item);
|
||
});
|
||
el.imageFit.addEventListener("change", () => {
|
||
const mode = el.imageFit.value, item = canvasEditor.selected;
|
||
el.imageFit.value = "";
|
||
if (mode && item?.type === "ImageObject" && !canvasEditor.crop) return changeDocument("ofdgoFitImage", item, mode);
|
||
});
|
||
el.resetCropButton.addEventListener("click", () => {
|
||
if (document.body.hasAttribute("data-busy")) return;
|
||
if (canvasEditor.crop) {
|
||
canvasEditor.closeCrop();
|
||
return;
|
||
}
|
||
const item = canvasEditor.selected;
|
||
if (item?.scoped) return changeDocument("ofdgoResetCompositeCrop", item);
|
||
if (canEditObject(item, "resetCrop")) return changeDocument("ofdgoResetImageCrop", item);
|
||
if (item?.imageBounds) return canvasEditor.options.onCrop(item, item.imageBounds);
|
||
});
|
||
for (const [button, direction, command] of [[el.undoButton, -1, "ofdgoUndo"], [el.redoButton, 1, "ofdgoRedo"]]) {
|
||
button.addEventListener("click", async () => {
|
||
if (canvasEditor.input?.rich || canvasEditor.input?.source) { canvasEditor.textUndo(direction); return; }
|
||
if (!await canvasEditor.commitNudge() || !await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return;
|
||
await changeDocument(command);
|
||
});
|
||
}
|
||
for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) {
|
||
editorClick(button, () => toggleDrawingTool(tool));
|
||
}
|
||
editorClick(el.eraseButton, () => toggleDrawingTool(el.eraseMode.value));
|
||
el.eraseMode.addEventListener("change", () => {
|
||
if (canvasEditor.tool.startsWith("erase-")) canvasEditor.setTool(el.eraseMode.value);
|
||
});
|
||
for (const input of [el.shapeFill, el.shapeFillColor, el.shapeStroke, el.shapeStrokeColor, el.shapeWidth]) {
|
||
input.addEventListener("change", changeShapeStyle);
|
||
}
|
||
el.addPageButton.addEventListener("change", async () => {
|
||
const action = el.addPageButton.value;
|
||
el.addPageButton.value = "";
|
||
if (canvasEditor.input && !await canvasEditor.commitText()) return;
|
||
if (canvasEditor.crop && !await canvasEditor.commitCrop()) return;
|
||
if (action === "import") { openImportPanel(); return; }
|
||
if (action === "images") { openCreatePanel(true); return; }
|
||
const page = currentPageInfo();
|
||
return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height);
|
||
});
|
||
el.importCancel.addEventListener("click", () => state.importing ? cancelExport() : el.importPanel.close());
|
||
el.importPanel.addEventListener("cancel", event => {
|
||
if (state.importing) { event.preventDefault(); cancelExport(); }
|
||
});
|
||
el.importPanel.addEventListener("close", () => {
|
||
state.importPageCount = 0;
|
||
state.importDocumentCount = 0;
|
||
state.importDocumentMode = false;
|
||
state.importEncrypted = false;
|
||
el.importForm.reset();
|
||
if (state.ready) callWASM("ofdgoLoadImport", null).catch(err => showError(err, false));
|
||
});
|
||
el.importFile.addEventListener("change", loadImportFile);
|
||
el.importRange.addEventListener("change", () => {
|
||
updateImportControls();
|
||
if (!el.importPages.disabled) el.importPages.focus();
|
||
});
|
||
el.importDocuments.addEventListener("change", () => updateImportControls());
|
||
el.importForm.addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
if (document.body.hasAttribute("data-busy") || !(state.importDocumentMode ? state.importDocumentCount : state.importPageCount) || !el.importForm.reportValidity()) return;
|
||
const position = state.importDocumentMode ? managedDocumentIndex() + 1 : { before:state.pageIndex, after:state.pageIndex+1, first:0, last:state.doc.pageCount }[el.importPosition.value];
|
||
const options = [];
|
||
if (state.importEncrypted) {
|
||
if (!window.confirm(state.doc.encryption?.encrypted ? "按目标文档的加密策略导入,是否继续?" : "目标未加密,导入内容将按明文保存,是否继续?")) return;
|
||
options.push(true);
|
||
}
|
||
const range = el.importRange.value === "custom" ? state.importDocumentMode ? selectedImportDocuments() : el.importPages.value : "";
|
||
if (state.importDocumentMode && el.importRange.value === "custom" && !range) return;
|
||
const saved = state.importDocumentMode
|
||
? await changeDocument("ofdgoImportDocuments", null, range, position, state.importEncrypted)
|
||
: await changeDocument("ofdgoImportPages", null, range, position, el.importOutlines.checked, ...options);
|
||
if (saved) el.importPanel.close();
|
||
});
|
||
editorClick(el.copyPageButton, () => changeSelectedPages("copy"));
|
||
editorClick(el.deletePageButton, () => changeSelectedPages("delete"));
|
||
editorClick(el.movePagePrevButton, () => changeSelectedPages("move", -1));
|
||
editorClick(el.movePageNextButton, () => changeSelectedPages("move", 1));
|
||
editorClick(el.pageSettingsButton, openPagePanel);
|
||
editorClick(el.batchPagesButton, openBatchPages);
|
||
function openBatchPages() {
|
||
el.batchPageAction.value = "copy";
|
||
el.batchPagesSubmit.textContent = "复制";
|
||
el.batchPagePositionRow.hidden = true;
|
||
el.batchPagePosition.value = "after";
|
||
el.batchPageRange.value = selectedPageRange();
|
||
el.batchPagesStatus.textContent = "";
|
||
el.batchPagesPanel.showModal();
|
||
el.batchPageRange.select();
|
||
}
|
||
el.batchPagesCancel.addEventListener("click", () => el.batchPagesPanel.close());
|
||
el.batchPageAction.addEventListener("change", () => {
|
||
el.batchPagesSubmit.textContent = { copy: "复制", move: "移动", delete: "删除", save: "另存" }[el.batchPageAction.value];
|
||
el.batchPagePositionRow.hidden = el.batchPageAction.value !== "move";
|
||
});
|
||
el.batchPagesForm.addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
if (document.body.hasAttribute("data-busy") || !el.batchPagesForm.reportValidity()) return;
|
||
const args = [el.batchPageAction.value, el.batchPageRange.value];
|
||
if (args[0] === "save") {
|
||
const seq = state.openSeq;
|
||
try {
|
||
const indices = await callWASM("ofdgoParsePageRange", args[1]);
|
||
if (seq === state.openSeq && el.batchPagesPanel.open && await exportFile(true, indices, "ofd")) el.batchPagesPanel.close();
|
||
} catch (err) {
|
||
if (seq === state.openSeq) el.batchPagesStatus.textContent = err.message;
|
||
}
|
||
return;
|
||
}
|
||
if (args[0] === "move") args.push({ before: state.pageIndex, after: state.pageIndex + 1, first: 0, last: state.doc.pageCount }[el.batchPagePosition.value]);
|
||
if (await changeDocument("ofdgoBatchPages", null, ...args)) el.batchPagesPanel.close();
|
||
});
|
||
editorClick(el.objectStyleButton, openObjectStyle);
|
||
editorClick(el.objectBoundsButton, openObjectBounds);
|
||
for (const [button, ungroup] of [[el.groupObjectsButton, false], [el.ungroupObjectButton, true]]) {
|
||
button.addEventListener("click", async () => {
|
||
const item = canvasEditor.selected;
|
||
if (ungroup && canvasEditor.items().some(member => member.richText)) return;
|
||
if (await changeDocument("ofdgoGroupObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, ungroup)) el.objectStylePanel.close();
|
||
});
|
||
}
|
||
el.arrowTool.addEventListener("change", async () => {
|
||
const tool = el.arrowTool.value;
|
||
el.arrowTool.value = "";
|
||
if (await canvasEditor.commitText() && await canvasEditor.commitCrop()) {
|
||
const item = canvasEditor.selected;
|
||
if (lineShape(item?.shape)) {
|
||
const box = item.geometry;
|
||
await changeDocument("ofdgoReshapeLine", item, box.x, box.y, box.width, box.height, tool);
|
||
return;
|
||
}
|
||
setPan(false);
|
||
state.selectObjects = true;
|
||
canvasEditor.setTool(tool);
|
||
updateControls();
|
||
}
|
||
});
|
||
el.copyStyleButton.addEventListener("click", async () => {
|
||
const item = canvasEditor.selected, seq = state.openSeq;
|
||
if (!canCopyStyle(item)) return;
|
||
setBusy(true);
|
||
try {
|
||
await callWASM("ofdgoCaptureStyle", item.index, item.id, state.composite?.key || "");
|
||
if (seq === state.openSeq) { state.styleClipboard = item.type; el.objectStylePanel.close(); }
|
||
} catch (err) { if (seq === state.openSeq) el.objectStyleStatus.textContent = err.message; }
|
||
finally { if (seq === state.openSeq) setBusy(false); }
|
||
});
|
||
el.pasteStyleButton.addEventListener("click", async () => {
|
||
const item = canvasEditor.selected;
|
||
if (canvasEditor.items().some(member => member.richText)) return;
|
||
if (await changeDocument("ofdgoPasteStyle", { ...item, id: canvasEditor.items().map(member => member.id) })) el.objectStylePanel.close();
|
||
});
|
||
el.objectBoundsCancel.addEventListener("click", () => el.objectBoundsPanel.close());
|
||
for (const input of [el.objectWidth, el.objectHeight]) input.addEventListener("input", () => {
|
||
if (!el.objectAspect.checked || !input.value || el.objectWidth.disabled || el.objectHeight.disabled) return;
|
||
const box = state.boundsOriginal;
|
||
if (input === el.objectWidth) setObjectDimension(el.objectHeight, Number(input.value) * box.height / box.width);
|
||
else setObjectDimension(el.objectWidth, Number(input.value) * box.width / box.height);
|
||
});
|
||
el.objectAspect.addEventListener("change", () => {
|
||
if (el.objectAspect.checked) setObjectDimension(el.objectHeight, objectDimension(el.objectWidth) * state.boundsOriginal.height / state.boundsOriginal.width);
|
||
});
|
||
el.objectBoundsForm.addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
const item = canvasEditor.selected;
|
||
const values = [el.objectX, el.objectY, el.objectWidth, el.objectHeight].map(objectDimension);
|
||
let saved;
|
||
if (item.oriented) {
|
||
const [a, b, c, d, e, f] = item.oriented.matrix, determinant = a * d - b * c;
|
||
const x = values[0] - e, y = values[1] - f;
|
||
saved = await changeDocument("ofdgoReshapeObject", item, (d * x - c * y) / determinant, (a * y - b * x) / determinant, values[2] / Math.hypot(a, b), values[3] / Math.hypot(c, d), true);
|
||
} else if (item.shape) {
|
||
if (lineShape(item.shape)) { values[2] *= Math.sign(item.geometry.width); values[3] *= Math.sign(item.geometry.height); }
|
||
saved = await changeDocument("ofdgoReshapeObject", item, ...values);
|
||
} else saved = await changeDocument("ofdgoResizeObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, ...values);
|
||
if (saved) el.objectBoundsPanel.close();
|
||
});
|
||
el.objectStyleCancel.addEventListener("click", () => el.objectStylePanel.close());
|
||
document.querySelector("#textReflow").addEventListener("click", reflowCanvasText);
|
||
el.objectLinkKind.addEventListener("change", updateObjectLinkFields);
|
||
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.gradientTarget.addEventListener("change", () => {
|
||
state.gradientForms[state.gradientTarget].form = gradientForm();
|
||
loadGradientForm(el.gradientTarget.value);
|
||
});
|
||
el.patternTarget.addEventListener("change", () => {
|
||
state.patternForms[state.patternTarget] = patternForm();
|
||
loadPatternForm(el.patternTarget.value);
|
||
});
|
||
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();
|
||
});
|
||
el.outlineCancel.addEventListener("click", () => el.outlinePanel.close());
|
||
el.structureManage.addEventListener("click", openStructureDialog);
|
||
document.querySelector("#structureCancel").addEventListener("click", () => el.structureDialog.close());
|
||
el.structureEntry.addEventListener("change", refreshStructureNodes);
|
||
el.structureNodeSelect.addEventListener("change", refreshStructureFields);
|
||
el.structureAction.addEventListener("change", refreshStructureFields);
|
||
document.querySelector("#structureAttributeAdd").addEventListener("click", () => addStructureAttribute());
|
||
document.querySelector("#structureReferenceAdd").addEventListener("click", () => addStructureReference());
|
||
document.querySelector("#structureSelection").addEventListener("click", () => {
|
||
const items = canvasEditor.items();
|
||
if (!items.length) { el.structureStatus.textContent = "尚未选择图元"; return; }
|
||
const page = state.doc.pages[items[0].index];
|
||
addStructureReference({ pageId: page.id, objectIds: items.map(item => item.id) });
|
||
});
|
||
el.structureForm.addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
if (!state.editing || document.body.hasAttribute("data-busy")) return;
|
||
const index = Number(el.structureEntry.value), path = JSON.parse(el.structureNodeSelect.value || "[]");
|
||
if (state.doc.structures?.[index]?.internal) return;
|
||
const action = index < 0 ? "create" : el.structureAction.value;
|
||
let original = state.doc.structures?.[index]?.root;
|
||
for (const part of path) original = original?.children?.[part];
|
||
const text = action === "update" && original?.text?.replace(/\r\n?/g, "\n") === el.structureText.value
|
||
? original.text : el.structureText.value;
|
||
const node = { name: el.structureName.value, namespace: el.structureNamespace.value, text,
|
||
attributes: [...el.structureAttributes.children].map(row => ({ name: row.querySelector('[name="name"]').value, namespace: row.querySelector('[name="namespace"]').value, value: row.querySelector('[name="value"]').value })) };
|
||
const references = [...el.structureReferences.children].map(row => ({ pageId: row.querySelector('[name="page"]').value,
|
||
objectIds: row.querySelector('[name="ids"]').value.split(/[,\s]+/u).filter(Boolean) })).filter(ref => ref.objectIds.length);
|
||
el.structureStatus.textContent = "";
|
||
const saved = await changeDocument("ofdgoChangeStructure", null, JSON.stringify({ action, index, path, typeId: el.structureType.value, node, references }));
|
||
if (saved) el.structureDialog.close();
|
||
});
|
||
el.outlineForm.addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
const path = state.outlineAction === "add" ? JSON.parse(el.outlineParent.value) : state.outlineSelection;
|
||
const page = el.outlinePage.value === "" ? -1 : Number(el.outlinePage.value) - 1;
|
||
const saved = state.outlineAction === "move"
|
||
? await changeDocument("ofdgoMoveOutline", null, state.outlineSelection, JSON.parse(el.outlineParent.value), Number(el.outlinePosition.value) - 1)
|
||
: await changeDocument("ofdgoChangeOutline", null, state.outlineAction, path, el.outlineTitle.value, page);
|
||
if (saved) el.outlinePanel.close();
|
||
});
|
||
el.outlineParent.addEventListener("change", updateOutlinePosition);
|
||
el.pageCancel.addEventListener("click", () => el.pagePanel.close());
|
||
el.pageWidth.addEventListener("input", updatePageDirection);
|
||
el.pageHeight.addEventListener("input", updatePageDirection);
|
||
for (const radio of [el.pagePortrait, el.pageLandscape]) {
|
||
radio.addEventListener("change", () => {
|
||
[el.pageWidth.value, el.pageHeight.value] = [el.pageHeight.value, el.pageWidth.value];
|
||
updatePageDirection();
|
||
});
|
||
}
|
||
el.pageForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
el.pageStatus.textContent = "";
|
||
if (await changeDocument("ofdgoBatchPages", null, "resize", selectedPageRange(), Number(el.pageWidth.value), Number(el.pageHeight.value))) {
|
||
el.pagePanel.close();
|
||
}
|
||
});
|
||
el.ofdButton.addEventListener("click", openOFDFile);
|
||
batchElements.Button.addEventListener("click", () => { updateBatchControls(); batchElements.Panel.showModal(); });
|
||
batchElements.Add.addEventListener("click", () => batchElements.Input.click());
|
||
batchElements.Input.addEventListener("change", () => { addBatchFiles(batchElements.Input.files); batchElements.Input.value = ""; });
|
||
batchElements.Clear.addEventListener("click", () => { batch.items = []; renderBatchList(); batchElements.Status.textContent = ""; batchElements.Progress.value = 0; });
|
||
batchElements.Format.addEventListener("change", updateBatchControls);
|
||
batchElements.Close.addEventListener("click", () => batchElements.Panel.close());
|
||
batchElements.Cancel.addEventListener("click", cancelBatch);
|
||
batchElements.Form.addEventListener("submit", event => { event.preventDefault(); runBatch(); });
|
||
batchElements.Panel.addEventListener("dragover", event => { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = batch.running ? "none" : "copy"; });
|
||
batchElements.Panel.addEventListener("drop", event => { event.preventDefault(); event.stopPropagation(); if (!batch.running) addBatchFiles(event.dataTransfer.files); });
|
||
batchElements.Destination.querySelector('[value="directory"]').disabled = !nativeHost && !window.showDirectoryPicker;
|
||
batchElements.Destination.value = nativeHost || window.showDirectoryPicker ? "directory" : "archive";
|
||
fontBindingElements.Cancel.addEventListener("click", () => fontBindingElements.Panel.close());
|
||
fontBindingElements.Panel.addEventListener("close", () => { fontBinding.font = null; fontBinding.choices = []; fontBinding.sequence++; fontBindingElements.File.value = ""; });
|
||
fontBindingElements.Source.addEventListener("change", () => {
|
||
if (fontBindingElements.Source.value === "file") { fontBindingElements.Source.value = ""; fontBindingElements.File.click(); }
|
||
});
|
||
fontBindingElements.File.addEventListener("change", async () => {
|
||
const file = fontBindingElements.File.files[0], openSeq = state.openSeq;
|
||
if (!file) return;
|
||
const sequence = ++fontBinding.sequence;
|
||
fontBindingElements.File.value = "";
|
||
try {
|
||
const data = new Uint8Array(await file.arrayBuffer());
|
||
const faces = await callWASM("ofdgoFontFaces", data);
|
||
if (sequence !== fontBinding.sequence || openSeq !== state.openSeq) return;
|
||
for (const face of faces) {
|
||
const value = String(fontBinding.choices.length);
|
||
fontBinding.choices.push({ data, index: face.index });
|
||
fontBindingElements.Source.add(new Option(face.fullName || face.family || file.name, value));
|
||
if (faces.length === 1) fontBindingElements.Source.value = value;
|
||
}
|
||
fontBindingElements.Status.textContent = faces.length > 1 ? "请选择字体" : "";
|
||
} catch (err) { if (sequence === fontBinding.sequence) fontBindingElements.Status.textContent = err.message; }
|
||
});
|
||
fontBindingElements.Form.addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
const font = fontBinding.font, sequence = fontBinding.sequence, openSeq = state.openSeq;
|
||
if (!font || document.body.hasAttribute("data-busy") || !fontBindingElements.Form.reportValidity()) return;
|
||
fontBindingElements.Form.inert = true;
|
||
try {
|
||
let data = null;
|
||
if (!font.embedded) {
|
||
const choice = fontBinding.choices[Number(fontBindingElements.Source.value)];
|
||
if (!choice) throw new Error("请选择字体");
|
||
data = choice.data ? (await callWASM("ofdgoFontFace", choice.data, choice.index || 0)).bytes : await readTextFont(choice);
|
||
}
|
||
if (sequence !== fontBinding.sequence || openSeq !== state.openSeq) return;
|
||
if (await changeDocument("ofdgoChangeFontBinding", null, font.id, font.embedded ? 2 : 1, data)) fontBindingElements.Panel.close();
|
||
} catch (err) { if (sequence === fontBinding.sequence) fontBindingElements.Status.textContent = fontBindingError(err.message); }
|
||
finally { if (sequence === fontBinding.sequence) fontBindingElements.Form.inert = false; }
|
||
});
|
||
el.newButton.addEventListener("click", openCreatePanel);
|
||
el.editButton.addEventListener("click", toggleEditor);
|
||
el.editButton.addEventListener("pointerdown", (event) => {
|
||
if (canvasEditor.input) event.preventDefault();
|
||
});
|
||
el.createCancel.addEventListener("click", () => state.imageImporting ? cancelExport() : el.createPanel.close());
|
||
el.createPanel.addEventListener("cancel", event => {
|
||
if (state.imageImporting) { event.preventDefault(); cancelExport(); }
|
||
});
|
||
el.createPanel.addEventListener("close", () => { el.createImages.value = ""; });
|
||
el.createMode.addEventListener("change", updateCreateOptions);
|
||
el.createSize.addEventListener("change", updateCreateOptions);
|
||
el.createForm.addEventListener("submit", createDocument);
|
||
editorClick(el.insertTextButton, toggleTextTool);
|
||
editorClick(el.insertImageButton, () => openInsertPanel());
|
||
el.insertCancel.addEventListener("click", () => el.insertPanel.close());
|
||
el.insertPanel.addEventListener("close", () => { state.insertObject = null; });
|
||
el.insertForm.addEventListener("submit", insertObject);
|
||
el.textFontAdd.addEventListener("click", () => openFontFile(el.fontInput));
|
||
el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd"));
|
||
editorClick(el.encryptSaveButton, openEncryptionPanel);
|
||
el.signButton.addEventListener("click", () => {
|
||
if (!state.doc || document.body.hasAttribute("data-busy")) return;
|
||
el.signForm.reset();
|
||
el.signPages.value = state.editing && state.pageSelection.size ? selectedPageRange() : String(state.pageIndex + 1);
|
||
el.signStatus.textContent = "";
|
||
updateSignPlacement();
|
||
el.signPanel.showModal();
|
||
});
|
||
el.signSeal.addEventListener("change", updateSignPlacement);
|
||
el.signPlacement.addEventListener("change", updateSignPlacement);
|
||
el.signCancel.addEventListener("click", () => state.signing ? cancelSigning() : el.signPanel.close());
|
||
el.signPanel.addEventListener("cancel", event => {
|
||
if (state.signing) { event.preventDefault(); cancelSigning(); }
|
||
});
|
||
el.signPanel.addEventListener("close", () => { if (!el.signPanel.open && !sealPreview) el.signForm.reset(); });
|
||
el.signForm.addEventListener("submit", signDocument);
|
||
const sealPosition = Object.fromEntries(["Layer", "Stamp", "Image"].map(name => [name, document.querySelector(`#signPosition${name}`)]));
|
||
let sealPreview = null;
|
||
document.querySelector("#signPick").addEventListener("click", openSealPosition);
|
||
el.viewerPanel.addEventListener("pointerdown", beginSealDrag, true);
|
||
el.viewerPanel.addEventListener("pointerdown", pickResourceObject, true);
|
||
document.addEventListener("keydown", event => {
|
||
if (!resourceManager.pick || el.objectPicker.open) return;
|
||
if (event.key === "Escape") { event.preventDefault(); event.stopImmediatePropagation(); finishResourcePick(); return; }
|
||
if (event.target.closest?.("input, textarea, select")) return;
|
||
if (event.key !== "Tab") { event.preventDefault(); event.stopImmediatePropagation(); }
|
||
}, true);
|
||
el.viewerPanel.addEventListener("pointermove", moveSealDrag, true);
|
||
el.viewerPanel.addEventListener("pointerup", confirmSealPosition, true);
|
||
el.viewerPanel.addEventListener("pointercancel", () => { if (sealPreview) finishSealPosition(); }, true);
|
||
el.verifyButton.addEventListener("click", () => {
|
||
if (!state.doc || document.body.hasAttribute("data-busy")) return;
|
||
el.verifyForm.reset();
|
||
el.verifyStatus.textContent = "";
|
||
el.verifyPanel.showModal();
|
||
});
|
||
el.verifyCancel.addEventListener("click", () => el.verifyPanel.close());
|
||
el.verifyPanel.addEventListener("close", () => { if (!el.verifyPanel.open) el.verifyForm.reset(); });
|
||
el.verifyForm.addEventListener("submit", verifyDocument);
|
||
el.credentialsCancel.addEventListener("click", () => finishCredentials(null));
|
||
el.credentialsPanel.addEventListener("cancel", event => { event.preventDefault(); finishCredentials(null); });
|
||
el.credentialsPanel.addEventListener("close", () => { if (!el.credentialsPanel.open) finishCredentials(null); });
|
||
el.credentialsType.addEventListener("change", updateCredentialsType);
|
||
el.credentialsForm.addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
if (!credentialRequest) return;
|
||
if (el.credentialsType.value === "certificate") {
|
||
const request = credentialRequest;
|
||
let key;
|
||
const keyPassword = new TextEncoder().encode(el.credentialsKeyPassword.value);
|
||
el.credentialsKeyPassword.value = "";
|
||
try {
|
||
key = (await securityFiles(el.credentialsKey))[0];
|
||
const certificate = (await securityFiles(el.credentialsCertificate))[0];
|
||
if (credentialRequest !== request) { key?.fill(0); keyPassword.fill(0); return; }
|
||
if (!key || !certificate) { el.credentialsStatus.textContent = "请选择证书和私钥"; key?.fill(0); keyPassword.fill(0); return; }
|
||
finishCredentials({password:new Uint8Array(), key, keyPassword, certificate, userName:el.credentialsUser.value.trim()});
|
||
} catch {
|
||
key?.fill(0);
|
||
keyPassword.fill(0);
|
||
if (credentialRequest === request) el.credentialsStatus.textContent = "文件读取失败";
|
||
}
|
||
return;
|
||
}
|
||
if (!el.credentialsPassword.value) return;
|
||
finishCredentials({ password: new TextEncoder().encode(el.credentialsPassword.value), userName: el.credentialsUser.value.trim() });
|
||
});
|
||
el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages"));
|
||
el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta"));
|
||
el.pagesTab.addEventListener("click", () => showNavigation(el.pagesTab));
|
||
el.annotationsTab.addEventListener("click", () => showNavigation(el.annotationsTab));
|
||
el.annotationsFilter.addEventListener("input", renderAnnotationSummary);
|
||
el.annotationsKind.addEventListener("change", renderAnnotationSummary);
|
||
el.versionExport.addEventListener("click", exportManagedVersion);
|
||
editorClick(settingsElements.Button, () => openDocumentSettings());
|
||
editorClick(el.invoiceButton, () => openDocumentSettings(true));
|
||
settingsElements.Kind.addEventListener("change", () => { rememberSettingsDraft(); settings.selected = -1; loadDocumentSettings(); });
|
||
settingsElements.Form.addEventListener("submit", event => event.preventDefault());
|
||
settingsElements.Panel.addEventListener("cancel", event => { event.preventDefault(); closeDocumentSettings(); });
|
||
settingsElements.Panel.addEventListener("close", () => { if (!settings.suspended) { settings.sequence++; settings.drafts.clear(); settings.view = ""; invoicePanel.drafts.clear(); invoicePanel.key = ""; } });
|
||
document.querySelector("#objectActionsButton").addEventListener("click", async () => {
|
||
const item = canvasEditor.selected;
|
||
if (!item || item.scoped || item.items) return;
|
||
const original = state.objectLinkOriginal;
|
||
if ((el.objectLinkKind.value !== original.kind || el.objectLinkAddress.value !== original.address || el.objectLinkPage.value !== original.page)
|
||
&& !window.confirm("链接尚未应用,放弃链接更改?")) return;
|
||
syncObjectLink(item);
|
||
settings.scope = "object";
|
||
settingsElements.Kind.value = "actions";
|
||
await openDocumentSettings();
|
||
settingsElements.Kind.disabled = true;
|
||
});
|
||
el.outlinesTab.addEventListener("click", () => showNavigation(el.outlinesTab));
|
||
el.searchTab.addEventListener("click", focusSearch);
|
||
el.navigationTabs.addEventListener("keydown", (event) => {
|
||
if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||
return;
|
||
}
|
||
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
|
||
event.preventDefault();
|
||
const tabs = [el.pagesTab, el.outlinesTab, el.annotationsTab, el.searchTab].filter((tab) => !tab.hidden);
|
||
const current = tabs.indexOf(event.target);
|
||
const index = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1
|
||
: (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
|
||
showNavigation(tabs[index]);
|
||
tabs[index].focus({ preventScroll: true });
|
||
}
|
||
});
|
||
el.searchForm.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
searchDocument();
|
||
});
|
||
el.searchInput.addEventListener("input", () => resetSearch(false));
|
||
el.searchPrev.addEventListener("click", () => selectSearchMatch(Math.max(0, state.searchIndex) - 1));
|
||
el.searchNext.addEventListener("click", () => selectSearchMatch(state.searchIndex + 1));
|
||
document.addEventListener("keydown", handleKeyDown);
|
||
document.addEventListener("keydown", event => {
|
||
if (event.key === "Tab") el.viewerPanel.classList.add("keyboard-focus");
|
||
}, true);
|
||
document.addEventListener("pointerdown", () => el.viewerPanel.classList.remove("keyboard-focus"), true);
|
||
el.fontAddButton.addEventListener("click", () => openFontFile(el.fontInput));
|
||
el.fontDirectoryButton.addEventListener("click", () => openFontFile(el.fontDirectoryInput));
|
||
el.localFontButton.addEventListener("click", loadLocalFonts);
|
||
el.ofdInput.addEventListener("change", () => openOFD(el.ofdInput.files[0]));
|
||
el.fontInput.addEventListener("change", openSelectedFonts);
|
||
el.fontDirectoryInput.addEventListener("change", openSelectedFonts);
|
||
el.prevButton.addEventListener("click", () => renderPage(state.pageIndex - 1));
|
||
el.imageDPI.addEventListener("change", () => changeDisplayMode());
|
||
el.renderVectorButton.addEventListener("click", () => changeDisplayMode("svg"));
|
||
el.renderRasterButton.addEventListener("click", () => changeDisplayMode("raster"));
|
||
el.nextButton.addEventListener("click", () => renderPage(state.pageIndex + 1));
|
||
el.zoomOutButton.addEventListener("click", () => setScale(state.scale - 0.1));
|
||
el.zoomInButton.addEventListener("click", () => setScale(state.scale + 0.1));
|
||
el.fitButton.addEventListener("click", fitWidth);
|
||
el.fitHeightButton.addEventListener("click", fitHeight);
|
||
el.rotateButton.addEventListener("click", rotatePages);
|
||
el.panButton.addEventListener("click", togglePan);
|
||
el.continuousButton.addEventListener("click", toggleContinuous);
|
||
el.annotationButton.addEventListener("click", toggleAnnotations);
|
||
document.addEventListener("copy", copySelection);
|
||
document.addEventListener("cut", copySelection);
|
||
el.viewerPanel.addEventListener("paste", pasteEditorContent);
|
||
document.addEventListener("selectionchange", syncSelection);
|
||
el.exportFormat.addEventListener("change", () => updateDPIControl());
|
||
el.outputCompression.addEventListener("change", () => {
|
||
el.compressionValue.textContent = el.outputCompression.selectedOptions[0].textContent;
|
||
el.compressionValue.dataset.short = ["原", "无", "轻", "均", "强"][Number(el.outputCompression.value)];
|
||
});
|
||
el.exportPageButton.addEventListener("click", () => exportFile(false));
|
||
el.exportButton.addEventListener("click", () => openExportPanel());
|
||
el.exportCancel.addEventListener("click", () => el.exportPanel.close());
|
||
el.encryptionPanel.addEventListener("close", () => { if (!el.encryptionPanel.open) clearEncryptionForm(); });
|
||
el.encryptionPanel.addEventListener("cancel", event => { if (el.encryptionForm.inert) event.preventDefault(); });
|
||
el.encryptionCancel.addEventListener("click", () => el.encryptionPanel.close());
|
||
el.encryptionType.addEventListener("change", () => {
|
||
state.encryptionChanged = false;
|
||
updateEncryptionType();
|
||
});
|
||
el.encryptionChange.addEventListener("click", () => {
|
||
state.encryptionChanged = true;
|
||
updateEncryptionType();
|
||
(el.encryptionType.value === "password" ? el.encryptionPassword : el.encryptionCertificates).focus();
|
||
});
|
||
el.encryptionForm.addEventListener("submit", applyEncryption);
|
||
el.cancelExportButton.addEventListener("click", cancelExport);
|
||
el.exportPanel.addEventListener("pointerdown", (event) => {
|
||
state.exportBackdrop = event.target === el.exportPanel;
|
||
});
|
||
el.exportPanel.addEventListener("click", (event) => {
|
||
if (state.exportBackdrop && event.target === el.exportPanel) {
|
||
el.exportPanel.close();
|
||
}
|
||
});
|
||
el.exportAll.addEventListener("change", updateExportRange);
|
||
el.exportSpecified.addEventListener("change", () => {
|
||
updateExportRange();
|
||
el.exportRange.focus();
|
||
});
|
||
el.exportRange.addEventListener("input", updateExportRange);
|
||
el.exportForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
if (!el.exportSubmit.disabled) {
|
||
return exportFile(true, state.exportPages);
|
||
}
|
||
});
|
||
el.refreshAppButton.addEventListener("click", refreshApplication);
|
||
el.pageInput.addEventListener("change", () => {
|
||
const page = Number.parseInt(el.pageInput.value, 10);
|
||
if (Number.isFinite(page)) {
|
||
renderPage(page - 1);
|
||
}
|
||
});
|
||
window.addEventListener("resize", resizeViewer);
|
||
window.addEventListener("blur", () => endPan());
|
||
COMPACT_LAYOUT.addEventListener("change", syncLayoutMode);
|
||
el.viewerPanel.addEventListener("scroll", () => {
|
||
schedulePageSync();
|
||
});
|
||
el.navigationContent.addEventListener("scroll", () => {
|
||
if (state.thumbnailFrame) return;
|
||
state.thumbnailFrame = requestAnimationFrame(() => {
|
||
state.thumbnailFrame = 0;
|
||
syncThumbnailWindow();
|
||
});
|
||
});
|
||
for (const [viewport, current] of [[el.viewerPanel, () => state.pageWindow], [el.navigationContent, () => el.pageList.hidden ? null : state.thumbnailWindow]]) {
|
||
viewport.addEventListener("wheel", event => {
|
||
const view = current();
|
||
if (!view || view.total <= SCROLL_SEGMENT_SIZE || event.ctrlKey || event.shiftKey || !event.deltaY) return;
|
||
event.preventDefault();
|
||
view.relativeScroll = false;
|
||
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1;
|
||
scrollPageWindowBy(view, event.deltaY * unit);
|
||
viewport.scrollLeft += event.deltaX * unit;
|
||
}, { passive: false });
|
||
viewport.addEventListener("pointerdown", event => {
|
||
const view = current();
|
||
if (view) view.relativeScroll = event.pointerType === "touch" || state.editing && !!event.target.closest(".page-shell");
|
||
});
|
||
}
|
||
el.viewerPanel.addEventListener("pointerdown", startPan);
|
||
el.viewerPanel.addEventListener("pointermove", movePan);
|
||
el.viewerPanel.addEventListener("pointerup", endPan);
|
||
el.viewerPanel.addEventListener("pointercancel", endPan);
|
||
el.viewerPanel.addEventListener("lostpointercapture", endPan);
|
||
el.viewerPanel.addEventListener("click", () => {
|
||
if (COMPACT_LAYOUT.matches && (state.showPages || state.showMeta)) {
|
||
state.showPages = false;
|
||
state.showMeta = false;
|
||
updateSidebarState();
|
||
}
|
||
});
|
||
el.viewerPanel.addEventListener("dblclick", openOFDFromViewer);
|
||
document.addEventListener("dragover", (event) => {
|
||
if (event.dataTransfer.types.includes("Files")) {
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = document.body.hasAttribute("data-busy") ? "none" : "copy";
|
||
}
|
||
});
|
||
document.addEventListener("drop", openOFDFromDrop);
|
||
el.fontList.addEventListener("focusout", () => {
|
||
if (state.fontSyncPending) {
|
||
window.setTimeout(syncUserFonts, 0);
|
||
}
|
||
});
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (!document.hidden) {
|
||
scheduleFontSync();
|
||
}
|
||
});
|
||
|
||
el.fontDirectoryButton.disabled = !("webkitdirectory" in el.fontDirectoryInput);
|
||
nativeHost?.installFileInputs(error => showError(error, false));
|
||
updateSidebarState();
|
||
boot();
|
||
|
||
function handleKeyDown(event) {
|
||
if (sealPreview) {
|
||
if (event.key === "Escape") { event.preventDefault(); finishSealPosition(); }
|
||
return;
|
||
}
|
||
if (event.defaultPrevented || event.isComposing || event.altKey) {
|
||
return;
|
||
}
|
||
const key = event.key;
|
||
const target = event.target;
|
||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && key.toLowerCase() === "s" && state.doc
|
||
&& (state.editorInfo || state.ofdBytes)) {
|
||
event.preventDefault();
|
||
if (!document.body.hasAttribute("data-busy") && !formDialogOpen()) exportFile(true, null, "ofd");
|
||
return;
|
||
}
|
||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && key.toLowerCase() === "a" && state.doc
|
||
&& el.viewerPanel.contains(target) && !target.closest("input, textarea, select, [contenteditable]")) {
|
||
event.preventDefault();
|
||
if (!document.body.hasAttribute("data-busy") && !formDialogOpen()) {
|
||
selectDocumentText();
|
||
}
|
||
return;
|
||
}
|
||
if (document.body.hasAttribute("data-busy") || formDialogOpen()) {
|
||
return;
|
||
}
|
||
if (state.pageWindow && (!event.shiftKey || key === " ") && el.viewerPanel.contains(target)
|
||
&& !target.closest("input, textarea, select, button, a, [contenteditable]")) {
|
||
const view = state.pageWindow;
|
||
const delta = { ArrowUp: -40, ArrowDown: 40, PageUp: -el.viewerPanel.clientHeight * 0.9, PageDown: el.viewerPanel.clientHeight * 0.9,
|
||
" ": el.viewerPanel.clientHeight * 0.9 * (event.shiftKey ? -1 : 1) }[key];
|
||
if (key === "Home" || key === "End") {
|
||
event.preventDefault();
|
||
view.relativeScroll = false;
|
||
syncPageWindow(view, key === "Home" ? -windowInset(view) : view.total);
|
||
syncCurrentPageFromScroll();
|
||
return;
|
||
}
|
||
if (delta !== undefined && !event.ctrlKey && !event.metaKey && view.total > SCROLL_SEGMENT_SIZE) {
|
||
event.preventDefault();
|
||
scrollPageWindowBy(view, delta);
|
||
return;
|
||
}
|
||
}
|
||
if (event.ctrlKey || event.metaKey) {
|
||
if (state.editing && !target.closest("input, textarea, select, [contenteditable]")
|
||
&& (key.toLowerCase() === "z" || (!event.shiftKey && key.toLowerCase() === "y"))) {
|
||
event.preventDefault();
|
||
(key.toLowerCase() === "y" || event.shiftKey ? el.redoButton : el.undoButton).click();
|
||
} else if (!event.shiftKey && key.toLowerCase() === "o") {
|
||
event.preventDefault();
|
||
openOFDFile();
|
||
} else if (!event.shiftKey && key.toLowerCase() === "f" && state.doc) {
|
||
event.preventDefault();
|
||
focusSearch();
|
||
}
|
||
return;
|
||
}
|
||
if (!state.doc) {
|
||
return;
|
||
}
|
||
if (key === "Escape" && !event.shiftKey && state.documentSelection && el.viewerPanel.contains(target)
|
||
&& !target.closest("input, textarea, select, [contenteditable]")) {
|
||
event.preventDefault();
|
||
document.getSelection().removeAllRanges();
|
||
syncSelection();
|
||
return;
|
||
}
|
||
if (key === "F3") {
|
||
event.preventDefault();
|
||
if (state.searchMatches.length) {
|
||
selectSearchMatch(event.shiftKey ? Math.max(0, state.searchIndex) - 1 : state.searchIndex + 1);
|
||
} else {
|
||
focusSearch();
|
||
}
|
||
return;
|
||
}
|
||
if (key === "Escape" && !event.shiftKey && !el.searchPanel.hidden && (el.pageListPanel.contains(target) || el.viewerPanel.contains(target))) {
|
||
event.preventDefault();
|
||
resetSearch();
|
||
showNavigation(el.pagesTab);
|
||
if (COMPACT_LAYOUT.matches && state.showPages) {
|
||
state.showPages = false;
|
||
updateSidebarState();
|
||
}
|
||
canvasEditor.focus();
|
||
return;
|
||
}
|
||
if (key === "Enter" && target === el.pageInput && !event.shiftKey) {
|
||
event.preventDefault();
|
||
if (!event.repeat) el.pageInput.dispatchEvent(new Event("change"));
|
||
return;
|
||
}
|
||
if (key === "Enter" && target === el.searchInput && event.shiftKey && state.searchMatches.length) {
|
||
event.preventDefault();
|
||
selectSearchMatch(Math.max(0, state.searchIndex) - 1);
|
||
return;
|
||
}
|
||
if (!el.viewerPanel.contains(target) || target.closest("input, textarea, select, button, a, [contenteditable]")) {
|
||
return;
|
||
}
|
||
let button;
|
||
if (key === "+" || (key === "=" && !event.shiftKey)) {
|
||
button = el.zoomInButton;
|
||
} else if (key === "-" && !event.shiftKey) {
|
||
button = el.zoomOutButton;
|
||
} else if (!event.shiftKey && (key === "ArrowLeft" || key === "ArrowRight")) {
|
||
if (document.getSelection().isCollapsed && el.viewerPanel.scrollWidth <= el.viewerPanel.clientWidth) {
|
||
button = key === "ArrowLeft" ? el.prevButton : el.nextButton;
|
||
}
|
||
}
|
||
if (button) {
|
||
event.preventDefault();
|
||
button.click();
|
||
}
|
||
}
|
||
|
||
function formDialogOpen() {
|
||
return Boolean(sealPreview || resourceManager.pick) || settingsElements.Panel.open || fontBindingElements.Panel.open || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open
|
||
|| resourceElements.Panel.open || el.structureDialog.open || el.versionPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
|
||
}
|
||
|
||
function setObjectDimension(input, value) {
|
||
input.value = String(Number(value.toFixed(2)));
|
||
input.defaultValue = input.value;
|
||
input.dataset.precise = String(value);
|
||
}
|
||
|
||
function objectDimension(input) {
|
||
return Number(input.value === input.defaultValue ? input.dataset.precise : input.value);
|
||
}
|
||
|
||
function openObjectBounds() {
|
||
const item = canvasEditor.selected;
|
||
let box = item.geometry || selectionBounds((item.items || [item]).map(member => member.bounds || member));
|
||
if (item.oriented) {
|
||
const frame = item.oriented, [a, b, c, d, e, f] = frame.matrix;
|
||
box = { x: a * frame.box.x + c * frame.box.y + e, y: b * frame.box.x + d * frame.box.y + f,
|
||
width: frame.box.width * Math.hypot(a, b), height: frame.box.height * Math.hypot(c, d) };
|
||
}
|
||
state.boundsOriginal = { x: box.x, y: box.y, width: Math.abs(box.width), height: Math.abs(box.height) };
|
||
for (const [input, key] of [[el.objectX,"x"], [el.objectY,"y"], [el.objectWidth,"width"], [el.objectHeight,"height"]]) setObjectDimension(input, state.boundsOriginal[key]);
|
||
el.objectAspect.checked = true;
|
||
el.objectAspect.disabled = !item.shape && !item.oriented && !canEditObject(item, "stretch") || lineShape(item.shape) && (!box.width || !box.height);
|
||
el.objectWidth.disabled = lineShape(item.shape) && !box.width;
|
||
el.objectHeight.disabled = lineShape(item.shape) && !box.height;
|
||
el.objectBoundsStatus.textContent = "";
|
||
el.objectBoundsPanel.showModal();
|
||
}
|
||
|
||
function canCopyStyle(item) {
|
||
if (item?.type === "Annotation" || item?.richText) return false;
|
||
if (!item?.scoped) return canEditObject(item, "update");
|
||
if (item.type === "TextObject") return canEditObject(item, "replaceFont") && canEditObject(item, "paint");
|
||
if (item.type === "PathObject") return canEditObject(item, "transform") && canEditObject(item, "paint");
|
||
return item.type === "ImageObject" && canEditObject(item, "transform");
|
||
}
|
||
|
||
function syncObjectLink(item) {
|
||
const link = item.link ? JSON.parse(item.link) : null;
|
||
const targetPage = link?.Goto?.Dest ? pageIndexByID(state.doc.pages, link.Goto.Dest.PageID) : -1;
|
||
el.objectLinkKind.value = item.linkCount > 1 ? "keep" : link?.URI ? "uri" : targetPage >= 0 ? "page" : link ? "keep" : "none";
|
||
el.objectLinkKind.disabled = item.linkCount > 1;
|
||
el.objectLinkAddress.value = link?.URI?.URI || "";
|
||
el.objectLinkPage.value = String(targetPage >= 0 ? targetPage + 1 : 1);
|
||
el.objectLinkPage.max = String(state.doc?.pages.length || 1);
|
||
state.objectLinkOriginal = {kind: el.objectLinkKind.value, address: el.objectLinkAddress.value, page: el.objectLinkPage.value, link};
|
||
updateObjectLinkFields();
|
||
}
|
||
|
||
function openObjectStyle() {
|
||
const items = canvasEditor.items();
|
||
if (!items.length) return;
|
||
const ordered = [...items].sort((a, b) => a.position - b.position);
|
||
el.groupObjectsButton.disabled = items.length < 2 || !ordered.every((item, index) => canEditObject(item, "copy") && canEditObject(item, "order") && item.container === ordered[0].container && item.position === ordered[0].position + index);
|
||
el.ungroupObjectButton.disabled = items.length !== 1 || Boolean(items[0].richText) || !canEditObject(items[0], "ungroup");
|
||
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;
|
||
el.objectLinkFields.hidden = items.length !== 1 || items[0].scoped;
|
||
syncObjectLink(items[0]);
|
||
el.objectBorderFields.hidden = items.length !== 1 || items[0].scoped || items[0].type !== "ImageObject";
|
||
const border = items[0].borderStyle ? JSON.parse(items[0].borderStyle) : null;
|
||
el.borderEnabled.checked = Boolean(border);
|
||
el.borderWidth.value = String(border?.LineWidth ?? .353);
|
||
el.borderHorizontal.value = String(border?.HorizonalCornerRadius ?? 0);
|
||
el.borderVertical.value = String(border?.VerticalCornerRadius ?? 0);
|
||
el.borderColor.value = items[0].borderColor || "#000000";
|
||
el.borderColor.disabled = Boolean(border && !items[0].borderColor);
|
||
el.borderColor.hidden = el.borderColor.disabled;
|
||
el.borderColorOriginal.hidden = !el.borderColor.disabled;
|
||
state.borderOriginal = {
|
||
Enabled: el.borderEnabled.checked, LineWidth: el.borderWidth.value,
|
||
HorizontalRadius: el.borderHorizontal.value, VerticalRadius: el.borderVertical.value,
|
||
Color: el.borderColor.value
|
||
};
|
||
el.objectGradientFields.hidden = items.length !== 1 || items[0].scoped || items[0].type !== "PathObject" || !items[0].paintSize || !canEditObject(items[0], "paint");
|
||
state.gradientForms = {};
|
||
for (const target of ["fill", "stroke"]) {
|
||
const gradient = items[0][`${target}Gradient`], base = gradient ? JSON.parse(gradient.paint) : null;
|
||
const node = base?.AxialShd || base?.RadialShd;
|
||
const start = node?.StartPoint.split(/\s+/).map(Number), end = node?.EndPoint.split(/\s+/).map(Number);
|
||
const form = {
|
||
kind: base?.AxialShd ? "linear" : base?.RadialShd ? "radial" : "keep",
|
||
angle: String(base?.AxialShd ? Math.atan2(end[1] - start[1], end[0] - start[0]) * 180 / Math.PI : 0),
|
||
stops: gradient ? gradient.stops.map((stop, i) => ({ position: String(stop.position), color: stop.color, transparency: String(stop.transparency), source: node.Segment[i] }))
|
||
: [{ position: "0", color: "#000000", transparency: "0" }, { position: "100", color: "#ffffff", transparency: "0" }]
|
||
};
|
||
state.gradientForms[target] = { base, form, original: JSON.stringify(form) };
|
||
}
|
||
el.gradientTarget.value = "fill";
|
||
loadGradientForm("fill");
|
||
state.patternForms = {};
|
||
for (const target of ["fill", "stroke"]) {
|
||
const pattern = items.length === 1 && !items[0].scoped && !items[0].richText ? items[0][`${target}Pattern`] : null;
|
||
if (pattern) state.patternForms[target] = Object.fromEntries(Object.entries(pattern).map(([key, value]) => [key, String(value)]));
|
||
}
|
||
state.patternOriginal = JSON.stringify(state.patternForms);
|
||
el.objectPatternFields.hidden = !Object.keys(state.patternForms).length;
|
||
el.patternTarget.disabled = Object.keys(state.patternForms).length < 2;
|
||
el.patternTarget.value = state.patternForms.fill ? "fill" : "stroke";
|
||
loadPatternForm(el.patternTarget.value);
|
||
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 ? "混合" : "";
|
||
el.objectStrokeFields.hidden = !items.every(item => item.type === "PathObject" && (!item.scoped || canEditObject(item, "paint")));
|
||
el.objectDash.value = dash === null ? "mixed" : dash === "" ? "solid" : "custom";
|
||
el.objectDashPattern.value = dash || "";
|
||
el.objectDashRow.hidden = el.objectDash.value !== "custom";
|
||
el.objectDashPattern.required = !el.objectStrokeFields.hidden && !el.objectDashRow.hidden;
|
||
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]));
|
||
el.objectStyleStatus.textContent = "";
|
||
el.objectStylePanel.showModal();
|
||
}
|
||
|
||
function readObjectStyle() {
|
||
const style = {}, changed = key => el[key].value !== state.styleOriginal[key];
|
||
if (!el.objectLinkFields.hidden) {
|
||
const original = state.objectLinkOriginal, kind = el.objectLinkKind.value;
|
||
if (kind !== "keep" && (kind !== original.kind || kind === "uri" && el.objectLinkAddress.value !== original.address || kind === "page" && el.objectLinkPage.value !== original.page)) {
|
||
style.link = kind === "none" ? null : JSON.stringify(kind === "uri"
|
||
? { URI: { ...original.link?.URI, URI: el.objectLinkAddress.value.trim() } }
|
||
: { Dest: { ...(original.link?.Goto?.Dest || { Type: "Fit" }), PageID: state.doc.pages[Number(el.objectLinkPage.value) - 1].id } });
|
||
}
|
||
}
|
||
if (!el.objectBorderFields.hidden) {
|
||
const border = {};
|
||
if (el.borderEnabled.checked !== state.borderOriginal.Enabled) border.Enabled = el.borderEnabled.checked;
|
||
if (el.borderEnabled.checked) {
|
||
for (const [input, key] of [[el.borderWidth, "LineWidth"], [el.borderHorizontal, "HorizontalRadius"], [el.borderVertical, "VerticalRadius"]]) {
|
||
if (input.value !== state.borderOriginal[key] || !state.borderOriginal.Enabled) border[key] = Number(input.value);
|
||
}
|
||
if (!el.borderColor.disabled && (el.borderColor.value !== state.borderOriginal.Color || !state.borderOriginal.Enabled)) {
|
||
border.Color = { Value: el.borderColor.value };
|
||
}
|
||
}
|
||
if (Object.keys(border).length) style.borderStyle = JSON.stringify(border);
|
||
}
|
||
if (!el.objectGradientFields.hidden) {
|
||
state.gradientForms[state.gradientTarget].form = gradientForm();
|
||
for (const target of ["fill", "stroke"]) {
|
||
const { base, form, original } = state.gradientForms[target];
|
||
if (form.kind === "keep" || JSON.stringify(form) === original) continue;
|
||
const [width, height] = canvasEditor.selected.paintSize;
|
||
const previous = JSON.parse(original);
|
||
const Segment = form.stops.map(stop => {
|
||
const old = previous.stops.find(entry => entry.source && JSON.stringify(entry.source) === JSON.stringify(stop.source));
|
||
const Color = old && old.color === stop.color ? { ...stop.source.Color } : { Value: stop.color };
|
||
if (!old || old.transparency !== stop.transparency || old.color !== stop.color) Color.Alpha = Math.round(255 * (1 - Number(stop.transparency) / 100));
|
||
return { Position: Number(stop.position) / 100, Color };
|
||
}).sort((a, b) => a.Position - b.Position);
|
||
const angle = Number(form.angle) * Math.PI / 180, x = Math.cos(angle), y = Math.sin(angle);
|
||
const length = Math.abs(width * x) + Math.abs(height * y);
|
||
let paint = form.kind === "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 } };
|
||
if (base && form.kind === previous.kind) {
|
||
paint = JSON.parse(JSON.stringify(base));
|
||
const node = paint.AxialShd || paint.RadialShd;
|
||
node.Segment = Segment;
|
||
if (paint.AxialShd && form.angle !== previous.angle) {
|
||
const start = node.StartPoint.split(/\s+/).map(Number), end = node.EndPoint.split(/\s+/).map(Number);
|
||
const half = Math.hypot(end[0] - start[0], end[1] - start[1]) / 2, cx = (start[0] + end[0]) / 2, cy = (start[1] + end[1]) / 2;
|
||
node.StartPoint = `${cx - x * half} ${cy - y * half}`;
|
||
node.EndPoint = `${cx + x * half} ${cy + y * half}`;
|
||
}
|
||
}
|
||
style[`${target}Paint`] = JSON.stringify(paint);
|
||
style[target] = true;
|
||
}
|
||
}
|
||
if (!el.objectPatternFields.hidden) {
|
||
state.patternForms[state.patternTarget] = patternForm();
|
||
const original = JSON.parse(state.patternOriginal);
|
||
for (const [target, form] of Object.entries(state.patternForms)) {
|
||
const pattern = {};
|
||
for (const [key, field] of [["width", "Width"], ["height", "Height"], ["xStep", "XStep"], ["yStep", "YStep"], ["ctm", "CTM"]]) {
|
||
if (form[key] !== original[target][key]) pattern[field] = key === "ctm" ? form[key].trim() : Number(form[key]);
|
||
}
|
||
if (Object.keys(pattern).length) style[`${target}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")) {
|
||
const patterns = { solid: "", dash: "3 2", dot: "0.3 1.5", dashdot: "3 1.5 0.3 1.5", custom: el.objectDashPattern.value.trim() };
|
||
if (el.objectDash.value in patterns) style.dashPattern = patterns[el.objectDash.value];
|
||
}
|
||
if (changed("objectDashOffset") && el.objectDashOffset.value !== "") style.dashOffset = Number(el.objectDashOffset.value);
|
||
if (changed("objectCap") && el.objectCap.value !== "mixed") style.cap = el.objectCap.value;
|
||
if (changed("objectJoin") && el.objectJoin.value !== "mixed") style.join = el.objectJoin.value;
|
||
}
|
||
return style;
|
||
}
|
||
|
||
function updateObjectLinkFields() {
|
||
const kind = el.objectLinkKind.value, visible = !el.objectLinkFields.hidden;
|
||
el.objectLinkAddressRow.hidden = !visible || kind !== "uri";
|
||
el.objectLinkPageRow.hidden = !visible || kind !== "page";
|
||
el.objectLinkAddress.disabled = el.objectLinkAddressRow.hidden;
|
||
el.objectLinkPage.disabled = el.objectLinkPageRow.hidden;
|
||
el.objectLinkAddress.required = visible && kind === "uri";
|
||
el.objectLinkPage.required = visible && kind === "page";
|
||
}
|
||
|
||
function gradientForm() {
|
||
return {
|
||
kind: el.gradientKind.value, angle: el.gradientAngle.value,
|
||
stops: [...el.gradientStops.children].map(row => ({ position: row.children[1].value, color: row.children[0].value, transparency: row.children[2].value, source: row.paintSource }))
|
||
};
|
||
}
|
||
|
||
function loadGradientForm(target) {
|
||
state.gradientTarget = target;
|
||
const form = state.gradientForms[target].form;
|
||
el.gradientKind.value = form.kind;
|
||
el.gradientAngle.value = form.angle;
|
||
el.gradientControls.hidden = form.kind === "keep";
|
||
el.gradientAngle.disabled = form.kind !== "linear";
|
||
el.gradientStops.replaceChildren();
|
||
for (const stop of form.stops) addGradientStop(stop.position, stop.color, stop.transparency, stop.source);
|
||
}
|
||
|
||
function patternForm() {
|
||
return { width: el.patternWidth.value, height: el.patternHeight.value, xStep: el.patternXStep.value, yStep: el.patternYStep.value, ctm: el.patternCTM.value };
|
||
}
|
||
|
||
function loadPatternForm(target) {
|
||
state.patternTarget = target;
|
||
const pattern = state.patternForms[target];
|
||
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;
|
||
}
|
||
}
|
||
|
||
function addGradientStop(position, color, transparency, source) {
|
||
const row = document.createElement("div");
|
||
row.paintSource = source;
|
||
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;
|
||
if (type === "color") input.className = "color-input";
|
||
input.value = value;
|
||
input.disabled = el.gradientKind.value === "keep";
|
||
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 = "icon-button";
|
||
remove.textContent = "×";
|
||
remove.title = "删除色标";
|
||
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 || [];
|
||
for (const index of state.outlineSelection || []) { outline = items[index]; items = outline.children || []; }
|
||
el.outlineTitle.value = action === "add" ? "" : outline.title;
|
||
el.outlinePage.value = action === "add" ? String(state.pageIndex + 1) : outline.page || "";
|
||
el.outlinePage.max = String(state.doc.pageCount);
|
||
el.outlineParent.replaceChildren();
|
||
const addOption = (path, title) => {
|
||
const option = document.createElement("option");
|
||
option.value = JSON.stringify(path);
|
||
option.textContent = title;
|
||
option.title = title;
|
||
el.outlineParent.append(option);
|
||
};
|
||
addOption([], "无");
|
||
const walk = (items, path = []) => items.forEach((item, index) => {
|
||
const next = [...path, index];
|
||
if (action === "move" && state.outlineSelection.every((part, i) => next[i] === part)) return;
|
||
addOption(next, `${" ".repeat(path.length)}${item.title}`);
|
||
walk(item.children || [], next);
|
||
});
|
||
walk(state.doc.outlines || []);
|
||
el.outlineParent.value = JSON.stringify(action === "add" ? state.outlineSelection || [] : state.outlineSelection.slice(0, -1));
|
||
el.outlineParent.disabled = action !== "add" && action !== "move";
|
||
el.outlineTitle.disabled = el.outlinePage.disabled = action === "delete" || action === "move";
|
||
el.outlinePositionRow.hidden = action !== "move";
|
||
el.outlinePosition.disabled = action !== "move";
|
||
updateOutlinePosition();
|
||
el.outlineSubmit.textContent = { add: "新增", update: "确定", delete: "删除", move: "确定" }[action];
|
||
el.outlineStatus.textContent = action === "delete" && outline.children?.length ? "同时删除子目录" : "";
|
||
el.outlinePanel.showModal();
|
||
}
|
||
|
||
function outlineChildren(outlines, path) {
|
||
for (const index of path) outlines = outlines[index].children || [];
|
||
return outlines;
|
||
}
|
||
|
||
function updateOutlinePosition() {
|
||
if (state.outlineAction !== "move") return;
|
||
const parent = JSON.parse(el.outlineParent.value);
|
||
const same = JSON.stringify(parent) === JSON.stringify(state.outlineSelection.slice(0, -1));
|
||
const count = outlineChildren(state.doc.outlines, parent).length + (same ? 0 : 1);
|
||
el.outlinePosition.max = String(count);
|
||
el.outlinePosition.value = String(same ? state.outlineSelection.at(-1) + 1 : count);
|
||
}
|
||
|
||
function remapOutlineExpansion(name, args, next) {
|
||
const roots = state.doc.outlines || [], expanded = new Map();
|
||
const walk = (items, visit, path = []) => items.forEach((item, index) => {
|
||
const current = [...path, index];
|
||
visit(item, JSON.stringify(current));
|
||
walk(item.children || [], visit, current);
|
||
});
|
||
walk(roots, (item, key) => expanded.set(item, state.outlineExpanded.get(key) ?? item.expanded));
|
||
const moving = name === "ofdgoMoveOutline", action = moving ? "move" : args[0], source = moving ? args[0] : args[1];
|
||
let item;
|
||
if (action === "move" || action === "delete") item = outlineChildren(roots, source.slice(0, -1)).splice(source.at(-1), 1)[0];
|
||
if (action === "move" || action === "add") {
|
||
let children = roots;
|
||
for (const index of next.slice(0, -1)) {
|
||
expanded.set(children[index], true);
|
||
children = children[index].children ||= [];
|
||
}
|
||
children.splice(next.at(-1), 0, item || {});
|
||
}
|
||
state.outlineExpanded.clear();
|
||
walk(roots, (item, key) => state.outlineExpanded.set(key, Boolean(expanded.get(item))));
|
||
}
|
||
|
||
function openImportPanel(documents = false) {
|
||
if (!state.editing || document.body.hasAttribute("data-busy")) return;
|
||
state.importPageCount = 0;
|
||
state.importDocumentCount = 0;
|
||
state.importDocumentMode = documents;
|
||
state.importEncrypted = false;
|
||
el.importForm.reset();
|
||
el.importStatus.textContent = "";
|
||
el.importDocuments.replaceChildren();
|
||
el.importDocuments.hidden = true;
|
||
el.importPagesLabel.textContent = documents ? "文档" : "页码";
|
||
el.importPages.removeAttribute("title");
|
||
el.importPositionRow.hidden = documents;
|
||
el.importOutlinesRow.hidden = documents;
|
||
el.importSubmit.textContent = documents ? "导入" : "插入";
|
||
el.importSubmit.disabled = true;
|
||
el.importPagesRow.hidden = true;
|
||
el.importPages.disabled = true;
|
||
el.importPages.required = false;
|
||
updateImportControls();
|
||
el.importPanel.showModal();
|
||
}
|
||
|
||
function selectedImportDocuments() {
|
||
return Array.from(el.importDocuments.children, row => row.children[0]).filter(input => input.checked).map(input => input.value).join(",");
|
||
}
|
||
|
||
function updateImportControls(busy = document.body.hasAttribute("data-busy")) {
|
||
const custom = el.importRange.value === "custom";
|
||
const documents = state.importDocumentMode;
|
||
const count = documents ? state.importDocumentCount : state.importPageCount;
|
||
el.importRange.disabled = busy || !count;
|
||
el.importDocuments.hidden = !documents || !custom || !count;
|
||
for (const row of el.importDocuments.children) row.children[0].disabled = busy;
|
||
el.importPagesRow.hidden = documents || !custom;
|
||
el.importPages.disabled = busy || documents || !custom;
|
||
el.importPages.required = !documents && custom;
|
||
el.importSubmit.disabled = busy || !count || (documents && custom && !selectedImportDocuments());
|
||
}
|
||
|
||
async function loadImportFile() {
|
||
const file = el.importFile.files[0];
|
||
const openSeq = state.openSeq;
|
||
state.importPageCount = 0;
|
||
state.importDocumentCount = 0;
|
||
el.importDocuments.replaceChildren();
|
||
state.importEncrypted = false;
|
||
el.importSubmit.disabled = true;
|
||
el.importStatus.textContent = "";
|
||
setBusy(true);
|
||
try {
|
||
const data = file ? await documentBytes(file) : null;
|
||
if (openSeq !== state.openSeq) return;
|
||
const info = data ? await openWithCredentials(null, openSeq, data, true) : await callWASM("ofdgoLoadImport", null);
|
||
if (openSeq !== state.openSeq || !el.importPanel.open) {
|
||
await callWASM("ofdgoLoadImport", null);
|
||
return;
|
||
}
|
||
state.importPageCount = info?.pageCount || 0;
|
||
state.importDocumentCount = info?.documentCount || 0;
|
||
state.importEncrypted = Boolean(info?.encrypted);
|
||
if (state.importDocumentMode) {
|
||
el.importDocuments.replaceChildren(...(info?.documents || []).map(entry => {
|
||
const row = document.createElement("label");
|
||
const input = document.createElement("input");
|
||
input.type = "checkbox";
|
||
input.value = String(entry.index + 1);
|
||
const name = document.createElement("span");
|
||
name.textContent = `${entry.index + 1} · ${entry.title || "未命名"}`;
|
||
row.append(input, name);
|
||
return row;
|
||
}));
|
||
el.importStatus.textContent = `共${state.importDocumentCount}个文档${info?.documentsSigned || state.doc.signatureCount ? " · 合并后需验签" : ""}`;
|
||
} else {
|
||
el.importPages.title = state.importPageCount ? `页码 1-${state.importPageCount}` : "页码";
|
||
el.importStatus.textContent = info?.signed ? "保留签章外观,原签名不再有效" : "";
|
||
}
|
||
el.importSubmit.disabled = !(state.importDocumentMode ? state.importDocumentCount : state.importPageCount);
|
||
updateImportControls(false);
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq && el.importPanel.open) el.importStatus.textContent = err.message;
|
||
} finally {
|
||
if (openSeq === state.openSeq) setBusy(false);
|
||
}
|
||
}
|
||
|
||
function openPagePanel() {
|
||
if (!state.editing || document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
const pages = selectedPageIndexes().map(index => state.doc.pages[index]);
|
||
if (!pages.length) return;
|
||
for (const [input, key] of [[el.pageWidth, "width"], [el.pageHeight, "height"]]) {
|
||
const same = pages.every(page => page[key] === pages[0][key]);
|
||
input.value = same ? String(pages[0][key]) : "";
|
||
input.placeholder = same ? "" : "混合";
|
||
}
|
||
el.pageRangeRow.hidden = pages.length === 1;
|
||
el.pageRange.textContent = `共${pages.length}页`;
|
||
el.pageRange.title = selectedPageRange();
|
||
el.pageStatus.textContent = "";
|
||
updatePageDirection();
|
||
el.pagePanel.showModal();
|
||
}
|
||
|
||
function updatePageDirection() {
|
||
const complete = el.pageWidth.value !== "" && el.pageHeight.value !== "";
|
||
el.pagePortrait.disabled = el.pageLandscape.disabled = !complete;
|
||
const landscape = Number(el.pageWidth.value) > Number(el.pageHeight.value);
|
||
el.pageLandscape.checked = complete && landscape;
|
||
el.pagePortrait.checked = complete && !landscape;
|
||
}
|
||
|
||
function warnUnsaved(event) {
|
||
event.preventDefault();
|
||
event.returnValue = "";
|
||
}
|
||
|
||
function setDirty(dirty) {
|
||
if (state.dirty === dirty) {
|
||
return;
|
||
}
|
||
state.dirty = dirty;
|
||
if (dirty) {
|
||
window.addEventListener("beforeunload", warnUnsaved);
|
||
} else {
|
||
window.removeEventListener("beforeunload", warnUnsaved);
|
||
}
|
||
}
|
||
|
||
function discardChanges() {
|
||
return !state.dirty || window.confirm("文档尚未保存,放弃更改?");
|
||
}
|
||
|
||
function setEditorInfo(doc) {
|
||
state.editorInfo = { revision: doc.revision, canUndo: doc.canUndo, canRedo: doc.canRedo,
|
||
pageCapabilities: doc.pageCapabilities, editWarnings: doc.editWarnings || [] };
|
||
setDirty(state.packageDirty || state.encryptionDirty || doc.revision !== state.savedRevision);
|
||
}
|
||
|
||
async function openVersionManagement() {
|
||
if (!state.doc || !state.ready || state.exporting || document.body.hasAttribute("data-busy")) return;
|
||
state.versionManagement = { id: state.doc.documentVersion || "", entries: null, loading: false, failed: false };
|
||
el.versionList.replaceChildren();
|
||
el.versionName.value = "";
|
||
el.versionPanel.showModal();
|
||
await refreshVersionManagement(true);
|
||
}
|
||
|
||
async function refreshVersionManagement(focus = false) {
|
||
const manager = state.versionManagement, openSeq = state.openSeq;
|
||
if (!manager || !el.versionPanel.open) return;
|
||
manager.loading = true;
|
||
manager.failed = false;
|
||
el.versionStatus.textContent = "正在读取版本";
|
||
updateVersionControls();
|
||
try {
|
||
const entries = await callWASM("ofdgoDocumentVersions");
|
||
if (state.versionManagement !== manager || !el.versionPanel.open) return;
|
||
if (openSeq !== state.openSeq) return refreshVersionManagement(focus);
|
||
manager.entries = entries;
|
||
if (!entries.some(entry => entry.id === manager.id)) manager.id = state.doc.documentVersion || "";
|
||
el.versionList.replaceChildren(...entries.map(entry => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "document-entry";
|
||
button.setAttribute("data-current", String(entry.id === (state.doc.documentVersion || "")));
|
||
const name = document.createElement("span"), marker = document.createElement("span");
|
||
name.className = "document-name";
|
||
name.textContent = entry.id ? entry.name || `版本 ${entry.index}` : "主文档";
|
||
marker.className = "document-pages";
|
||
marker.textContent = entry.default ? "默认" : "";
|
||
button.title = name.textContent;
|
||
button.append(name, marker);
|
||
button.addEventListener("click", () => selectManagedVersion(entry.id));
|
||
return button;
|
||
}));
|
||
manager.loading = false;
|
||
el.versionStatus.textContent = "";
|
||
selectManagedVersion(manager.id, focus);
|
||
} catch (err) {
|
||
if (state.versionManagement !== manager || !el.versionPanel.open) return;
|
||
if (openSeq !== state.openSeq) return refreshVersionManagement(focus);
|
||
manager.failed = true;
|
||
el.versionStatus.textContent = err.message;
|
||
} finally {
|
||
if (state.versionManagement === manager && openSeq === state.openSeq) {
|
||
manager.loading = false;
|
||
updateVersionControls();
|
||
}
|
||
}
|
||
}
|
||
|
||
function selectManagedVersion(id, focus = false) {
|
||
const manager = state.versionManagement;
|
||
if (!manager || manager.loading || document.body.hasAttribute("data-busy")) return;
|
||
const index = manager.entries?.findIndex(entry => entry.id === id) ?? -1;
|
||
if (index < 0) return;
|
||
manager.id = id;
|
||
el.versionName.value = manager.entries[index].name;
|
||
Array.from(el.versionList.children).forEach((button, position) => {
|
||
button.setAttribute("data-selected", String(position === index));
|
||
button.tabIndex = position === index ? 0 : -1;
|
||
});
|
||
updateVersionControls();
|
||
if (focus) el.versionList.children[index]?.focus();
|
||
}
|
||
|
||
async function changeVersion(action) {
|
||
const manager = state.versionManagement;
|
||
if (!state.ready || state.exporting || !state.editing && action !== "select" || !manager?.entries || manager.loading || manager.failed || document.body.hasAttribute("data-busy")) return;
|
||
const entry = manager.entries.find(entry => entry.id === manager.id);
|
||
if (!entry) return;
|
||
if (!state.editing) {
|
||
if (await switchReadingVersion(entry.id) && state.versionManagement === manager && el.versionPanel.open) {
|
||
manager.id = state.doc.documentVersion || "";
|
||
await refreshVersionManagement(true);
|
||
}
|
||
return;
|
||
}
|
||
if (action === "delete" && (!entry.id || !window.confirm(`删除版本“${entry.name || entry.index}”?`))) return;
|
||
el.versionStatus.textContent = "正在处理版本";
|
||
if (await changeDocument("ofdgoChangeVersion", null, action, entry.id, el.versionName.value.trim())) {
|
||
if (state.versionManagement !== manager || !el.versionPanel.open) return;
|
||
if (action === "add" || action === "select") manager.id = state.doc.documentVersion || "";
|
||
await refreshVersionManagement(true);
|
||
}
|
||
}
|
||
|
||
async function switchReadingVersion(id) {
|
||
if (!state.doc || state.editing || !state.ready || state.exporting || document.body.hasAttribute("data-busy")) return;
|
||
if (id === (state.doc.documentVersion || "")) return true;
|
||
const dirty = state.dirty;
|
||
const before = state.editorInfo ? editorLocation() : null, revision = state.editorInfo?.revision;
|
||
const openSeq = ++state.openSeq;
|
||
setBusy(true, "正在切换版本");
|
||
try {
|
||
const doc = await callWASM("ofdgoSelectVersion", id);
|
||
if (openSeq !== state.openSeq) return;
|
||
state.objectClipboard = state.styleClipboard = null;
|
||
canvasEditor.richClipboard = null;
|
||
resetCompositeScope();
|
||
canvasEditor.clear();
|
||
canvasEditor.setTool("");
|
||
delete state.textDefaults.fontChoice;
|
||
updateTextFonts(null, true);
|
||
if (state.editorInfo) {
|
||
if (!dirty) state.savedRevision = doc.revision;
|
||
setEditorInfo(doc);
|
||
}
|
||
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true, clearSelection: true,
|
||
fitMode: state.fitMode, scale: state.scale, activateActions: true });
|
||
if (openSeq !== state.openSeq) return;
|
||
if (before) {
|
||
rememberEditorView(before, revision);
|
||
const view = state.editorViews.get(doc.revision);
|
||
if (view) view.reading = true;
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
if (openSeq === state.openSeq) el.versionStatus.textContent = error.message;
|
||
} finally {
|
||
if (openSeq === state.openSeq) setBusy(false);
|
||
}
|
||
}
|
||
|
||
function updateVersionControls() {
|
||
const manager = state.versionManagement, busy = document.body.hasAttribute("data-busy");
|
||
const disabled = !state.doc || !state.ready || state.exporting || busy;
|
||
const title = state.editing ? "版本管理" : "版本浏览";
|
||
el.versionManageButton.hidden = !state.editing && !(state.doc?.versionCount > 0);
|
||
el.versionTitle.textContent = title;
|
||
el.versionManageButton.disabled = disabled;
|
||
el.versionNotice.textContent = state.editing ? state.editorInfo?.editWarnings?.join(" · ") || "" : "";
|
||
el.versionNotice.hidden = !el.versionNotice.textContent;
|
||
el.versionNameRow.hidden = !state.editing;
|
||
el.versionActions.classList.toggle("version-readonly", !state.editing);
|
||
for (const button of [el.versionAdd, el.versionRename, el.versionDelete, el.versionDefault]) button.hidden = !state.editing;
|
||
const entry = manager?.entries?.find(entry => entry.id === manager.id);
|
||
const unavailable = disabled || !entry || Boolean(manager?.loading || manager?.failed);
|
||
el.versionList.inert = busy || Boolean(manager?.loading);
|
||
el.versionName.disabled = el.versionAdd.disabled = unavailable || !state.editing;
|
||
el.versionRename.disabled = unavailable || !state.editing || !entry?.id || el.versionName.value.trim() === entry.name;
|
||
el.versionDelete.disabled = unavailable || !state.editing || !entry?.id || manager.entries.length < 2;
|
||
el.versionDefault.disabled = unavailable || !state.editing || Boolean(entry?.default);
|
||
el.versionSelect.disabled = unavailable || entry?.id === (state.doc?.documentVersion || "");
|
||
el.versionExport.disabled = unavailable || !entry?.id;
|
||
}
|
||
|
||
async function exportManagedVersion() {
|
||
const manager = state.versionManagement;
|
||
const entry = manager?.entries?.find(item => item.id === manager.id);
|
||
if (!entry?.id || !state.ready || state.exporting || manager.loading || manager.failed || document.body.hasAttribute("data-busy")) return;
|
||
if (state.doc.signatureCount && !window.confirm("版本副本可能使原签名失效,是否继续?")) return;
|
||
const openSeq = state.openSeq;
|
||
const name = `${baseFileName()}_${entry.name || `版本${entry.index}`}`.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_") + ".ofd";
|
||
state.exporting = true;
|
||
setBusy(true, "正在另存版本");
|
||
el.versionStatus.textContent = "正在另存版本";
|
||
try {
|
||
const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({suggestedName: name, types: [{description: "OFD", accept: {"application/ofd": [".ofd"]}}]}) : null;
|
||
if (openSeq !== state.openSeq) return;
|
||
const result = await callWASM("ofdgoExportVersion", entry.id, file);
|
||
if (openSeq !== state.openSeq) return;
|
||
if (result.blob) downloadBytes(result.blob, result.mime, name);
|
||
el.versionStatus.textContent = `已另存(${formatBytes(result.size)})`;
|
||
} catch (error) {
|
||
if (openSeq === state.openSeq) el.versionStatus.textContent = error.name === "AbortError" ? "另存已取消" : error.message;
|
||
} finally {
|
||
state.exporting = false;
|
||
state.exportRequestID = 0;
|
||
el.cancelExportButton.hidden = true;
|
||
if (openSeq === state.openSeq) { setBusy(false); updateControls(); }
|
||
}
|
||
}
|
||
|
||
function managedDocumentIndex() {
|
||
return state.documentManagement?.index ?? state.doc?.documentIndex ?? 0;
|
||
}
|
||
|
||
function documentEntryKey(entry) {
|
||
return JSON.stringify([entry?.root, entry?.id]);
|
||
}
|
||
|
||
async function openDocumentManagement() {
|
||
if (!state.editing || !state.ready || state.exporting || document.body.hasAttribute("data-busy")) return;
|
||
const index = state.doc.documentIndex || 0;
|
||
state.documentManagement = { index, key: documentEntryKey(state.doc.documents[index]), entries: null, loading: false, failed: false };
|
||
renderDocumentManagement();
|
||
el.documentPanel.showModal();
|
||
await refreshDocumentManagement(true);
|
||
}
|
||
|
||
async function refreshDocumentManagement(focus = false) {
|
||
const manager = state.documentManagement, openSeq = state.openSeq;
|
||
if (!manager || !el.documentPanel.open) return;
|
||
manager.loading = true;
|
||
manager.failed = false;
|
||
el.documentStatus.textContent = "正在读取文档";
|
||
updateDocumentControls();
|
||
try {
|
||
const entries = await callWASM("ofdgoPackageDocuments");
|
||
if (state.documentManagement !== manager || !el.documentPanel.open) return;
|
||
if (openSeq !== state.openSeq) return await refreshDocumentManagement(focus);
|
||
const index = entries.findIndex(entry => documentEntryKey(entry) === manager.key);
|
||
manager.index = index < 0 ? Math.min(manager.index, entries.length - 1) : index;
|
||
manager.entries = entries;
|
||
manager.key = documentEntryKey(entries[manager.index]);
|
||
el.documentStatus.textContent = "";
|
||
renderDocumentManagement();
|
||
if (focus) el.documentList.children[manager.index]?.focus();
|
||
} catch (error) {
|
||
if (state.documentManagement === manager && openSeq === state.openSeq) {
|
||
manager.failed = true;
|
||
el.documentStatus.textContent = error.message;
|
||
}
|
||
} finally {
|
||
if (state.documentManagement === manager) {
|
||
manager.loading = false;
|
||
updateDocumentControls();
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderDocumentManagement() {
|
||
const manager = state.documentManagement;
|
||
if (!manager) return;
|
||
const entries = manager.entries || state.doc.documents;
|
||
el.documentList.replaceChildren(...entries.map(entry => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "document-entry";
|
||
button.setAttribute("data-selected", String(entry.index === manager.index));
|
||
button.tabIndex = entry.index === manager.index ? 0 : -1;
|
||
const name = document.createElement("span");
|
||
name.className = "document-name";
|
||
name.textContent = `${entry.index + 1} · ${entry.title || "未命名"}`;
|
||
button.title = name.textContent;
|
||
const pages = document.createElement("span");
|
||
pages.className = "document-pages";
|
||
const count = entry.pageCount ?? (entry.index === state.doc.documentIndex ? state.doc.pageCount : null);
|
||
pages.textContent = count === null ? "-" : `${count}页`;
|
||
button.append(name, pages);
|
||
button.addEventListener("click", () => selectManagedDocument(entry.index));
|
||
return button;
|
||
}));
|
||
updateDocumentControls();
|
||
}
|
||
|
||
function selectManagedDocument(index, focus = false) {
|
||
const manager = state.documentManagement;
|
||
if (!manager || manager.loading || document.body.hasAttribute("data-busy") || index < 0 || index >= state.doc.documents.length) return;
|
||
manager.index = index;
|
||
manager.key = documentEntryKey(state.doc.documents[index]);
|
||
for (const [i, button] of [...el.documentList.children].entries()) {
|
||
button.setAttribute("data-selected", String(i === index));
|
||
button.tabIndex = i === index ? 0 : -1;
|
||
}
|
||
updateDocumentControls();
|
||
if (focus) el.documentList.children[index]?.focus();
|
||
}
|
||
|
||
async function switchDocument(index) {
|
||
if (!state.doc || document.body.hasAttribute("data-busy") || state.exporting || formDialogOpen()) return;
|
||
const previous = state.doc.documentIndex || 0;
|
||
if (index === previous) return;
|
||
if (!await canvasEditor.commitNudge() || !await canvasEditor.commitText() || !await canvasEditor.commitCrop()) {
|
||
el.documentSelect.value = String(previous);
|
||
return;
|
||
}
|
||
const openSeq = ++state.openSeq;
|
||
setBusy(true, "正在切换文档");
|
||
try {
|
||
const doc = await callWASM("ofdgoSelectDocument", index);
|
||
if (openSeq !== state.openSeq) return;
|
||
state.packageDirty ||= state.dirty;
|
||
state.savedRevision = doc.revision;
|
||
state.editorViews.clear();
|
||
state.objectClipboard = state.styleClipboard = null;
|
||
canvasEditor.richClipboard = null;
|
||
resetCompositeScope();
|
||
canvasEditor.clear();
|
||
canvasEditor.setTool("");
|
||
delete state.textDefaults.fontChoice;
|
||
updateTextFonts(null, true);
|
||
state.ofdBytes = null;
|
||
setEditorInfo(doc);
|
||
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true, clearSelection: true, fitMode: state.fitMode, scale: state.scale, activateActions: true });
|
||
} catch (error) {
|
||
if (openSeq === state.openSeq) {
|
||
el.documentSelect.value = String(previous);
|
||
showError(error, false);
|
||
}
|
||
} finally {
|
||
if (openSeq === state.openSeq) setBusy(false);
|
||
}
|
||
}
|
||
|
||
function editorLocation() {
|
||
const pending = canvasEditor.pendingSelection;
|
||
const index = pending?.index ?? canvasEditor.selected?.index ?? state.pageIndex;
|
||
return { page: state.doc.pages[index].id, scope: state.composite?.key || "", ids: pending?.ids || canvasEditor.items().map(item => item.id) };
|
||
}
|
||
|
||
function rememberEditorView(before, revision, reindex = false) {
|
||
for (const key of state.editorViews.keys()) {
|
||
if (key > revision) state.editorViews.delete(key);
|
||
}
|
||
state.editorViews.set(state.editorInfo.revision, { before, after: editorLocation(), reindex });
|
||
if (state.editorViews.size > 100) state.editorViews.delete(state.editorViews.keys().next().value);
|
||
}
|
||
|
||
function pageCan(capability, index = state.pageIndex) {
|
||
const bit = { insert: 1, copy: 2, delete: 4, move: 8, resize: 16 }[capability];
|
||
return Boolean(state.editorInfo?.pageCapabilities?.[index] & bit);
|
||
}
|
||
|
||
async function toggleEditor() {
|
||
if (!state.doc || !state.ready || document.body.hasAttribute("data-busy") || formDialogOpen()) return;
|
||
if (state.editorInfo) {
|
||
if (!await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return;
|
||
const anchor = scaleAnchor(0);
|
||
state.editing = !state.editing;
|
||
renderDocumentFonts();
|
||
resetCompositeScope();
|
||
canvasEditor.clear();
|
||
canvasEditor.setTool("");
|
||
if (state.editing) {
|
||
releaseMediaPlayers();
|
||
state.selectObjects = true;
|
||
setPan(false);
|
||
}
|
||
updateControls();
|
||
renderOutlines(false);
|
||
if (state.renderMode !== "svg") {
|
||
try {
|
||
await refreshPageDisplay(anchor);
|
||
} catch (err) {
|
||
state.editing = !state.editing;
|
||
renderDocumentFonts();
|
||
updateControls();
|
||
showError(err, false);
|
||
}
|
||
return;
|
||
}
|
||
renderPageList();
|
||
applyFit(false);
|
||
restoreScaleAnchor(anchor);
|
||
updatePageListCurrent(true);
|
||
return;
|
||
}
|
||
const { pageIndex, fitMode, scale } = state;
|
||
const anchor = scaleAnchor(0);
|
||
const openSeq = ++state.openSeq;
|
||
setBusy(true, "正在准备编辑", null, "正在准备编辑");
|
||
try {
|
||
const doc = await callWASM("ofdgoEditDocument", pageIndex);
|
||
if (openSeq !== state.openSeq) return;
|
||
state.savedRevision = doc.revision;
|
||
state.editorViews.clear();
|
||
setEditorInfo(doc);
|
||
state.editing = true;
|
||
releaseMediaPlayers();
|
||
state.composite = null;
|
||
state.selectObjects = true;
|
||
state.ofdBytes = null;
|
||
state.conversionWarnings = [];
|
||
state.objectClipboard = null;
|
||
state.styleClipboard = null;
|
||
canvasEditor.richClipboard = null;
|
||
canvasEditor.clear();
|
||
setPan(false);
|
||
await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode, scale,
|
||
keepPreview: true, preserveThumbnails: true, previewAnchor: anchor });
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) showError(err, false);
|
||
} finally {
|
||
if (openSeq === state.openSeq) {
|
||
setBusy(false);
|
||
updateControls();
|
||
if (state.editing) updatePageListCurrent(true);
|
||
}
|
||
}
|
||
}
|
||
|
||
function confirmTextReflow(item) {
|
||
return canEditObject(item, "reflow") && (canEditObject(item, "layoutKnown")
|
||
|| window.confirm("将重新排版原文,是否继续?"));
|
||
}
|
||
|
||
function openCreatePanel(insert = false, packageDocument = false) {
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
el.createForm.reset();
|
||
state.createInsert = insert === true;
|
||
state.createPackage = packageDocument === true;
|
||
state.createDocumentAt = state.createPackage ? managedDocumentIndex() + 1 : null;
|
||
el.createMode.value = state.createInsert ? "images" : "blank";
|
||
el.createSubmit.textContent = state.createInsert ? "插入" : state.createPackage ? "新增" : "新建";
|
||
el.createCancel.disabled = false;
|
||
updateCreateOptions();
|
||
el.createStatus.textContent = "";
|
||
el.createPanel.showModal();
|
||
}
|
||
|
||
function updateCreateOptions() {
|
||
const images = el.createMode.value === "images", busy = Boolean(state.imageImporting);
|
||
const toggle = (id, visible) => { document.getElementById(id).hidden = !visible; };
|
||
toggle("createModeRow", !state.createInsert && !state.createPackage);
|
||
el.createName.closest(".form-row").hidden = Boolean(state.createInsert);
|
||
el.createName.disabled = busy || Boolean(state.createInsert);
|
||
el.createMode.disabled = busy;
|
||
for (const [id, input] of [["createImagesRow", el.createImages], ["createSizeRow", el.createSize], ["createMarginRow", el.createMargin]]) {
|
||
toggle(id, images);
|
||
input.disabled = busy || !images;
|
||
}
|
||
el.createImages.required = images;
|
||
const custom = !images || el.createSize.value === "custom";
|
||
toggle("createDimensions", custom);
|
||
el.createWidth.disabled = el.createHeight.disabled = busy || !custom;
|
||
toggle("createPositionRow", Boolean(state.createInsert));
|
||
el.createPosition.disabled = busy || !state.createInsert;
|
||
el.createSubmit.disabled = busy;
|
||
}
|
||
|
||
function imagePageRequest() {
|
||
const files = [...el.createImages.files].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||
const size = el.createSize.value;
|
||
return [files, {
|
||
Width: size === "image" ? 0 : Number(el.createWidth.value),
|
||
Height: size === "image" ? 0 : Number(el.createHeight.value),
|
||
Margin: Number(el.createMargin.value),
|
||
}];
|
||
}
|
||
|
||
async function createDocument(event) {
|
||
event.preventDefault();
|
||
if (state.createPackage) {
|
||
if (await changeDocument("ofdgoChangeDocument", null, "add", el.createName.value.trim() || "未命名",
|
||
Number(el.createWidth.value), Number(el.createHeight.value), state.createDocumentAt)) el.createPanel.close();
|
||
return;
|
||
}
|
||
if (state.createInsert) {
|
||
const at = { before: state.pageIndex, after: state.pageIndex + 1, first: 0, last: state.doc.pageCount }[el.createPosition.value];
|
||
if (await changeDocument("ofdgoImportImages", null, ...imagePageRequest(), at, "", state.renderAnnotations)) el.createPanel.close();
|
||
return;
|
||
}
|
||
if (document.body.hasAttribute("data-busy") || !discardChanges()) {
|
||
return;
|
||
}
|
||
const title = el.createName.value.trim() || "未命名";
|
||
clearSecurityForms();
|
||
const openSeq = ++state.openSeq;
|
||
const images = el.createMode.value === "images";
|
||
state.imageImporting = images;
|
||
setBusy(true, "正在新建文档", null, "正在新建文档");
|
||
try {
|
||
await ensureWASM();
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
const doc = images
|
||
? await callWASM("ofdgoImportImages", ...imagePageRequest(), -1, title, state.renderAnnotations)
|
||
: await callWASM("ofdgoCreateDocument", title, Number(el.createWidth.value), Number(el.createHeight.value), state.renderAnnotations);
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
state.editing = true;
|
||
state.composite = null;
|
||
state.objectClipboard = null;
|
||
state.editorViews.clear();
|
||
state.styleClipboard = null;
|
||
canvasEditor.richClipboard = null;
|
||
state.fontRenderPending = false;
|
||
state.annotationCreate = null;
|
||
canvasEditor.setTool("");
|
||
canvasEditor.clear();
|
||
delete state.textDefaults.fontChoice;
|
||
updateTextFonts(null, true);
|
||
state.selectObjects = true;
|
||
setPan(false);
|
||
state.ofdBytes = null;
|
||
state.fileName = `${title.replace(/[\\/:*?"<>|]+/g, "_").replace(/\.ofd$/i, "")}.ofd`;
|
||
state.conversionWarnings = [];
|
||
state.savedRevision = null;
|
||
state.packageDirty = false;
|
||
state.encryptionDirty = false;
|
||
setEditorInfo(doc);
|
||
el.createPanel.close();
|
||
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true });
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
el.createStatus.textContent = err.message;
|
||
}
|
||
} finally {
|
||
if (images) {
|
||
state.imageImporting = false;
|
||
state.exportRequestID = 0;
|
||
el.cancelExportButton.hidden = true;
|
||
el.createCancel.disabled = false;
|
||
}
|
||
if (openSeq === state.openSeq) {
|
||
setBusy(false);
|
||
updateControls();
|
||
}
|
||
}
|
||
}
|
||
|
||
function editAnnotationDetails(item) {
|
||
state.annotationEdit = item;
|
||
el.annotationRemark.value = item.remark || "";
|
||
el.annotationCreator.value = item.creator || "";
|
||
el.annotationFields.hidden = el.annotationSubmit.hidden = false;
|
||
el.annotationDetail.hidden = true;
|
||
el.annotationStatus.textContent = "";
|
||
el.annotationClose.textContent = "取消";
|
||
el.annotationNote.showModal();
|
||
el.annotationRemark.focus();
|
||
}
|
||
|
||
function openObjectPicker(items = canvasEditor.pageItems(state.pageIndex)) {
|
||
el.objectPickerList.replaceChildren();
|
||
const labels = { TextObject: "文字", PathObject: "图形", ImageObject: "图片", CompositeObject: "组合", CompositeGraphicUnit: "组合", Annotation: "注解" };
|
||
const kinds = { Path: "批注", Stamp: "印章", Watermark: "水印", Link: "链接", Highlight: "高亮" };
|
||
for (const item of [...items].reverse()) {
|
||
const button = document.createElement("button");
|
||
button.className = "button";
|
||
button.type = "button";
|
||
button.textContent = `${kinds[item.annotationType] || labels[item.type] || "对象"} · ${item.text || item.remark || item.id.replace("annotation:", "")}`;
|
||
button.title = button.textContent;
|
||
button.addEventListener("click", () => {
|
||
el.objectPicker.close();
|
||
canvasEditor.select(item);
|
||
canvasEditor.focus();
|
||
});
|
||
el.objectPickerList.append(button);
|
||
}
|
||
if (!items.length) { setStatus("页面暂无对象"); return; }
|
||
el.objectPicker.showModal();
|
||
}
|
||
|
||
function openAnnotationCreate(kind, index, box = null, item = null, info = null) {
|
||
const page = state.doc.pages[index];
|
||
const width = Math.min(60, page.width), height = 12;
|
||
state.annotationCreate = { kind, index, item, info, openSeq: state.openSeq, height: box?.height || height };
|
||
annotationFields.CreateForm.reset();
|
||
annotationFields.Text.value = kind === "watermark" ? "水印" : "";
|
||
annotationFields.Pages.value = String(index + 1);
|
||
annotationFields.Target.max = state.doc.pageCount;
|
||
annotationFields.Target.value = String(index + 1);
|
||
annotationFields.LinkKind.querySelector('[value="keep"]').hidden = !item;
|
||
if (kind === "link" && info) {
|
||
annotationFields.LinkKind.value = info.linkKind;
|
||
annotationFields.Address.value = info.linkURI || "";
|
||
if (info.linkPage) annotationFields.Target.value = String(info.linkPage);
|
||
}
|
||
annotationFields.X.value = box?.x ?? (page.width - width) / 2;
|
||
annotationFields.Y.value = box?.y ?? (page.height - height) / 2;
|
||
annotationFields.Width.value = box?.width || width;
|
||
annotationFields.Angle.value = kind === "watermark" ? -30 : 0;
|
||
annotationFields.Opacity.value = kind === "watermark" ? 80 : 0;
|
||
annotationFields.Old.replaceChildren(...(info?.texts || []).map(text => new Option(text, text)));
|
||
if (kind === "replace") annotationFields.Text.value = info?.texts[0] || "";
|
||
el.annotationCreateStatus.textContent = "";
|
||
updateAnnotationFields();
|
||
el.annotationCreate.showModal();
|
||
const focus = [annotationFields.Text, annotationFields.Image, annotationFields.Address].find(input => !input.disabled);
|
||
focus?.focus();
|
||
}
|
||
|
||
function pickWatermarkArea() {
|
||
state.annotationCreate.picking = true;
|
||
annotationFields.Placement.value = state.annotationCreate.area ? "custom" : "page";
|
||
el.annotationCreate.close();
|
||
canvasEditor.select(null);
|
||
toggleDrawingTool("annotation:watermark");
|
||
}
|
||
|
||
function placeAnnotation(index, kind, box) {
|
||
const entry = state.annotationCreate;
|
||
if (kind !== "watermark" || !entry?.picking) {
|
||
openAnnotationCreate(kind, index, box);
|
||
return;
|
||
}
|
||
entry.picking = false;
|
||
const page = state.doc.pages[index], tile = annotationFields.Tile.value === "tile";
|
||
const x = Math.max(0, Math.min(1, box.x / page.width));
|
||
const y = Math.max(0, Math.min(1, box.y / page.height));
|
||
const right = Math.max(x, Math.min(1, (box.x + box.width) / page.width));
|
||
const bottom = Math.max(y, Math.min(1, (box.y + box.height) / page.height));
|
||
if (!tile || right > x && bottom > y) {
|
||
entry.index = index;
|
||
entry.area = tile ? { x, y, w: right - x, h: bottom - y } : { x: (x + right) / 2, y: (y + bottom) / 2, w: 0, h: 0 };
|
||
annotationFields.Placement.value = "custom";
|
||
if (annotationFields.Scope.value !== "custom") annotationFields.Pages.value = String(index + 1);
|
||
el.annotationCreateStatus.textContent = "";
|
||
} else {
|
||
el.annotationCreateStatus.textContent = "范围过小";
|
||
}
|
||
el.annotationCreate.showModal();
|
||
}
|
||
|
||
function updateAnnotationFields() {
|
||
const { kind, item } = state.annotationCreate;
|
||
for (const key of ["X", "Y", "Width", "Height", "Pages", "Author"]) annotationFields[key].closest(".form-row").hidden = false;
|
||
for (const row of el.annotationCreate.querySelectorAll("[data-annotation]")) row.hidden = !row.dataset.annotation.split(" ").includes(kind);
|
||
const image = kind === "stamp" || kind === "watermark" && annotationFields.Content.value === "image";
|
||
annotationFields.ImageRow.hidden = !image;
|
||
annotationFields.Image.required = image;
|
||
annotationFields.TextRow.hidden = !["note", "replace", "watermark"].includes(kind) || image;
|
||
annotationFields.AddressRow.hidden = kind !== "link" || annotationFields.LinkKind.value !== "uri";
|
||
annotationFields.TargetRow.hidden = kind !== "link" || annotationFields.LinkKind.value !== "page";
|
||
annotationFields.Height.value = image ? "" : state.annotationCreate.height;
|
||
annotationFields.Height.placeholder = image ? "等比" : "";
|
||
if (kind === "watermark") {
|
||
for (const key of ["X", "Y"]) annotationFields[key].closest(".form-row").hidden = true;
|
||
annotationFields.Pages.closest(".form-row").hidden = annotationFields.Scope.value !== "custom";
|
||
const tile = annotationFields.Tile.value === "tile";
|
||
annotationFields.PlacementLabel.textContent = tile ? "范围" : "位置";
|
||
annotationFields.Placement.options[0].textContent = tile ? "整页" : "居中";
|
||
annotationFields.Placement.options[1].textContent = tile ? "选区" : "指定";
|
||
annotationFields.Pick.textContent = tile ? "框选" : "定位";
|
||
}
|
||
if (kind === "link" && item) {
|
||
for (const key of ["X", "Y", "Width", "Height", "Pages", "Author"]) annotationFields[key].closest(".form-row").hidden = true;
|
||
}
|
||
for (const input of annotationFields.CreateForm.querySelectorAll("input,select,textarea")) {
|
||
input.disabled = Boolean(input.closest("[hidden]")) || image && input === annotationFields.Height;
|
||
}
|
||
}
|
||
|
||
async function editCanvasObject(item) {
|
||
if (!state.editing || document.body.hasAttribute("data-busy") || canvasEditor.input) return;
|
||
if (item.items) {
|
||
if (!item.items.every(member => member.type === "TextObject" && !member.richText)) return;
|
||
const openSeq = state.openSeq;
|
||
setBusy(true);
|
||
try {
|
||
const ids = item.items.map(member => member.id);
|
||
const data = await callWASM("ofdgoTextSelection", item.index, ids);
|
||
const faces = new Map();
|
||
for (const font of data.fonts) {
|
||
faces.set(font.id, await new FontFace(`ofdgo-selection-${crypto.randomUUID()}`, font.bytes, fontFaceStyle(font.bytes)).load());
|
||
}
|
||
if (openSeq !== state.openSeq || canvasEditor.selected !== item || !item.node.isConnected) return;
|
||
const face = faces.get(data.item.font);
|
||
if (!face) throw new Error("字体不可用");
|
||
Object.assign(item, data.item, { type: "TextObject", selectionIDs: ids, id: ids });
|
||
canvasEditor.editText(item, face, data.source, null, faces);
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) showError(err, false);
|
||
} finally {
|
||
if (openSeq === state.openSeq) setBusy(false);
|
||
}
|
||
return;
|
||
}
|
||
if (!item.richText && canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id);
|
||
if (item.type === "Annotation") {
|
||
editAnnotationDetails(item);
|
||
return;
|
||
}
|
||
if (item.type === "TextObject") {
|
||
if (!canEditObject(item, "textContent") && !canEditObject(item, "rewriteText")) return;
|
||
} else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return;
|
||
if (item.type === "PathObject") {
|
||
el.shapeWidth.focus();
|
||
return;
|
||
}
|
||
if (item.type === "ImageObject") {
|
||
return openInsertPanel(item);
|
||
}
|
||
const openSeq = state.openSeq;
|
||
setBusy(true);
|
||
try {
|
||
const data = await callWASM("ofdgoEditorFont", item.font, !canEditObject(item, "layoutKnown"));
|
||
const face = new FontFace(`ofdgo-edit-${item.font}`, data.bytes, fontFaceStyle(data.bytes));
|
||
await face.load();
|
||
let rich = null;
|
||
if (item.richText) rich = await loadRichText(item, face);
|
||
else if (!item.scoped && canEditObject(item, "layoutKnown")) {
|
||
try { rich = await loadRichText(item, face); } catch {}
|
||
}
|
||
const source = rich || canEditObject(item, "layoutKnown") ? null : await callWASM("ofdgoPreviewText", item.index, item.id, item.text);
|
||
if (openSeq === state.openSeq && item.node.isConnected) {
|
||
canvasEditor.editText(item, face, source, rich);
|
||
}
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
showError(err, false);
|
||
}
|
||
} finally {
|
||
if (openSeq === state.openSeq) {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
function mountEditorObjects(index, page, surface) {
|
||
const scope = state.composite?.index === index ? state.composite : null;
|
||
canvasEditor.mount(index, { ...page, objects: scope ? scope.objects : page.objects.filter(item => state.renderAnnotations || item.type !== "Annotation") }, surface);
|
||
}
|
||
|
||
function changeAnnotations(item, action, ...args) {
|
||
return changeDocument("ofdgoChangeAnnotations", { ...item, id: item.items ? item.id : [item.id] }, action, ...args);
|
||
}
|
||
|
||
function resetCompositeScope() {
|
||
const scope = state.composite;
|
||
if (!scope) return;
|
||
state.composite = null;
|
||
canvasEditor.clear();
|
||
const page = state.pageCache.get(scope.index), surface = pageShell(scope.index)?.querySelector(".page-surface");
|
||
if (page && surface) mountEditorObjects(scope.index, page, surface);
|
||
}
|
||
|
||
async function enterCompositeScope(index, key) {
|
||
const openSeq = state.openSeq;
|
||
setBusy(true);
|
||
try {
|
||
const objects = await callWASM("ofdgoCompositeObjects", index, key);
|
||
if (openSeq !== state.openSeq) return;
|
||
canvasEditor.clear();
|
||
canvasEditor.setTool("");
|
||
state.composite = { index, key, objects };
|
||
const page = state.pageCache.get(index), surface = pageShell(index)?.querySelector(".page-surface");
|
||
if (page && surface) mountEditorObjects(index, page, surface);
|
||
updateControls();
|
||
setStatus(pageStatus(index, state.doc.pageCount));
|
||
canvasEditor.focus();
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) showError(err, false);
|
||
} finally {
|
||
if (openSeq === state.openSeq) setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function exitCompositeScope() {
|
||
const scope = state.composite;
|
||
if (!scope) return;
|
||
const split = scope.key.lastIndexOf("/");
|
||
if (split >= 0) return enterCompositeScope(scope.index, scope.key.slice(0, split));
|
||
resetCompositeScope();
|
||
updateControls();
|
||
canvasEditor.focus();
|
||
}
|
||
|
||
async function startImageCrop(item) {
|
||
if (item.scoped) {
|
||
canvasEditor.startCrop(item, { svg: item.surface.querySelector(".ofd-svg").cloneNode(true), urls: [] });
|
||
return;
|
||
}
|
||
const openSeq = state.openSeq;
|
||
const urls = [];
|
||
let mounted = false;
|
||
setBusy(true);
|
||
try {
|
||
const page = await callWASM("ofdgoPreviewImage", item.index, item.id);
|
||
if (openSeq !== state.openSeq || !item.surface.isConnected) return;
|
||
const images = new Map(state.svgImages);
|
||
for (const image of page.images) {
|
||
if (images.has(image.name)) continue;
|
||
const url = URL.createObjectURL(new Blob([image.bytes], { type: image.mime }));
|
||
urls.push(url);
|
||
images.set(image.name, { url });
|
||
}
|
||
await Promise.all(urls.map(url => {
|
||
const image = new Image();
|
||
image.src = url;
|
||
return image.decode();
|
||
}));
|
||
if (openSeq !== state.openSeq || canvasEditor.selected !== item) return;
|
||
const svg = parseSVG(page.svg, `crop${openSeq}-${item.id}`, images);
|
||
svg.classList.add("ofd-svg");
|
||
canvasEditor.startCrop(item, { svg, urls });
|
||
mounted = true;
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) showError(err, false);
|
||
} finally {
|
||
if (!mounted) urls.forEach(url => URL.revokeObjectURL(url));
|
||
if (openSeq === state.openSeq) setBusy(false);
|
||
}
|
||
}
|
||
|
||
function openInsertPanel(item = null) {
|
||
if (!state.editing || document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
el.insertForm.reset();
|
||
state.insertObject = item;
|
||
el.insertFitRow.hidden = !item || Boolean(item.scoped);
|
||
el.insertSubmit.textContent = item ? "替换" : "添加";
|
||
el.insertStatus.textContent = "";
|
||
el.insertPanel.showModal();
|
||
el.insertImage.focus();
|
||
}
|
||
|
||
function currentTextStyle() {
|
||
const text = selectedText(canvasEditor.selected) || state.textDefaults;
|
||
if (canvasEditor.input?.rich) return { ...text, ...canvasEditor.input.rich.state() };
|
||
if (text.richText) {
|
||
const formats = text.richText.runs.map(run => run.format);
|
||
return { ...text, ...Object.fromEntries(["font", "size", "color", "bold", "italic", "underline", "strikeout", "shadow", "background"]
|
||
.map(key => [key, formats.every(format => format[key] === formats[0]?.[key]) ? formats[0]?.[key] : undefined])) };
|
||
}
|
||
return canvasEditor.input?.style ? { ...text, ...canvasEditor.input.style } : text;
|
||
}
|
||
|
||
async function loadRichText(item, firstFace) {
|
||
const model = item.richText || await callWASM("ofdgoGetRichText", item.index, item.id);
|
||
const faces = new Map();
|
||
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 face = data ? await new FontFace(`ofdgo-rich-${id}`, data, fontFaceStyle(data)).load() : firstFace;
|
||
faces.set(id, face);
|
||
}
|
||
return { model, faces };
|
||
}
|
||
|
||
async function ensureRichInput() {
|
||
let editing = canvasEditor.input;
|
||
if (!editing) {
|
||
const item = canvasEditor.selected;
|
||
if (!item || item.items || item.scoped || item.type !== "TextObject") return false;
|
||
await editCanvasObject(item);
|
||
editing = canvasEditor.input;
|
||
}
|
||
if (!editing) return false;
|
||
if (editing.item.selectionIDs) return false;
|
||
if (editing.rich) return true;
|
||
if (editing.composing || editing.saving) return false;
|
||
const rich = await loadRichText(editing.item, editing.face);
|
||
if (canvasEditor.input !== editing) return false;
|
||
return canvasEditor.promoteText(rich);
|
||
}
|
||
|
||
async function reflowCanvasText() {
|
||
const editing = canvasEditor.input;
|
||
if (document.body.hasAttribute("data-busy") || !editing?.source || editing.composing || editing.saving || !canEditObject(editing.item, "reflow")) return;
|
||
if (!window.confirm("将重新排版原文,是否继续?")) return;
|
||
editing.reflow = true;
|
||
canvasEditor.recordSourceText();
|
||
await canvasEditor.previewSourceText();
|
||
updateObjectControls(canvasEditor.selected, true);
|
||
}
|
||
|
||
async function changeRangeFormat(key, value) {
|
||
if (!state.ready || document.body.hasAttribute("data-busy") || canvasEditor.input?.composing) return;
|
||
try {
|
||
if (!canvasEditor.selected && !canvasEditor.input) {
|
||
state.textDefaults[key] = value ?? (key === "background" ? state.textDefaults.background ? "" : textBackground.value : !state.textDefaults[key]);
|
||
updateObjectControls(null); return;
|
||
}
|
||
if (!await ensureRichInput()) return;
|
||
const rich = canvasEditor.input.rich, current = rich.state()[key];
|
||
rich.apply({ [key]: value ?? (key === "background" ? current ? "" : textBackground.value : current !== true) });
|
||
} catch (error) { showError(error, false); }
|
||
}
|
||
|
||
async function validateRichText(item, model, width, fonts = []) {
|
||
const openSeq = state.openSeq, editing = canvasEditor.input;
|
||
model = structuredClone(model);
|
||
const prepared = [...fonts], attempted = new Set();
|
||
while (true) {
|
||
try {
|
||
await callWASM("ofdgoPreviewRichText", item.index, item.draft ? "" : item.id, JSON.stringify(model), width, prepared);
|
||
if (openSeq !== state.openSeq) throw new Error("文档已更改");
|
||
return prepared;
|
||
} catch (error) {
|
||
const id = error.missingGlyphs?.fontID;
|
||
if (!id || attempted.has(id) || prepared.some(font => font.key === id) || openSeq !== state.openSeq) throw error;
|
||
attempted.add(id);
|
||
const original = (await callWASM("ofdgoEditorFont", id)).bytes;
|
||
const [source] = await callWASM("ofdgoFontFaces", original);
|
||
if (!source?.postscriptName) throw error;
|
||
await fontManager.loadFaces(data => callWASM("ofdgoFontFaces", data));
|
||
const candidates = fontManager.editorFonts().filter(font => !font.disabled && font.postscriptName === source.postscriptName);
|
||
let recovered;
|
||
for (const choice of candidates) {
|
||
try {
|
||
const data = await readTextFont(choice);
|
||
const [face] = await callWASM("ofdgoFontFaces", data);
|
||
if (face?.postscriptName !== source.postscriptName) continue;
|
||
await callWASM("ofdgoCheckTextFont", data, model.runs.filter(run => run.format.font === id).map(run => run.text).join(""));
|
||
recovered = { key: id, data, choice };
|
||
break;
|
||
} catch {}
|
||
}
|
||
if (!recovered || openSeq !== state.openSeq) throw error;
|
||
prepared.push({ key: id, data: recovered.data });
|
||
if (editing?.rich && canvasEditor.input === editing) {
|
||
const rich = editing.rich;
|
||
const face = await new FontFace(`ofdgo-recovered-${crypto.randomUUID()}`, recovered.data, fontFaceStyle(recovered.data)).load();
|
||
if (openSeq !== state.openSeq || canvasEditor.input !== editing) throw error;
|
||
rich.imports.set(id, recovered.data);
|
||
rich.choices.set(id, recovered.choice);
|
||
const previous = rich.faces.get(id);
|
||
if (previous) document.fonts.delete(previous);
|
||
rich.faces.set(id, face); document.fonts.add(face);
|
||
if (!editing.composing && !editing.saving) { rich.selection(); rich.render(); updateObjectControls(canvasEditor.selected); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function saveRichText(item, model, fonts = [], box = item) {
|
||
const boundary = { x: box.x, y: box.y, width: box.width, height: box.height };
|
||
if (box.frameWidth !== undefined) Object.assign(boundary, { frameWidth: box.frameWidth, frameOffset: box.frameOffset || 0 });
|
||
fonts = await validateRichText(item, model, box.frameWidth ?? item.textFrame?.width ?? item.width, fonts);
|
||
return changeDocument("ofdgoSaveRichText", item.draft ? null : item,
|
||
...(item.draft ? [item.index, ""] : []), JSON.stringify(model), boundary, fonts);
|
||
}
|
||
|
||
function textIndents(item) {
|
||
return [item.leftIndent || 0, item.rightIndent || 0, item.firstLineIndent || 0];
|
||
}
|
||
|
||
async function toggleTextTool() {
|
||
if (!state.editing || !pageCan("insert") || document.body.hasAttribute("data-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 = !canvasEditor.selected?.items && canEditObject(canvasEditor.selected, "layoutKnown") ? currentTextStyle() : state.textDefaults;
|
||
const font = state.textFonts[Number(fontPicker.value)];
|
||
state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap,
|
||
bold: Boolean(style.bold), italic: Boolean(style.italic), underline: Boolean(style.underline), strikeout: Boolean(style.strikeout), shadow: Boolean(style.shadow), background: style.background || "",
|
||
wrapOverride: style === state.textDefaults ? style.wrapOverride : style.wrap,
|
||
align: style.align || "left", paragraphHeight: style.paragraphHeight || 0, letterSpacing: style.letterSpacing || 0,
|
||
leftIndent: style.leftIndent || 0, rightIndent: style.rightIndent || 0, firstLineIndent: style.firstLineIndent || 0,
|
||
fontChoice: font && !font.disabled ? font : state.textDefaults.fontChoice };
|
||
state.selectObjects = true;
|
||
setPan(false);
|
||
canvasEditor.setTool("text");
|
||
canvasEditor.focus();
|
||
}
|
||
|
||
async function beginCanvasText(index, box, surface, page) {
|
||
const font = state.textDefaults.fontChoice || state.textFonts[Number(fontPicker.value)];
|
||
if (!font || font.disabled) {
|
||
setStatus("尚未添加字体");
|
||
el.textFontAdd.focus();
|
||
return;
|
||
}
|
||
const openSeq = state.openSeq;
|
||
setBusy(true);
|
||
try {
|
||
const data = await readTextFont(font);
|
||
const face = new FontFace("ofdgo-draft", data, fontFaceStyle(data));
|
||
await face.load();
|
||
if (openSeq !== state.openSeq || !surface.isConnected) return;
|
||
const node = document.createElement("div");
|
||
const item = { ...state.textDefaults, ...box, wrap: state.textDefaults.wrapOverride ?? box.wrap ?? state.textDefaults.wrap,
|
||
index, page, surface, node, text: "", draft: true, fontData: data };
|
||
const model = richTextModel(item);
|
||
const key = `temp-font-${crypto.randomUUID()}`;
|
||
model.runs[0].format.font = key;
|
||
canvasEditor.editText(item, face, null, state.composite ? null : { model, faces: new Map([[key, face]]), fonts: [{ key, data }] });
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) showError(err, false);
|
||
} finally {
|
||
if (openSeq === state.openSeq) setBusy(false);
|
||
}
|
||
}
|
||
|
||
function editorFonts(item) {
|
||
const fonts = fontManager.editorFonts();
|
||
if (item) {
|
||
fonts.unshift({ id: `embedded:${item.font}`, name: item.fontName || item.font, embedded: true, disabled: !canEditObject(item, "textContent") });
|
||
}
|
||
return fonts;
|
||
}
|
||
|
||
async function loadEditorFonts() {
|
||
const tasks = [];
|
||
if (!state.fontCatalogLoading && fontManager.canReadLocal() && !fontManager.catalogLoaded && fontManager.permission !== "denied") {
|
||
state.fontCatalogLoading = true;
|
||
tasks.push((async () => {
|
||
try {
|
||
await fontManager.queryLocal();
|
||
refreshEditorFonts();
|
||
} catch (err) {
|
||
setStatus(err?.name === "NotAllowedError" ? "系统字体未授权" : String(err.message || err));
|
||
} finally {
|
||
state.fontCatalogLoading = false;
|
||
}
|
||
})());
|
||
}
|
||
if (!state.fontFacesLoading && fontManager.userFonts.some(font => font.enabled && !font.faces)) {
|
||
state.fontFacesLoading = true;
|
||
tasks.push((async () => {
|
||
try {
|
||
await fontManager.loadFaces(data => callWASM("ofdgoFontFaces", data));
|
||
refreshEditorFonts();
|
||
} finally {
|
||
state.fontFacesLoading = false;
|
||
}
|
||
})());
|
||
}
|
||
await Promise.all(tasks);
|
||
}
|
||
|
||
function refreshEditorFonts() {
|
||
const open = fontPicker.open;
|
||
const selected = state.textFonts[Number(fontPicker.value)];
|
||
const query = el.textFont.value === (selected?.fullName || selected?.name || "") ? "" : el.textFont.value;
|
||
updateTextFonts(canvasEditor.selected, true);
|
||
if (open && !el.textFont.disabled) { if (query) el.textFont.value = query; fontPicker.show(query, false); }
|
||
}
|
||
|
||
async function readTextFont(font) {
|
||
if (font.embedded) return (await callWASM("ofdgoEditorFont", font.id.slice(9))).bytes;
|
||
return (await callWASM("ofdgoFontFace", await fontManager.read(font.file || font), font.index || 0)).bytes;
|
||
}
|
||
|
||
function updateTextFonts(item, refresh = false) {
|
||
item = selectedText(item);
|
||
const rich = canvasEditor.input?.rich;
|
||
if (rich) {
|
||
const format = rich.state(), id = format.font;
|
||
const key = `rich:${id ?? "mixed"}:${rich.faces.size}`;
|
||
if (refresh || state.textFontID !== key) {
|
||
if (document.activeElement === el.textFont && fontPicker.open) return;
|
||
state.textFontID = key;
|
||
state.textFonts = editorFonts(null);
|
||
for (const [font, face] of rich.faces) {
|
||
const choice = rich.choices?.get(font);
|
||
state.textFonts.unshift(choice || { id: `embedded:${font}`, name: font === rich.editing.item.font ? rich.editing.item.fontName || font : face.family, embedded: !rich.imports.has(font), richKey: font });
|
||
}
|
||
fontPicker.setFonts(state.textFonts, id === undefined ? null : state.textFonts.find(font => font.richKey === id || rich.choices?.get(id) === font));
|
||
el.textFont.placeholder = id === undefined ? "混合" : "";
|
||
}
|
||
return;
|
||
}
|
||
if (item?.richText) item = { ...item, font: currentTextStyle().font };
|
||
const id = item?.type === "TextObject" && !item.draft ? item.font : null;
|
||
const key = item?.items ? `selection:${item.items.map(member => member.id).join(",")}:${id || ""}` : id;
|
||
if (refresh || state.textFontID !== key) {
|
||
state.textFontID = key;
|
||
state.textFonts = editorFonts(id ? item : null);
|
||
const selected = canvasEditor.input?.fontChoice || (id ? state.textFonts[0] : state.textDefaults.fontChoice);
|
||
if (selected && (canvasEditor.input || selected.embedded)
|
||
&& !state.textFonts.some(font => (font.id || font.postscriptName) === (selected.id || selected.postscriptName))) {
|
||
state.textFonts.unshift(selected);
|
||
}
|
||
const mixed = (item?.items || item?.richText) && !id;
|
||
fontPicker.setFonts(state.textFonts, mixed ? null : selected || state.textFonts[0]);
|
||
el.textFont.placeholder = mixed ? "混合" : "";
|
||
if (!id && !item?.items && !item?.richText) state.textDefaults.fontChoice = state.textFonts[Number(fontPicker.value)];
|
||
}
|
||
}
|
||
|
||
async function changeTextFont() {
|
||
const item = selectedText(canvasEditor.selected);
|
||
const font = state.textFonts[Number(fontPicker.value)];
|
||
if (!state.ready || document.body.hasAttribute("data-busy") || !font || font.disabled) return;
|
||
if (item?.richText && !canvasEditor.input) {
|
||
try { if (!await ensureRichInput()) return; } catch (error) { showError(error, false); return; }
|
||
}
|
||
const editing = canvasEditor.input;
|
||
if (editing) {
|
||
if (editing.composing) return;
|
||
editing.rich?.remember();
|
||
const openSeq = state.openSeq;
|
||
setBusy(true);
|
||
try {
|
||
const data = font.richKey && editing.rich?.imports.has(font.richKey) ? editing.rich.imports.get(font.richKey) : await readTextFont(font);
|
||
const [start, end] = editing.rich?.selection() || [0, editTextValue(editing.input).length];
|
||
await callWASM("ofdgoCheckTextFont", data, editTextValue(editing.input).slice(start, end));
|
||
const face = new FontFace(`ofdgo-edit-font-${crypto.randomUUID()}`, data, fontFaceStyle(data));
|
||
await face.load();
|
||
if (openSeq !== state.openSeq || canvasEditor.input !== editing) return;
|
||
editing.fontChoice = font;
|
||
if (item.draft) state.textDefaults.fontChoice = font;
|
||
if (editing.rich) {
|
||
const key = font.richKey || (font.embedded ? font.id.slice(9) : `temp-font-${crypto.randomUUID()}`);
|
||
editing.rich.choices ||= new Map();
|
||
editing.rich.choices.set(key, font);
|
||
canvasEditor.setTextFont(face, font.embedded ? null : data, key);
|
||
} else canvasEditor.setTextFont(face, data);
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
updateTextFonts(canvasEditor.selected, true);
|
||
showError(err, false);
|
||
}
|
||
} finally {
|
||
if (openSeq === state.openSeq) setBusy(false);
|
||
}
|
||
} else if (item?.type === "TextObject") {
|
||
if (item.items?.some(member => member.richText)) return;
|
||
if (canEditObject(item, "replaceFont") && (!font.embedded || item.font !== font.id.slice(9))) {
|
||
await changeDocument("ofdgoStyleText", { ...item, id: (item.items || [item]).map(member => member.id) }, readTextFont(font), 0, null);
|
||
updateTextFonts(canvasEditor.selected, true);
|
||
}
|
||
} else {
|
||
state.textDefaults.fontChoice = font;
|
||
}
|
||
}
|
||
|
||
async function insertObject(event) {
|
||
event.preventDefault();
|
||
if (!state.editing || document.body.hasAttribute("data-busy") || el.insertSubmit.disabled) {
|
||
return;
|
||
}
|
||
let openSeq = state.openSeq;
|
||
const item = state.insertObject;
|
||
const before = editorLocation(), revision = state.editorInfo.revision;
|
||
const index = item?.index ?? state.pageIndex;
|
||
const page = state.doc.pages[index];
|
||
const x = Math.min(20, page.width / 10);
|
||
const y = Math.min(20, page.height / 10);
|
||
el.insertStatus.textContent = "";
|
||
setBusy(true);
|
||
try {
|
||
const data = new Uint8Array(await el.insertImage.files[0].arrayBuffer());
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
const doc = item?.scoped
|
||
? await callWASM("ofdgoChangeCompositeObjects", item.index, state.composite.key, [item.id], "image", data)
|
||
: item ? await callWASM("ofdgoReplaceImage", item.index, item.id, data, el.insertFit.value)
|
||
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2, state.composite?.key || "");
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
el.insertPanel.close();
|
||
if (!item && resourceElements.Panel.open) resourceElements.Panel.close();
|
||
if (item && doc.revision === state.editorInfo?.revision) {
|
||
canvasEditor.focus();
|
||
return;
|
||
}
|
||
setEditorInfo(doc);
|
||
canvasEditor.pendingSelection = item ? null : { index, ids: doc.selectedIDs };
|
||
state.selectObjects = true;
|
||
setPan(false);
|
||
openSeq = ++state.openSeq;
|
||
await refreshEditorPage(doc, index, openSeq);
|
||
if (openSeq === state.openSeq) {
|
||
rememberEditorView(before, revision, !item && !!state.composite);
|
||
canvasEditor.focus();
|
||
}
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
if (el.insertPanel.open) {
|
||
el.insertStatus.textContent = err.message;
|
||
} else {
|
||
showError(err, false);
|
||
}
|
||
}
|
||
} finally {
|
||
if (openSeq === state.openSeq) {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
function displayPoints(length) {
|
||
return Number((length * 72 / 25.4).toPrecision(6));
|
||
}
|
||
|
||
function inputMillimeters(input, original) {
|
||
const points = Number(input.value);
|
||
return points === displayPoints(original) ? original : points * 25.4 / 72;
|
||
}
|
||
|
||
async function changeParagraph(wrap) {
|
||
const item = currentTextStyle();
|
||
const explicitWrap = wrap !== undefined;
|
||
wrap ??= item.wrap;
|
||
if (document.body.hasAttribute("data-busy") || item.draft) return;
|
||
if (!el.textLineHeight.checkValidity()) {
|
||
updateObjectControls(item, true);
|
||
return;
|
||
}
|
||
const height = inputMillimeters(el.textLineHeight, item.paragraphHeight || 0);
|
||
if (!el.textSpacing.checkValidity()) {
|
||
updateObjectControls(canvasEditor.selected, true);
|
||
return;
|
||
}
|
||
const spacing = inputMillimeters(el.textSpacing, item.letterSpacing || 0);
|
||
if (item === state.textDefaults) {
|
||
Object.assign(item, { wrap: Boolean(wrap), align: el.textAlign.value, paragraphHeight: height, letterSpacing: spacing });
|
||
if (explicitWrap) item.wrapOverride = Boolean(wrap);
|
||
updateObjectControls(canvasEditor.selected, true);
|
||
return;
|
||
}
|
||
if (!confirmTextReflow(item)) {
|
||
updateObjectControls(item, true);
|
||
return;
|
||
}
|
||
if (item.richText) await saveRichText(item, { ...item.richText, layout: { ...item.richText.layout, wrap: Boolean(wrap), align: el.textAlign.value, lineHeight: height, letterSpacing: spacing } });
|
||
else await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height, spacing, ...textIndents(item));
|
||
updateObjectControls(canvasEditor.selected, true);
|
||
}
|
||
|
||
async function changeTextStyle(color) {
|
||
const item = currentTextStyle();
|
||
if (!state.ready || state.exporting || document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
if (!color && !el.textSize.checkValidity()) {
|
||
showError(new Error("字号无效"), false);
|
||
updateObjectControls(item, true);
|
||
return;
|
||
}
|
||
const size = color ? item.size : inputMillimeters(el.textSize, item.size);
|
||
const selectedColor = el.textColor.value;
|
||
const fill = color && selectedColor !== item.color ? selectedColor : null;
|
||
if (item.richText && !canvasEditor.input) {
|
||
try { if (!await ensureRichInput()) return; } catch (error) { showError(error, false); return; }
|
||
}
|
||
if (canvasEditor.input) {
|
||
if (!color && canvasEditor.input.source && !canvasEditor.input.reflow && size !== item.size && !confirmTextReflow(item)) return;
|
||
canvasEditor.setTextStyle(color ? { color: selectedColor } : { size });
|
||
updateObjectControls(canvasEditor.selected, true);
|
||
return;
|
||
}
|
||
if (state.composite && color) {
|
||
if (color && fill !== null && canEditObject(item, "paint")) {
|
||
await changeDocument("ofdgoCompositeTextColor", item, fill);
|
||
}
|
||
updateObjectControls(canvasEditor.selected, true);
|
||
return;
|
||
}
|
||
if (item.items) {
|
||
if (item.items.some(member => member.richText)) return;
|
||
if (color ? !canEditObject(item, "paint") : !canEditObject(item, "layoutKnown") || !canEditObject(item, "reflow")) return;
|
||
if (!color && !el.textSize.value) return;
|
||
if (size !== item.size || fill !== null) {
|
||
await changeDocument("ofdgoStyleText", { ...item, id: item.items.map(member => member.id) }, null, color ? 0 : size, fill);
|
||
}
|
||
updateObjectControls(canvasEditor.selected, true);
|
||
return;
|
||
}
|
||
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 (color ? !canEditObject(item, "paint") : !confirmTextReflow(item)) {
|
||
updateObjectControls(item, true);
|
||
return;
|
||
}
|
||
await changeDocument("ofdgoUpdateText", item, color ? null : item.text, null, size, fill);
|
||
}
|
||
updateObjectControls(canvasEditor.selected, true);
|
||
}
|
||
|
||
function shapeStyle() {
|
||
return { fill: el.shapeFill.checked, fillColor: el.shapeFillColor.value,
|
||
stroke: el.shapeStroke.checked, strokeColor: el.shapeStrokeColor.value,
|
||
lineWidth: Number(el.shapeWidth.value) * 25.4 / 72 };
|
||
}
|
||
|
||
function selectedPath(item) {
|
||
if (!item?.items) return item?.type === "PathObject" ? item : null;
|
||
if (!item.items.every(member => member.type === "PathObject")) return null;
|
||
const path = { ...item, type: "PathObject", shape: item.items.every(member => lineShape(member.shape)) ? "line" : "" };
|
||
for (const key of ["fill", "stroke", "fillColor", "strokeColor", "lineWidth"]) {
|
||
const value = item.items[0][key];
|
||
path[key] = item.items.every(member => member[key] === value) ? value : undefined;
|
||
}
|
||
return path;
|
||
}
|
||
|
||
async function changeShapeStyle({ target }) {
|
||
const item = selectedPath(canvasEditor.selected);
|
||
if (!state.editing || document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
if (canvasEditor.selected && (!item || !canEditObject(item, "paint"))) return;
|
||
if (target === el.shapeWidth && (!el.shapeWidth.checkValidity() || Number(el.shapeWidth.value) <= 0)) {
|
||
el.shapeWidth.value = item ? item.lineWidth === undefined ? "" : String(displayPoints(item.lineWidth)) : "1";
|
||
return;
|
||
}
|
||
if (item) {
|
||
const values = [el.shapeFill, el.shapeFillColor, el.shapeStroke, el.shapeStrokeColor, el.shapeWidth].map(input => {
|
||
if (input !== target) return null;
|
||
if (input === el.shapeFill || input === el.shapeStroke) return input.checked;
|
||
return input === el.shapeWidth ? inputMillimeters(input, item.lineWidth) : input.value;
|
||
});
|
||
await changeDocument("ofdgoUpdatePathStyle", { ...item, id: (item.items || [item]).map(member => member.id) }, ...values);
|
||
updateObjectControls(canvasEditor.selected, true);
|
||
} else {
|
||
el.shapeWidth.value = String(Number(el.shapeWidth.value));
|
||
}
|
||
updateDrawingControls();
|
||
}
|
||
|
||
function toggleDrawingTool(tool) {
|
||
if (!state.editing || document.body.hasAttribute("data-busy")) return;
|
||
const next = canvasEditor.tool === tool ? "" : tool;
|
||
state.selectObjects = true;
|
||
setPan(false);
|
||
canvasEditor.setTool(next);
|
||
canvasEditor.focus();
|
||
}
|
||
|
||
function updateDrawingControls() {
|
||
const tool = canvasEditor.tool;
|
||
const path = selectedPath(canvasEditor.selected);
|
||
el.insertTextButton.setAttribute("data-pressed", String(tool === "text"));
|
||
const line = lineShape(tool) || tool.startsWith("pen-") || !tool && lineShape(path?.shape);
|
||
if (line) {
|
||
el.shapeFill.checked = false;
|
||
el.shapeStroke.checked = true;
|
||
} else if (!path && !el.shapeFill.checked && !el.shapeStroke.checked) {
|
||
el.shapeStroke.checked = true;
|
||
}
|
||
const disabled = !state.editing || !state.ready || state.exporting;
|
||
el.arrowTool.disabled = disabled || !pageCan("insert");
|
||
el.penTool.disabled = el.arrowTool.disabled;
|
||
el.penTool.classList.toggle("active", tool.startsWith("pen-"));
|
||
el.annotationTool.disabled = disabled;
|
||
for (const [button, name] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) {
|
||
button.disabled = disabled || !pageCan("insert");
|
||
button.setAttribute("data-pressed", String(tool === name));
|
||
}
|
||
el.eraseButton.disabled = el.eraseMode.disabled = disabled;
|
||
el.eraseButton.setAttribute("data-pressed", String(tool.startsWith("erase-")));
|
||
el.selectObjectButton.setAttribute("data-pressed", String(canvasEditor.enabled && !tool));
|
||
const styleDisabled = disabled || (canvasEditor.selected ? !path || !canEditObject(path, "paint") : !pageCan("insert"));
|
||
el.shapeFill.disabled = el.shapeStroke.disabled = styleDisabled || line;
|
||
el.shapeFillColor.disabled = styleDisabled || line || !el.shapeFill.checked && !el.shapeFill.indeterminate;
|
||
el.shapeStrokeColor.disabled = el.shapeWidth.disabled = styleDisabled || !line && !el.shapeStroke.checked && !el.shapeStroke.indeterminate;
|
||
}
|
||
|
||
async function changeDocument(name, item, ...args) {
|
||
if (!state.editing || document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
let openSeq = state.openSeq;
|
||
const active = document.activeElement;
|
||
const toolbarFocus = el.editorTools.contains(active) ? active : null;
|
||
const previous = currentPageInfo();
|
||
const before = editorLocation(), revision = state.editorInfo.revision;
|
||
const restoring = name === "ofdgoUndo" || name === "ofdgoRedo";
|
||
if (restoring) canvasEditor.richClipboard = null;
|
||
const { scrollLeft, scrollTop } = el.viewerPanel;
|
||
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments";
|
||
const changingFont = name === "ofdgoChangeFontBinding";
|
||
let refreshManagement = false;
|
||
state.imageImporting = name === "ofdgoImportImages";
|
||
if (importing) state.importing = true;
|
||
setBusy(true, importing ? name === "ofdgoImportDocuments" ? "正在导入文档" : "正在导入页面" : "", null);
|
||
const progressTimer = changingFont ? window.setTimeout(() => {
|
||
if (openSeq === state.openSeq && document.body.hasAttribute("data-busy")) {
|
||
el.progressPanel.hidden = false;
|
||
setProgress("正在处理字体", null);
|
||
}
|
||
}, 250) : null;
|
||
try {
|
||
args = await Promise.all(args);
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
const scope = state.composite;
|
||
const operation = { ofdgoTransformObject: "transform", ofdgoTransformObjects: "transform", ofdgoRotateObjects: "rotate", ofdgoFlipObjects: "flip", ofdgoResizeObjects: "resize",
|
||
ofdgoAlignObject: "align", ofdgoAlignObjects: "align", ofdgoDistributeObjects: "distribute", ofdgoStyleObjects: "style", ofdgoUpdatePathStyle: "paint", ofdgoCompositeTextColor: "textColor",
|
||
ofdgoUpdateText: "text", ofdgoStyleText: "textStyle", ofdgoCropImage: "crop", ofdgoLayoutText: "layout", ofdgoFitImage: "fit", ofdgoResetCompositeCrop: "resetCrop",
|
||
ofdgoReshapeObject: "reshape", ofdgoReshapeLine: "line", ofdgoEraseObjects: "erase", ofdgoEraseObjectsPath: "erasePath", ofdgoPasteStyle: "pasteStyle",
|
||
ofdgoDeleteObject: "delete", ofdgoDeleteObjects: "delete", ofdgoCopyObjects: "copy", ofdgoOrderObjects: "order", ofdgoGroupObjects: "group" }[name];
|
||
const members = item?.items || (item ? [item] : []);
|
||
const scoped = scope && members.length && members.every(member => member.scoped);
|
||
const ids = item ? Array.isArray(item.id) ? item.id : members.map(member => member.id) : [];
|
||
const annotated = !scoped && ids.some(id => id.startsWith("annotation:"));
|
||
const inserting = ["ofdgoInsertShape", "ofdgoInsertText", "ofdgoInsertImage", "ofdgoInsertInk"].includes(name) || name === "ofdgoSaveRichText" && !item;
|
||
const editingAnnotation = name === "ofdgoWriteAnnotation" && Boolean(state.annotationCreate?.item);
|
||
if (scope && inserting && name === "ofdgoSaveRichText") throw new Error("组合内部暂不支持新增富文本");
|
||
if (scope && inserting) args.push(scope.key);
|
||
const textSelection = name === "ofdgoUpdateTextSelection";
|
||
if (scoped && !operation && !textSelection) throw new Error("内部对象暂不支持此操作");
|
||
const doc = textSelection
|
||
? await callWASM(name, item.index, item.selectionIDs, ...args)
|
||
: scoped
|
||
? await callWASM("ofdgoChangeCompositeObjects", item.index, scope.key, Array.isArray(item.id) ? item.id : members.map(member => member.id), operation, ...args)
|
||
: annotated && operation ? await callWASM("ofdgoChangeSelection", item.index, ids, operation, ...args)
|
||
: await callWASM(name, ...(item ? [item.index, item.id] : []), ...args);
|
||
if (name === "ofdgoChangeInvoice") invoicePanel.result = doc.invoiceResult;
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
if (doc.revision === state.editorInfo?.revision && !(name === "ofdgoSaveRichText" && canvasEditor.input?.refreshPending)) {
|
||
return true;
|
||
}
|
||
const richEditing = name === "ofdgoSaveRichText" && canvasEditor.input?.rich && canvasEditor.input;
|
||
if (richEditing?.saving) {
|
||
richEditing.rich.initial = JSON.stringify(richEditing.rich.model);
|
||
richEditing.refreshPending = true;
|
||
}
|
||
if (richEditing?.saving && richEditing.item.draft && doc.selectedIDs?.length) {
|
||
richEditing.item.id = doc.selectedIDs[0];
|
||
richEditing.item.draft = false;
|
||
}
|
||
const restoredView = name === "ofdgoUndo" ? state.editorViews.get(revision) : name === "ofdgoRedo" ? state.editorViews.get(doc.revision) : null;
|
||
if (restoredView?.reading && state.savedRevision === revision) state.savedRevision = doc.revision;
|
||
refreshManagement = el.documentPanel.open && (name === "ofdgoChangeDocument" || name === "ofdgoImportDocuments" || name === "ofdgoUpdateInfo" || restoring);
|
||
const previousDocument = state.doc.documents?.[state.doc.documentIndex || 0];
|
||
const nextDocument = doc.documents?.[doc.documentIndex || 0];
|
||
const sameDocument = previousDocument?.id && previousDocument.id === nextDocument?.id && state.doc.documentIndex === doc.documentIndex;
|
||
const documentChanged = (state.doc.documentVersion || "") !== (doc.documentVersion || "")
|
||
|| previousDocument?.id !== nextDocument?.id || previousDocument?.root !== nextDocument?.root
|
||
&& !(sameDocument && (name === "ofdgoChangeFontBinding" || restoring));
|
||
const packageChanged = documentChanged || name === "ofdgoChangeDocument" || name === "ofdgoImportDocuments" || restoring
|
||
&& JSON.stringify(doc.documents?.map(entry => [entry.root, entry.id])) !== JSON.stringify(state.doc.documents?.map(entry => [entry.root, entry.id]));
|
||
if (packageChanged) {
|
||
if (state.documentManagement && (name === "ofdgoImportDocuments" || name === "ofdgoChangeDocument" && args[0] === "add")) {
|
||
state.documentManagement.key = documentEntryKey(nextDocument);
|
||
}
|
||
resetCompositeScope();
|
||
state.objectClipboard = state.styleClipboard = null;
|
||
canvasEditor.richClipboard = null;
|
||
state.pageSelection.clear();
|
||
state.pageSelectionAnchor = null;
|
||
state.pageMultiSelect = false;
|
||
state.outlineSelection = null;
|
||
state.outlineExpanded.clear();
|
||
canvasEditor.clear();
|
||
canvasEditor.setTool("");
|
||
if (documentChanged) {
|
||
delete state.textDefaults.fontChoice;
|
||
updateTextFonts(null, true);
|
||
}
|
||
state.ofdBytes = null;
|
||
setEditorInfo(doc);
|
||
openSeq = ++state.openSeq;
|
||
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: documentChanged, clearSelection: true,
|
||
pageIndex: documentChanged ? 0 : state.pageIndex, fitMode: state.fitMode, scale: state.scale });
|
||
if (openSeq === state.openSeq) {
|
||
if (!restoring) rememberEditorView(before, revision);
|
||
return true;
|
||
}
|
||
return;
|
||
}
|
||
const view = restoredView;
|
||
const reindex = !!scope && (scoped && ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoCopyObjects", "ofdgoOrderObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath", "ofdgoGroupObjects"].includes(name)
|
||
|| textSelection || inserting || name === "ofdgoPasteObjects" && !!args[4]);
|
||
const reindexed = restoring ? view?.reindex && view.before : reindex && before;
|
||
const clipboard = state.objectClipboard;
|
||
if (reindexed && clipboard?.scope?.startsWith(reindexed.scope + "/") && clipboard.page === reindexed.page) state.objectClipboard = null;
|
||
const location = name === "ofdgoUndo" ? view?.before : view?.after;
|
||
const restoredIndex = location ? pageIndexByID(doc.pages, location.page) : -1;
|
||
if (restoredIndex >= 0) {
|
||
state.composite = location.scope ? { index: restoredIndex, key: location.scope, objects: [] } : null;
|
||
} else if (!item && !inserting && !editingAnnotation && !(name === "ofdgoPasteObjects" && args[4])) {
|
||
resetCompositeScope();
|
||
}
|
||
if (restoring && JSON.stringify(doc.outlines) !== JSON.stringify(state.doc.outlines)) {
|
||
state.outlineSelection = null;
|
||
state.outlineExpanded.clear();
|
||
}
|
||
setEditorInfo(doc);
|
||
if (name === "ofdgoUpdateInfo") {
|
||
Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject, customData: doc.customData,
|
||
documents: doc.documents, documentIndex: doc.documentIndex });
|
||
renderMeta();
|
||
updateControls();
|
||
rememberEditorView(before, revision);
|
||
return true;
|
||
}
|
||
if (name === "ofdgoChangeOutline" || name === "ofdgoMoveOutline") {
|
||
remapOutlineExpansion(name, args, doc.outlinePath);
|
||
state.doc.outlines = doc.outlines;
|
||
state.outlineSelection = doc.outlinePath;
|
||
renderOutlines(false);
|
||
showNavigation(el.outlinesTab);
|
||
updateControls();
|
||
rememberEditorView(before, revision);
|
||
return true;
|
||
}
|
||
const clearSelection = !item && !editingAnnotation || name === "ofdgoChangeAnnotations" && args[0] === "delete"
|
||
|| ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name);
|
||
if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoGroupObjects" || inserting || name === "ofdgoWriteAnnotation" && !editingAnnotation) {
|
||
state.selectObjects = true;
|
||
if (name !== "ofdgoInsertInk") canvasEditor.setTool("");
|
||
setPan(false);
|
||
canvasEditor.pendingSelection = { index: item?.index ?? args[0], ids: name === "ofdgoInsertInk" ? [] : doc.selectedIDs };
|
||
}
|
||
if (scoped && name === "ofdgoOrderObjects") canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs };
|
||
if (textSelection) canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs || [] };
|
||
openSeq = ++state.openSeq;
|
||
if (changingFont) {
|
||
await refreshFontBinding(doc, openSeq);
|
||
} else if (item || name === "ofdgoPasteObjects" || inserting || editingAnnotation) {
|
||
await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection);
|
||
} else {
|
||
const samePage = pageIndexByID(doc.pages, previous.id);
|
||
const pageIndex = restoredIndex >= 0 ? restoredIndex : doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage);
|
||
const page = doc.pages[pageIndex];
|
||
const keepScroll = doc.pageIndex === undefined && page.id === previous.id && pageIndex === state.pageIndex
|
||
&& page.width === previous.width && page.height === previous.height;
|
||
await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode: state.fitMode, scale: state.scale,
|
||
keepPreview: true, clearSelection, selection: restoredIndex >= 0 ? { index: restoredIndex, ids: location.ids } : null,
|
||
previewScroll: keepScroll ? { scrollLeft, scrollTop } : null });
|
||
}
|
||
if (openSeq === state.openSeq) {
|
||
if (!restoring) rememberEditorView(before, revision, reindex);
|
||
if (el.annotationsTab.getAttribute("data-selected") === "true") await loadAnnotationSummary();
|
||
if (!toolbarFocus && !changingFont) canvasEditor.focus();
|
||
return true;
|
||
}
|
||
} catch (err) {
|
||
refreshManagement = false;
|
||
if (name === "ofdgoChangeFontBinding") err.message = fontBindingError(err.message);
|
||
if (name === "ofdgoChangeInvoice") err.message = invoiceIssues([err.message]);
|
||
if (openSeq === state.openSeq) {
|
||
if (settingsElements.Panel.open) {
|
||
settingsElements.Status.textContent = err.message;
|
||
} else if (fontBindingElements.Panel.open) {
|
||
fontBindingElements.Status.textContent = fontBindingError(err.message);
|
||
} else if (el.annotationCreate.open) {
|
||
el.annotationCreateStatus.textContent = err.message;
|
||
} else if (el.annotationNote.open) {
|
||
el.annotationStatus.textContent = err.message;
|
||
} else if (el.batchPagesPanel.open) {
|
||
el.batchPagesStatus.textContent = err.message;
|
||
} else if (el.objectStylePanel.open) {
|
||
el.objectStyleStatus.textContent = err.message;
|
||
} else if (el.objectBoundsPanel.open) {
|
||
el.objectBoundsStatus.textContent = err.message;
|
||
} else if (el.outlinePanel.open) {
|
||
el.outlineStatus.textContent = err.message;
|
||
} else if (el.structureDialog.open) {
|
||
el.structureStatus.textContent = err.message;
|
||
} else if (el.infoPanel.open) {
|
||
el.infoStatus.textContent = err.message;
|
||
} else if (el.attachmentDialog.open) {
|
||
el.attachmentStatus.textContent = err.message;
|
||
} else if (el.pagePanel.open) {
|
||
el.pageStatus.textContent = err.message;
|
||
} else if (el.paragraphPanel.open) {
|
||
el.paragraphStatus.textContent = err.message;
|
||
} else if (el.createPanel.open) {
|
||
el.createStatus.textContent = err.message;
|
||
} else if (el.importPanel.open) {
|
||
el.importStatus.textContent = err.message;
|
||
} else if (el.versionPanel.open) {
|
||
el.versionStatus.textContent = err.message;
|
||
} else if (el.documentPanel.open) {
|
||
el.documentStatus.textContent = err.message;
|
||
} else if (resourceElements.Panel.open) {
|
||
resourceElements.Status.textContent = resourceError(err.message);
|
||
} else {
|
||
showError(err, false);
|
||
}
|
||
}
|
||
} finally {
|
||
if (progressTimer !== null) window.clearTimeout(progressTimer);
|
||
if (importing) {
|
||
state.importing = false;
|
||
state.imageImporting = false;
|
||
el.createCancel.disabled = false;
|
||
state.exportRequestID = 0;
|
||
el.cancelExportButton.hidden = true;
|
||
el.importCancel.disabled = false;
|
||
}
|
||
if (openSeq === state.openSeq) {
|
||
setBusy(false);
|
||
if (refreshManagement) await refreshDocumentManagement();
|
||
if (toolbarFocus?.isConnected && !toolbarFocus.disabled) toolbarFocus.focus({ preventScroll: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function refreshFontBinding(doc, openSeq) {
|
||
const indices = new Set([state.pageIndex, ...state.visiblePages, ...state.visibleThumbnails]);
|
||
for (const shell of el.svgHost.querySelectorAll(".page-shell.rendered")) indices.add(Number(shell.dataset.pageIndex));
|
||
state.doc = doc;
|
||
resetSearch();
|
||
state.documentSelection = null;
|
||
resetPageLoading();
|
||
state.pageCache.clear();
|
||
canvasEditor.clear();
|
||
try {
|
||
try {
|
||
const details = await callWASM("ofdgoDocumentInfo");
|
||
if (openSeq !== state.openSeq) return;
|
||
Object.assign(doc, details, { detailsPending: false, detailsError: "" });
|
||
} catch (err) {
|
||
if (openSeq !== state.openSeq) return;
|
||
Object.assign(doc, { detailsPending: false, detailsError: err.message });
|
||
}
|
||
for (const index of indices) {
|
||
try {
|
||
const page = await loadPageData(index, { openSeq, priority: 0, refresh: true });
|
||
if (openSeq !== state.openSeq) return;
|
||
if (page) {
|
||
mountPageSVG(index, page, openSeq);
|
||
updateThumbnail(index, openSeq);
|
||
}
|
||
} catch (err) {
|
||
if (openSeq !== state.openSeq) return;
|
||
unmountPage(index);
|
||
markFlowPageError(index, err);
|
||
markThumbnailError(index);
|
||
showError(err, false);
|
||
}
|
||
}
|
||
} finally {
|
||
if (openSeq === state.openSeq) {
|
||
releasePageResources();
|
||
renderMeta(true);
|
||
updateControls();
|
||
loadDocumentDetails(openSeq);
|
||
for (const shell of el.svgHost.children) observeFlowPage(shell);
|
||
for (const button of el.pageList.children) observeThumbnail(button, Number(button.dataset.pageIndex), openSeq);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function refreshEditorPage(doc, index, openSeq, clearSelection = false) {
|
||
state.doc = doc;
|
||
resetSearch();
|
||
state.documentSelection = null;
|
||
resetPageLoading();
|
||
try {
|
||
const page = await loadPageData(index, { openSeq, priority: 0, refresh: true });
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
if (clearSelection) {
|
||
const pending = canvasEditor.pendingSelection;
|
||
canvasEditor.clear();
|
||
canvasEditor.pendingSelection = pending;
|
||
}
|
||
mountPageSVG(index, page, openSeq);
|
||
updateThumbnail(index, openSeq);
|
||
setStatus(pageStatus(state.pageIndex, doc.pageCount));
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
if (canvasEditor.input?.saving && canvasEditor.input.rich && canvasEditor.input.item.index === index) {
|
||
canvasEditor.select(canvasEditor.input.item);
|
||
} else {
|
||
state.pageCache.delete(index);
|
||
canvasEditor.clear();
|
||
const shell = pageShell(index);
|
||
shell.querySelector(".page-surface").replaceChildren();
|
||
shell.classList.remove("rendered");
|
||
markFlowPageError(index, err);
|
||
markThumbnailError(index);
|
||
}
|
||
}
|
||
throw err;
|
||
} finally {
|
||
if (openSeq === state.openSeq) {
|
||
releasePageResources();
|
||
renderMeta(true);
|
||
updateControls();
|
||
loadDocumentDetails(openSeq);
|
||
for (const shell of el.svgHost.children) observeFlowPage(shell);
|
||
for (const button of el.pageList.children) observeThumbnail(button, Number(button.dataset.pageIndex), openSeq);
|
||
}
|
||
}
|
||
}
|
||
|
||
function releasePageResources() {
|
||
const images = new Set(), fonts = new Set();
|
||
for (const page of state.pageCache.values()) {
|
||
for (const name of page.imageNames) images.add(name);
|
||
for (const font of page.fonts || []) fonts.add(font.name);
|
||
}
|
||
for (const [name, image] of state.svgImages) {
|
||
if (!images.has(name)) {
|
||
URL.revokeObjectURL(image.url);
|
||
state.svgImages.delete(name);
|
||
}
|
||
}
|
||
for (const [name, font] of state.svgFonts) {
|
||
if (!fonts.has(name)) {
|
||
document.fonts.delete(font);
|
||
state.svgFonts.delete(name);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function openOFDFile() {
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
await requestLocalFontsBeforeOpen();
|
||
el.ofdInput.value = "";
|
||
el.ofdInput.click();
|
||
}
|
||
|
||
async function openOFDFromViewer() {
|
||
if (state.doc || document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
await openOFDFile();
|
||
}
|
||
|
||
async function openOFDFromDrop(event) {
|
||
if (!event.dataTransfer.types.includes("Files")) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
if (event.dataTransfer.files.length > 1) {
|
||
setStatus("一次拖入一个文件");
|
||
return;
|
||
}
|
||
const file = event.dataTransfer.files[0];
|
||
if (file && isDocumentFile(file)) {
|
||
await requestLocalFontsBeforeOpen();
|
||
}
|
||
await openOFD(file);
|
||
}
|
||
|
||
function openFontFile(input) {
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
const editing = canvasEditor.input;
|
||
if (editing) {
|
||
editing.rich?.remember();
|
||
editing.fontDialog = true;
|
||
const finish = () => {
|
||
editing.fontDialog = false;
|
||
input.removeEventListener("change", finish);
|
||
input.removeEventListener("cancel", finish);
|
||
if (canvasEditor.input === editing) editing.input.focus({ preventScroll: true });
|
||
};
|
||
input.addEventListener("change", finish);
|
||
input.addEventListener("cancel", finish);
|
||
}
|
||
input.value = "";
|
||
input.click();
|
||
}
|
||
|
||
function toggleSidebar(side) {
|
||
if (side === "pages") {
|
||
state.showPages = !state.showPages;
|
||
if (COMPACT_LAYOUT.matches) {
|
||
state.showMeta = false;
|
||
}
|
||
} else if (side === "meta") {
|
||
state.showMeta = !state.showMeta;
|
||
if (COMPACT_LAYOUT.matches) {
|
||
state.showPages = false;
|
||
}
|
||
}
|
||
updateSidebarState();
|
||
if (!COMPACT_LAYOUT.matches) {
|
||
resizeViewer();
|
||
}
|
||
}
|
||
|
||
function updateSidebarState() {
|
||
const openingPages = state.showPages && el.pageListPanel.inert;
|
||
document.body.toggleAttribute("data-hide-pages", !state.showPages);
|
||
document.body.toggleAttribute("data-hide-meta", !state.showMeta);
|
||
el.pageListPanel.inert = !state.showPages;
|
||
el.metaPanel.inert = !state.showMeta;
|
||
el.togglePagesButton.setAttribute("data-pressed", String(state.showPages));
|
||
el.toggleMetaButton.setAttribute("data-pressed", String(state.showMeta));
|
||
if (openingPages) {
|
||
updatePageListCurrent(true);
|
||
}
|
||
}
|
||
|
||
function syncLayoutMode(event) {
|
||
state.showPages = !event.matches;
|
||
state.showMeta = !event.matches;
|
||
updateSidebarState();
|
||
resizeViewer();
|
||
}
|
||
|
||
function resizeViewer() {
|
||
if (state.doc) {
|
||
syncThumbnailWindow();
|
||
applyFit(false);
|
||
if (state.fitMode !== "free") {
|
||
scrollToPage(state.pageIndex);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function boot() {
|
||
const openSeq = state.openSeq;
|
||
setBusy(true, "正在准备引擎", 8, STATUS.engine);
|
||
try {
|
||
if (!await prepareOffline()) {
|
||
return;
|
||
}
|
||
if (openSeq === state.openSeq) setProgress("正在准备引擎", 8, STATUS.engine);
|
||
await ensureWASM();
|
||
let fontsRestored = true;
|
||
try {
|
||
await fontManager.restore();
|
||
} catch {
|
||
fontsRestored = false;
|
||
}
|
||
if (openSeq === state.openSeq) setProgress("引擎准备完成", 70);
|
||
await loadExportFormats();
|
||
if (openSeq === state.openSeq) setEmpty("选择 OFD、PDF 文件");
|
||
updateFontSummary();
|
||
renderFontList();
|
||
updateControls();
|
||
updateLocalFontButton();
|
||
await fontManager.refreshPermission();
|
||
if (openSeq === state.openSeq) {
|
||
setStatus(fontsRestored ? STATUS.ready : "字体读取失败");
|
||
setBusy(false);
|
||
}
|
||
if ("launchQueue" in window) {
|
||
let opening = Promise.resolve();
|
||
window.launchQueue.setConsumer(({ files }) => {
|
||
if (!files.length) {
|
||
return;
|
||
}
|
||
opening = opening.then(async () => {
|
||
await openOFD(await files[0].getFile());
|
||
}).catch((err) => showError(err, !state.doc));
|
||
});
|
||
}
|
||
if (nativeHost) await nativeHost.start(files => {
|
||
if (document.body.hasAttribute("data-busy")) { setStatus("请等待当前操作完成"); return; }
|
||
if (batchElements.Panel.open) addBatchFiles(files);
|
||
else if (files.length === 1) openOFD(files[0]);
|
||
else if (files.length) setStatus("请选择一个文档");
|
||
}, error => showError(error, !state.doc));
|
||
try {
|
||
const remote = remoteDocumentOptions(window.location.hash);
|
||
if (remote && openSeq === state.openSeq) await openOFD({ name: remote.name }, remote);
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) showError(err, !state.doc);
|
||
}
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
setStatus("引擎加载失败");
|
||
setEmpty(String(err.message || err));
|
||
setBusy(false);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
async function registerOffline() {
|
||
const scope = new URL("./", location.href).href;
|
||
let registration = await navigator.serviceWorker.getRegistration(scope);
|
||
if (!registration || registration.scope !== scope) {
|
||
registration = await navigator.serviceWorker.register("./ofdgo_work.js", { updateViaCache: "none" });
|
||
}
|
||
const worker = registration.active || registration.installing || registration.waiting;
|
||
await waitForWorker(worker, "activated");
|
||
return registration;
|
||
}
|
||
|
||
async function prepareOffline() {
|
||
if (nativeHost) {
|
||
el.appPanel.hidden = false;
|
||
el.offlineStatus.textContent = "可离线";
|
||
return true;
|
||
}
|
||
if (!window.isSecureContext || !("serviceWorker" in navigator) || !("caches" in window) || !("locks" in navigator)) {
|
||
return true;
|
||
}
|
||
el.appPanel.hidden = false;
|
||
setProgress("正在准备离线", null, "正在准备离线");
|
||
try {
|
||
const registration = await registerOffline();
|
||
const result = await requestOffline(registration.active, "prepare");
|
||
if (result.reload) {
|
||
location.reload();
|
||
return false;
|
||
}
|
||
if (navigator.storage?.persist) {
|
||
navigator.storage.persist().catch(() => false);
|
||
}
|
||
el.offlineStatus.textContent = "可离线";
|
||
watchApplicationUpdates(result.checksum);
|
||
} catch {
|
||
el.offlineStatus.textContent = "未就绪";
|
||
}
|
||
el.refreshAppButton.disabled = false;
|
||
return true;
|
||
}
|
||
|
||
function watchApplicationUpdates(checksum) {
|
||
let checking = false;
|
||
async function check() {
|
||
if (checking || document.hidden || !navigator.onLine) {
|
||
return;
|
||
}
|
||
checking = true;
|
||
try {
|
||
const response = await fetch("./", { method: "HEAD", cache: "no-store", signal: AbortSignal.timeout(10000) });
|
||
const latest = response.headers.get("X-OFDGo-Checksum");
|
||
if (response.ok && !response.redirected && latest) {
|
||
el.offlineStatus.textContent = latest === checksum ? "可离线" : "可更新";
|
||
}
|
||
} catch {
|
||
} finally {
|
||
checking = false;
|
||
}
|
||
}
|
||
window.addEventListener("online", check);
|
||
document.addEventListener("visibilitychange", check);
|
||
check();
|
||
}
|
||
|
||
function waitForWorker(worker, target) {
|
||
return new Promise((resolve, reject) => {
|
||
function changed() {
|
||
if (worker.state === target || worker.state === "activated") {
|
||
worker.removeEventListener("statechange", changed);
|
||
resolve();
|
||
} else if (worker.state === "redundant") {
|
||
worker.removeEventListener("statechange", changed);
|
||
reject(new Error("离线启动失败"));
|
||
}
|
||
}
|
||
worker.addEventListener("statechange", changed);
|
||
changed();
|
||
});
|
||
}
|
||
|
||
function requestOffline(worker, type) {
|
||
return new Promise((resolve, reject) => {
|
||
const channel = new MessageChannel();
|
||
function close() {
|
||
channel.port1.close();
|
||
worker.removeEventListener("statechange", changed);
|
||
}
|
||
function changed() {
|
||
if (worker.state === "redundant") {
|
||
close();
|
||
reject(new Error("离线服务中断"));
|
||
}
|
||
}
|
||
channel.port1.onmessage = ({ data }) => {
|
||
close();
|
||
if (data.ok) {
|
||
resolve(data);
|
||
} else {
|
||
reject(new Error(data.error));
|
||
}
|
||
};
|
||
worker.addEventListener("statechange", changed);
|
||
worker.postMessage({ type }, [channel.port2]);
|
||
changed();
|
||
});
|
||
}
|
||
|
||
async function refreshApplication() {
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
const temporaryFonts = fontManager.userFonts.some((font) => font.source === "upload");
|
||
const message = [state.dirty ? "文档尚未保存,刷新将丢失更改" : "刷新后需重新打开文件", temporaryFonts ? "未保存字体需重新添加" : ""].filter(Boolean).join("\n");
|
||
if ((state.ofdBytes || state.doc || temporaryFonts) && !window.confirm(message)) {
|
||
return;
|
||
}
|
||
setBusy(true, "正在刷新应用", null, "正在刷新应用");
|
||
el.refreshAppButton.disabled = true;
|
||
const panels = document.querySelectorAll(".toolbar, .workspace");
|
||
panels.forEach((panel) => { panel.inert = true; });
|
||
try {
|
||
if (navigator.storage?.persist) {
|
||
await navigator.storage.persist().catch(() => false);
|
||
}
|
||
const scope = new URL("./", location.href).href;
|
||
await navigator.locks.request(`ofdgo:${scope}:refresh`, { ifAvailable: true }, async (lock) => {
|
||
if (!lock) {
|
||
throw new Error("应用正在刷新");
|
||
}
|
||
const registration = await registerOffline();
|
||
await registration.update();
|
||
if (registration.installing) {
|
||
await waitForWorker(registration.installing, "installed");
|
||
}
|
||
const worker = registration.waiting || registration.active;
|
||
await requestOffline(worker, "refresh");
|
||
await waitForWorker(worker, "activated");
|
||
window.removeEventListener("beforeunload", warnUnsaved);
|
||
location.reload();
|
||
});
|
||
} catch (err) {
|
||
setStatus(err.message === "应用正在刷新" ? err.message : "应用刷新失败");
|
||
} finally {
|
||
panels.forEach((panel) => { panel.inert = false; });
|
||
el.refreshAppButton.disabled = false;
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function loadExportFormats() {
|
||
batch.formats = await callWASM("ofdgoOutputFormats");
|
||
batchElements.Format.replaceChildren(...batch.formats.map(format => new Option(format.label, format.value)));
|
||
updateBatchControls();
|
||
const formats = await callWASM("ofdgoExportFormats") || [];
|
||
state.exportFormats = formats;
|
||
el.exportFormat.replaceChildren();
|
||
for (const format of formats) {
|
||
const option = document.createElement("option");
|
||
option.value = format.value;
|
||
option.textContent = format.label;
|
||
el.exportFormat.append(option);
|
||
}
|
||
if (formats.length) {
|
||
el.exportFormat.value = formats[0].value;
|
||
}
|
||
updateDPIControl();
|
||
}
|
||
|
||
async function ensureWASM() {
|
||
if (state.ready && !state.wasmExited) {
|
||
return;
|
||
}
|
||
if (!wasmPromise) {
|
||
wasmPromise = loadWASM().finally(() => {
|
||
wasmPromise = null;
|
||
});
|
||
}
|
||
await wasmPromise;
|
||
}
|
||
|
||
async function loadWASM() {
|
||
const wasmSeq = state.wasmSeq + 1;
|
||
state.wasmSeq = wasmSeq;
|
||
state.ready = false;
|
||
state.wasmExited = false;
|
||
await new Promise((resolve, reject) => {
|
||
const worker = createEngineWorker();
|
||
wasmWorker = worker;
|
||
const fail = (err) => {
|
||
markWASMExited(wasmSeq, err);
|
||
reject(err);
|
||
};
|
||
worker.onerror = (event) => fail(new Error(event.message || "引擎加载失败"));
|
||
worker.onmessageerror = () => fail(new Error("引擎通信失败"));
|
||
worker.onmessage = async ({ data }) => {
|
||
if (worker !== wasmWorker) {
|
||
return;
|
||
}
|
||
if (data.type === "font") {
|
||
try {
|
||
const request = wasmRequests.get(data.id);
|
||
const font = request?.localFonts?.get(data.name);
|
||
if (!font || request.openSeq !== state.openSeq) throw new Error("系统字体不可用");
|
||
if (!request.fontData.has(font)) request.fontData.set(font, fontManager.read(font));
|
||
const bytes = await request.fontData.get(font);
|
||
if (worker === wasmWorker) worker.postMessage({ type: "font", request: data.request, bytes });
|
||
} catch (err) {
|
||
if (worker === wasmWorker) worker.postMessage({ type: "font", request: data.request, error: err.message });
|
||
}
|
||
return;
|
||
}
|
||
if (data.type === "progress") {
|
||
setProgress(data.text, data.percent);
|
||
} else if (data.type === "conversion") {
|
||
if (wasmRequests.get(data.id)?.openSeq === state.openSeq && !el.cancelExportButton.disabled) {
|
||
if (data.phase === "open") setProgress("正在读取文档", null);
|
||
else if (data.phase === "pages") setProgress(data.completed ? `正在整理页面 · ${data.completed} 页` : "正在整理页面", null);
|
||
else if (data.phase === "convert") setProgress(`正在转换页面 · ${data.completed} / ${data.total} 页`, data.total ? 28 + data.completed / data.total * 22 : null);
|
||
else if (data.phase === "write.fonts") setProgress("正在处理字体", null);
|
||
else setProgress("正在生成文档", null);
|
||
}
|
||
} else if (data.type === "export") {
|
||
if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.exporting) {
|
||
if (data.stage === "save") {
|
||
el.cancelExportButton.disabled = true;
|
||
if (state.signing) el.signCancel.disabled = true;
|
||
setProgress("正在保存文件", null);
|
||
} else if (!el.cancelExportButton.disabled) {
|
||
if (data.stage === "prepare") {
|
||
const label = { snapshot: "正在准备文档", ids: "正在检查标识", commit: "正在整理文档", fonts: "正在处理字体", pages: "正在处理页面", references: "正在检查引用", resources: "正在整理资源", compress: "正在压缩文件", write: "正在写入文件", sign: "正在签署文档", encrypt: "正在加密文档" }[data.phase];
|
||
setProgress(data.phase === "compress" && data.completed ? `${label} · 已处理 ${data.completed} 项` : label, data.total ? data.completed / data.total * 100 : null);
|
||
} else if (data.completed === data.total) {
|
||
setProgress("正在整理文件", null);
|
||
} else {
|
||
setProgress(`正在导出页面 · ${data.completed} / ${data.total} 页`, data.completed / data.total * 100);
|
||
}
|
||
}
|
||
}
|
||
} else if (data.type === "import") {
|
||
if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.imageImporting) {
|
||
const text = data.phase === "commit" ? "正在完成导入" : `正在导入图片 · ${data.completed} / ${data.total} 张`;
|
||
el.createStatus.textContent = text;
|
||
setProgress(text, data.total ? data.completed / data.total * 100 : null);
|
||
if (data.phase === "commit") el.createCancel.disabled = el.cancelExportButton.disabled = true;
|
||
return;
|
||
}
|
||
if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.importing && !el.importCancel.disabled) {
|
||
const label = { ids: "正在检查标识", documents: "正在导入文档", pages: "正在导入页面", resources: "正在读取资源", commit: "正在完成导入" }[data.phase] || "正在导入文档";
|
||
el.importStatus.textContent = data.total ? `${label} · ${data.completed} / ${data.total}${state.importDocumentMode ? "" : " 页"}` : label;
|
||
setProgress(label, data.total ? data.completed / data.total * 100 : null);
|
||
if (data.phase === "commit") el.importCancel.disabled = el.cancelExportButton.disabled = true;
|
||
}
|
||
} else if (data.type === "ready") {
|
||
state.ready = true;
|
||
resolve();
|
||
} else if (data.type === "exit") {
|
||
fail(new Error(data.error));
|
||
} else {
|
||
const request = wasmRequests.get(data.id);
|
||
wasmRequests.delete(data.id);
|
||
if (data.ok) {
|
||
request.resolve(data.data);
|
||
} else {
|
||
const err = new Error(missingGlyphMessage(data.missingGlyphs) || (data.reasonCode === "layoutRequired" ? "跨段或换行修改需先设置段落排版" : data.error));
|
||
err.code = data.code;
|
||
err.missingGlyphs = data.missingGlyphs;
|
||
if (data.canceled) {
|
||
err.name = "AbortError";
|
||
}
|
||
request.reject(err);
|
||
}
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
function markWASMExited(wasmSeq = state.wasmSeq, err) {
|
||
if (wasmSeq !== state.wasmSeq) {
|
||
return;
|
||
}
|
||
state.ready = false;
|
||
clearSecurityForms();
|
||
state.wasmExited = true;
|
||
wasmWorker?.terminate();
|
||
wasmWorker = null;
|
||
for (const request of wasmRequests.values()) {
|
||
request.reject(err || new Error("引擎运行中断"));
|
||
}
|
||
wasmRequests.clear();
|
||
if (err) {
|
||
setStatus(state.editorInfo ? "引擎中断,未保存内容无法恢复" : "引擎运行中断");
|
||
}
|
||
updateControls();
|
||
scheduleWASMRecovery();
|
||
}
|
||
|
||
function scheduleWASMRecovery() {
|
||
if (!state.ofdBytes || state.wasmRecovering || state.wasmRecoveries >= 2) {
|
||
return;
|
||
}
|
||
window.clearTimeout(wasmRecoveryTimer);
|
||
wasmRecoveryTimer = window.setTimeout(recoverWASM, 0);
|
||
}
|
||
|
||
async function recoverWASM() {
|
||
if (state.wasmRecovering || !state.ofdBytes) {
|
||
return;
|
||
}
|
||
state.wasmRecovering = true;
|
||
state.wasmRecoveries += 1;
|
||
const openSeq = ++state.openSeq;
|
||
setBusy(true, "正在恢复引擎", 18, STATUS.recovering);
|
||
try {
|
||
await openDocument({
|
||
pageIndex: state.pageIndex,
|
||
versionID: state.doc?.documentVersion,
|
||
fitMode: state.fitMode || "width",
|
||
scale: state.scale,
|
||
skipAutoFonts: true,
|
||
openSeq,
|
||
});
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
showError(err, !state.doc);
|
||
}
|
||
} finally {
|
||
state.wasmRecovering = false;
|
||
if (openSeq === state.openSeq) {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
function remoteDocumentOptions(hash) {
|
||
const params = new URLSearchParams(hash.replace(/^#/, ""));
|
||
for (const key of ["url", "name", "page"]) {
|
||
if (params.getAll(key).length > 1) throw new Error(`重复的链接参数:${key}`);
|
||
}
|
||
if (!params.has("url")) return null;
|
||
let url;
|
||
try { url = new URL(params.get("url")); } catch { throw new Error("文件地址无效"); }
|
||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error("文件地址必须为不含账号密码的 HTTP 或 HTTPS 地址");
|
||
if (window.location.protocol === "https:" && url.protocol !== "https:") throw new Error("请使用 HTTPS 文件地址");
|
||
const page = params.get("page") || "1";
|
||
return { url: url.href, name: params.get("name") || "", pageIndex: /^[1-9]\d*$/.test(page) ? Math.min(Number(page), Number.MAX_SAFE_INTEGER) - 1 : 0 };
|
||
}
|
||
|
||
function remoteDocumentName(name) {
|
||
return String(name || "").split(/[\\/]/).pop().replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
||
}
|
||
|
||
async function downloadRemoteDocument(options) {
|
||
const controller = new AbortController();
|
||
remoteDownload = controller;
|
||
el.cancelExportButton.hidden = false;
|
||
el.cancelExportButton.disabled = false;
|
||
setProgress("正在下载文档", null);
|
||
try {
|
||
const response = await fetch(options.url, { mode: "cors", credentials: "omit", referrerPolicy: "no-referrer", cache: "no-store", signal: controller.signal });
|
||
if (!response.ok) throw new Error(`文件下载失败(HTTP ${response.status})`);
|
||
const total = Number(response.headers.get("Content-Length")) || 0;
|
||
if (!response.body) throw new Error("下载内容为空");
|
||
const chunks = [];
|
||
let received = 0;
|
||
const reader = response.body.getReader();
|
||
try {
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
chunks.push(value);
|
||
received += value.byteLength;
|
||
setProgress(`正在下载文档 · ${(received / 1048576).toFixed(1)} MB`, total > 0 && received <= total ? received / total * 20 : null);
|
||
}
|
||
} finally { reader.releaseLock(); }
|
||
controller.signal.throwIfAborted();
|
||
const disposition = response.headers.get("Content-Disposition") || "";
|
||
let supplied = /(?:^|;)\s*filename\*=UTF-8'[^']*'([^;]+)/i.exec(disposition)?.[1];
|
||
if (supplied) { try { supplied = decodeURIComponent(supplied.trim()); } catch { supplied = ""; } }
|
||
if (!supplied) supplied = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;]*))/i.exec(disposition)?.slice(1).find(value => value !== undefined)?.replace(/\\(.)/g, "$1");
|
||
let pathName = new URL(response.url || options.url).pathname.split("/").pop();
|
||
try { pathName = decodeURIComponent(pathName); } catch { pathName = ""; }
|
||
const name = remoteDocumentName(options.name) || remoteDocumentName(supplied) || remoteDocumentName(pathName);
|
||
return { file: new Blob(chunks), name };
|
||
} catch (err) {
|
||
if (err instanceof TypeError) throw new Error("下载失败,请检查网络、链接及跨域配置");
|
||
throw err;
|
||
} finally {
|
||
if (remoteDownload === controller) {
|
||
remoteDownload = null;
|
||
el.cancelExportButton.hidden = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function openOFD(file, remote = null) {
|
||
if (!file) {
|
||
return;
|
||
}
|
||
if (!remote && !isDocumentFile(file)) {
|
||
el.ofdInput.value = "";
|
||
showError(new Error("选择 OFD、PDF 文件"), !state.doc);
|
||
return;
|
||
}
|
||
if (!discardChanges()) {
|
||
return;
|
||
}
|
||
remoteDownload?.abort();
|
||
resourceElements.Panel.close();
|
||
clearSecurityForms();
|
||
state.wasmRecoveries = 0;
|
||
const openSeq = ++state.openSeq;
|
||
setBusy(true, "正在读取文档", 10, STATUS.opening);
|
||
try {
|
||
const downloaded = remote ? await downloadRemoteDocument(remote) : null;
|
||
if (openSeq !== state.openSeq) return;
|
||
await ensureWASM();
|
||
if (openSeq !== state.openSeq) return;
|
||
const source = downloaded ? downloaded.file : file;
|
||
const format = await callWASM("ofdgoDetectFormat", source);
|
||
if (openSeq !== state.openSeq) return;
|
||
let bytes = await documentBytes(source);
|
||
const name = (downloaded ? downloaded.name : file.name) || `ofdgo.${format}`;
|
||
let warnings = [];
|
||
let convertedDoc;
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
if (format === "pdf") {
|
||
setProgress("正在转换文档", 25);
|
||
await ensureWASM();
|
||
if (openSeq !== state.openSeq) return;
|
||
let converted;
|
||
try {
|
||
converted = await openWithCredentials(await fontManager.files(fontManager.records()), openSeq, bytes, false, true);
|
||
} finally {
|
||
if (openSeq === state.openSeq) {
|
||
state.exportRequestID = 0;
|
||
el.cancelExportButton.hidden = true;
|
||
}
|
||
}
|
||
if (openSeq !== state.openSeq) return;
|
||
bytes = converted.bytes;
|
||
convertedDoc = converted.doc;
|
||
warnings = JSON.parse(converted.warnings || "null") || [];
|
||
}
|
||
await ensureWASM();
|
||
if (openSeq !== state.openSeq) return;
|
||
const doc = convertedDoc || await openWithCredentials(await fontManager.files(fontManager.userFonts), openSeq, bytes);
|
||
if (openSeq !== state.openSeq) return;
|
||
state.ofdBytes = bytes;
|
||
state.conversionWarnings = warnings;
|
||
state.fileName = (name || "ofdgo.ofd").replace(/\.pdf$/i, ".ofd");
|
||
if (remote && !/\.ofd$/i.test(state.fileName)) state.fileName += ".ofd";
|
||
state.editing = false;
|
||
state.composite = null;
|
||
state.annotationCreate = null;
|
||
state.pageSelection.clear();
|
||
state.pageSelectionAnchor = null;
|
||
state.pageMultiSelect = false;
|
||
state.objectClipboard = null;
|
||
state.styleClipboard = null;
|
||
canvasEditor.richClipboard = null;
|
||
state.editorInfo = null;
|
||
state.editorViews.clear();
|
||
state.fontRenderPending = false;
|
||
state.savedRevision = null;
|
||
state.packageDirty = false;
|
||
state.encryptionDirty = false;
|
||
canvasEditor.clear();
|
||
setDirty(false);
|
||
el.createPanel.close();
|
||
el.insertPanel.close();
|
||
fontBindingElements.Panel.close();
|
||
el.pagePanel.close();
|
||
el.paragraphPanel.close();
|
||
el.infoPanel.close();
|
||
el.documentPanel.close();
|
||
state.documentManagement = null;
|
||
el.versionPanel.close();
|
||
state.versionManagement = null;
|
||
state.infoDocumentIndex = null;
|
||
el.annotationNote.close();
|
||
el.annotationCreate.close();
|
||
el.objectPicker.close();
|
||
el.importPanel.close();
|
||
el.batchPagesPanel.close();
|
||
el.objectStylePanel.close();
|
||
el.objectBoundsPanel.close();
|
||
el.outlinePanel.close();
|
||
el.structureDialog.close();
|
||
updateControls();
|
||
await openDocument({ doc, pageIndex: remote?.pageIndex || 0, resetScroll: true, openSeq, activateActions: true });
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
if (err.name === "AbortError") setStatus("打开已取消");
|
||
else showError(err, !state.doc);
|
||
setBusy(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
function isDocumentFile(file) {
|
||
return /\.(ofd|pdf)$/i.test(file.name || "");
|
||
}
|
||
|
||
async function openSelectedFonts(event) {
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
const input = event.currentTarget;
|
||
const files = Array.from(input.files || []).filter((file) => (
|
||
!input.webkitdirectory || /\.(ttf|otf|ttc)$/i.test(file.name)
|
||
));
|
||
input.value = "";
|
||
if (!files.length) {
|
||
setStatus("暂无字体文件");
|
||
return;
|
||
}
|
||
setBusy(true, "正在读取字体", 10, STATUS.fonts);
|
||
try {
|
||
const fonts = [];
|
||
for (let i = 0; i < files.length; i += 1) {
|
||
setProgress(`正在读取字体 · ${i + 1} / ${files.length}`, 10 + Math.round(i / files.length * 60));
|
||
const file = files[i];
|
||
fonts.push(fontManager.record(file.name, new Uint8Array(await file.arrayBuffer()), "upload"));
|
||
}
|
||
const { saved, changed } = await fontManager.add(fonts);
|
||
if (changed) {
|
||
await applyFontChange();
|
||
if (resourceElements.Panel.open) await resourceTask(loadManagedResources);
|
||
if (settingsElements.Panel.open && settings.invoice) { await loadEditorFonts(); renderDocumentSettings(); }
|
||
}
|
||
setStatus(saved ? "字体已保存" : "字体未保存,仅本次可用");
|
||
} catch (err) {
|
||
showError(err, false);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function loadLocalFonts() {
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
if (!fontManager.canReadLocal()) {
|
||
setStatus("浏览器不支持系统字体");
|
||
return;
|
||
}
|
||
setBusy(true, state.doc ? "正在匹配字体" : "正在请求授权", 12, state.doc ? STATUS.fonts : "正在请求授权");
|
||
try {
|
||
await nextFrame();
|
||
const available = await fontManager.queryLocal();
|
||
if (!state.doc) {
|
||
setStatus(available.length ? `已授权${available.length}种字体` : "暂无系统字体");
|
||
return;
|
||
}
|
||
if (await loadDocumentLocalFonts(available)) {
|
||
await applyFontChange();
|
||
}
|
||
} catch (err) {
|
||
if (err && err.name === "NotAllowedError") {
|
||
setStatus("系统字体未授权");
|
||
} else {
|
||
setStatus(String(err.message || err));
|
||
}
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function requestLocalFontsBeforeOpen() {
|
||
if (!fontManager.canReadLocal() || fontManager.catalogLoaded || fontManager.permission === "denied") {
|
||
return;
|
||
}
|
||
setBusy(true, "正在请求授权", 12, "正在请求授权");
|
||
try {
|
||
const available = await fontManager.queryLocal();
|
||
setStatus(available.length ? `已授权${available.length}种字体` : "暂无系统字体");
|
||
} catch (err) {
|
||
if (err && err.name === "NotAllowedError") {
|
||
setStatus("系统字体未授权");
|
||
return;
|
||
}
|
||
setStatus(String(err.message || err));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function autoLoadDocumentLocalFonts(openSeq) {
|
||
if (!state.doc?.fonts?.some((font) => !font.embedded) || !fontManager.canReadLocal() || fontManager.permission === "denied") {
|
||
return false;
|
||
}
|
||
setProgress("正在匹配字体", 62, STATUS.fonts);
|
||
try {
|
||
const available = fontManager.catalogLoaded ? fontManager.catalog : await fontManager.queryLocal();
|
||
if (openSeq !== state.openSeq) {
|
||
return false;
|
||
}
|
||
return await loadDocumentLocalFonts(available, openSeq);
|
||
} catch (err) {
|
||
if (err && err.name === "NotAllowedError") {
|
||
setStatus("系统字体未授权");
|
||
return false;
|
||
}
|
||
setStatus(String(err.message || err));
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
|
||
const docFonts = state.doc?.fonts || [];
|
||
if (!docFonts.length) {
|
||
fontManager.localFonts = [];
|
||
setStatus("暂无文档字体");
|
||
updateFontSummary();
|
||
renderFontList();
|
||
return false;
|
||
}
|
||
if (docFonts.every((font) => font.embedded)) {
|
||
fontManager.localFonts = [];
|
||
setStatus("字体均已内嵌");
|
||
updateFontSummary();
|
||
renderFontList();
|
||
return false;
|
||
}
|
||
const selected = await selectLocalFonts(available);
|
||
if (openSeq !== state.openSeq) {
|
||
return false;
|
||
}
|
||
const emptyStatus = available.length === 0 ? "暂无系统字体" : "暂无匹配字体";
|
||
const fonts = [];
|
||
for (let i = 0; i < selected.length; i += 1) {
|
||
setProgress(`正在读取字体 · ${i + 1} / ${selected.length}`, 20 + Math.round(i / selected.length * 60));
|
||
const item = selected[i];
|
||
const data = await fontManager.read(item);
|
||
if (openSeq !== state.openSeq) {
|
||
return false;
|
||
}
|
||
fonts.push(fontManager.record(fontManager.localName(item), data, "browser"));
|
||
}
|
||
fontManager.localFonts = fonts;
|
||
setStatus(fonts.length ? `已加载${fonts.length}种字体` : emptyStatus);
|
||
updateFontSummary();
|
||
renderFontList();
|
||
return fonts.length > 0;
|
||
}
|
||
|
||
async function selectLocalFonts(fonts) {
|
||
const available = fontManager.localEntries(fonts);
|
||
const matched = await callWASM("ofdgoMatchFontFiles", [...available.keys()]);
|
||
return [...new Set(matched.map((name) => available.get(name)))];
|
||
}
|
||
|
||
function scheduleFontSync() {
|
||
state.fontSyncPending = true;
|
||
syncUserFonts();
|
||
}
|
||
|
||
async function syncUserFonts() {
|
||
if (!state.ready || !state.fontSyncPending || state.fontSyncing || document.hidden || document.body.hasAttribute("data-busy") || document.activeElement?.classList.contains("font-name-input")) {
|
||
return;
|
||
}
|
||
state.fontSyncPending = false;
|
||
state.fontSyncing = true;
|
||
setBusy(true, "正在同步字体", null);
|
||
try {
|
||
if (await fontManager.restore()) {
|
||
await applyFontChange();
|
||
}
|
||
if (!state.doc) {
|
||
setStatus(STATUS.ready);
|
||
}
|
||
} catch {
|
||
setStatus("字体读取失败");
|
||
} finally {
|
||
state.fontSyncing = false;
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function updateFontSummary() {
|
||
const fonts = fontManager.records();
|
||
const total = fonts.length;
|
||
const enabled = fonts.filter((font) => font.enabled).length;
|
||
el.availableFontSummary.textContent = total ? `${enabled}/${total}` : "0";
|
||
}
|
||
|
||
async function applyFontChange() {
|
||
updateFontSummary();
|
||
renderFontList();
|
||
if (!state.doc) {
|
||
return;
|
||
}
|
||
if (canvasEditor.input || canvasEditor.crop) {
|
||
const openSeq = state.openSeq;
|
||
const fonts = await fontManager.files(fontManager.records());
|
||
if (openSeq !== state.openSeq) return;
|
||
await callWASM("ofdgoConfigure", fonts, state.renderAnnotations);
|
||
if (openSeq !== state.openSeq) return;
|
||
state.fontRenderPending = true;
|
||
setStatus(pageStatus(state.pageIndex, state.doc.pageCount));
|
||
return;
|
||
}
|
||
await refreshFontPreview(true);
|
||
}
|
||
|
||
async function refreshFontPreview(fontsChanged) {
|
||
state.fontRenderPending = false;
|
||
await openDocument({
|
||
pageIndex: state.pageIndex,
|
||
fitMode: state.fitMode,
|
||
scale: state.scale,
|
||
skipAutoFonts: true,
|
||
reuseSession: true,
|
||
fontsChanged,
|
||
keepPreview: Boolean(state.editorInfo),
|
||
previewScroll: { scrollLeft: el.viewerPanel.scrollLeft, scrollTop: el.viewerPanel.scrollTop },
|
||
});
|
||
}
|
||
|
||
async function refreshPendingFonts() {
|
||
if (!state.fontRenderPending || !state.ready || document.body.hasAttribute("data-busy") || canvasEditor.input || canvasEditor.crop) return;
|
||
await refreshFontPreview(false);
|
||
}
|
||
|
||
async function toggleAnnotations() {
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
state.renderAnnotations = !state.renderAnnotations;
|
||
updateAnnotationButton();
|
||
if (!state.doc) {
|
||
return;
|
||
}
|
||
await openDocument({
|
||
pageIndex: state.pageIndex,
|
||
fitMode: state.fitMode,
|
||
skipAutoFonts: true,
|
||
reuseSession: true,
|
||
});
|
||
}
|
||
|
||
function displayMode() {
|
||
return state.editing ? "svg" : state.renderMode;
|
||
}
|
||
|
||
function updateDisplayControls() {
|
||
const disabled = !state.ready || !state.doc || state.exporting || document.body.hasAttribute("data-busy");
|
||
el.renderVectorButton.disabled = el.renderRasterButton.disabled = disabled || state.editing;
|
||
el.renderVectorButton.setAttribute("data-pressed", String(displayMode() === "svg"));
|
||
el.renderRasterButton.setAttribute("data-pressed", String(displayMode() === "raster"));
|
||
updateDPIControl();
|
||
}
|
||
|
||
async function refreshPageDisplay(anchor = scaleAnchor(0)) {
|
||
const selection = state.editing ? { index: canvasEditor.selected?.index ?? state.pageIndex, ids: canvasEditor.items().map(item => item.id) } : null;
|
||
await openDocument({
|
||
pageIndex: state.pageIndex, fitMode: state.fitMode, scale: state.scale,
|
||
skipAutoFonts: true, reuseSession: true, keepPreview: true, keepSearch: true,
|
||
previewAnchor: anchor, selection, throwError: true,
|
||
});
|
||
}
|
||
|
||
async function changeDisplayMode(mode = state.renderMode) {
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
el.imageDPI.value = String(state.renderDPI);
|
||
updateDPIControl();
|
||
return;
|
||
}
|
||
if (state.editing) mode = state.renderMode;
|
||
const dpi = currentImageDPI();
|
||
if (state.renderDPI === dpi && state.renderMode === mode) return;
|
||
const previous = { dpi: state.renderDPI, mode: state.renderMode };
|
||
state.renderDPI = dpi;
|
||
state.renderMode = mode;
|
||
updateDisplayControls();
|
||
if (!state.doc || state.editing || (previous.mode === mode && mode === "svg")) return;
|
||
try {
|
||
await refreshPageDisplay();
|
||
} catch (err) {
|
||
state.renderDPI = previous.dpi;
|
||
state.renderMode = previous.mode;
|
||
el.imageDPI.value = String(previous.dpi);
|
||
resetPageLoading();
|
||
state.pageCache.clear();
|
||
updateDisplayControls();
|
||
showError(err, false);
|
||
}
|
||
}
|
||
|
||
async function changeFont(font, changes) {
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
setBusy(true, "正在更新字体", null, STATUS.fonts);
|
||
try {
|
||
await fontManager.change(font, changes);
|
||
await applyFontChange();
|
||
if (!state.doc) {
|
||
setStatus(STATUS.ready);
|
||
}
|
||
} catch {
|
||
renderFontList();
|
||
setStatus("字体更新失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function renderFontList() {
|
||
updateTextFonts(canvasEditor.selected, true);
|
||
el.fontList.replaceChildren();
|
||
const fonts = fontManager.records();
|
||
if (!fonts.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "font-empty";
|
||
empty.textContent = "暂无字体";
|
||
el.fontList.append(empty);
|
||
return;
|
||
}
|
||
const fragment = document.createDocumentFragment();
|
||
for (const font of fonts) {
|
||
const row = document.createElement("div");
|
||
row.className = "font-row";
|
||
row.dataset.fontId = font.id;
|
||
|
||
const source = document.createElement("span");
|
||
source.className = "font-source";
|
||
source.textContent = fontSourceText(font.source);
|
||
|
||
const enabled = document.createElement("input");
|
||
enabled.type = "checkbox";
|
||
enabled.checked = font.enabled;
|
||
enabled.title = "启用字体";
|
||
|
||
const enabledText = document.createElement("span");
|
||
enabledText.textContent = font.enabled ? "启用" : "停用";
|
||
enabled.addEventListener("change", async () => {
|
||
await changeFont(font, { enabled: enabled.checked });
|
||
});
|
||
|
||
const name = document.createElement("input");
|
||
name.className = "font-name-input";
|
||
name.value = font.name;
|
||
name.title = "字体文件名或匹配名";
|
||
name.addEventListener("change", async () => {
|
||
const next = name.value.trim();
|
||
if (!next) {
|
||
name.value = font.name;
|
||
return;
|
||
}
|
||
await changeFont(font, { name: next });
|
||
});
|
||
|
||
const main = document.createElement("div");
|
||
main.className = "font-main";
|
||
main.append(source, name);
|
||
|
||
const toggle = document.createElement("label");
|
||
toggle.className = "font-toggle";
|
||
toggle.append(enabled, enabledText);
|
||
|
||
const remove = document.createElement("button");
|
||
remove.className = "font-delete";
|
||
remove.type = "button";
|
||
remove.title = "删除字体";
|
||
remove.textContent = "×";
|
||
remove.addEventListener("click", async () => {
|
||
await changeFont(font);
|
||
});
|
||
|
||
const actions = document.createElement("div");
|
||
actions.className = "font-actions";
|
||
actions.append(toggle, remove);
|
||
|
||
row.append(main, actions);
|
||
fragment.append(row);
|
||
}
|
||
el.fontList.append(fragment);
|
||
}
|
||
|
||
function fontSourceText(source) {
|
||
switch (source) {
|
||
case "browser":
|
||
return "系统";
|
||
default:
|
||
return "上传";
|
||
}
|
||
}
|
||
|
||
function updateLocalFontButton() {
|
||
const supported = fontManager.canReadLocal();
|
||
el.localFontButton.disabled = !supported;
|
||
updateFontPermissionHint();
|
||
}
|
||
|
||
function updateFontPermissionHint() {
|
||
el.fontPermissionHint.hidden = !fontManager.canReadLocal() || fontManager.permission === "granted";
|
||
}
|
||
|
||
function clearSecurityForms() {
|
||
finishResourcePick(false);
|
||
if (sealPreview) finishSealPosition(false);
|
||
finishCredentials(null);
|
||
clearEncryptionForm();
|
||
el.encryptionPanel.close();
|
||
el.signKey.value = "";
|
||
el.signKeyPassword.value = "";
|
||
if (el.signPanel.open) el.signPanel.close();
|
||
if (el.verifyPanel.open) el.verifyPanel.close();
|
||
}
|
||
|
||
function updateCredentialsType() {
|
||
const certificate = el.credentialsType.value === "certificate";
|
||
el.credentialsStatus.textContent = "";
|
||
el.credentialsPasswordRow.hidden = certificate;
|
||
el.credentialsCertificateRow.hidden = el.credentialsKeyRow.hidden = !certificate;
|
||
el.credentialsKeyPasswordRow.hidden = !certificate;
|
||
el.credentialsPassword.required = !certificate;
|
||
el.credentialsCertificate.required = el.credentialsKey.required = certificate;
|
||
el.credentialsPassword.value = "";
|
||
el.credentialsCertificate.value = el.credentialsKey.value = "";
|
||
el.credentialsKeyPassword.value = "";
|
||
}
|
||
|
||
function finishCredentials(credentials) {
|
||
const request = credentialRequest;
|
||
credentialRequest = null;
|
||
el.credentialsPassword.value = "";
|
||
el.credentialsUser.value = "";
|
||
el.credentialsCertificate.value = el.credentialsKey.value = "";
|
||
el.credentialsKeyPassword.value = "";
|
||
if (el.credentialsPanel.open) el.credentialsPanel.close();
|
||
if (request && request.openSeq === state.openSeq) request.resolve(credentials);
|
||
else {
|
||
credentials?.password.fill(0);
|
||
credentials?.key?.fill(0);
|
||
credentials?.keyPassword?.fill(0);
|
||
request?.resolve(null);
|
||
}
|
||
}
|
||
|
||
function requestCredentials(code, openSeq, pdf = false) {
|
||
finishCredentials(null);
|
||
el.credentialsTypeRow.hidden = pdf;
|
||
el.credentialsUserRow.hidden = pdf;
|
||
if (pdf) {
|
||
el.credentialsType.value = "password";
|
||
updateCredentialsType();
|
||
}
|
||
el.credentialsStatus.textContent = code === "invalidCredentials" ? "解锁失败,请重试" : "";
|
||
el.progressPanel.hidden = true;
|
||
return new Promise(resolve => {
|
||
credentialRequest = { resolve, openSeq };
|
||
el.credentialsPanel.showModal();
|
||
(el.credentialsType.value === "certificate" ? el.credentialsCertificate : el.credentialsPassword).focus();
|
||
});
|
||
}
|
||
|
||
async function openWithCredentials(fonts, openSeq, data = state.ofdBytes, importing = false, pdf = false) {
|
||
let credentials = null;
|
||
try {
|
||
while (openSeq === state.openSeq) {
|
||
try {
|
||
if (pdf) {
|
||
const converted = await callWASM("ofdgoConvertPDF", data, fonts, credentials?.password || null);
|
||
if (openSeq !== state.openSeq) return converted;
|
||
converted.doc = await callWASM("ofdgoOpen", converted.bytes, fonts, state.renderAnnotations, credentials);
|
||
return converted;
|
||
}
|
||
return importing ? await callWASM("ofdgoLoadImport", data?.nativeFile ? data : data.slice(), credentials)
|
||
: await callWASM("ofdgoOpen", data, fonts, state.renderAnnotations, credentials);
|
||
} catch (err) {
|
||
if (openSeq !== state.openSeq || !(pdf ? err.code === "pdfPassword" : ["credentialsRequired", "invalidCredentials"].includes(err.code))) throw err;
|
||
const code = pdf ? credentials ? "invalidCredentials" : "credentialsRequired" : err.code;
|
||
credentials?.password.fill(0);
|
||
credentials?.key?.fill(0);
|
||
credentials?.keyPassword?.fill(0);
|
||
credentials = await requestCredentials(code, openSeq, pdf);
|
||
if (!credentials || openSeq !== state.openSeq) {
|
||
const canceled = new Error("打开已取消");
|
||
canceled.name = "AbortError";
|
||
throw canceled;
|
||
}
|
||
setProgress(pdf ? "正在读取文档" : "正在解析文档", null);
|
||
el.progressPanel.hidden = false;
|
||
}
|
||
}
|
||
const canceled = new Error("打开已取消");
|
||
canceled.name = "AbortError";
|
||
throw canceled;
|
||
} finally {
|
||
credentials?.password.fill(0);
|
||
credentials?.key?.fill(0);
|
||
credentials?.keyPassword?.fill(0);
|
||
}
|
||
}
|
||
|
||
async function openDocument(options = {}) {
|
||
if (!state.ofdBytes && !state.editorInfo) {
|
||
return;
|
||
}
|
||
const openSeq = options.openSeq || (state.openSeq += 1);
|
||
const previewPosition = options.previewScroll && state.pageWindow ? pageWindowAnchor(state.pageWindow) : null;
|
||
const viewport = options.keepPreview ? el.viewerPanel.getBoundingClientRect() : null;
|
||
const previewIDs = viewport ? [...state.visiblePages].filter((index) => {
|
||
const bounds = pageShell(index)?.getBoundingClientRect();
|
||
return bounds && bounds.bottom > viewport.top && bounds.top < viewport.bottom;
|
||
}).map((index) => state.doc.pages[index].id) : [];
|
||
const thumbnailPreviews = options.preserveThumbnails ? new Map([...state.pageCache.values()]
|
||
.filter((page) => page.id !== state.doc.pages[state.pageIndex].id).map((page) => [page.id, page])) : null;
|
||
if (!state.ready || state.wasmExited) {
|
||
await ensureWASM();
|
||
}
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
const resetLocalFonts = !options.skipAutoFonts;
|
||
if (!options.keepSearch) resetSearch();
|
||
if (resetLocalFonts) {
|
||
fontManager.localFonts = [];
|
||
updateFontSummary();
|
||
renderFontList();
|
||
}
|
||
options.keepPreview ? setBusy(true) : setBusy(true, "正在打开文档", 20, STATUS.opening);
|
||
try {
|
||
if (!options.keepPreview) {
|
||
setProgress("正在解析文档", 52);
|
||
await nextFrame();
|
||
}
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
const fonts = !options.doc && (!options.reuseSession || options.fontsChanged)
|
||
? await fontManager.files(resetLocalFonts ? fontManager.userFonts : fontManager.records()) : null;
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
let doc = options.doc || (options.reuseSession
|
||
? await callWASM("ofdgoConfigure", fonts, state.renderAnnotations)
|
||
: await openWithCredentials(fonts, openSeq));
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
if (options.versionID !== undefined && options.versionID !== (doc.documentVersion || "")) {
|
||
doc = await callWASM("ofdgoSelectVersion", options.versionID);
|
||
if (openSeq !== state.openSeq) return;
|
||
}
|
||
const pageCount = doc.pageCount || 0;
|
||
const pageIndex = Math.min(Math.max(options.pageIndex || 0, 0), Math.max(pageCount - 1, 0));
|
||
state.doc = doc;
|
||
if (options.resetScroll || !options.skipAutoFonts) {
|
||
state.pageSelection.clear();
|
||
state.pageSelectionAnchor = null;
|
||
state.pageMultiSelect = false;
|
||
}
|
||
state.pageIndex = pageIndex;
|
||
state.scale = options.scale || 1;
|
||
if (!options.fitMode) {
|
||
state.rotation = 0;
|
||
setContinuous(window.matchMedia("(max-width: 640px)").matches);
|
||
}
|
||
state.fitMode = options.fitMode || (!state.continuous && pageCount === 1 ? "height" : "width");
|
||
if (!options.skipAutoFonts && await autoLoadDocumentLocalFonts(openSeq)) {
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
await openDocument({
|
||
pageIndex,
|
||
fitMode: state.fitMode,
|
||
resetScroll: options.resetScroll,
|
||
skipAutoFonts: true,
|
||
openSeq,
|
||
reuseSession: true,
|
||
fontsChanged: true,
|
||
activateActions: options.activateActions,
|
||
});
|
||
return;
|
||
}
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
if (options.keepPreview) {
|
||
resetPageLoading();
|
||
state.pageCache.clear();
|
||
if (thumbnailPreviews) {
|
||
for (const page of doc.pages) {
|
||
const preview = thumbnailPreviews.get(page.id);
|
||
if (preview) state.pageCache.set(page.index, { ...preview, previewOnly: true });
|
||
}
|
||
}
|
||
const indices = new Set([pageIndex, ...previewIDs.map(id => pageIndexByID(doc.pages, id)).filter(index => index >= 0)]);
|
||
for (const index of indices) {
|
||
await loadPageData(index, { openSeq, priority: 0, refresh: true });
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
if (options.clearSelection) canvasEditor.clear();
|
||
if (options.selection) canvasEditor.pendingSelection = options.selection;
|
||
resetPageFlow(options.keepPreview);
|
||
renderPageList();
|
||
if (options.keepPreview || options.resetScroll || el.outlineList.childElementCount === 0) {
|
||
renderOutlines(!options.keepPreview);
|
||
}
|
||
renderMeta(options.keepPreview);
|
||
renderPageFlow();
|
||
if (options.keepPreview) {
|
||
for (const [index, page] of state.pageCache) {
|
||
if (!page.previewOnly) mountPageSVG(index, page, openSeq);
|
||
}
|
||
releasePageResources();
|
||
}
|
||
if (options.resetScroll) {
|
||
el.viewerPanel.scrollLeft = 0;
|
||
el.viewerPanel.scrollTop = 0;
|
||
el.navigationContent.scrollLeft = 0;
|
||
el.navigationContent.scrollTop = 0;
|
||
el.metaPanel.scrollLeft = 0;
|
||
el.metaPanel.scrollTop = 0;
|
||
}
|
||
applyFit(false);
|
||
if (options.keepPreview) {
|
||
if (options.previewAnchor) {
|
||
restoreScaleAnchor(options.previewAnchor);
|
||
} else if (options.previewScroll) {
|
||
Object.assign(el.viewerPanel, options.previewScroll);
|
||
if (previewPosition && state.pageWindow) syncPageWindow(state.pageWindow,
|
||
state.pageWindow.offsets[previewPosition.index] + previewPosition.offset);
|
||
} else {
|
||
scrollToPage(pageIndex);
|
||
}
|
||
setStatus(pageStatus(pageIndex, pageCount));
|
||
}
|
||
await nextFrame();
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
const page = options.keepPreview ? state.pageCache.get(pageIndex)
|
||
: await renderPage(pageIndex, { keepBusy: true, scroll: false, openSeq });
|
||
if (page && options.resetScroll) {
|
||
canvasEditor.focus();
|
||
}
|
||
queueNearbyPages(pageIndex, openSeq);
|
||
loadDocumentDetails(openSeq);
|
||
if (page && options.activateActions) {
|
||
state.openActionEnabled = true;
|
||
state.openActionDocument = !state.editing;
|
||
state.openActionPage = state.editing ? state.doc.pages[state.pageIndex]?.id : null;
|
||
state.openActionTask = null;
|
||
scheduleOpenActions();
|
||
}
|
||
} catch (err) {
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
if (options.throwError) throw err;
|
||
if (!options.reuseSession && !options.doc) {
|
||
state.ofdBytes = null;
|
||
state.doc = null;
|
||
renderSecurity();
|
||
updateControls();
|
||
}
|
||
showError(err, true);
|
||
} finally {
|
||
if (openSeq === state.openSeq) {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadDocumentDetails(openSeq) {
|
||
if (openSeq !== state.openSeq || !state.doc) return;
|
||
try {
|
||
const details = await callWASM("ofdgoDocumentInfo");
|
||
if (openSeq !== state.openSeq) return;
|
||
Object.assign(state.doc, details, { detailsPending: false, detailsError: "" });
|
||
renderMeta();
|
||
while (openSeq === state.openSeq) {
|
||
await waitForPaint();
|
||
if (openSeq !== state.openSeq) return;
|
||
const info = await callWASM("ofdgoDocumentInfo", true);
|
||
if (openSeq !== state.openSeq) return;
|
||
if (!info.detailsPending) {
|
||
const knownFonts = new Set((state.doc.fonts || []).map(font => font.id));
|
||
const missingFonts = info.fonts?.some(font => font.used > 0 && font.status === "missing" && !knownFonts.has(font.id));
|
||
Object.assign(state.doc, info, { detailsPending: false, detailsError: "" });
|
||
renderMeta();
|
||
if (missingFonts && fontManager.permission === "granted" && !state.editorInfo && !document.body.hasAttribute("data-busy")) {
|
||
setBusy(true);
|
||
try {
|
||
if (await autoLoadDocumentLocalFonts(openSeq) && openSeq === state.openSeq) await applyFontChange();
|
||
} finally {
|
||
if (openSeq === state.openSeq) setBusy(false);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
Object.assign(state.doc, { detailsPending: false, detailsError: err.message });
|
||
renderMeta();
|
||
showError(err, false);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function renderPage(index, options = {}) {
|
||
if (!state.doc || (state.editorInfo && document.body.hasAttribute("data-busy") && !options.keepBusy)) {
|
||
return null;
|
||
}
|
||
const openSeq = options.openSeq || state.openSeq;
|
||
if (openSeq !== state.openSeq) {
|
||
return null;
|
||
}
|
||
const pageCount = state.doc.pageCount || 0;
|
||
if (index < 0 || index >= pageCount) {
|
||
return null;
|
||
}
|
||
const pageSeq = ++state.pageSeq;
|
||
if (!state.exporting) {
|
||
if (options.keepBusy) {
|
||
setProgress("正在渲染页面", 76);
|
||
} else {
|
||
setBusy(true, "正在渲染页面", 35, "正在渲染页面");
|
||
}
|
||
}
|
||
try {
|
||
setCurrentPage(index);
|
||
if (options.fit !== false) {
|
||
applyFit(false);
|
||
}
|
||
if (options.scroll !== false) {
|
||
scrollToPage(index);
|
||
} else if (state.pageWindow && (!pageShell(index) || pageShell(index).style.visibility === "hidden")) {
|
||
scrollToPage(index);
|
||
}
|
||
const page = await renderFlowPage(index, { throwError: true, openSeq, priority: 0 });
|
||
if (openSeq !== state.openSeq || index !== state.pageIndex) {
|
||
return null;
|
||
}
|
||
if (options.scroll !== false) {
|
||
scrollToPage(index);
|
||
}
|
||
queueNearbyPages(index, openSeq);
|
||
return page;
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
showError(err, false);
|
||
}
|
||
return null;
|
||
} finally {
|
||
if (!options.keepBusy && !state.exporting && openSeq === state.openSeq && pageSeq === state.pageSeq) {
|
||
setBusy(false);
|
||
if (!options.openAction) scheduleOpenActions();
|
||
}
|
||
}
|
||
}
|
||
|
||
function settingsField(container, label, id, value = "", type = "text", unit = "") {
|
||
const row = document.createElement("label"), title = document.createElement("span");
|
||
row.className = "form-row";
|
||
title.textContent = label;
|
||
const field = document.createElement(type === "textarea" ? "textarea" : "input");
|
||
field.id = `settings${id}`;
|
||
if (type === "textarea") field.rows = 3;
|
||
else field.type = type;
|
||
if (type === "number") field.step = "any";
|
||
if (type === "checkbox") field.checked = Boolean(value);
|
||
else field.value = value ?? "";
|
||
row.append(title, field);
|
||
if (unit) {
|
||
const wrapper = document.createElement("span"), suffix = document.createElement("span");
|
||
wrapper.className = "form-unit";
|
||
suffix.textContent = unit;
|
||
wrapper.append(field, suffix);
|
||
row.append(wrapper);
|
||
}
|
||
container.append(row);
|
||
return field;
|
||
}
|
||
|
||
function settingsSelect(container, label, id, choices, value, change) {
|
||
const row = document.createElement("label"), title = document.createElement("span"), field = document.createElement("select");
|
||
row.className = "form-row";
|
||
title.textContent = label;
|
||
field.id = `settings${id}`;
|
||
field.replaceChildren(...choices.map(([value, text]) => new Option(text, String(value))));
|
||
if (value != null && choices.some(([key]) => String(key) === String(value))) field.value = String(value);
|
||
if (change) field.addEventListener("change", () => { rememberSettingsDraft(); change(field.value); });
|
||
row.append(title, field);
|
||
container.append(row);
|
||
return field;
|
||
}
|
||
|
||
function settingsButton(label, action, disabled = false, id = "", container = settingsElements.Actions, reason = "") {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "button";
|
||
button.textContent = label;
|
||
button.disabled = disabled;
|
||
if (disabled && reason) button.title = reason;
|
||
if (id) button.id = `settings${id}`;
|
||
button.addEventListener("click", async () => {
|
||
try { await action(); }
|
||
catch (error) { settingsElements.Status.textContent = settings.invoice ? invoiceIssues([error.message]) : error.message; }
|
||
});
|
||
container.append(button);
|
||
return button;
|
||
}
|
||
|
||
function settingsControlRow(field) {
|
||
const row = document.createElement("div"), label = field.closest(".form-row");
|
||
row.className = "settings-control-row";
|
||
label.replaceWith(row);
|
||
row.append(label);
|
||
return row;
|
||
}
|
||
|
||
function settingsSection(label) {
|
||
const section = document.createElement("section"), title = document.createElement("div"), actions = document.createElement("div");
|
||
section.className = "settings-section";
|
||
title.className = "settings-section-title";
|
||
title.textContent = label;
|
||
actions.className = "settings-section-actions";
|
||
section.append(title);
|
||
settingsElements.Content.append(section);
|
||
return {section, actions};
|
||
}
|
||
|
||
function settingsSnapshot() {
|
||
return Object.fromEntries([...settingsElements.Content.querySelectorAll("input,select,textarea")].filter(field => field.id).map(field => [field.id,
|
||
field.type === "checkbox" ? field.checked : field.type === "file" ? [...field.files] : field.multiple ? [...field.selectedOptions].map(option => option.value) : field.value]));
|
||
}
|
||
|
||
function rememberSettingsDraft() {
|
||
if (settings.renderedKind === "invoices") return;
|
||
if (!state.editing || !settings.view || settings.busy) return;
|
||
const values = settingsSnapshot();
|
||
const changed = Object.keys(values).some(id => {
|
||
if (["settingsTemplateReference", "settingsLayerObjects", "settingsTargetPage", "settingsTargetObject"].includes(id)) return false;
|
||
const value = values[id], before = settings.baseline[id];
|
||
return Array.isArray(value) ? value.length !== (before?.length || 0) || value.some((entry, index) => entry !== before?.[index]) : value !== before;
|
||
});
|
||
if (!changed) { settings.drafts.delete(settings.view); return; }
|
||
settings.drafts.set(settings.view, {values, scroll: document.querySelector("#settingsBody")?.scrollTop || 0,
|
||
expanded: [...settingsElements.Content.querySelectorAll("details")].map(node => node.open)});
|
||
}
|
||
|
||
function restoreSettingsDraft() {
|
||
const kind = settingsElements.Kind.value;
|
||
const entry = kind === "bookmarks" ? settings.data.bookmarks?.[settings.selected]?.Name
|
||
: kind === "templates" ? settings.data.page.templates?.[settings.selected]?.ID
|
||
: kind === "layers" ? settings.data.page.layers?.[settings.selected]?.ID : settings.selected;
|
||
settings.view = JSON.stringify([kind, settings.resource?.id || "", kind === "actions" ? [settings.scope, settings.page, settings.object, settings.path] : ["templates", "layers"].includes(kind) ? settings.page : "", entry ?? -1]);
|
||
settings.baseline = settingsSnapshot();
|
||
const draft = settings.drafts.get(settings.view);
|
||
if (!draft) return;
|
||
if (kind === "actions" && draft.values.settingsActionType) {
|
||
renderActionFields(draft.values.settingsActionType, settings.actions[settings.selected] || {});
|
||
}
|
||
for (const [id, value] of Object.entries(draft.values)) {
|
||
const field = document.getElementById(id);
|
||
if (!field || !settingsElements.Content.contains(field)) continue;
|
||
if (field.type === "checkbox") field.checked = value;
|
||
else if (field.type === "file") {
|
||
const transfer = new DataTransfer();
|
||
for (const file of value) transfer.items.add(file);
|
||
field.files = transfer.files;
|
||
} else if (field.multiple) for (const option of field.options) option.selected = value.includes(option.value);
|
||
else if (field.tagName !== "SELECT" || [...field.options].some(option => option.value === value)) field.value = value;
|
||
}
|
||
if (document.querySelector("#settingsDestType")) updateDestinationFields();
|
||
for (const key of ["FillColor", "StrokeColor"]) {
|
||
const field = document.querySelector(`#settings${key}`);
|
||
if (field) field.closest("label").hidden = settingsValue(key + "Mode") !== "solid";
|
||
}
|
||
const body = document.querySelector("#settingsBody");
|
||
for (const [index, node] of [...settingsElements.Content.querySelectorAll("details")].entries()) node.open = draft.expanded?.[index] || false;
|
||
if (body) body.scrollTop = draft.scroll;
|
||
}
|
||
|
||
function closeDocumentSettings() {
|
||
if (settings.busy) return;
|
||
rememberSettingsDraft();
|
||
if ((settings.drafts.size || [...invoicePanel.drafts.values()].some(draft => draft.dirty.size)) && !window.confirm(settings.invoice ? "发票尚未应用,放弃更改?" : "配置尚未应用,放弃更改?")) return;
|
||
settingsElements.Panel.close();
|
||
}
|
||
|
||
function renderReadonlySettings() {
|
||
for (const field of settingsElements.Content.querySelectorAll("input,select,textarea")) {
|
||
field.disabled = true;
|
||
if (field.id === "settingsTemplateReference") { field.disabled = false; continue; }
|
||
const row = field.closest(".form-row");
|
||
if (!row || row.hidden || field.type === "file") { if (field.type === "file" && row) row.hidden = true; continue; }
|
||
const value = document.createElement("span");
|
||
value.className = "settings-readonly";
|
||
value.textContent = field.tagName === "SELECT" ? field.selectedOptions[0]?.textContent || "—" : field.type === "checkbox" ? field.checked ? "是" : "否" : field.value || field.placeholder || "—";
|
||
field.hidden = true;
|
||
field.after(value);
|
||
}
|
||
}
|
||
|
||
function settingsValue(id) {
|
||
const field = document.querySelector(`#settings${id}`);
|
||
return field?.type === "checkbox" ? field.checked : field?.value ?? "";
|
||
}
|
||
|
||
async function openDocumentSettings(invoice = false) {
|
||
if (!state.doc || !state.ready || state.exporting || document.body.hasAttribute("data-busy")) return;
|
||
if (resourceManager.pick?.settings) { finishResourcePick(); return; }
|
||
invoicePanel.drafts.clear();
|
||
invoicePanel.key = "";
|
||
settings.invoice = invoice;
|
||
settingsElements.Kind.closest("label").hidden = invoice;
|
||
settingsElements.Title.textContent = invoice ? "电子发票" : "文档配置";
|
||
settingsElements.Panel.classList.toggle("invoice-dialog", invoice);
|
||
settingsElements.Context.replaceChildren();
|
||
settingsElements.Content.replaceChildren();
|
||
settingsElements.Actions.replaceChildren();
|
||
settings.page = state.pageIndex;
|
||
settings.object = canvasEditor.selected?.index === settings.page && !canvasEditor.selected.scoped ? canvasEditor.selected.id : "";
|
||
settings.path = state.outlineSelection || [];
|
||
settings.selected = -1;
|
||
settings.drafts.clear();
|
||
settings.view = "";
|
||
settings.resource = null;
|
||
settingsElements.Kind.disabled = false;
|
||
for (const option of settingsElements.Kind.options) option.hidden = option.value === "actions" && !state.editing || ["ColorSpace", "DrawParam"].includes(option.value);
|
||
if (settingsElements.Kind.selectedOptions[0]?.hidden) settingsElements.Kind.value = "bookmarks";
|
||
settingsElements.Panel.showModal();
|
||
await loadDocumentSettings();
|
||
}
|
||
|
||
async function openResourceSettings(kind, id) {
|
||
if (!state.doc || !state.ready || document.body.hasAttribute("data-busy")) return;
|
||
settings.invoice = false;
|
||
settingsElements.Kind.closest("label").hidden = false;
|
||
settings.resource = {kind, id};
|
||
settings.selected = -1;
|
||
settings.drafts.clear();
|
||
settings.view = "";
|
||
const page = Number(resourceElements.Page.value);
|
||
settings.page = resourceElements.Page.value !== "" && Number.isInteger(page) && page >= 0 && page < state.doc.pages.length ? page : state.pageIndex;
|
||
settings.object = resourceElements.Object.value || "";
|
||
settingsElements.Kind.querySelector(`option[value="${kind}"]`).hidden = false;
|
||
settingsElements.Kind.value = kind;
|
||
settingsElements.Kind.disabled = true;
|
||
settingsElements.Panel.showModal();
|
||
await loadDocumentSettings();
|
||
}
|
||
|
||
async function loadDocumentSettings() {
|
||
const sequence = ++settings.sequence, openSeq = state.openSeq;
|
||
const kind = settings.invoice ? "invoices" : settingsElements.Kind.value;
|
||
settings.busy = true;
|
||
settingsElements.Form.inert = true;
|
||
settingsElements.Status.textContent = "正在读取信息";
|
||
settingsElements.Status.title = "";
|
||
try {
|
||
if (kind === "invoices") { await loadInvoiceSettings(sequence, openSeq); return; }
|
||
const data = ["bookmarks", "actions", "extensions"].includes(kind) ? await callWASM("ofdgoDocumentNavigation", kind) : {};
|
||
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
|
||
data.resources = ["bookmarks", "extensions"].includes(kind) ? [] : await callWASM("ofdgoResources") || [];
|
||
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
|
||
if (kind === "actions" && state.editing) {
|
||
if (settings.scope === "object") {
|
||
const page = await loadPageData(settings.page);
|
||
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
|
||
data.objects = (page.objects || []).filter(item => item.type !== "Annotation" && !item.scoped);
|
||
if (!data.objects.some(item => item.id === settings.object)) settings.object = data.objects[0]?.id || "";
|
||
}
|
||
if (settings.scope === "outline" && !settings.path.length) settings.path = outlineSettingsChoices()[0]?.[0] ? JSON.parse(outlineSettingsChoices()[0][0]) : [];
|
||
data.actions = (settings.scope === "object" && !settings.object || settings.scope === "outline" && !settings.path.length) ? [] : await callWASM("ofdgoActionList", settings.scope, settings.page, settings.object, settings.path) || [];
|
||
}
|
||
if (kind === "templates" || kind === "layers") {
|
||
data.page = await callWASM("ofdgoPageSettings", settings.page, kind);
|
||
if (kind === "layers" && state.editing) {
|
||
const page = await loadPageData(settings.page);
|
||
data.objects = (page.objects || []).filter(item => item.type !== "Annotation" && !item.scoped);
|
||
}
|
||
}
|
||
if (kind === "ColorSpace" || kind === "DrawParam") {
|
||
data.resource = settings.resource.id ? await callWASM("ofdgoStyleResource", kind, settings.resource.id) : {};
|
||
if (state.editing) {
|
||
const page = await loadPageData(settings.page);
|
||
data.objects = (page.objects || []).filter(item => ["TextObject", "PathObject", "CompositeObject", "CompositeGraphicUnit"].includes(item.type) && !item.scoped);
|
||
}
|
||
}
|
||
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
|
||
settings.data = data;
|
||
settings.actions = data.actions || [];
|
||
settingsElements.Status.textContent = "";
|
||
renderDocumentSettings();
|
||
} catch (error) {
|
||
if (sequence === settings.sequence && openSeq === state.openSeq) {
|
||
settingsElements.Content.replaceChildren();
|
||
settingsElements.Actions.replaceChildren();
|
||
settingsButton("关闭", closeDocumentSettings);
|
||
settingsElements.Status.textContent = error.message;
|
||
}
|
||
} finally {
|
||
if (sequence === settings.sequence) { settings.busy = false; settingsElements.Form.inert = false; }
|
||
}
|
||
}
|
||
|
||
function renderDocumentSettings() {
|
||
settingsElements.Context.replaceChildren();
|
||
settingsElements.Content.replaceChildren();
|
||
settingsElements.Actions.replaceChildren();
|
||
const kind = settings.invoice ? "invoices" : settingsElements.Kind.value;
|
||
settings.renderedKind = kind;
|
||
const location = document.querySelector("#settingsLocation"), doc = state.doc;
|
||
if (location) {
|
||
const current = doc?.documents?.find(entry => entry.index === doc.documentIndex) || doc?.documents?.[doc?.documentIndex || 0];
|
||
location.textContent = `${current?.title || current?.name || `文档${(doc?.documentIndex || 0) + 1}`}${doc?.documentVersion ? ` · 版本${doc.documentVersion}` : ""}`;
|
||
location.hidden = !(doc?.documents?.length > 1 || doc?.documentVersion);
|
||
}
|
||
settingsElements.Title.textContent = {ColorSpace: "色彩设置", DrawParam: "样式设置", invoices: "电子发票"}[kind] || "文档配置";
|
||
settingsElements.Panel.classList.toggle("invoice-dialog", kind === "invoices");
|
||
if (kind === "invoices") {
|
||
renderInvoiceSettings();
|
||
settingsButton("关闭", closeDocumentSettings, false, "Close").classList.add("settings-close");
|
||
return;
|
||
}
|
||
if (kind === "actions") renderActionSettings();
|
||
else if (kind === "bookmarks") renderBookmarkSettings();
|
||
else if (kind === "extensions") renderExtensionSettings();
|
||
else if (kind === "templates") renderTemplateSettings();
|
||
else if (kind === "layers") renderLayerSettings();
|
||
else if (kind === "ColorSpace" || kind === "DrawParam") renderStyleResourceSettings(kind);
|
||
if (!state.editing) renderReadonlySettings();
|
||
restoreSettingsDraft();
|
||
const move = document.querySelector("#settingsMoveObjects");
|
||
if (move) move.disabled = !document.querySelector("#settingsLayerObjects").selectedOptions.length;
|
||
const target = document.querySelector("#settingsTargetObject");
|
||
if (target) target.dispatchEvent(new Event("change"));
|
||
const lastActions = settingsElements.Content.querySelector(".settings-section:last-child > .settings-section-actions");
|
||
if (lastActions) settingsElements.Actions.append(lastActions);
|
||
settingsButton("关闭", closeDocumentSettings, false, "Close").classList.add("settings-close");
|
||
}
|
||
|
||
function invoiceSourceKey(source = {}) {
|
||
return JSON.stringify([source.kind || "", source.id || "", source.fileLoc || "", source.nodePath || ""]);
|
||
}
|
||
|
||
function invoiceScope() {
|
||
return {
|
||
documentIndex: state.doc.documentIndex || 0,
|
||
version: state.doc.documentVersion || "",
|
||
...(state.editing ? { revision: state.editorInfo.revision } : {})
|
||
};
|
||
}
|
||
|
||
function invoiceCandidates(invoices) {
|
||
const counts = new Map(), result = [];
|
||
for (const invoice of invoices || []) {
|
||
for (const source of new Map((invoice.sources || []).map(value => [invoiceSourceKey(value), value])).values()) {
|
||
const sourceKey = invoiceSourceKey(source);
|
||
const index = counts.get(sourceKey) || 0;
|
||
counts.set(sourceKey, index + 1);
|
||
result.push({ invoice, source, index, key: JSON.stringify([sourceKey, index]) });
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function loadInvoiceSettings(sequence, openSeq) {
|
||
const cached = !state.editing && invoiceDocuments.get(state.doc);
|
||
const data = cached?.openSeq === openSeq && cached.data ? cached.data : await callWASM("ofdgoInvoices");
|
||
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
|
||
invoicePanel.entries = invoiceCandidates(data.report?.invoices);
|
||
invoicePanel.formats = data.formats || [];
|
||
invoicePanel.issues = (data.report?.issues || []).filter(issue => issue.code !== "missing-data");
|
||
if (!invoicePanel.drafts.has(invoicePanel.key) && !invoicePanel.entries.some(entry => entry.key === invoicePanel.key)) invoicePanel.key = invoicePanel.entries[0]?.key || "";
|
||
const entry = invoicePanel.entries.find(entry => entry.key === invoicePanel.key);
|
||
if (entry && !invoicePanel.drafts.has(entry.key)) {
|
||
const scope = invoiceScope();
|
||
const detail = await callWASM("ofdgoInvoice", JSON.stringify({ source: entry.source, index: entry.index, ...scope }));
|
||
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
|
||
invoicePanel.drafts.set(entry.key, invoiceDraft(detail, entry, scope));
|
||
}
|
||
settingsElements.Status.textContent = "";
|
||
renderDocumentSettings();
|
||
}
|
||
|
||
function invoiceDraft(detail, entry, scope) {
|
||
return {
|
||
detail, invoice: structuredClone(detail.invoice), entry, scope,
|
||
baselineFields: structuredClone(detail.fields || []),
|
||
dirty: new Set(), changes: new Map(),
|
||
addedItems: new Set(),
|
||
bindings: structuredClone(detail.bindings || []), xml: detail.xml || "", page: state.pageIndex, object: "", field: "",
|
||
layout: { font: "", fontSize: 4, pageWidth: 210, pageHeight: 297, margin: 15 }, synchronize: !!detail.bindings?.length && !!detail.capabilities?.sync,
|
||
options: { name: "", encoding: { schemaRef: "", context: {}, units: [] } },
|
||
fragments: {}, item: 0
|
||
};
|
||
}
|
||
|
||
function invoiceCurrent() {
|
||
return invoicePanel.drafts.get(invoicePanel.key);
|
||
}
|
||
|
||
function invoiceDirty(draft, section) {
|
||
draft.dirty.add(section);
|
||
const button = document.querySelector("#settingsInvoiceApply");
|
||
if (button) button.disabled = !state.editing;
|
||
const reset = document.querySelector("#settingsInvoiceReset");
|
||
if (reset) reset.disabled = !state.editing;
|
||
}
|
||
|
||
function invoiceInput(container, label, id, value, change, disabled = false, type = "text", unit = "") {
|
||
const field = settingsField(container, label, `Invoice${id}`, value, type, unit);
|
||
field.disabled = disabled;
|
||
field.addEventListener("input", () => change(type === "checkbox" ? field.checked : field.value));
|
||
if (!state.editing && type !== "textarea") {
|
||
const text = document.createElement("span");
|
||
text.className = "settings-readonly";
|
||
text.id = field.id;
|
||
text.textContent = type === "checkbox" ? value ? "是" : "否" : value || "-";
|
||
field.replaceWith(text);
|
||
return text;
|
||
}
|
||
if (!state.editing && type === "textarea") {
|
||
field.disabled = false;
|
||
field.readOnly = true;
|
||
}
|
||
return field;
|
||
}
|
||
|
||
function invoiceValue(invoice, field, item = -1) {
|
||
const keys = field.split(".");
|
||
let value = keys[0] === "items" ? invoice.items?.[item] : invoice;
|
||
if (keys[0] === "items") keys.shift();
|
||
for (const key of keys) value = value?.[key];
|
||
return value ?? "";
|
||
}
|
||
|
||
function invoiceSetValue(invoice, field, item, value) {
|
||
const keys = field.split(".");
|
||
let target = keys[0] === "items" ? invoice.items[item] : invoice;
|
||
if (keys[0] === "items") keys.shift();
|
||
for (const key of keys.slice(0, -1)) target = target[key] ||= {};
|
||
target[keys.at(-1)] = value;
|
||
}
|
||
|
||
function invoiceIssues(issues, details = false) {
|
||
const codes = {
|
||
"appearance-not-synchronized": "票面未同步",
|
||
"signature-review-required": "需重新验签"
|
||
};
|
||
const messages = {
|
||
"unsupported invoice XML root or version": "暂不支持此发票格式或版本",
|
||
"unsupported eInvoice namespace or version": "暂不支持此发票格式或版本",
|
||
"unsupported EInvoice version": "暂不支持此发票版本",
|
||
"EInvoice requires a unique versioned Header": "发票头信息不完整",
|
||
"invoice text exceeds original reference slot capacity": "原框空间不足,请缩短文字",
|
||
"invoice slot requires Font or FontData with the missing characters:": "字体缺字,请更换字体",
|
||
"invoice references cross columns or reverse reading order": "图元跨列,无法重排",
|
||
"invoice references do not form adjacent aligned text rows": "图元未对齐,无法重排"
|
||
};
|
||
return [...new Set((issues || []).map(issue => {
|
||
const message = typeof issue === "string" ? issue : issue.message || issue.code;
|
||
const translated = messages[message] || Object.entries(messages).find(([prefix]) => prefix.endsWith(":") && message?.startsWith(prefix))?.[1] || message;
|
||
if (typeof issue === "string") return details ? message : translated;
|
||
if (details) return [issue.source?.fileLoc || issue.source?.id || issue.source?.kind, issue.source?.nodePath, issue.field, message].filter(Boolean).join(":");
|
||
return [issue.field, codes[issue.code] || translated].filter(Boolean).join(":");
|
||
}).filter(Boolean))].join("\n");
|
||
}
|
||
|
||
function renderInvoiceSettings() {
|
||
const context = settingsElements.Context, content = settingsElements.Content;
|
||
settingsElements.Status.title = "";
|
||
const draft = invoiceCurrent();
|
||
const sources = [...new Map(invoicePanel.entries.map(entry => [invoiceSourceKey(entry.source), entry.source])).entries()];
|
||
if (sources.length) settingsSelect(context, "来源", "InvoiceSource", sources.map(([key, source]) => [key, source.fileLoc || source.id || source.kind]), draft?.entry ? invoiceSourceKey(draft.entry.source) : null, value => {
|
||
invoicePanel.key = invoicePanel.entries.find(entry => invoiceSourceKey(entry.source) === value).key;
|
||
loadDocumentSettings();
|
||
});
|
||
const candidates = invoicePanel.entries.filter(entry => !draft?.entry || invoiceSourceKey(entry.source) === invoiceSourceKey(draft.entry.source));
|
||
const choices = candidates.map(entry => [entry.key, entry.invoice.number || entry.invoice.code || `${entry.invoice.kind} · ${entry.index + 1}`]);
|
||
if (invoicePanel.drafts.has("new")) choices.push(["new", "新建"]);
|
||
if (choices.length) settingsSelect(context, "发票", "InvoiceEntry", choices, invoicePanel.key, value => { invoicePanel.key = value; loadDocumentSettings(); });
|
||
const actions = document.createElement("div");
|
||
actions.className = "invoice-actions";
|
||
context.append(actions);
|
||
if (state.editing) {
|
||
settingsButton("新建", newInvoiceDraft, false, "InvoiceNew", actions);
|
||
settingsButton("导入", () => readInvoiceFile(async text => {
|
||
const value = await newInvoiceDraft();
|
||
if (!value) return;
|
||
value.xml = text;
|
||
value.importing = true;
|
||
value.dirty.add("xml");
|
||
invoicePanel.tab = "xml";
|
||
renderDocumentSettings();
|
||
}), false, "InvoiceImport", actions);
|
||
}
|
||
if (!draft) {
|
||
settingsElements.Status.textContent = invoiceIssues(invoicePanel.issues) || "暂无发票";
|
||
settingsElements.Status.title = invoiceIssues(invoicePanel.issues, true);
|
||
return;
|
||
}
|
||
const tabs = document.createElement("div");
|
||
tabs.className = "invoice-tabs";
|
||
for (const [key, label, id] of [["basic", "基本", "Basic"], ["items", "明细", "Items"], ["fields", "字段", "Fields"], ["appearance", "票面", "Appearance"], ["xml", "原文", "XML"]]) {
|
||
const button = settingsButton(label, () => { invoicePanel.tab = key; renderDocumentSettings(); }, false, `InvoiceTab${id}`, tabs);
|
||
button.setAttribute("data-selected", String(invoicePanel.tab === key));
|
||
button.tabIndex = invoicePanel.tab === key ? 0 : -1;
|
||
button.addEventListener("keydown", event => {
|
||
const buttons = [...tabs.children];
|
||
const index = buttons.indexOf(button);
|
||
const next = event.key === "ArrowRight" ? (index + 1) % buttons.length : event.key === "ArrowLeft" ? (index + buttons.length - 1) % buttons.length : event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : -1;
|
||
if (next >= 0) {
|
||
event.preventDefault();
|
||
const target = buttons[next].id;
|
||
buttons[next].click();
|
||
document.getElementById(target)?.focus();
|
||
}
|
||
});
|
||
}
|
||
content.append(tabs);
|
||
const body = document.createElement("div");
|
||
body.id = "invoiceTabContent";
|
||
content.append(body);
|
||
if (invoicePanel.tab === "basic") renderInvoiceBasic(body, draft);
|
||
else if (invoicePanel.tab === "items") renderInvoiceItems(body, draft);
|
||
else if (invoicePanel.tab === "fields") renderInvoiceFields(body, draft);
|
||
else if (invoicePanel.tab === "appearance") renderInvoiceAppearance(body, draft);
|
||
else renderInvoiceXML(body, draft);
|
||
settingsButton("校验", () => validateInvoiceDraft(draft), false, "InvoiceValidate");
|
||
settingsButton("导出", () => downloadBytes(new TextEncoder().encode(JSON.stringify(draft.invoice, null, 2)), "application/json", "invoice.json"), false, "InvoiceExportJSON");
|
||
if (state.editing) {
|
||
settingsButton("应用", () => applyInvoiceDraft(draft), !draft.dirty.size, "InvoiceApply");
|
||
if (draft.entry) settingsButton("同步", async () => {
|
||
if (!draft.detail.capabilities?.sync || draft.dirty.has("bindings")) {
|
||
invoicePanel.tab = "appearance";
|
||
draft.status = draft.dirty.has("bindings") ? "请先应用票面绑定" : draft.bindings.length ? draft.detail.capabilities?.reasons?.sync || "票面关联不可用" : "票面未绑定,请选择字段和图元后绑定";
|
||
renderDocumentSettings();
|
||
return;
|
||
}
|
||
if (draft.dirty.size) {
|
||
await applyInvoiceDraft(draft, true);
|
||
return;
|
||
}
|
||
await commitInvoice(draft, { operation: "sync" });
|
||
}, false, "InvoiceSync");
|
||
settingsButton("还原", () => resetInvoiceDraft(draft), !draft.dirty.size, "InvoiceReset");
|
||
if (draft.entry) settingsButton("删除", async () => {
|
||
if (draft.dirty.size) throw new Error("请先应用或还原草稿");
|
||
if (window.confirm("删除此来源中的发票数据?页面将保留" + invoiceOtherDraftWarning(draft))) await commitInvoice(draft, { operation: "delete" });
|
||
}, !draft.detail.capabilities?.delete, "InvoiceDelete");
|
||
}
|
||
settingsElements.Status.textContent = draft.status || invoiceIssues(draft.detail.issues) || "";
|
||
if (!draft.status) settingsElements.Status.title = invoiceIssues(draft.detail.issues, true);
|
||
}
|
||
|
||
async function newInvoiceDraft() {
|
||
if (!state.editing) return;
|
||
if (invoicePanel.drafts.get("new")?.dirty.size && !window.confirm("放弃尚未应用的新建发票?")) return;
|
||
const format = invoicePanel.formats.find(value => value.create && value.standard !== invoiceReferenceStandard) || invoicePanel.formats.find(value => value.create);
|
||
if (!format) throw new Error("暂无可新建票种");
|
||
const invoice = { kind: format.kind, standard: format.standard, version: format.version, buyer: {}, seller: {}, items: [], facts: [] };
|
||
const scope = invoiceScope();
|
||
const sequence = settings.sequence;
|
||
const detail = await callWASM("ofdgoInvoice", JSON.stringify({ invoice, ...scope }));
|
||
if (sequence !== settings.sequence || !settingsElements.Panel.open) return;
|
||
invoicePanel.drafts.set("new", invoiceDraft(detail, null, scope));
|
||
invoicePanel.key = "new";
|
||
invoicePanel.tab = "basic";
|
||
renderDocumentSettings();
|
||
return invoiceCurrent();
|
||
}
|
||
|
||
function renderInvoiceBasic(container, draft) {
|
||
const fields = (draft.detail.fields || []).filter(field => field.item < 0 && !field.field.startsWith("facts.") && !field.field.startsWith("items."));
|
||
const labels = {
|
||
kind: "票种", standard: "规范", version: "版本", number: "号码", code: "代码", date: "日期",
|
||
currency: "币种", status: "状态", amount: "金额", taxAmount: "税额", totalAmount: "合计", remarks: "备注",
|
||
name: "名称", taxId: "税号", address: "地址", phone: "电话", bank: "银行", account: "账号"
|
||
};
|
||
for (const [group, label] of [["basic", "票据"], ["buyer", "购方"], ["seller", "销方"]]) {
|
||
const title = document.createElement("h3"), grid = document.createElement("div");
|
||
title.className = "invoice-group-title";
|
||
title.textContent = label;
|
||
grid.className = "settings-grid";
|
||
container.append(title, grid);
|
||
for (const field of fields.filter(field => field.group === group)) {
|
||
const value = invoiceValue(draft.invoice, field.field);
|
||
const display = field.field === "standard" ? draft.detail.standardLabel || value : value;
|
||
invoiceInput(grid, labels[field.field.split(".").at(-1)] || field.label, `Field_${field.field.replaceAll(".", "_")}`, display, value => {
|
||
invoiceSetValue(draft.invoice, field.field, -1, value);
|
||
invoiceDirty(draft, "model");
|
||
}, !state.editing || !(field.editable || !draft.entry && field.field === "currency" && invoiceIsXBRL(draft.invoice)) || !!draft.entry && !draft.detail.capabilities?.edit).title = display !== value ? value : field.label;
|
||
}
|
||
}
|
||
if (!draft.entry) renderInvoiceAdvanced(container, draft);
|
||
}
|
||
|
||
function invoiceIsXBRL(invoice) {
|
||
return invoice.standard?.startsWith("http://xbrl.mof.gov.cn/taxonomy/") || false;
|
||
}
|
||
|
||
function renderInvoiceAdvanced(container, draft) {
|
||
const details = document.createElement("details"), summary = document.createElement("summary");
|
||
summary.textContent = "高级";
|
||
details.className = "invoice-advanced";
|
||
details.open = draft.advanced || false;
|
||
details.addEventListener("toggle", () => { draft.advanced = details.open; });
|
||
details.append(summary);
|
||
container.append(details);
|
||
const formats = invoicePanel.formats.filter(value => value.create);
|
||
const selected = formats.findIndex(value => value.kind === draft.invoice.kind && value.standard === draft.invoice.standard && value.version === draft.invoice.version);
|
||
settingsSelect(details, "票种", "InvoiceFormat", formats.map((value, index) => [index, `${{ inv: "增值税", einv: "电子票", atr: "航空票", rai: "铁路票" }[value.kind] || value.kind} · ${value.version} · ${value.standard === invoiceReferenceStandard ? "标引" : invoiceIsXBRL(value) ? "XBRL" : "XML"}`]), selected, async value => {
|
||
try {
|
||
const format = formats[Number(value)];
|
||
if (draft.invoice.standard !== format.standard) draft.options.encoding.schemaRef = "";
|
||
Object.assign(draft.invoice, { kind: format.kind, standard: format.standard, version: format.version });
|
||
invoiceDirty(draft, "model");
|
||
await invoiceRefreshFields(draft);
|
||
if (invoiceCurrent() === draft) renderDocumentSettings();
|
||
} catch (error) {
|
||
settingsElements.Status.textContent = error.message;
|
||
}
|
||
});
|
||
invoiceInput(details, "名称", "Name", draft.options.name, value => { draft.options.name = value; invoiceDirty(draft, "model"); });
|
||
const encoding = draft.options.encoding;
|
||
if (invoiceIsXBRL(draft.invoice)) {
|
||
invoiceInput(details, "模式", "SchemaRef", encoding.schemaRef, value => { encoding.schemaRef = value; invoiceDirty(draft, "model"); }).title = "schemaRef";
|
||
for (const [key, label] of [["id", "标识"], ["identifier", "主体"], ["scheme", "方案"], ["instant", "时点"], ["startDate", "起日"], ["endDate", "止日"]]) {
|
||
invoiceInput(details, label, `Context_${key}`, encoding.context[key], value => { encoding.context[key] = value; invoiceDirty(draft, "model"); }).title = `context.${key}`;
|
||
}
|
||
for (const [index, unit] of encoding.units.entries()) {
|
||
const row = document.createElement("div");
|
||
row.className = "invoice-unit";
|
||
details.append(row);
|
||
for (const [key, label] of [["id", "标识"], ["namespace", "空间"], ["name", "单位"]]) {
|
||
invoiceInput(row, label, `Unit_${index}_${key}`, unit[key], value => { unit[key] = value; invoiceDirty(draft, "model"); }).title = key === "namespace" ? "命名空间" : label;
|
||
}
|
||
settingsButton("删除", () => {
|
||
encoding.units.splice(index, 1);
|
||
invoiceDirty(draft, "model");
|
||
renderDocumentSettings();
|
||
}, false, `InvoiceUnitDelete${index}`, row);
|
||
}
|
||
settingsButton("添加", () => {
|
||
encoding.units.push({ id: "", namespace: "", name: "" });
|
||
invoiceDirty(draft, "model");
|
||
renderDocumentSettings();
|
||
}, false, "InvoiceUnitAdd", details);
|
||
}
|
||
for (const [key, label] of draft.invoice.standard === "EInvoice" ? [["headerXML", "票头"], ["sellerAuthenticationXML", "认证"], ["taxSupervisionXML", "监制"], ["taxBureauSignatureXML", "签名"]] : []) {
|
||
invoiceInput(details, label, `Fragment_${key}`, draft.fragments[key], value => {
|
||
draft.fragments[key] = value;
|
||
invoiceDirty(draft, "model");
|
||
}, false, "textarea");
|
||
settingsButton("导入", () => readInvoiceFile(text => {
|
||
draft.fragments[key] = text;
|
||
invoiceDirty(draft, "model");
|
||
renderDocumentSettings();
|
||
}), false, `InvoiceFragmentImport_${key}`, details);
|
||
}
|
||
}
|
||
|
||
function renderInvoiceItems(container, draft) {
|
||
const items = draft.invoice.items ||= [];
|
||
const editable = state.editing && (!draft.entry ? invoicePanel.formats.find(value => value.kind === draft.invoice.kind && value.standard === draft.invoice.standard && value.version === draft.invoice.version)?.items : draft.detail.capabilities?.edit && draft.detail.capabilities?.items);
|
||
draft.item = Math.max(0, Math.min(draft.item, items.length - 1));
|
||
const columns = [["name", "名称"], ["specification", "规格"], ["unit", "单位"], ["quantity", "数量"], ["unitPrice", "单价"], ["amount", "金额"], ["taxRate", "税率"], ["taxAmount", "税额"]];
|
||
const scroll = document.createElement("div"), table = document.createElement("table");
|
||
const head = table.createTHead().insertRow();
|
||
scroll.className = "invoice-table-scroll";
|
||
table.className = "invoice-table";
|
||
table.id = "settingsInvoiceItems";
|
||
for (const label of ["选择", ...columns.map(entry => entry[1])]) {
|
||
const cell = document.createElement("th");
|
||
cell.scope = "col";
|
||
cell.textContent = label;
|
||
head.append(cell);
|
||
}
|
||
const body = table.createTBody();
|
||
for (const [index, item] of items.entries()) {
|
||
const row = body.insertRow(), radio = document.createElement("input");
|
||
radio.type = "radio";
|
||
radio.name = "invoiceItem";
|
||
radio.checked = index === draft.item;
|
||
radio.addEventListener("change", () => {
|
||
draft.item = index;
|
||
renderDocumentSettings();
|
||
});
|
||
row.insertCell().append(radio);
|
||
for (const [key, label] of columns) {
|
||
const input = document.createElement("input");
|
||
input.type = "text";
|
||
input.value = item[key] || "";
|
||
input.id = `settingsInvoiceItem_${index}_${key}`;
|
||
const descriptor = draft.detail.fields?.find(field => field.field === `items.${key}` && field.item === index);
|
||
input.disabled = !editable || !descriptor?.editable;
|
||
input.addEventListener("input", () => {
|
||
item[key] = input.value;
|
||
invoiceDirty(draft, "model");
|
||
});
|
||
const cell = row.insertCell();
|
||
if (!state.editing) {
|
||
const text = document.createElement("span");
|
||
text.className = "settings-readonly";
|
||
text.textContent = item[key] || "-";
|
||
cell.append(text);
|
||
} else cell.append(input);
|
||
}
|
||
}
|
||
scroll.append(table);
|
||
container.append(scroll);
|
||
if (state.editing) {
|
||
const actions = document.createElement("div");
|
||
actions.className = "invoice-actions";
|
||
container.append(actions);
|
||
const reorder = async (direction) => {
|
||
if (draft.changes.size) throw new Error("请先应用原字段修改,再调整明细顺序");
|
||
const next = draft.item + direction;
|
||
[items[next], items[draft.item]] = [items[draft.item], items[next]];
|
||
draft.item = next;
|
||
invoiceDirty(draft, "model");
|
||
await invoiceRefreshFields(draft);
|
||
if (invoiceCurrent() === draft) renderDocumentSettings();
|
||
};
|
||
settingsButton("添加", async () => {
|
||
const item = {};
|
||
items.push(item);
|
||
draft.addedItems.add(item);
|
||
draft.item = items.length - 1;
|
||
invoiceDirty(draft, "model");
|
||
await invoiceRefreshFields(draft);
|
||
if (invoiceCurrent() === draft) renderDocumentSettings();
|
||
}, !editable, "InvoiceItemAdd", actions);
|
||
settingsButton("删除", async () => {
|
||
if (draft.changes.size) throw new Error("请先应用原字段修改,再删除明细");
|
||
items.splice(draft.item, 1);
|
||
invoiceDirty(draft, "model");
|
||
await invoiceRefreshFields(draft);
|
||
if (invoiceCurrent() === draft) renderDocumentSettings();
|
||
}, !editable || !items.length, "InvoiceItemDelete", actions);
|
||
settingsButton("上移", () => reorder(-1), !editable || draft.item === 0 || !items.length, "InvoiceItemUp", actions);
|
||
settingsButton("下移", () => reorder(1), !editable || draft.item >= items.length - 1, "InvoiceItemDown", actions);
|
||
}
|
||
}
|
||
|
||
async function invoiceRefreshFields(draft) {
|
||
const invoice = structuredClone(draft.invoice);
|
||
const detail = await callWASM("ofdgoInvoice", JSON.stringify({ invoice, ...draft.scope }));
|
||
if ([...invoicePanel.drafts.values()].includes(draft) && JSON.stringify(draft.invoice) === JSON.stringify(invoice)) draft.detail.fields = detail.fields;
|
||
}
|
||
|
||
function renderInvoiceFields(container, draft) {
|
||
const facts = (draft.invoice.facts || []).map((fact, index) => ({ fact, field: `facts.${index}`, item: -1 }));
|
||
for (const [item, value] of (draft.invoice.items || []).entries()) {
|
||
for (const [index, fact] of (value.fields || []).entries()) {
|
||
facts.push({ fact, field: `items.fields.${index}`, item });
|
||
}
|
||
}
|
||
const options = facts.map(({ fact, item }, index) => [index, `${item >= 0 ? `明细${item + 1} · ` : ""}${fact.name || "未命名"}${fact.context ? ` · ${fact.context}` : ""} · ${index + 1}`]);
|
||
const owners = [...(!draft.entry && draft.invoice.standard !== invoiceReferenceStandard ? [[-1, "基本"]] : []), ...(draft.invoice.items || []).flatMap((item, index) => !draft.entry || draft.addedItems.has(item) ? [[index, `明细${index + 1}`]] : [])];
|
||
if (state.editing && owners.length) {
|
||
if (!owners.some(([index]) => index === draft.factOwner)) draft.factOwner = owners[0][0];
|
||
settingsSelect(container, "归属", "InvoiceFactOwner", owners, draft.factOwner, value => { draft.factOwner = Number(value); });
|
||
settingsButton("添加", () => {
|
||
const owner = draft.factOwner;
|
||
const target = owner < 0 ? draft.invoice.facts ||= [] : draft.invoice.items[owner].fields ||= [];
|
||
target.push({ name: "", namespace: "", value: "", context: "", unit: "" });
|
||
draft.fact = owner < 0 ? target.length - 1 : (draft.invoice.facts || []).length + draft.invoice.items.slice(0, owner).reduce((sum, item) => sum + (item.fields || []).length, 0) + target.length - 1;
|
||
invoiceDirty(draft, "model");
|
||
renderDocumentSettings();
|
||
}, false, "InvoiceFactAdd", container);
|
||
}
|
||
if (!options.length) return;
|
||
draft.fact = Math.min(draft.fact || 0, facts.length - 1);
|
||
settingsSelect(container, "字段", "InvoiceFact", options, draft.fact, value => {
|
||
draft.fact = Number(value);
|
||
renderDocumentSettings();
|
||
});
|
||
const { fact, field, item } = facts[draft.fact];
|
||
const patchKey = invoiceSourceKey(fact.source);
|
||
const change = draft.changes.get(patchKey);
|
||
const descriptor = draft.detail.fields?.find(value => value.field === field && value.item === item);
|
||
if (descriptor?.bindable) {
|
||
draft.field = JSON.stringify([field, item]);
|
||
const binding = draft.bindings.find(value => value.field === field && value.item === item);
|
||
draft.binding = 0;
|
||
draft.page = binding?.page ?? state.pageIndex;
|
||
draft.object = binding?.objectId || "";
|
||
}
|
||
const fresh = !draft.entry || item >= 0 && draft.addedItems.has(draft.invoice.items[item]);
|
||
const writable = state.editing && (fresh || descriptor?.editable && !!fact.source?.nodePath);
|
||
for (const [key, label] of [["name", "名称"], ["namespace", "空间"], ["context", "语境"], ["unit", "单位"], ["decimals", "小数"], ["precision", "精度"]]) {
|
||
invoiceInput(container, label, `FactMeta_${key}`, fact[key], value => {
|
||
fact[key] = value;
|
||
invoiceDirty(draft, "model");
|
||
}, !fresh || !state.editing).title = key === "namespace" ? "命名空间" : key === "context" ? "上下文标识" : label;
|
||
}
|
||
if (fact.source?.nodePath) invoiceInput(container, "路径", "FactPath", fact.source.nodePath, () => {}, true);
|
||
const update = (key, value) => {
|
||
if (fresh) {
|
||
fact[key] = value;
|
||
if (key === "nil" && value) {
|
||
fact.value = "";
|
||
document.querySelector("#settingsInvoiceFactValue").value = "";
|
||
}
|
||
if (key === "value") {
|
||
fact.nil = false;
|
||
document.querySelector("#settingsInvoiceFactNil").checked = false;
|
||
}
|
||
invoiceDirty(draft, "model");
|
||
return;
|
||
}
|
||
const patch = draft.changes.get(patchKey) || {
|
||
source: structuredClone(fact.source), value: fact.value || "", nil: !!fact.nil,
|
||
expectedValue: fact.value || "", expectedNil: !!fact.nil
|
||
};
|
||
patch[key] = value;
|
||
if ((key === "nil" || key === "delete") && value) {
|
||
patch.value = "";
|
||
document.querySelector("#settingsInvoiceFactValue").value = "";
|
||
}
|
||
if (key === "nil" && value) {
|
||
patch.delete = false;
|
||
document.querySelector("#settingsInvoiceFactDelete").checked = false;
|
||
}
|
||
if (key === "delete" && value) {
|
||
patch.nil = false;
|
||
document.querySelector("#settingsInvoiceFactNil").checked = false;
|
||
}
|
||
if (key === "value") {
|
||
patch.nil = false;
|
||
patch.delete = false;
|
||
document.querySelector("#settingsInvoiceFactNil").checked = false;
|
||
document.querySelector("#settingsInvoiceFactDelete").checked = false;
|
||
}
|
||
draft.changes.set(patchKey, patch);
|
||
invoiceDirty(draft, "fields");
|
||
};
|
||
invoiceInput(container, "数值", "FactValue", change?.value ?? fact.value, value => update("value", value), !writable, "textarea");
|
||
if (writable && !fresh && draft.invoice.standard === invoiceReferenceStandard) {
|
||
const fonts = state.doc.fonts || [];
|
||
const libraryFonts = fontManager.editorFonts().filter(font => !font.disabled);
|
||
const font = settingsSelect(container, "字体", "InvoiceFactFont", [["", "原字体"], ...fonts.map(font => [font.id, font.fontName || font.familyName || font.id]), ...libraryFonts.map(font => [`file:${font.id}`, font.fullName || font.name])], change?.font || "", value => update("font", value));
|
||
settingsButton("添加", () => openFontFile(el.fontInput), false, "InvoiceFactAddFont", settingsControlRow(font));
|
||
}
|
||
invoiceInput(container, "空值", "FactNil", change?.nil ?? fact.nil, value => update("nil", value), !writable || draft.invoice.standard === invoiceReferenceStandard, "checkbox");
|
||
if (!fresh) invoiceInput(container, "删除", "FactDelete", change?.delete || false, value => update("delete", value), !writable || draft.invoice.standard === invoiceReferenceStandard, "checkbox");
|
||
else if (state.editing) settingsButton("删除", () => {
|
||
const index = Number(field.split(".").at(-1));
|
||
(item < 0 ? draft.invoice.facts : draft.invoice.items[item].fields).splice(index, 1);
|
||
invoiceDirty(draft, "model");
|
||
renderDocumentSettings();
|
||
}, false, "InvoiceFactDelete", container);
|
||
}
|
||
|
||
function renderInvoiceAppearance(container, draft) {
|
||
const capabilities = draft.detail.capabilities || {};
|
||
const creatingReference = !draft.entry && draft.invoice.standard === invoiceReferenceStandard;
|
||
const writable = state.editing && !!draft.entry;
|
||
const fields = (draft.detail.fields || []).filter(field => field.bindable);
|
||
const choices = fields.map(field => [JSON.stringify([field.field, field.item]), `${field.item >= 0 ? `明细${field.item + 1} · ` : ""}${field.label}`]);
|
||
if (!choices.some(([key]) => key === draft.field)) {
|
||
draft.field = choices[0]?.[0] || "";
|
||
const binding = draft.bindings.find(binding => JSON.stringify([binding.field, binding.item]) === draft.field);
|
||
draft.binding = 0;
|
||
draft.page = binding?.page ?? state.pageIndex;
|
||
draft.object = binding?.objectId || "";
|
||
}
|
||
settingsSelect(container, "字段", "InvoiceBindingField", choices, draft.field, value => {
|
||
draft.field = value;
|
||
draft.binding = 0;
|
||
const binding = draft.bindings.find(binding => JSON.stringify([binding.field, binding.item]) === value);
|
||
draft.page = binding?.page ?? state.pageIndex;
|
||
draft.object = binding?.objectId || "";
|
||
renderDocumentSettings();
|
||
});
|
||
const bound = draft.bindings.filter(binding => JSON.stringify([binding.field, binding.item]) === draft.field);
|
||
if (bound.length) {
|
||
draft.binding = Math.min(draft.binding || 0, bound.length - 1);
|
||
settingsSelect(container, "引用", "InvoiceBinding", bound.map((binding, index) => [index, `第${binding.page + 1}页 · ${binding.objectId}`]), draft.binding, value => {
|
||
draft.binding = Number(value);
|
||
draft.page = bound[draft.binding].page;
|
||
draft.object = bound[draft.binding].objectId;
|
||
renderDocumentSettings();
|
||
});
|
||
}
|
||
settingsPages(container, "InvoicePage", draft.page, value => {
|
||
draft.page = Number(value);
|
||
draft.object = "";
|
||
renderDocumentSettings();
|
||
});
|
||
const object = settingsSelect(container, "图元", "InvoiceObject", [[draft.object, draft.object || "选择"]], draft.object, value => {
|
||
draft.object = value;
|
||
renderDocumentSettings();
|
||
});
|
||
object.disabled = !writable || !capabilities.bind;
|
||
const actions = document.createElement("div");
|
||
actions.className = "invoice-actions";
|
||
container.append(actions);
|
||
settingsButton("定位", async () => {
|
||
if (!draft.object) return;
|
||
settings.page = draft.page;
|
||
await locateSettingsObjects([draft.object]);
|
||
}, !draft.object, "InvoiceLocate", actions);
|
||
if (state.editing) {
|
||
settingsButton("选取", async () => {
|
||
settings.page = draft.page;
|
||
await beginSettingsPick("invoice");
|
||
}, !writable || !capabilities.bind, "InvoicePick", actions, capabilities.reasons?.bind);
|
||
settingsButton("绑定", () => {
|
||
if (!draft.object || !draft.field) throw new Error("请选择字段和图元");
|
||
const [field, item] = JSON.parse(draft.field);
|
||
if (draft.bindings.some(binding => binding.page === draft.page && binding.objectId === draft.object && (binding.field !== field || binding.item !== item))) throw new Error("此图元已绑定其他字段");
|
||
draft.bindings = draft.bindings.filter(binding => binding.field !== field || binding.item !== item);
|
||
draft.bindings.push({ field, item, page: draft.page, objectId: draft.object });
|
||
invoiceDirty(draft, "bindings");
|
||
renderDocumentSettings();
|
||
}, !writable || !capabilities.bind || !draft.field, "InvoiceBind", actions, capabilities.reasons?.bind);
|
||
settingsButton("解绑", () => {
|
||
draft.bindings = draft.bindings.filter(binding => !(JSON.stringify([binding.field, binding.item]) === draft.field && binding.page === draft.page && binding.objectId === draft.object));
|
||
invoiceDirty(draft, "bindings");
|
||
renderDocumentSettings();
|
||
}, !writable || !capabilities.bind || !bound.length, "InvoiceUnbind", actions);
|
||
settingsButton("清空", () => {
|
||
draft.bindings = [];
|
||
invoiceDirty(draft, "bindings");
|
||
renderDocumentSettings();
|
||
}, !writable || !capabilities.bind || !draft.bindings.length, "InvoiceUnbindAll", actions);
|
||
}
|
||
if (writable || state.editing && creatingReference) {
|
||
const details = document.createElement("details"), summary = document.createElement("summary");
|
||
summary.textContent = "生成";
|
||
details.className = "invoice-advanced";
|
||
details.append(summary);
|
||
container.append(details);
|
||
details.open = draft.layoutOpen || false;
|
||
details.addEventListener("toggle", () => { draft.layoutOpen = details.open; });
|
||
const fonts = state.doc.fonts || [];
|
||
const libraryFonts = fontManager.editorFonts().filter(font => !font.disabled);
|
||
const font = settingsSelect(details, "字体", "InvoiceFont", [["", "选择"], ...fonts.map(font => [font.id, font.fontName || font.familyName || font.id]), ...libraryFonts.map(font => [`file:${font.id}`, font.fullName || font.name])], draft.fontChoice || draft.layout.font, value => {
|
||
draft.fontChoice = value;
|
||
draft.layout.font = value.startsWith("file:") ? "" : value;
|
||
if (creatingReference) invoiceDirty(draft, "model");
|
||
renderDocumentSettings();
|
||
});
|
||
const fontRow = settingsControlRow(font);
|
||
settingsButton("添加", () => openFontFile(el.fontInput), false, "InvoiceAddFont", fontRow);
|
||
settingsButton("嵌入", async () => {
|
||
const font = fonts.find(value => value.id === draft.layout.font);
|
||
if (!font) throw new Error("请选择字体");
|
||
const before = invoiceScope();
|
||
const refresh = () => {
|
||
const next = invoiceScope();
|
||
if (next.documentIndex !== before.documentIndex || next.version !== before.version) return;
|
||
for (const value of invoicePanel.drafts.values()) {
|
||
if (value.scope.documentIndex === before.documentIndex && value.scope.version === before.version && value.scope.revision === before.revision) {
|
||
value.scope.revision = next.revision;
|
||
}
|
||
}
|
||
if (settingsElements.Panel.open && settings.invoice) renderDocumentSettings();
|
||
};
|
||
await openFontBinding(font);
|
||
if (fontBindingElements.Panel.open) fontBindingElements.Panel.addEventListener("close", refresh, { once: true });
|
||
else refresh();
|
||
}, !draft.layout.font || !!fonts.find(font => font.id === draft.layout.font)?.embedded, "InvoiceEmbedFont", fontRow);
|
||
for (const [key, label] of [["fontSize", "字号"], ["pageWidth", "页宽"], ["pageHeight", "页高"], ["margin", "边距"]]) {
|
||
const input = invoiceInput(details, label, `Layout_${key}`, draft.layout[key], value => { draft.layout[key] = Number(value); if (creatingReference) invoiceDirty(draft, "model"); }, false, "number", "mm");
|
||
input.min = key === "margin" ? "0" : "0.01";
|
||
}
|
||
settingsButton("生成", async () => {
|
||
if (draft.dirty.size) throw new Error("请先应用草稿");
|
||
if (!draft.layout.font && !draft.fontChoice?.startsWith("file:")) throw new Error("请选择字体");
|
||
const openSeq = state.openSeq, sequence = settings.sequence;
|
||
const fontChoice = draft.fontChoice, layout = structuredClone(draft.layout);
|
||
let fontData;
|
||
if (fontChoice?.startsWith("file:")) {
|
||
const font = libraryFonts.find(value => `file:${value.id}` === fontChoice);
|
||
if (!font) throw new Error("所选字体不可用");
|
||
fontData = invoiceBase64(await readTextFont(font));
|
||
}
|
||
if (openSeq !== state.openSeq || sequence !== settings.sequence || invoiceCurrent() !== draft || !settingsElements.Panel.open || !settings.invoice) return;
|
||
if (draft.dirty.size || draft.fontChoice !== fontChoice || JSON.stringify(draft.layout) !== JSON.stringify(layout)) throw new Error("内容已变更,请重新生成");
|
||
await commitInvoice(draft, { operation: "generate", layout, fontData });
|
||
}, !capabilities.generate, "InvoiceGenerate", details, capabilities.reasons?.generate || (draft.bindings.length ? "请先解除绑定" : ""));
|
||
}
|
||
loadInvoiceObjects(draft).catch(error => { if (invoiceCurrent() === draft) settingsElements.Status.textContent = error.message; });
|
||
}
|
||
|
||
async function loadInvoiceObjects(draft) {
|
||
const page = draft.page, sequence = settings.sequence;
|
||
const field = document.querySelector("#settingsInvoiceObject");
|
||
if (!field || !state.doc.pages[page]) return;
|
||
const data = await loadPageData(page);
|
||
if (invoiceCurrent() !== draft || draft.page !== page || sequence !== settings.sequence || !field.isConnected) return;
|
||
const objects = (data.objects || []).filter(item => !item.scoped && item.type === "TextObject");
|
||
field.replaceChildren(new Option("选择", ""), ...objects.map(item => new Option(`${item.id} · ${(item.text || "文字").slice(0, 60)}`, item.id)));
|
||
field.value = draft.object;
|
||
}
|
||
|
||
function invoiceOtherDraftWarning(draft) {
|
||
return [...invoicePanel.drafts.values()].some(value => value !== draft && value.entry && value.dirty.size && invoiceSourceKey(value.entry.source) === invoiceSourceKey(draft.entry.source)) ? "\n该来源其他发票的未应用修改也将放弃" : "";
|
||
}
|
||
|
||
function resetInvoiceDraft(draft) {
|
||
const section = { xml: "xml", fields: draft.entry ? "fields" : "model", appearance: "bindings" }[invoicePanel.tab] || "model";
|
||
if (!draft.dirty.has(section) || !window.confirm("放弃当前编辑项的未应用修改?")) return;
|
||
if (section === "xml") draft.xml = draft.detail.xml || "";
|
||
else if (section === "fields") draft.changes.clear();
|
||
else if (section === "bindings") draft.bindings = structuredClone(draft.detail.bindings || []);
|
||
else {
|
||
draft.invoice = structuredClone(draft.detail.invoice);
|
||
draft.detail.fields = structuredClone(draft.baselineFields);
|
||
draft.addedItems.clear();
|
||
draft.item = 0;
|
||
draft.fact = 0;
|
||
draft.factOwner = -1;
|
||
draft.options = { name: "", encoding: { schemaRef: "", context: {}, units: [] } };
|
||
draft.fragments = {};
|
||
}
|
||
draft.dirty.delete(section);
|
||
draft.status = "";
|
||
renderDocumentSettings();
|
||
}
|
||
|
||
function renderInvoiceXML(container, draft) {
|
||
const writable = state.editing && (!draft.entry || draft.detail.capabilities?.edit);
|
||
invoiceInput(container, "原文", "XML", draft.xml, value => {
|
||
draft.xml = value;
|
||
invoiceDirty(draft, "xml");
|
||
}, !writable, "textarea").rows = 14;
|
||
const actions = document.createElement("div");
|
||
actions.className = "invoice-actions";
|
||
container.append(actions);
|
||
if (state.editing) settingsButton("导入", () => readInvoiceFile(text => {
|
||
draft.xml = text;
|
||
if (!draft.entry) draft.importing = true;
|
||
invoiceDirty(draft, "xml");
|
||
renderDocumentSettings();
|
||
}), !writable, "InvoiceLoadXML", actions);
|
||
settingsButton("导出", async () => {
|
||
const result = await callWASM("ofdgoExportInvoice", JSON.stringify({ ...draft.scope, source: draft.entry.source, index: draft.entry.index }));
|
||
downloadBytes(result.bytes, "application/xml", (draft.entry.source.fileLoc?.split("/").at(-1) || "invoice.xml"));
|
||
}, !draft.entry, "InvoiceExportXML", actions);
|
||
}
|
||
|
||
function readInvoiceFile(accept) {
|
||
const sequence = settings.sequence, openSeq = state.openSeq;
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
input.accept = ".xml,.xbrl,application/xml,text/xml";
|
||
input.addEventListener("change", async () => {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
try {
|
||
const text = new TextDecoder("utf-8", { fatal: true }).decode(await file.arrayBuffer());
|
||
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
|
||
await accept(text);
|
||
} catch (error) {
|
||
settingsElements.Status.textContent = error.message;
|
||
}
|
||
});
|
||
input.click();
|
||
}
|
||
|
||
async function validateInvoiceDraft(draft) {
|
||
const request = draft.dirty.size ? buildInvoiceRequest(draft) : { invoice: draft.invoice };
|
||
if (!await prepareInvoiceRequest(draft, request)) return;
|
||
const result = await callWASM("ofdgoValidateInvoice", JSON.stringify({ ...draft.scope, source: draft.entry?.source, index: draft.entry?.index, digest: draft.detail.digest, ...request }));
|
||
if (invoiceCurrent() !== draft) return;
|
||
const appearance = result.appearance || [];
|
||
const unbound = appearance.some(issue => issue.code === "appearance-unbound");
|
||
const issues = (result.issues || []).filter(issue => issue.code !== "appearance-not-synchronized");
|
||
draft.status = invoiceIssues([...issues, ...appearance.filter(issue => issue.code !== "appearance-unbound")])
|
||
|| (unbound ? "数据校验通过,票面未校验" : draft.entry ? "数据与票面校验通过" : "数据校验通过");
|
||
settingsElements.Status.textContent = draft.status;
|
||
}
|
||
|
||
async function applyInvoiceDraft(draft, synchronize = draft.synchronize) {
|
||
if (!state.editing || !draft.dirty.size) return;
|
||
const request = buildInvoiceRequest(draft);
|
||
request.synchronize = draft.invoice.standard === invoiceReferenceStandard || synchronize;
|
||
if (!await prepareInvoiceRequest(draft, request)) return;
|
||
if (request.operation === "replace" && !window.confirm("替换此来源中的全部发票原文?" + invoiceOtherDraftWarning(draft))) return;
|
||
await commitInvoice(draft, request);
|
||
}
|
||
|
||
async function prepareInvoiceRequest(draft, request) {
|
||
if (draft.invoice.standard !== invoiceReferenceStandard) return true;
|
||
const creating = request.operation === "create";
|
||
const patches = (request.operation === "batch" ? request.operations : [request]).filter(operation => operation.operation === "patch");
|
||
if (!creating && !patches.length) return true;
|
||
if (creating && !draft.layout.font && !draft.fontChoice?.startsWith("file:")) {
|
||
invoicePanel.tab = "appearance";
|
||
draft.layoutOpen = true;
|
||
renderDocumentSettings();
|
||
throw new Error("请选择标引票面字体");
|
||
}
|
||
const sequence = settings.sequence, openSeq = state.openSeq;
|
||
const doc = state.doc, scope = JSON.stringify(invoiceScope());
|
||
const snapshot = JSON.stringify(request), fontChoice = draft.fontChoice;
|
||
for (const patch of patches) patch.changes = structuredClone(patch.changes);
|
||
settingsElements.Form.inert = true;
|
||
try {
|
||
if (creating && fontChoice?.startsWith("file:")) {
|
||
const font = fontManager.editorFonts().find(value => `file:${value.id}` === fontChoice && !value.disabled);
|
||
if (!font) throw new Error("所选字体不可用");
|
||
request.fontData = invoiceBase64(await readTextFont(font));
|
||
}
|
||
const fonts = new Map();
|
||
for (const patch of patches) {
|
||
for (const change of patch.changes) {
|
||
if (!change.font?.startsWith("file:")) continue;
|
||
if (!fonts.has(change.font)) {
|
||
const font = fontManager.editorFonts().find(value => `file:${value.id}` === change.font && !value.disabled);
|
||
if (!font) throw new Error("所选字体不可用");
|
||
fonts.set(change.font, invoiceBase64(await readTextFont(font)));
|
||
}
|
||
change.fontData = fonts.get(change.font);
|
||
delete change.font;
|
||
}
|
||
}
|
||
if (sequence !== settings.sequence || openSeq !== state.openSeq || doc !== state.doc || scope !== JSON.stringify(invoiceScope()) || invoiceCurrent() !== draft || !settingsElements.Panel.open || !settings.invoice) return false;
|
||
if (draft.fontChoice !== fontChoice || JSON.stringify(buildInvoiceRequest(draft)) !== snapshot) throw new Error("内容已变更,请重新应用");
|
||
return true;
|
||
} finally {
|
||
if (sequence === settings.sequence) settingsElements.Form.inert = false;
|
||
}
|
||
}
|
||
|
||
function buildInvoiceRequest(draft) {
|
||
if (draft.dirty.has("xml") && draft.dirty.size > 1) throw new Error("原文替换与其他修改冲突,草稿已保留,请先还原其中一项");
|
||
if (draft.dirty.has("model") && draft.dirty.has("fields")) throw new Error("基本、明细与原字段不能同时应用,请先还原一项");
|
||
const section = draft.dirty.has("xml") ? "xml" : "model";
|
||
const request = { synchronize: draft.invoice.standard === invoiceReferenceStandard || draft.synchronize };
|
||
if (section === "xml") {
|
||
Object.assign(request, { operation: draft.entry ? "replace" : "import", xml: draft.xml, options: { name: draft.options.name } });
|
||
} else {
|
||
const options = structuredClone(draft.options);
|
||
if (!invoiceIsXBRL(draft.invoice)) options.encoding = {};
|
||
if (!draft.entry && draft.invoice.standard === invoiceReferenceStandard) {
|
||
delete options.encoding;
|
||
options.layout = structuredClone(draft.layout);
|
||
}
|
||
for (const [key, text] of draft.invoice.standard === "EInvoice" ? Object.entries(draft.fragments) : []) {
|
||
options.encoding[key] = invoiceBase64(new TextEncoder().encode(text));
|
||
}
|
||
if (!draft.entry) Object.assign(request, { operation: "create", invoice: draft.invoice, options });
|
||
else {
|
||
const operations = [];
|
||
if (draft.dirty.has("model")) operations.push({ operation: "set", index: draft.entry.index, invoice: draft.invoice });
|
||
if (draft.dirty.has("fields")) operations.push({ operation: "patch", index: draft.entry.index, changes: structuredClone([...draft.changes.values()]) });
|
||
if (draft.dirty.has("bindings")) operations.push({ operation: "bindings", index: draft.entry.index, bindings: draft.bindings });
|
||
Object.assign(request, operations.length === 1 ? operations[0] : { operation: "batch", operations });
|
||
}
|
||
}
|
||
return request;
|
||
}
|
||
|
||
function invoiceBase64(bytes) {
|
||
let value = "";
|
||
for (let offset = 0; offset < bytes.length; offset += 8192) value += String.fromCharCode(...bytes.subarray(offset, offset + 8192));
|
||
return btoa(value);
|
||
}
|
||
|
||
async function commitInvoice(draft, request) {
|
||
if (!state.editing || settings.busy || document.body.hasAttribute("data-busy")) return;
|
||
if (!settingsElements.Panel.open || !settings.invoice || invoiceCurrent() !== draft) return;
|
||
const scope = invoiceScope();
|
||
if (draft.scope.documentIndex !== scope.documentIndex || draft.scope.version !== scope.version || draft.scope.revision !== scope.revision) throw new Error("文档已变更,请重新打开发票");
|
||
const key = invoicePanel.key;
|
||
settings.busy = true;
|
||
settingsElements.Form.inert = true;
|
||
settingsElements.Status.textContent = "正在保存修改";
|
||
try {
|
||
invoicePanel.result = null;
|
||
if (!await changeDocument("ofdgoChangeInvoice", null, JSON.stringify({ ...draft.scope, source: draft.entry?.source, index: draft.entry?.index || 0, digest: draft.detail.digest, ...request }))) {
|
||
if (settingsElements.Status.textContent === "正在保存修改") settingsElements.Status.textContent = el.statusText.textContent;
|
||
return;
|
||
}
|
||
const result = invoicePanel.result;
|
||
if (invoicePanel.drafts.get(key) === draft) invoicePanel.drafts.delete(key);
|
||
const currentScope = invoiceScope();
|
||
for (const [key, value] of invoicePanel.drafts) {
|
||
if (value.scope.documentIndex !== currentScope.documentIndex || value.scope.version !== currentScope.version) continue;
|
||
value.scope.revision = currentScope.revision;
|
||
if (value.entry && draft.entry && invoiceSourceKey(value.entry.source) === invoiceSourceKey(draft.entry.source)) {
|
||
if (!value.dirty.size) invoicePanel.drafts.delete(key);
|
||
else value.status = "同源数据已修改,应用时将检查来源冲突";
|
||
}
|
||
}
|
||
if (request.operation === "delete" || request.operation === "replace") {
|
||
for (const [key, value] of invoicePanel.drafts) {
|
||
if (value.entry && invoiceSourceKey(value.entry.source) === invoiceSourceKey(draft.entry.source)) {
|
||
invoicePanel.drafts.delete(key);
|
||
}
|
||
}
|
||
}
|
||
if (!settingsElements.Panel.open || !settings.invoice || invoicePanel.key !== key) return;
|
||
if (result?.source?.kind) invoicePanel.key = JSON.stringify([invoiceSourceKey(result.source), draft.entry?.index || 0]);
|
||
await loadDocumentSettings();
|
||
const warning = invoiceIssues(result?.issues).replaceAll("\n", " · ");
|
||
if (warning) {
|
||
const status = "已保存 · " + warning;
|
||
if (invoiceCurrent()) invoiceCurrent().status = status;
|
||
settingsElements.Status.textContent = status;
|
||
}
|
||
} finally {
|
||
settings.busy = false;
|
||
settingsElements.Form.inert = false;
|
||
}
|
||
}
|
||
|
||
function settingsPages(container, id, value, change) {
|
||
return settingsSelect(container, "页码", id, state.doc.pages.map((page, index) => [index, `第${index + 1}页`]), value, change);
|
||
}
|
||
|
||
function outlineSettingsChoices() {
|
||
const result = [];
|
||
const visit = (nodes, path = [], prefix = "") => {
|
||
for (const [index, node] of (nodes || []).entries()) {
|
||
const next = [...path, index], title = prefix + node.title;
|
||
result.push([JSON.stringify(next), title]);
|
||
visit(node.children, next, title + " / ");
|
||
}
|
||
};
|
||
visit(state.doc?.outlines);
|
||
return result;
|
||
}
|
||
|
||
function actionSettingsLabel(action) {
|
||
if (action.Goto) return action.Goto.Bookmark ? `书签 · ${action.Goto.Bookmark.Name}` : `页面 · ${pageIndexByID(state.doc.pages, action.Goto.Dest?.PageID) + 1}`;
|
||
if (action.URI) return `网址 · ${action.URI.URI}`;
|
||
const [key, label, id] = action.Sound ? ["Audio", "音频", action.Sound.ResourceID] : action.Movie ? ["Video", "视频", action.Movie.ResourceID] : ["Attachment", "附件", action.GotoA?.AttachID];
|
||
return `${label} · ${settings.data.resources.find(item => item.kind === key && item.id === referenceIDKey(id))?.name || id || ""}`;
|
||
}
|
||
|
||
function renderActionSettings() {
|
||
const context = settingsElements.Context, content = settingsElements.Content;
|
||
settingsSelect(context, "归属", "Scope", [["document", "文档"], ["page", "页面"], ["object", "图元"], ["outline", "目录"]], settings.scope, value => { settings.scope = value; settings.selected = -1; loadDocumentSettings(); });
|
||
if (settings.scope === "page" || settings.scope === "object") settingsPages(context, "Page", settings.page, value => { settings.page = Number(value); settings.selected = -1; loadDocumentSettings(); });
|
||
if (settings.scope === "object") {
|
||
const objects = settingsSelect(context, "图元", "Object", (settings.data.objects || []).map(item => [item.id, `${item.id} · ${item.text || ({TextObject: "文字", PathObject: "图形", ImageObject: "图片"}[item.type] || "组合")}`]), settings.object, value => { settings.object = value; settings.selected = -1; loadDocumentSettings(); });
|
||
if (!el.objectStylePanel.open) settingsPickControl(objects, "action");
|
||
}
|
||
if (settings.scope === "outline") settingsSelect(context, "目录", "Outline", outlineSettingsChoices(), JSON.stringify(settings.path), value => { settings.path = JSON.parse(value); settings.selected = -1; loadDocumentSettings(); });
|
||
if (el.objectStylePanel.open) for (const field of context.querySelectorAll("select")) field.disabled = true;
|
||
if (settings.selected >= settings.actions.length) settings.selected = -1;
|
||
settingsSelect(context, "动作", "Entry", [[-1, "新增"], ...settings.actions.map((item, index) => [index, `${index + 1} · ${actionSettingsLabel(item)}`])], settings.selected, value => { settings.selected = Number(value); renderDocumentSettings(); });
|
||
const action = settings.actions[settings.selected] || {};
|
||
const type = action.Goto ? action.Goto.Bookmark ? "Bookmark" : "Goto" : ["URI", "GotoA", "Sound", "Movie"].find(key => action[key]) || "Goto";
|
||
settingsSelect(content, "类型", "ActionType", [["Goto", "页面"], ["Bookmark", "书签"], ["URI", "网址"], ["GotoA", "附件"], ["Sound", "音频"], ["Movie", "视频"]], type, value => renderActionFields(value, action));
|
||
const fields = document.createElement("div");
|
||
fields.id = "settingsActionFields";
|
||
content.append(fields);
|
||
renderActionFields(type, action);
|
||
const missing = settings.scope === "object" && !settings.object || settings.scope === "outline" && !settings.path.length;
|
||
settingsButton(settings.selected < 0 ? "添加" : "修改", () => writeActionSettings("save"), missing, "Save");
|
||
settingsButton("删除", () => writeActionSettings("delete"), settings.selected < 0, "Delete");
|
||
settingsButton("上移", () => writeActionSettings("up"), settings.selected <= 0, "Up");
|
||
settingsButton("下移", () => writeActionSettings("down"), settings.selected < 0 || settings.selected >= settings.actions.length - 1, "Down");
|
||
}
|
||
|
||
function renderActionFields(type, action) {
|
||
const fields = document.querySelector("#settingsActionFields");
|
||
fields.replaceChildren();
|
||
if (type === "Goto") renderDestinationFields(fields, action.Goto?.Dest);
|
||
else if (type === "Bookmark") settingsSelect(fields, "书签", "Bookmark", (settings.data.bookmarks || []).map(item => [item.Name, item.Name]), action.Goto?.Bookmark?.Name);
|
||
else if (type === "URI") {
|
||
settingsField(fields, "网址", "URI", action.URI?.URI);
|
||
settingsField(fields, "基址", "Base", action.URI?.Base);
|
||
settingsField(fields, "目标", "Target", action.URI?.Target);
|
||
} else {
|
||
const kind = {GotoA: "Attachment", Sound: "Audio", Movie: "Video"}[type];
|
||
settingsSelect(fields, "资源", "Resource", settings.data.resources.filter(item => item.kind === kind).map(item => [item.id, item.name]), referenceIDKey(action[type]?.ResourceID || action[type]?.AttachID));
|
||
if (type === "GotoA") settingsSelect(fields, "窗口", "NewWindow", [["", "默认"], ["true", "新建"], ["false", "当前"]], action.GotoA?.NewWindow == null ? "" : String(action.GotoA.NewWindow));
|
||
if (type === "Sound") {
|
||
const volume = settingsField(fields, "音量", "Volume", action.Sound?.Volume ?? "", "number");
|
||
volume.min = "0"; volume.max = "100"; volume.step = "1"; volume.placeholder = "默认";
|
||
settingsField(fields, "循环", "Repeat", action.Sound?.Repeat, "checkbox");
|
||
settingsField(fields, "同步", "Synchronous", action.Sound?.Synchronous, "checkbox");
|
||
}
|
||
if (type === "Movie") settingsSelect(fields, "操作", "Operator", [["", "默认"], ["Play", "播放"], ["Stop", "停止"], ["Pause", "暂停"], ["Resume", "继续"]], action.Movie?.Operator || "");
|
||
}
|
||
}
|
||
|
||
function renderDestinationFields(container, dest = {}) {
|
||
const type = dest?.Type || "XYZ";
|
||
settingsSelect(container, "定位", "DestType", [["XYZ", "坐标"], ["Fit", "适页"], ["FitH", "适宽"], ["FitV", "适高"], ["FitR", "区域"]], type, () => updateDestinationFields());
|
||
const index = pageIndexByID(state.doc.pages, dest?.PageID);
|
||
const pages = state.doc.pages.map((page, index) => [page.id, `第${index + 1}页`]);
|
||
if (dest?.PageID && index < 0) pages.push([dest.PageID, "页面缺失"]);
|
||
settingsSelect(container, "页码", "DestPage", pages, index >= 0 ? state.doc.pages[index].id : dest?.PageID || state.doc.pages[settings.page]?.id);
|
||
for (const [id, label] of [["Left", "左侧"], ["Top", "顶部"], ["Right", "右侧"], ["Bottom", "底部"], ["Zoom", "缩放"]]) {
|
||
const field = settingsField(container, label, id, dest?.[`Omit${id}`] ? "" : dest?.[id] ?? "", "number");
|
||
field.placeholder = id === "Zoom" ? "保持" : "默认";
|
||
}
|
||
updateDestinationFields();
|
||
}
|
||
|
||
function updateDestinationFields() {
|
||
const type = settingsValue("DestType"), required = {XYZ: ["Left", "Top", "Zoom"], Fit: [], FitH: ["Top"], FitV: ["Left"], FitR: ["Left", "Top", "Right", "Bottom"]}[type] || [];
|
||
for (const id of ["Left", "Top", "Right", "Bottom", "Zoom"]) {
|
||
const field = document.querySelector(`#settings${id}`);
|
||
field.closest("label").hidden = !required.includes(id);
|
||
field.disabled = !state.editing || !required.includes(id);
|
||
}
|
||
}
|
||
|
||
function destinationFromSettings() {
|
||
const dest = {Type: settingsValue("DestType"), PageID: settingsValue("DestPage")};
|
||
for (const id of ["Left", "Top", "Right", "Bottom", "Zoom"]) dest[id] = Number(settingsValue(id));
|
||
for (const id of ["Left", "Top", "Zoom"]) dest[`Omit${id}`] = settingsValue(id) === "";
|
||
return dest;
|
||
}
|
||
|
||
async function commitSettings(name, ...args) {
|
||
if (!state.editing || settings.busy || document.body.hasAttribute("data-busy")) return false;
|
||
rememberSettingsDraft();
|
||
const pending = settings.drafts.get(settings.view), baseline = settings.baseline;
|
||
settings.busy = true;
|
||
settingsElements.Form.inert = true;
|
||
settingsElements.Status.textContent = "正在保存修改";
|
||
const view = settings.view, focus = document.activeElement?.id, body = document.querySelector("#settingsBody"), scroll = body?.scrollTop || 0;
|
||
try {
|
||
if (!await changeDocument(name, null, ...args)) {
|
||
if (settingsElements.Status.textContent === "正在保存修改") settingsElements.Status.textContent = el.statusText.textContent;
|
||
return false;
|
||
}
|
||
settings.drafts.delete(view);
|
||
if (pending) {
|
||
const fields = settingsCommittedFields(name, args, Object.keys(pending.values));
|
||
const values = Object.fromEntries(Object.entries(pending.values).filter(([id, value]) => !fields.includes(id) && JSON.stringify(value) !== JSON.stringify(baseline[id])));
|
||
if (Object.keys(values).length) settings.drafts.set(view, {...pending, values});
|
||
}
|
||
await loadDocumentSettings();
|
||
if (name === "ofdgoChangeActions" && el.objectStylePanel.open && settings.scope === "object") {
|
||
const item = canvasEditor.pageItems(settings.page).find(item => item.id === settings.object && !item.scoped);
|
||
if (item) { canvasEditor.select(item); syncObjectLink(item); }
|
||
}
|
||
if (body) body.scrollTop = scroll;
|
||
document.getElementById(focus)?.focus({preventScroll: true});
|
||
return true;
|
||
} finally { settings.busy = false; settingsElements.Form.inert = false; }
|
||
}
|
||
|
||
function settingsCommittedFields(name, args, fields) {
|
||
const binding = ["settingsTargetPage", "settingsTargetObject", "settingsPaintTarget", "settingsPaintMode", "settingsPaintValue"];
|
||
if (name === "ofdgoChangeTemplate") {
|
||
if (args[0] === "update") return ["settingsName", "settingsTemplateOrder"];
|
||
if (args[0] === "replace") return ["settingsSourcePage"];
|
||
if (["bind", "order", "unbind"].includes(args[0])) return ["settingsBindingOrder"];
|
||
if (args[0] === "move") return [];
|
||
}
|
||
if (name === "ofdgoChangeLayer") {
|
||
if (args[0] === "update") return ["settingsLayerType", "settingsDrawParam"];
|
||
if (args[0] === "objects") return ["settingsLayerObjects"];
|
||
if (args[0] === "move") return [];
|
||
}
|
||
if (name === "ofdgoChangeStyleResource") return fields.filter(id => !binding.includes(id));
|
||
if (name === "ofdgoApplyStyleResource") return binding;
|
||
return fields;
|
||
}
|
||
|
||
async function writeActionSettings(operation) {
|
||
if (operation === "save" && !settingsElements.Form.reportValidity()) return;
|
||
rememberSettingsDraft();
|
||
const drafts = new Map(settings.drafts), view = settings.view;
|
||
const actions = structuredClone(settings.actions), index = settings.selected;
|
||
if (operation === "save") {
|
||
const original = actions[index] || {};
|
||
const action = {Event: {document: "DO", page: "PO", object: "CLICK", outline: original.Event || "CLICK"}[settings.scope], Region: original.Region || null};
|
||
const type = settingsValue("ActionType");
|
||
if (type === "Goto") action.Goto = {Dest: destinationFromSettings()};
|
||
else if (type === "Bookmark") action.Goto = {Bookmark: {Name: settingsValue("Bookmark")}};
|
||
else if (type === "URI") action.URI = {URI: settingsValue("URI"), Base: settingsValue("Base"), Target: settingsValue("Target")};
|
||
else if (type === "GotoA") action.GotoA = {AttachID: settingsValue("Resource"), NewWindow: settingsValue("NewWindow") === "" ? null : settingsValue("NewWindow") === "true"};
|
||
else if (type === "Sound") action.Sound = {ResourceID: settingsValue("Resource"), Volume: settingsValue("Volume") === "" ? null : Number(settingsValue("Volume")), Repeat: settingsValue("Repeat"), Synchronous: settingsValue("Synchronous")};
|
||
else action.Movie = {ResourceID: settingsValue("Resource"), Operator: settingsValue("Operator")};
|
||
if (index < 0) { settings.selected = actions.length; actions.push(action); }
|
||
else actions[index] = action;
|
||
} else if (operation === "delete") { actions.splice(index, 1); settings.selected = Math.min(index, actions.length - 1); }
|
||
else {
|
||
const next = index + (operation === "up" ? -1 : 1);
|
||
[actions[index], actions[next]] = [actions[next], actions[index]];
|
||
settings.selected = next;
|
||
}
|
||
if (!await commitSettings("ofdgoChangeActions", settings.scope, settings.page, settings.object, settings.path, JSON.stringify(actions))) { settings.selected = index; return; }
|
||
if (operation !== "save") {
|
||
const context = JSON.parse(view).slice(0, -1), prefix = JSON.stringify(context);
|
||
for (const key of settings.drafts.keys()) if (JSON.stringify(JSON.parse(key).slice(0, -1)) === prefix) settings.drafts.delete(key);
|
||
for (const [key, draft] of drafts) {
|
||
const parts = JSON.parse(key);
|
||
if (JSON.stringify(parts.slice(0, -1)) !== prefix) continue;
|
||
let position = parts.at(-1);
|
||
if (operation === "delete") {
|
||
if (position === index) continue;
|
||
if (position > index) position--;
|
||
} else if (position === index) position = settings.selected;
|
||
else if (position === settings.selected) position = index;
|
||
settings.drafts.set(JSON.stringify([...context, position]), draft);
|
||
}
|
||
renderDocumentSettings();
|
||
}
|
||
}
|
||
|
||
function renderStyleResourceSettings(kind) {
|
||
const properties = settingsSection("属性"), content = properties.section, value = settings.data.resource || {};
|
||
const existing = Boolean(settings.resource.id);
|
||
if (kind === "ColorSpace") {
|
||
settingsSelect(content, "模型", "ColorType", [["RGB", "RGB"], ["GRAY", "GRAY"], ["CMYK", "CMYK"]], value.Type || "RGB");
|
||
settingsSelect(content, "位深", "Bits", [1, 2, 4, 8, 16].map(bits => [bits, String(bits)]), value.BitsPerComponent || 8);
|
||
settingsField(content, "调色板", "Palette", (value.Palette || []).join("\n"), "textarea");
|
||
if (value.Profile) appendInfoLine(content, "配置", value.Profile);
|
||
const file = settingsField(content, "配置文件", "Profile", "", "file");
|
||
file.accept = ".icc,.icm";
|
||
if (value.Profile) settingsField(content, "移除配置", "ClearProfile", false, "checkbox");
|
||
} else {
|
||
settingsSelect(content, "继承", "Relative", [["", "无"], ...settings.data.resources.filter(item => item.kind === "DrawParam" && item.id !== settings.resource.id).map(item => [item.id, item.name])], referenceIDKey(value.Relative));
|
||
settingsSelect(content, "字体", "Font", [["", "继承"], ...settings.data.resources.filter(item => item.kind === "Font").map(item => [item.id, item.name])], referenceIDKey(value.Font));
|
||
const grid = document.createElement("div");
|
||
grid.className = "settings-grid";
|
||
content.append(grid);
|
||
for (const [id, label, unit] of [["Size", "字号", "mm"], ["Weight", "字重", ""], ["LineWidth", "线宽", "mm"], ["MiterLimit", "截断值", ""]]) {
|
||
const field = settingsField(grid, label, id, value[id] || (value[`${id}Set`] ? 0 : ""), "number", unit);
|
||
field.min = "0";
|
||
field.placeholder = "继承";
|
||
if (id === "Weight") { field.step = "100"; field.max = "900"; }
|
||
}
|
||
settingsField(content, "斜体", "Italic", value.Italic, "checkbox");
|
||
settingsSelect(content, "端点", "Cap", [["", "继承"], ["Butt", "平头"], ["Round", "圆头"], ["Square", "方头"]], value.Cap || "");
|
||
settingsSelect(content, "转角", "Join", [["", "继承"], ["Miter", "尖角"], ["Round", "圆角"], ["Bevel", "斜角"]], value.Join || "");
|
||
settingsField(content, "间隔", "DashPattern", value.DashPattern || "").title = "虚线间隔";
|
||
settingsField(content, "偏移", "DashOffset", value.DashOffset ?? "", "number", "mm");
|
||
for (const [key, label] of [["FillColor", "填充"], ["StrokeColor", "描边"]]) {
|
||
settingsSelect(content, label, key + "Mode", [["keep", "原有"], ["inherit", "继承"], ["solid", "颜色"]], "keep");
|
||
const field = settingsField(content, label + "颜色", key, "#000000", "color");
|
||
field.closest("label").hidden = true;
|
||
document.querySelector(`#settings${key}Mode`).addEventListener("change", event => { field.closest("label").hidden = event.target.value !== "solid"; });
|
||
}
|
||
}
|
||
content.querySelectorAll("input,select,textarea").forEach(field => { field.disabled = !state.editing; });
|
||
if (!state.editing) return;
|
||
settingsButton(existing ? "修改" : "添加", () => writeStyleResource(kind), false, "Save", properties.actions);
|
||
content.append(properties.actions);
|
||
if (!existing) return;
|
||
const group = settingsSection("绑定"), binding = group.section;
|
||
settingsPages(binding, "TargetPage", settings.page, value => { settings.page = Number(value); loadSettingsObjects(); });
|
||
const objects = settingsSelect(binding, "图元", "TargetObject", [["", "选择"], ...(settings.data.objects || []).map(item => [item.id, `${item.id} · ${item.text || "图元"}`])], settings.object);
|
||
settingsPickControl(objects, "style");
|
||
if (kind === "ColorSpace") {
|
||
settingsSelect(binding, "应用", "PaintTarget", [["fill", "填充"], ["stroke", "描边"]], "fill");
|
||
settingsSelect(binding, "方式", "PaintMode", [["value", "分量"], ["index", "索引"]], "value");
|
||
settingsField(binding, "色值", "PaintValue", value.Type === "CMYK" ? `0 0 0 ${2 ** (value.BitsPerComponent || 8) - 1}` : value.Type === "GRAY" ? "0" : "0 0 0");
|
||
}
|
||
const apply = settingsButton("应用", () => applyStyleResource(kind), !objects.value, "Apply", group.actions);
|
||
const unbind = kind === "DrawParam" ? settingsButton("解绑", () => applyStyleResource(kind, true), !objects.value, "Unbind", group.actions) : null;
|
||
objects.addEventListener("change", () => { apply.disabled = !objects.value; if (unbind) unbind.disabled = !objects.value; });
|
||
binding.append(group.actions);
|
||
}
|
||
|
||
async function loadSettingsObjects() {
|
||
const sequence = settings.sequence, page = settings.page, openSeq = state.openSeq;
|
||
const field = document.querySelector("#settingsTargetObject");
|
||
field.disabled = true;
|
||
try {
|
||
const data = await loadPageData(page);
|
||
if (sequence !== settings.sequence || page !== settings.page || openSeq !== state.openSeq) return;
|
||
field.replaceChildren(new Option("选择", ""), ...(data.objects || []).filter(item => ["TextObject", "PathObject", "CompositeObject", "CompositeGraphicUnit"].includes(item.type) && !item.scoped).map(item => new Option(`${item.id} · ${item.text || "图元"}`, item.id)));
|
||
} catch (error) { if (sequence === settings.sequence) settingsElements.Status.textContent = error.message; }
|
||
finally { if (sequence === settings.sequence) { field.disabled = false; field.dispatchEvent(new Event("change")); } }
|
||
}
|
||
|
||
async function writeStyleResource(kind) {
|
||
if (!settingsElements.Form.reportValidity()) return;
|
||
const sequence = settings.sequence, openSeq = state.openSeq, resource = settings.resource, open = settingsElements.Panel.open;
|
||
const value = structuredClone(settings.data.resource || {});
|
||
let profile = null;
|
||
const colors = {};
|
||
if (kind === "ColorSpace") {
|
||
value.Type = settingsValue("ColorType");
|
||
value.BitsPerComponent = Number(settingsValue("Bits"));
|
||
value.Palette = settingsValue("Palette").split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
||
const file = document.querySelector("#settingsProfile").files[0];
|
||
if (file) {
|
||
try { profile = new Uint8Array(await file.arrayBuffer()); }
|
||
catch (error) { if (sequence === settings.sequence && openSeq === state.openSeq) settingsElements.Status.textContent = error.message; return; }
|
||
if (sequence !== settings.sequence || openSeq !== state.openSeq || resource !== settings.resource || open !== settingsElements.Panel.open) return;
|
||
}
|
||
else if (settingsValue("ClearProfile")) profile = new Uint8Array();
|
||
} else {
|
||
for (const id of ["Relative", "Font", "Cap", "Join", "DashPattern"]) value[id] = settingsValue(id);
|
||
for (const id of ["Size", "Weight", "LineWidth", "MiterLimit"]) value[id] = Number(settingsValue(id));
|
||
value.LineWidthSet = settingsValue("LineWidth") !== "";
|
||
value.MiterLimitSet = settingsValue("MiterLimit") !== "";
|
||
value.DashOffset = settingsValue("DashOffset") === "" ? null : Number(settingsValue("DashOffset"));
|
||
value.Italic = settingsValue("Italic");
|
||
for (const key of ["FillColor", "StrokeColor"]) {
|
||
const mode = settingsValue(key + "Mode");
|
||
if (mode === "inherit") value[key] = null;
|
||
if (mode === "solid") colors[key] = settingsValue(key);
|
||
}
|
||
}
|
||
const before = new Set(settings.data.resources.filter(item => item.kind === kind).map(item => item.id));
|
||
if (await commitSettings("ofdgoChangeStyleResource", kind, settings.resource.id, JSON.stringify(value), profile, JSON.stringify(colors))) {
|
||
await resourceTask(loadManagedResources);
|
||
if (!settings.resource.id) {
|
||
settings.resource.id = resourceManager.items.find(item => item.kind === kind && !before.has(item.id))?.id || "";
|
||
await loadDocumentSettings();
|
||
}
|
||
}
|
||
}
|
||
|
||
async function applyStyleResource(kind, clear = false) {
|
||
const object = settingsValue("TargetObject");
|
||
if (!object) { settingsElements.Status.textContent = "请选择图元"; return; }
|
||
settings.object = object;
|
||
const paint = kind === "ColorSpace" ? {target: settingsValue("PaintTarget"), mode: settingsValue("PaintMode"), value: settingsValue("PaintValue")} : {};
|
||
if (await commitSettings("ofdgoApplyStyleResource", kind, clear ? "" : settings.resource.id, settings.page, object, JSON.stringify(paint))) await resourceTask(loadManagedResources);
|
||
}
|
||
|
||
function renderLayerSettings() {
|
||
const entries = settings.data.page.layers || [];
|
||
settingsPages(settingsElements.Context, "Page", settings.page, value => { settings.page = Number(value); settings.selected = -1; loadDocumentSettings(); });
|
||
if (!entries.length && !state.editing) { settingsElements.Status.textContent = "暂无图层"; return; }
|
||
if (settings.selected >= entries.length) settings.selected = -1;
|
||
if (!state.editing && settings.selected < 0 && entries.length) settings.selected = 0;
|
||
const types = {Background: "背景", Body: "正文", Foreground: "前景"};
|
||
const options = entries.map((entry, index) => [index, `${entry.ID} · ${types[entry.Type] || entry.Type} · ${entry.ObjectCount}个图元`]);
|
||
if (state.editing) options.unshift([-1, "新增"]);
|
||
settingsSelect(settingsElements.Context, "图层", "Entry", options, settings.selected, value => { settings.selected = Number(value); renderDocumentSettings(); });
|
||
const entry = entries[settings.selected];
|
||
const properties = settingsSection("属性"), content = properties.section;
|
||
settingsSelect(content, "类型", "LayerType", Object.entries(types), entry?.Type || "Body");
|
||
settingsSelect(content, "样式", "DrawParam", [["", "无"], ...settings.data.resources.filter(item => item.kind === "DrawParam").map(item => [item.id, item.name])], referenceIDKey(entry?.DrawParam));
|
||
content.querySelectorAll("select").forEach(field => { field.disabled = !state.editing; });
|
||
if (state.editing) settingsButton("定位", () => locateSettingsObjects(entry?.ObjectIDs || []), !entry?.ObjectCount, "LocateLayer", properties.actions, "图层没有可定位的图元");
|
||
content.append(properties.actions);
|
||
if (!state.editing) return;
|
||
const write = async (operation, order = 0, objects = []) => {
|
||
const selected = settings.selected;
|
||
if (!entry && operation === "add") settings.selected = entries.length;
|
||
if (!await commitSettings("ofdgoChangeLayer", operation, settings.page, entry?.ID || "", settingsValue("LayerType"), settingsValue("DrawParam"), order, objects)) settings.selected = selected;
|
||
};
|
||
settingsButton(entry ? "修改" : "添加", () => write(entry ? "update" : "add"), false, "Save", properties.actions);
|
||
settingsButton("删除", () => write("delete"), !entry || entry.ObjectCount > 0, "Delete", properties.actions, entry?.ObjectCount ? "图层仍有图元" : "未选择图层");
|
||
settingsButton("上移", () => write("move", entry.Order - 1), !entry || entry.Order === 0, "Up", properties.actions);
|
||
settingsButton("下移", () => write("move", entry.Order + 1), !entry || entry.Order >= entries.filter(item => item.Type === entry.Type).length - 1, "Down", properties.actions);
|
||
if (entry) {
|
||
const binding = settingsSection("图元");
|
||
const objects = settingsSelect(binding.section, "图元", "LayerObjects", (settings.data.objects || []).filter(item => !entry.ObjectIDs?.includes(item.id)).map(item => [item.id, `${item.id} · ${item.text || "图元"}`]), null);
|
||
objects.multiple = true;
|
||
objects.size = Math.min(4, Math.max(2, objects.options.length));
|
||
for (const option of objects.options) option.selected = canvasEditor.items().some(item => item.index === settings.page && item.id === option.value && !item.scoped);
|
||
settingsButton("选取", () => beginSettingsPick("layer"), Boolean(state.composite), "PickLayer", binding.actions);
|
||
const move = settingsButton("移入", () => write("objects", 0, [...objects.selectedOptions].map(option => option.value)), !objects.selectedOptions.length, "MoveObjects", binding.actions);
|
||
objects.addEventListener("change", () => { move.disabled = !objects.selectedOptions.length; });
|
||
binding.section.append(binding.actions);
|
||
}
|
||
}
|
||
|
||
function renderTemplateSettings() {
|
||
const entries = settings.data.page.templates || [], bindings = settings.data.page.bindings || [];
|
||
settingsPages(settingsElements.Context, "Page", settings.page, value => { settings.page = Number(value); loadDocumentSettings(); });
|
||
if (!entries.length && !state.editing) { settingsElements.Status.textContent = "暂无模板"; return; }
|
||
if (settings.selected >= entries.length) settings.selected = -1;
|
||
if (!state.editing && settings.selected < 0 && entries.length) settings.selected = 0;
|
||
const options = entries.map((entry, index) => [index, `${entry.Name || entry.ID}${bindings.some(binding => referenceIDKey(binding.TemplateID) === referenceIDKey(entry.ID)) ? " · 已绑定" : ""}`]);
|
||
if (state.editing) options.unshift([-1, "新增"]);
|
||
settingsSelect(settingsElements.Context, "模板", "Entry", options, settings.selected, value => { settings.selected = Number(value); renderDocumentSettings(); });
|
||
const entry = entries[settings.selected];
|
||
const properties = settingsSection("属性"), content = properties.section;
|
||
settingsField(content, "名称", "Name", entry?.Name);
|
||
settingsSelect(content, "层次", "TemplateOrder", [["Background", "背景"], ["Foreground", "前景"]], entry?.ZOrder || "Background");
|
||
if (entry) appendInfoLine(content, "文件", entry.BaseLoc);
|
||
content.querySelectorAll("input,select").forEach(field => { field.disabled = !state.editing; });
|
||
const bound = entry ? bindings.findIndex(binding => referenceIDKey(binding.TemplateID) === referenceIDKey(entry.ID)) : -1;
|
||
const references = (settings.data.page.templateReferences || []).filter(reference => referenceIDKey(reference.TemplateID) === referenceIDKey(entry?.ID));
|
||
if (references.length) {
|
||
const reference = settingsSelect(content, "引用", "TemplateReference", references.map((ref, index) => [index, `第${ref.PageIndex + 1}页`]), 0);
|
||
settingsButton("定位", () => locateSettingsPage(references[Number(reference.value)].PageIndex), false, "LocateTemplate", properties.actions);
|
||
}
|
||
content.append(properties.actions);
|
||
if (!state.editing) {
|
||
if (bound >= 0) appendInfoLine(content, "绑定层次", {Background: "背景", Foreground: "前景"}[bindings[bound].ZOrder] || "默认");
|
||
return;
|
||
}
|
||
const source = entry ? document.createElement("details") : content;
|
||
if (entry) {
|
||
source.className = "settings-section";
|
||
const title = document.createElement("summary");
|
||
title.textContent = "内容";
|
||
source.append(title);
|
||
settingsElements.Content.append(source);
|
||
}
|
||
settingsPages(source, "SourcePage", settings.page);
|
||
document.querySelector("#settingsSourcePage").closest("label").firstElementChild.textContent = "来源";
|
||
const write = async (operation, order = 0) => {
|
||
if (operation === "replace" && !window.confirm("替换模板内容?当前文档中引用此模板的页面将同步更新。")) return;
|
||
const selected = settings.selected;
|
||
if (operation === "add") settings.selected = entries.length;
|
||
if (!await commitSettings("ofdgoChangeTemplate", operation, ["add", "replace"].includes(operation) ? Number(settingsValue("SourcePage")) : settings.page, entry?.ID || "", settingsValue("Name"), ["bind", "order"].includes(operation) ? settingsValue("BindingOrder") : settingsValue("TemplateOrder"), order)) settings.selected = selected;
|
||
};
|
||
settingsButton(entry ? "修改" : "添加", () => write(entry ? "update" : "add"), false, "Save", properties.actions);
|
||
if (!entry) content.append(properties.actions);
|
||
if (entry) settingsButton("替换", () => write("replace"), false, "Replace", source);
|
||
settingsButton("删除", () => write("delete"), !entry || references.length > 0, "Delete", properties.actions, references.length ? "模板仍被页面引用" : "未选择模板");
|
||
if (entry) {
|
||
const binding = settingsSection("绑定");
|
||
settingsSelect(binding.section, "层次", "BindingOrder", [["", "默认"], ["Background", "背景"], ["Foreground", "前景"]], bound < 0 ? "" : bindings[bound].ZOrder || "");
|
||
settingsButton(bound < 0 ? "绑定" : "应用", () => write(bound < 0 ? "bind" : "order"), false, "Bind", binding.actions);
|
||
settingsButton("解绑", () => write("unbind"), bound < 0, "Unbind", binding.actions, "当前页未绑定此模板");
|
||
settingsButton("上移", () => write("move", bound - 1), bound <= 0, "Up", binding.actions);
|
||
settingsButton("下移", () => write("move", bound + 1), bound < 0 || bound >= bindings.length - 1, "Down", binding.actions);
|
||
binding.section.append(binding.actions);
|
||
}
|
||
}
|
||
|
||
function renderBookmarkSettings() {
|
||
const entries = settings.data.bookmarks || [];
|
||
if (!entries.length && !state.editing) { settingsElements.Status.textContent = "暂无书签"; return; }
|
||
if (settings.selected >= entries.length) settings.selected = -1;
|
||
if (!state.editing && settings.selected < 0 && entries.length) settings.selected = 0;
|
||
const options = entries.map((entry, index) => [index, entry.Name]);
|
||
if (state.editing) options.unshift([-1, "新增"]);
|
||
settingsSelect(settingsElements.Context, "书签", "Entry", options, settings.selected, value => { settings.selected = Number(value); renderDocumentSettings(); });
|
||
const entry = entries[settings.selected];
|
||
settingsField(settingsElements.Content, "名称", "Name", entry?.Name).required = true;
|
||
renderDestinationFields(settingsElements.Content, entry?.Dest);
|
||
settingsElements.Content.querySelectorAll("input,select").forEach(field => { field.disabled = !state.editing; });
|
||
updateDestinationFields();
|
||
settingsButton("定位", () => {
|
||
settingsElements.Panel.close();
|
||
const d = entry.Dest;
|
||
navigateDestination({type: d.Type, pageID: d.PageID, left: d.Left, top: d.Top, right: d.Right, bottom: d.Bottom, zoom: d.Zoom, omitLeft: d.OmitLeft, omitTop: d.OmitTop, omitZoom: d.OmitZoom}, false);
|
||
}, !entry, "Locate");
|
||
if (!state.editing) return;
|
||
settingsButton(entry ? "修改" : "添加", async () => {
|
||
if (!settingsElements.Form.reportValidity()) return;
|
||
const bookmark = {Name: settingsValue("Name"), Dest: destinationFromSettings()};
|
||
const selected = settings.selected;
|
||
if (!entry) settings.selected = entries.length;
|
||
if (!await commitSettings("ofdgoChangeBookmark", entry ? "update" : "add", entry?.Name || "", JSON.stringify(bookmark))) settings.selected = selected;
|
||
}, false, "Save");
|
||
settingsButton("删除", () => commitSettings("ofdgoChangeBookmark", "delete", entry.Name, "{}"), !entry, "Delete");
|
||
}
|
||
|
||
function renderExtensionSettings() {
|
||
const entries = settings.data.extensions || [];
|
||
if (!entries.length) settingsElements.Status.textContent = "暂无扩展";
|
||
for (const entry of entries) {
|
||
const section = document.createElement("div");
|
||
section.className = "settings-detail";
|
||
for (const [key, label] of [["AppName", "应用"], ["Company", "单位"], ["AppVersion", "版本"], ["Date", "日期"], ["RefID", "引用"]]) appendInfoLine(section, label, entry[key]);
|
||
for (const property of entry.Property || []) appendInfoLine(section, property.Name, property.Value);
|
||
for (const value of entry.ExtendData || []) appendInfoLine(section, "文件", value);
|
||
for (const value of entry.Data || []) {
|
||
for (const attr of value.Attr || []) appendInfoLine(section, attr.Name.Local, attr.Value);
|
||
const text = document.createElement("pre");
|
||
text.textContent = value.Content;
|
||
section.append(text);
|
||
}
|
||
settingsElements.Content.append(section);
|
||
}
|
||
}
|
||
|
||
function settingsPickControl(field, kind) {
|
||
const wrapper = document.createElement("span");
|
||
wrapper.className = "form-unit settings-pick";
|
||
field.replaceWith(wrapper);
|
||
wrapper.append(field);
|
||
settingsButton("选取", () => beginSettingsPick(kind), Boolean(state.composite), "PickObject", wrapper);
|
||
}
|
||
|
||
async function beginSettingsPick(kind) {
|
||
if (!state.editing || settings.busy || state.composite) return;
|
||
rememberSettingsDraft();
|
||
const page = kind === "style" ? Number(settingsValue("TargetPage")) : settings.page, sequence = state.openSeq;
|
||
if (!await renderPage(page, {fit: false}) || sequence !== state.openSeq || !settingsElements.Panel.open) return;
|
||
suspendSettings(kind);
|
||
}
|
||
|
||
function suspendSettings(kind) {
|
||
settings.suspended = true;
|
||
const parents = [resourceElements.Panel, el.objectStylePanel].filter(panel => panel.open);
|
||
resourceManager.pick = {sequence: state.openSeq, status: el.statusText.textContent, settings: kind, parents};
|
||
settingsElements.Panel.close();
|
||
for (const panel of parents) panel.close();
|
||
canvasEditor.enabled = false;
|
||
el.viewerPanel.classList.add("resource-picking");
|
||
setBusy(false);
|
||
setStatus(kind === "locate" ? "定位" : "选取图元");
|
||
el.viewerPanel.focus({preventScroll: true});
|
||
}
|
||
|
||
async function pickSettingsObject(event, picking) {
|
||
if (picking.settings === "locate") { finishResourcePick(); return; }
|
||
const available = canvasEditor.itemsAt(event).filter(item => !item.scoped && !item.draft && item.type !== "Annotation"
|
||
&& (picking.settings !== "invoice" || item.type === "TextObject")
|
||
&& (picking.settings !== "style" || ["TextObject", "PathObject", "CompositeObject", "CompositeGraphicUnit"].includes(item.type))
|
||
&& (picking.settings !== "layer" || item.index === settings.page && !settings.data.page.layers[settings.selected]?.ObjectIDs?.includes(item.id)));
|
||
if (!available.length) return;
|
||
const choose = async item => {
|
||
if (resourceManager.pick !== picking || picking.sequence !== state.openSeq) return;
|
||
finishResourcePick();
|
||
if (picking.settings === "invoice") {
|
||
const draft = invoiceCurrent();
|
||
if (draft) { draft.page = item.index; draft.object = item.id; renderDocumentSettings(); }
|
||
} else if (picking.settings === "action") {
|
||
settings.page = item.index;
|
||
settings.object = item.id;
|
||
settings.selected = -1;
|
||
await loadDocumentSettings();
|
||
} else if (picking.settings === "style") {
|
||
settings.page = item.index;
|
||
settings.object = item.id;
|
||
document.querySelector("#settingsTargetPage").value = String(item.index);
|
||
await loadSettingsObjects();
|
||
document.querySelector("#settingsTargetObject").value = item.id;
|
||
document.querySelector("#settingsTargetObject").dispatchEvent(new Event("change"));
|
||
} else {
|
||
const field = document.querySelector("#settingsLayerObjects");
|
||
for (const option of field.options) if (option.value === item.id) option.selected = true;
|
||
field.dispatchEvent(new Event("change"));
|
||
}
|
||
};
|
||
if (available.length === 1) await choose(available[0]);
|
||
else resourceChoices(available, item => `${item.id} · ${(item.text || "图元").slice(0, 50)}`, choose);
|
||
}
|
||
|
||
async function locateSettingsObjects(ids) {
|
||
if (settings.busy) return;
|
||
const sequence = settings.sequence, data = await loadPageData(settings.page);
|
||
if (sequence !== settings.sequence || !settingsElements.Panel.open) return;
|
||
const objects = (data.objects || []).filter(item => ids.includes(item.id) && !item.scoped);
|
||
if (!objects.length) { settingsElements.Status.textContent = "暂无可见位置"; return; }
|
||
const box = selectionBounds(objects);
|
||
rememberSettingsDraft();
|
||
suspendSettings("locate");
|
||
await focusPageRegion({page: settings.page + 1, ...box}, "图层", "center");
|
||
}
|
||
|
||
async function locateSettingsPage(page) {
|
||
rememberSettingsDraft();
|
||
suspendSettings("locate");
|
||
const area = state.doc.pages[page];
|
||
await focusPageRegion({page: page + 1, x: 0, y: 0, width: area.width, height: area.height}, "模板", "center");
|
||
}
|
||
|
||
async function openResourceManagement() {
|
||
if (resourceManager.busy || !state.doc || !state.ready || document.body.hasAttribute("data-busy")) return;
|
||
if (resourceManager.pick) { finishResourcePick(); return; }
|
||
resourceElements.Panel.showModal();
|
||
resourceElements.Page.replaceChildren(...state.doc.pages.map((page, index) => new Option(`第${index + 1}页`, String(index))));
|
||
resourceElements.Page.value = String(state.pageIndex);
|
||
await resourceTask(loadManagedResources);
|
||
}
|
||
|
||
async function resourceTask(action, delay = 0) {
|
||
if (resourceManager.busy) return;
|
||
resourceManager.busy = true;
|
||
resourceElements.List.inert = !delay;
|
||
updateResourceControls();
|
||
const showBusy = () => {
|
||
resourceElements.Status.textContent = "处理中";
|
||
};
|
||
const timer = delay ? setTimeout(showBusy, delay) : null;
|
||
if (!delay) showBusy();
|
||
resourceElements.Panel.setAttribute("data-busy", "true");
|
||
try {
|
||
await action();
|
||
if (resourceElements.Status.textContent === "处理中") resourceElements.Status.textContent = "";
|
||
} catch (err) {
|
||
resourceElements.Status.textContent = resourceError(err.message);
|
||
} finally {
|
||
if (timer !== null) clearTimeout(timer);
|
||
resourceManager.busy = false;
|
||
resourceElements.List.inert = false;
|
||
resourceElements.Panel.removeAttribute("data-busy");
|
||
updateResourceControls();
|
||
}
|
||
}
|
||
|
||
function resourceError(message) {
|
||
if (/still referenced/.test(message)) return "资源仍被引用";
|
||
if (/unsupported content/.test(message)) return "引用无法确认";
|
||
if (/empty media|nonempty Audio/.test(message)) return "文件内容为空";
|
||
if (/image mask .*dimensions differ from source/.test(message)) return "蒙版尺寸不符";
|
||
if (/image mask contains non-binary pixel/.test(message)) return "蒙版像素不符";
|
||
return message;
|
||
}
|
||
|
||
async function loadManagedResources() {
|
||
const sequence = state.openSeq;
|
||
let items;
|
||
try { items = await callWASM("ofdgoResources"); }
|
||
finally { resourceManager.requestID = 0; }
|
||
if (sequence !== state.openSeq || !resourceElements.Panel.open) return;
|
||
resourceManager.items = items || [];
|
||
await renderResourceList();
|
||
}
|
||
|
||
async function renderResourceList() {
|
||
const items = resourceManager.items.filter(item => item.kind === resourceElements.Kind.value);
|
||
const selected = items.find(item => item.id === resourceManager.selected?.id && item.kind === resourceManager.selected?.kind) || items[0];
|
||
resourceElements.List.replaceChildren();
|
||
resourceElements.Empty.hidden = items.length !== 0;
|
||
const sequence = resourceManager.sequence;
|
||
let fragment = document.createDocumentFragment();
|
||
for (const item of items) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "resource-option";
|
||
button.setAttribute("data-selected", String(item === selected));
|
||
button.tabIndex = item === selected ? 0 : -1;
|
||
button.title = item.file || item.name;
|
||
for (const value of [item.name, item.missing ? "缺失" : item.file ? formatBytes(item.size) : item.kind === "Font" ? "外部" : "-", item.complete ? String(item.referenceCount || 0) : "未知"]) {
|
||
const span = document.createElement("span");
|
||
span.textContent = value;
|
||
button.append(span);
|
||
}
|
||
button.addEventListener("click", () => chooseManagedResource(item));
|
||
button.addEventListener("keydown", event => {
|
||
const index = items.indexOf(item);
|
||
const target = { ArrowDown: Math.min(items.length - 1, index + 1), ArrowUp: Math.max(0, index - 1), Home: 0, End: items.length - 1 }[event.key];
|
||
if (target == null) return;
|
||
event.preventDefault();
|
||
chooseManagedResource(items[target]).then(() => {
|
||
if (resourceElements.Panel.open && resourceManager.selected === items[target]) resourceElements.List.children[target]?.focus();
|
||
});
|
||
});
|
||
fragment.append(button);
|
||
if (fragment.childElementCount === 100) {
|
||
resourceElements.List.append(fragment);
|
||
await new Promise(resolve => setTimeout(resolve, 0));
|
||
if (sequence !== resourceManager.sequence || !resourceElements.Panel.open) return;
|
||
fragment = document.createDocumentFragment();
|
||
}
|
||
}
|
||
resourceElements.List.append(fragment);
|
||
await selectManagedResource(selected);
|
||
}
|
||
|
||
async function chooseManagedResource(item) {
|
||
if (item === resourceManager.selected) return;
|
||
await resourceTask(() => {
|
||
resourceElements.Status.textContent = "";
|
||
return selectManagedResource(item);
|
||
}, 150);
|
||
}
|
||
|
||
async function selectManagedResource(item) {
|
||
const same = item?.id === resourceManager.selected?.id && item?.kind === resourceManager.selected?.kind;
|
||
const reference = same ? resourceElements.Reference.value : "0";
|
||
releaseResourcePreview();
|
||
resourceManager.selected = item || null;
|
||
const items = resourceManager.items.filter(entry => entry.kind === resourceElements.Kind.value);
|
||
const previous = resourceElements.List.querySelector('[data-selected="true"]');
|
||
if (previous) { previous.setAttribute("data-selected", "false"); previous.tabIndex = -1; }
|
||
const current = resourceElements.List.children[items.indexOf(item)];
|
||
if (current) { current.setAttribute("data-selected", "true"); current.tabIndex = 0; }
|
||
await loadManagedReferences(same ? Number(reference) : 0);
|
||
}
|
||
|
||
async function loadManagedReferences(index = 0) {
|
||
const item = resourceManager.selected, sequence = state.openSeq, panelSequence = resourceManager.sequence;
|
||
const count = item?.referenceCount || 0;
|
||
index = Math.max(0, Math.min(index, count - 1));
|
||
const offset = Math.floor(index / 100) * 100;
|
||
const references = count ? await callWASM("ofdgoResourceReferences", item.kind, item.id, offset, 100) || [] : [];
|
||
if (sequence !== state.openSeq || panelSequence !== resourceManager.sequence || !resourceElements.Panel.open || item !== resourceManager.selected) return;
|
||
resourceManager.references = references;
|
||
resourceManager.referenceOffset = offset;
|
||
const roles = {Font: "字体", ResourceID: "资源", Substitution: "替换", ImageMask: "蒙版", Thumbnail: "缩略", AttachID: "附件"};
|
||
resourceElements.Reference.replaceChildren(...references.map((ref, index) => new Option(`${ref.page >= 0 ? `第${ref.page + 1}页` : ref.file}${ref.object ? ` · ${ref.object}` : ""} · ${roles[ref.role] || ref.role}`, String(offset + index))));
|
||
if (!references.length) resourceElements.Reference.add(new Option(!item || item.complete ? "暂无引用" : "引用未知", ""));
|
||
else resourceElements.Reference.value = String(index);
|
||
resourceElements.Reference.title = count ? `${offset + 1}-${offset + references.length} / ${count}` : "";
|
||
const groups = Math.ceil(count / 100);
|
||
if (resourceElements.ReferenceGroup.options.length !== groups) {
|
||
const fragment = document.createDocumentFragment();
|
||
for (let group = 0; group < groups; group++) fragment.append(new Option(`${group + 1} / ${groups}`, String(group)));
|
||
resourceElements.ReferenceGroup.replaceChildren(fragment);
|
||
}
|
||
resourceElements.ReferenceGroup.value = String(offset / 100);
|
||
updateResourceControls();
|
||
await syncResourceReference();
|
||
}
|
||
|
||
function updateResourceControls() {
|
||
const item = resourceManager.selected, editing = state.editing;
|
||
const disabled = resourceManager.busy || !state.doc || document.body.hasAttribute("data-busy");
|
||
resourceElements.Kind.disabled = resourceElements.Page.disabled = resourceElements.Object.disabled = disabled;
|
||
const referenceCount = item?.referenceCount || 0;
|
||
resourceElements.ReferenceGroup.hidden = referenceCount <= 100;
|
||
resourceElements.ReferenceGroup.disabled = disabled || referenceCount <= 100;
|
||
const actionable = item && ["Audio", "Video", "Attachment"].includes(item.kind);
|
||
resourceElements.Add.hidden = resourceElements.Replace.hidden = resourceElements.Delete.hidden = !editing;
|
||
resourceElements.Rename.hidden = !editing || item?.kind !== "Attachment";
|
||
resourceElements.Rename.disabled = disabled || item?.kind !== "Attachment";
|
||
resourceElements.Add.disabled = disabled;
|
||
resourceElements.Reference.disabled = disabled || !referenceCount;
|
||
const style = item && ["ColorSpace", "DrawParam"].includes(item.kind);
|
||
resourceElements.Preview.disabled = disabled || (!style && (!item?.file || item.missing || item.kind === "Attachment"));
|
||
resourceElements.Preview.textContent = style ? "查看" : "预览";
|
||
resourceElements.Export.disabled = disabled || !item?.file || item.missing;
|
||
resourceElements.Replace.disabled = disabled || !item || item.kind !== "Attachment" && !item.complete;
|
||
resourceElements.Replace.textContent = style ? "修改" : item?.kind === "Font" ? item.file ? "外置" : "嵌入" : "替换";
|
||
resourceElements.Delete.disabled = disabled || !item || item.kind !== "Attachment" && (!item.complete || Boolean(referenceCount));
|
||
resourceElements.Delete.title = item?.kind !== "Attachment" && referenceCount ? "资源仍被引用" : "";
|
||
resourceElements.Locate.disabled = disabled || !referenceCount;
|
||
resourceElements.Binding.hidden = !editing || !actionable;
|
||
const actions = resourceElements.Binding.hidden ? resourceElements.Actions : resourceElements.BindingActions;
|
||
if (resourceElements.Close.parentElement !== actions) actions.append(resourceElements.Close);
|
||
const target = resourceManager.targets.find(target => target.id === resourceElements.Object.value);
|
||
resourceElements.Pick.disabled = disabled || !editing || !actionable || Boolean(state.composite);
|
||
resourceElements.Bind.disabled = disabled || !actionable || !target || target.bound;
|
||
resourceElements.Unbind.disabled = disabled || !actionable || !target?.bound;
|
||
}
|
||
|
||
async function syncResourceReference() {
|
||
const reference = resourceManager.references[Number(resourceElements.Reference.value) - resourceManager.referenceOffset];
|
||
if (reference?.page >= 0) resourceElements.Page.value = String(reference.page);
|
||
await loadResourceTargets(reference?.page >= 0 ? reference.object : "");
|
||
updateResourceControls();
|
||
}
|
||
|
||
async function loadResourceTargets(preferred) {
|
||
const resource = resourceManager.selected;
|
||
if (preferred === undefined) {
|
||
const reference = resourceManager.references[Number(resourceElements.Reference.value) - resourceManager.referenceOffset];
|
||
preferred = resourceElements.Object.value || (reference?.page === Number(resourceElements.Page.value) ? reference.object : "");
|
||
}
|
||
resourceManager.targets = [];
|
||
resourceElements.Object.replaceChildren(new Option("选择", ""));
|
||
if (!state.editing || !resource || !["Audio", "Video", "Attachment"].includes(resource.kind)) return;
|
||
const page = Number(resourceElements.Page.value), sequence = state.openSeq;
|
||
const items = await callWASM("ofdgoResourceTargets", page, resource.kind, resource.id);
|
||
if (!resourceElements.Panel.open || sequence !== state.openSeq || resource !== resourceManager.selected || page !== Number(resourceElements.Page.value)) return;
|
||
resourceManager.targets = items || [];
|
||
const types = {TextObject: "文字", PathObject: "图形", ImageObject: "图片", CompositeObject: "组合", CompositeGraphicUnit: "组合"};
|
||
for (const item of resourceManager.targets) resourceElements.Object.add(new Option(`${item.id} · ${(item.text || types[item.type] || "图元").slice(0, 50)}${item.bound ? " · 已绑定" : ""}`, item.id));
|
||
const selected = canvasEditor.selected;
|
||
if (!preferred && selected?.index === page && !selected.scoped) preferred = selected.id;
|
||
resourceElements.Object.value = resourceManager.targets.some(item => item.id === preferred) ? preferred : "";
|
||
}
|
||
|
||
async function locateManagedResource() {
|
||
const item = resourceManager.selected, sequence = state.openSeq;
|
||
if (!item?.referenceCount) return;
|
||
const locations = await callWASM("ofdgoResourceLocations", item.kind, item.id, Number(resourceElements.Reference.value));
|
||
if (sequence !== state.openSeq || !resourceElements.Panel.open || item !== resourceManager.selected) return;
|
||
if (!locations?.length) { resourceElements.Status.textContent = "暂无可见位置"; return; }
|
||
const focus = async location => {
|
||
if (sequence !== state.openSeq) return;
|
||
resourceElements.Panel.close();
|
||
const box = location.bounds;
|
||
await focusPageRegion({page: location.page, x: box.X, y: box.Y, width: box.W, height: box.H}, "资源", "center");
|
||
if (sequence !== state.openSeq || !state.editing) return;
|
||
const target = canvasEditor.pageItems(location.page - 1).find(item => item.id === location.object && !item.scoped);
|
||
if (target) canvasEditor.select(target);
|
||
};
|
||
if (locations.length === 1) await focus(locations[0]);
|
||
else resourceChoices(locations, location => `第${location.page}页 · ${location.object}`, location => resourceTask(() => focus(location)));
|
||
}
|
||
|
||
function resourceChoices(items, label, choose) {
|
||
el.objectPickerList.replaceChildren();
|
||
for (const item of items) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "button";
|
||
button.textContent = label(item);
|
||
button.addEventListener("click", () => { el.objectPicker.close(); choose(item); });
|
||
el.objectPickerList.append(button);
|
||
}
|
||
el.objectPicker.showModal();
|
||
}
|
||
|
||
async function beginResourcePick() {
|
||
if (!state.editing || state.composite || !["Audio", "Video", "Attachment"].includes(resourceManager.selected?.kind)) return;
|
||
const sequence = state.openSeq;
|
||
if (!await renderPage(Number(resourceElements.Page.value), {fit: false})) return;
|
||
if (sequence !== state.openSeq || !resourceElements.Panel.open) return;
|
||
resourceManager.pick = {sequence, status: el.statusText.textContent};
|
||
resourceElements.Panel.close();
|
||
canvasEditor.enabled = false;
|
||
el.viewerPanel.classList.add("resource-picking");
|
||
setBusy(false);
|
||
setStatus("选取图元");
|
||
el.viewerPanel.focus({preventScroll: true});
|
||
}
|
||
|
||
function finishResourcePick(reopen = true) {
|
||
const picking = resourceManager.pick;
|
||
if (!picking) return;
|
||
resourceManager.pick = null;
|
||
el.viewerPanel.classList.remove("resource-picking");
|
||
if (picking.settings && (!reopen || picking.sequence !== state.openSeq)) {
|
||
settings.suspended = false;
|
||
settings.drafts.clear();
|
||
settings.view = "";
|
||
settings.sequence++;
|
||
}
|
||
if (picking.sequence === state.openSeq) {
|
||
updateEditorTools();
|
||
setBusy(document.body.hasAttribute("data-busy"));
|
||
setStatus(picking.status);
|
||
if (picking.settings) {
|
||
if (reopen) {
|
||
for (const panel of picking.parents) if (!panel.open) panel.showModal();
|
||
settingsElements.Panel.showModal();
|
||
(document.querySelector("#settingsPickObject") || document.querySelector("#settingsPickLayer"))?.focus();
|
||
}
|
||
settings.suspended = false;
|
||
} else if (reopen && !resourceElements.Panel.open) {
|
||
resourceElements.Panel.showModal();
|
||
resourceElements.Pick.focus();
|
||
}
|
||
}
|
||
}
|
||
|
||
function pickResourceObject(event) {
|
||
const picking = resourceManager.pick;
|
||
if (!picking || event.button !== 0) return;
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
if (resourceManager.busy || document.body.hasAttribute("data-busy")) return;
|
||
if (picking.settings) { pickSettingsObject(event, picking).catch(error => { finishResourcePick(); settingsElements.Status.textContent = error.message; }); return; }
|
||
const items = canvasEditor.itemsAt(event).filter(item => !item.scoped && !item.draft);
|
||
if (!items.length) { setStatus("未选中图元"); return; }
|
||
resourceTask(async () => {
|
||
const resource = resourceManager.selected, page = items[0].index;
|
||
const targets = await callWASM("ofdgoResourceTargets", page, resource.kind, resource.id);
|
||
if (resourceManager.pick !== picking || picking.sequence !== state.openSeq) return;
|
||
const available = items.filter(item => targets?.some(target => target.id === item.id));
|
||
if (!available.length) { setStatus("图元不可绑定"); return; }
|
||
const choose = async item => {
|
||
if (resourceManager.pick !== picking || picking.sequence !== state.openSeq) return;
|
||
finishResourcePick();
|
||
resourceElements.Page.value = String(item.index);
|
||
await loadResourceTargets(item.id);
|
||
resourceElements.Status.textContent = "";
|
||
};
|
||
if (available.length === 1) await choose(available[0]);
|
||
else resourceChoices(available, item => `${item.id} · ${(item.text || "图元").slice(0, 50)}`, item => resourceTask(() => choose(item)));
|
||
});
|
||
}
|
||
|
||
function chooseResourceFile(action) {
|
||
const item = action === "add" ? {kind: resourceElements.Kind.value, id: ""} : resourceManager.selected;
|
||
if (!item) return;
|
||
resourceManager.fileAction = {action, item, sequence: state.openSeq};
|
||
resourceElements.File.value = "";
|
||
resourceElements.File.accept = {Audio: "audio/*", Video: "video/*", Image: ".png,.jpg,.jpeg,.bmp,.tif,.tiff,.jb2,.jbig2"}[item.kind] || "";
|
||
resourceElements.File.click();
|
||
}
|
||
|
||
async function changeManagedResource(action, item, file) {
|
||
if (!item || !state.editing) return;
|
||
releaseResourcePreview();
|
||
const format = file?.name.includes(".") ? file.name.split(".").at(-1).toUpperCase() : "";
|
||
const changed = item.kind === "Attachment" && action !== "bind" && action !== "unbind"
|
||
? await changeDocument("ofdgoChangeAttachment", null, action, item.id, file ? [file.name] : [], file ? [file] : [])
|
||
: await changeDocument("ofdgoChangeResource", null, action, item.kind, item.id, format, file || null, Number(resourceElements.Page.value), resourceElements.Object.value);
|
||
if (changed) {
|
||
await loadManagedResources();
|
||
if (action === "bind" || action === "unbind") resourceElements.Status.textContent = action === "bind" ? "已绑定" : "已解绑";
|
||
}
|
||
}
|
||
|
||
function releaseResourcePreview() {
|
||
resourceManager.sequence++;
|
||
for (const player of resourceElements.Viewer.querySelectorAll("audio, video")) { player.pause(); player.removeAttribute("src"); player.load(); }
|
||
resourceElements.Viewer.replaceChildren();
|
||
resourceElements.Viewer.hidden = true;
|
||
if (resourceManager.url) URL.revokeObjectURL(resourceManager.url);
|
||
resourceManager.url = null;
|
||
}
|
||
|
||
async function previewManagedResource() {
|
||
if (["ColorSpace", "DrawParam"].includes(resourceManager.selected?.kind)) {
|
||
await openResourceSettings(resourceManager.selected.kind, resourceManager.selected.id);
|
||
return;
|
||
}
|
||
const item = resourceManager.selected;
|
||
if (!item?.file) return;
|
||
if (state.doc.encryption?.encrypted && !window.confirm("资源将按明文读取,是否继续?")) return;
|
||
releaseResourcePreview();
|
||
const sequence = resourceManager.sequence, openSeq = state.openSeq;
|
||
let result;
|
||
try {
|
||
result = item.kind === "Font"
|
||
? await callWASM("ofdgoFontPreview", item.id)
|
||
: await callWASM("ofdgoExportResource", JSON.stringify([item.kind, item.id]), null);
|
||
} catch (error) {
|
||
if (sequence !== resourceManager.sequence || openSeq !== state.openSeq || !resourceElements.Panel.open) return;
|
||
if (item.kind === "Font") throw new Error("字体无法预览", {cause: error});
|
||
throw error;
|
||
} finally {
|
||
state.exportRequestID = 0;
|
||
el.cancelExportButton.hidden = true;
|
||
}
|
||
if (sequence !== resourceManager.sequence || openSeq !== state.openSeq || !resourceElements.Panel.open) return;
|
||
const format = item.kind === "Font" ? "svg" : item.format.toLowerCase();
|
||
const mime = {svg: "image/svg+xml", mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4", mp4: "video/mp4", webm: "video/webm", png: "image/png", jpeg: "image/jpeg", jpg: "image/jpeg", bmp: "image/bmp"}[format] || "application/octet-stream";
|
||
resourceManager.url = URL.createObjectURL(new Blob([result.bytes || result.blob], {type: mime}));
|
||
const image = item.kind === "Image" || item.kind === "Font";
|
||
const element = document.createElement(image ? "img" : item.kind === "Audio" ? "audio" : "video");
|
||
element.src = resourceManager.url;
|
||
if (image) element.alt = item.name;
|
||
else { element.controls = true; element.preload = "metadata"; }
|
||
element.addEventListener("error", () => {
|
||
if (sequence === resourceManager.sequence && openSeq === state.openSeq) resourceElements.Status.textContent = "浏览器无法预览此格式";
|
||
});
|
||
resourceElements.Viewer.append(element);
|
||
resourceElements.Viewer.hidden = false;
|
||
}
|
||
|
||
function resourceFileName(item) {
|
||
let name = item.kind === "Font" ? item.file.split("/").at(-1) : item.name;
|
||
if (!name.includes(".") && item.format) name += "." + item.format.toLowerCase();
|
||
return name;
|
||
}
|
||
|
||
function downloadAttachment(attachment) {
|
||
return downloadResource(attachment, "ofdgoExportAttachment", "附件");
|
||
}
|
||
|
||
el.attachmentCancel.addEventListener("click", () => el.attachmentDialog.close());
|
||
el.attachmentDialog.addEventListener("cancel", event => { if (el.attachmentForm.inert) event.preventDefault(); });
|
||
el.attachmentDialog.addEventListener("close", () => {
|
||
el.attachmentForm.reset();
|
||
delete el.attachmentDialog.attachmentEdit;
|
||
});
|
||
el.attachmentForm.addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
const edit = el.attachmentDialog.attachmentEdit;
|
||
if (!edit || edit.openSeq !== state.openSeq || !el.attachmentForm.reportValidity()) return;
|
||
const files = Array.from(el.attachmentFile.files);
|
||
const names = edit.action === "rename" ? [el.attachmentName.value] : files.map(file => file.name);
|
||
if (await changeDocument("ofdgoChangeAttachment", null, edit.action, edit.id, names, files)) {
|
||
el.attachmentDialog.close();
|
||
setStatus(edit.action === "rename" ? "附件已改名" : "附件已添加");
|
||
}
|
||
});
|
||
|
||
function openAttachmentDialog(action, attachment) {
|
||
if (!state.editing || !state.ready || state.exporting || document.body.hasAttribute("data-busy")) return;
|
||
const rename = action === "rename";
|
||
el.attachmentForm.reset();
|
||
el.attachmentDialog.attachmentEdit = {action, id: attachment?.id || "", openSeq: state.openSeq};
|
||
el.attachmentTitle.textContent = rename ? "附件改名" : "添加附件";
|
||
el.attachmentSubmit.textContent = rename ? "确定" : "添加";
|
||
el.attachmentFileRow.hidden = rename;
|
||
el.attachmentFile.required = !rename;
|
||
el.attachmentNameRow.hidden = !rename;
|
||
el.attachmentName.required = rename;
|
||
el.attachmentName.value = attachment?.name || "";
|
||
el.attachmentStatus.textContent = "";
|
||
el.attachmentDialog.showModal();
|
||
(rename ? el.attachmentName : el.attachmentFile).focus();
|
||
}
|
||
|
||
async function saveDocumentImage(resource) {
|
||
if (!resource || !state.doc || document.body.hasAttribute("data-busy")) return;
|
||
const seq = state.openSeq;
|
||
try {
|
||
const image = await callWASM("ofdgoImages", resource);
|
||
if (seq !== state.openSeq) return;
|
||
await downloadResource({id: image.id, fileName: image.name}, "ofdgoExportImages", "图片");
|
||
} catch (err) {
|
||
if (seq === state.openSeq) showError(err, false);
|
||
}
|
||
}
|
||
|
||
el.saveImageButton.addEventListener("click", () => saveDocumentImage(canvasEditor.selected?.imageResource));
|
||
|
||
async function downloadResource(attachment, api, label) {
|
||
if (!state.doc || document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
if (state.doc.encryption?.encrypted && !window.confirm(`${label}将按明文下载,是否继续?`)) return;
|
||
const openSeq = state.openSeq;
|
||
state.exporting = true;
|
||
updateControls();
|
||
setBusy(true, `正在读取${label}`, null, `正在读取${label}`);
|
||
try {
|
||
const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({ suggestedName: attachment.fileName }) : null;
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
const result = await callWASM(api, attachment.id, file);
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
if (result.blob) {
|
||
downloadBytes(result.blob, result.mime, attachment.fileName);
|
||
}
|
||
setStatus(`${label}已下载(${formatBytes(result.size)})`);
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
if (err.name === "AbortError") {
|
||
setStatus("下载已取消");
|
||
} else {
|
||
showError(err, false);
|
||
}
|
||
}
|
||
} finally {
|
||
state.exporting = false;
|
||
state.exportRequestID = 0;
|
||
el.cancelExportButton.hidden = true;
|
||
if (openSeq === state.openSeq) {
|
||
setBusy(false);
|
||
updateControls();
|
||
}
|
||
}
|
||
}
|
||
|
||
function encryptionMode() {
|
||
const info = state.doc?.encryption;
|
||
if (!info?.encrypted) return "none";
|
||
if (info.layers !== 1 || info.policyRequired) return "keep";
|
||
return info.method === "1.1.1" ? "password" : info.method === "1.1.2" ? "certificate" : "keep";
|
||
}
|
||
|
||
function openEncryptionPanel() {
|
||
if (!state.doc || document.body.hasAttribute("data-busy")) return;
|
||
clearEncryptionForm();
|
||
el.encryptionType.value = encryptionMode();
|
||
el.encryptionKeep.hidden = el.encryptionType.value !== "keep";
|
||
updateEncryptionType();
|
||
el.encryptionPanel.showModal();
|
||
}
|
||
|
||
function updateEncryptionType() {
|
||
const mode = el.encryptionType.value;
|
||
const existing = mode !== "none" && mode === encryptionMode() && !state.encryptionChanged;
|
||
const password = mode === "password" && !existing;
|
||
const certificates = mode === "certificate" && !existing;
|
||
el.encryptionCurrentRow.hidden = !existing;
|
||
el.encryptionCurrent.textContent = mode === "password" ? state.encryptionDirty ? "待保存" : "已加密" : (state.doc?.encryption?.users || []).join("、") || "已加密";
|
||
el.encryptionChange.hidden = mode === "keep";
|
||
el.encryptionUserRow.hidden = el.encryptionPasswordRow.hidden = el.encryptionConfirmRow.hidden = !password;
|
||
el.encryptionCertificatesRow.hidden = !certificates;
|
||
el.encryptionPassword.required = el.encryptionConfirm.required = password;
|
||
el.encryptionCertificates.required = certificates;
|
||
el.encryptionUser.value = mode === "password" && encryptionMode() === mode ? state.doc.encryption.users?.[0] || "" : "";
|
||
el.encryptionPassword.value = el.encryptionConfirm.value = "";
|
||
el.encryptionCertificates.value = "";
|
||
el.encryptionStatus.textContent = mode === "keep" && state.doc?.encryption?.policyRequired ? "请重新选择加密方式" : "";
|
||
el.encryptionSubmit.disabled = mode === "keep" && Boolean(state.doc?.encryption?.policyRequired);
|
||
el.encryptionSubmit.textContent = mode === encryptionMode() && !state.encryptionChanged ? "确定"
|
||
: mode === "none" ? "解密" : mode !== encryptionMode() ? "加密" : mode === "password" ? "改密" : "修改";
|
||
}
|
||
|
||
async function applyEncryption(event) {
|
||
event.preventDefault();
|
||
if (!state.doc || document.body.hasAttribute("data-busy") || el.encryptionSubmit.disabled) return;
|
||
const mode = el.encryptionType.value;
|
||
if (mode === encryptionMode() && !state.encryptionChanged) {
|
||
el.encryptionPanel.close();
|
||
return;
|
||
}
|
||
if (mode === "password" && !el.encryptionPassword.value) {
|
||
el.encryptionStatus.textContent = "请输入口令";
|
||
el.encryptionPassword.focus();
|
||
return;
|
||
}
|
||
if (mode === "password" && el.encryptionPassword.value !== el.encryptionConfirm.value) {
|
||
el.encryptionStatus.textContent = "口令不一致";
|
||
el.encryptionConfirm.focus();
|
||
return;
|
||
}
|
||
if (mode === "certificate" && !el.encryptionCertificates.files?.length) {
|
||
el.encryptionStatus.textContent = "请选择接收者证书";
|
||
return;
|
||
}
|
||
const openSeq = state.openSeq;
|
||
const status = mode === "none" ? "保存后将解密" : mode !== encryptionMode() ? "保存后将加密"
|
||
: mode === "password" ? "保存后将改密" : "保存后将生效";
|
||
const options = mode === "none" ? null : { password: new TextEncoder().encode(el.encryptionPassword.value), userName: el.encryptionUser.value.trim() };
|
||
el.encryptionForm.inert = true;
|
||
setBusy(true, "正在处理文档");
|
||
try {
|
||
if (mode === "certificate") options.recipients = await encryptionRecipients(Array.from(el.encryptionCertificates.files));
|
||
if (openSeq !== state.openSeq) return;
|
||
const doc = await callWASM("ofdgoSetEncryption", options);
|
||
if (openSeq !== state.openSeq) return;
|
||
if (!state.editorInfo) state.savedRevision = doc.revision;
|
||
state.encryptionDirty = true;
|
||
setEditorInfo(doc);
|
||
state.doc.encryption = doc.encryption;
|
||
state.ofdBytes = null;
|
||
el.encryptionPanel.close();
|
||
renderSecurity();
|
||
setStatus(status);
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) el.encryptionStatus.textContent = err.message;
|
||
} finally {
|
||
options?.password.fill(0);
|
||
if (openSeq === state.openSeq) {
|
||
el.encryptionPassword.value = el.encryptionConfirm.value = "";
|
||
el.encryptionForm.inert = false;
|
||
setBusy(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function encryptionRecipients(files) {
|
||
const names = new Set();
|
||
return Promise.all(files.map(async file => {
|
||
const base = file.name.replace(/\.[^.]+$/, "") || "用户";
|
||
let name = base, index = 2;
|
||
while (names.has(name)) name = `${base} ${index++}`;
|
||
names.add(name);
|
||
return {userName:name, certificate:new Uint8Array(await file.arrayBuffer())};
|
||
}));
|
||
}
|
||
|
||
function clearEncryptionForm() {
|
||
el.encryptionForm.inert = false;
|
||
el.encryptionPassword.value = "";
|
||
el.encryptionConfirm.value = "";
|
||
el.encryptionUser.value = "";
|
||
el.encryptionCertificates.value = "";
|
||
state.encryptionChanged = false;
|
||
}
|
||
|
||
function renderSecurity() {
|
||
const encryption = state.doc?.encryption;
|
||
el.securityPanel.hidden = !state.doc;
|
||
el.securityEncryption.textContent = state.encryptionDirty ? "待保存" : encryption?.encrypted ? "已加密" : "未加密";
|
||
const scheme = { "1.1.1": "口令", "1.1.2": "证书" }[encryption?.method];
|
||
el.securityScheme.textContent = encryption?.layers > 1 ? "多重" : scheme || encryption?.method || "";
|
||
el.securitySchemeRow.hidden = !encryption?.encrypted;
|
||
el.securityMethod.textContent = scheme ? "SM4-CBC" : "";
|
||
el.securityMethodRow.hidden = !encryption?.encrypted || !scheme;
|
||
el.encryptSaveButton.disabled = !state.doc || !state.ready || state.exporting || document.body.hasAttribute("data-busy");
|
||
el.verifyButton.disabled = el.encryptSaveButton.disabled;
|
||
el.signButton.disabled = el.encryptSaveButton.disabled;
|
||
}
|
||
|
||
function cancelSigning() {
|
||
if (el.signCancel.disabled) return;
|
||
state.signCanceled = true;
|
||
el.signStatus.textContent = "正在取消操作";
|
||
cancelExport();
|
||
}
|
||
|
||
function finishSealPosition(returnToForm = true) {
|
||
const preview = sealPreview;
|
||
if (!preview) return;
|
||
sealPreview = null;
|
||
if (preview.pointer != null && el.viewerPanel.hasPointerCapture(preview.pointer)) el.viewerPanel.releasePointerCapture(preview.pointer);
|
||
URL.revokeObjectURL(preview.url);
|
||
sealPosition.Layer.remove();
|
||
sealPosition.Image.removeAttribute("src");
|
||
el.viewerPanel.classList.remove("seal-picking");
|
||
canvasEditor.enabled = preview.enabled;
|
||
if (preview.sidebar && COMPACT_LAYOUT.matches) {
|
||
Object.assign(state, preview.sidebar);
|
||
updateSidebarState();
|
||
}
|
||
setBusy(document.body.hasAttribute("data-busy"));
|
||
if (returnToForm && preview.sequence === state.openSeq && !el.signPanel.open) el.signPanel.showModal();
|
||
if (preview.sequence === state.openSeq) setStatus(preview.status);
|
||
}
|
||
|
||
async function openSealPosition() {
|
||
const file = el.signSeal.files?.[0];
|
||
if (!file || !state.doc || document.body.hasAttribute("data-busy")) return;
|
||
const sequence = state.openSeq;
|
||
let preview;
|
||
setBusy(true);
|
||
try {
|
||
const range = el.signPages.value.trim(), mode = el.signPlacement.value;
|
||
const selected = await callWASM("ofdgoParsePageRange", range);
|
||
if (mode === "seam" && selected.length < 2) throw new Error("骑缝签章至少选择两页");
|
||
const result = await callWASM("ofdgoPreviewSignatureSeal", new Uint8Array(await file.arrayBuffer()), `1-${state.doc.pageCount}`);
|
||
if (sequence !== state.openSeq || !el.signPanel.open || file !== el.signSeal.files?.[0]) return;
|
||
sealPreview = {pages:result.pages, selected, range, mode, ratio:document.querySelector("#signKeepRatio").checked ? result.width / result.height : 0, sequence, enabled:canvasEditor.enabled, status:el.statusText.textContent, url:URL.createObjectURL(new Blob([result.svg], {type:"image/svg+xml"}))};
|
||
preview = sealPreview;
|
||
sealPosition.Image.src = sealPreview.url;
|
||
await sealPosition.Image.decode();
|
||
if (sealPreview !== preview) return;
|
||
if (sequence !== state.openSeq || !el.signPanel.open) { finishSealPosition(false); return; }
|
||
canvasEditor.enabled = false;
|
||
if (COMPACT_LAYOUT.matches) {
|
||
sealPreview.sidebar = {showPages:state.showPages, showMeta:state.showMeta};
|
||
state.showPages = state.showMeta = false;
|
||
updateSidebarState();
|
||
}
|
||
el.viewerPanel.classList.add("seal-picking");
|
||
el.signPanel.close();
|
||
el.signStatus.textContent = "";
|
||
setStatus("拖动选取签章区域,Esc 取消");
|
||
el.viewerPanel.focus({preventScroll:true});
|
||
} catch (error) {
|
||
if (sequence === state.openSeq && (!preview || sealPreview === preview)) {
|
||
finishSealPosition();
|
||
el.signStatus.textContent = error.message;
|
||
}
|
||
}
|
||
finally { if (sequence === state.openSeq) setBusy(false); }
|
||
}
|
||
|
||
function sealPointer(event) {
|
||
const preview = sealPreview;
|
||
const point = pagePoint(event.clientX, event.clientY, preview.surface.getBoundingClientRect(), preview.page, state.rotation);
|
||
return {x:preview.page.x + point.x, y:preview.page.y + point.y};
|
||
}
|
||
|
||
function beginSealDrag(event) {
|
||
const preview = sealPreview;
|
||
if (!preview || preview.confirming || event.button !== 0) return;
|
||
const surface = event.target.closest(".page-surface");
|
||
if (!surface) return;
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
const index = Number(surface.closest(".page-shell").dataset.pageIndex);
|
||
if ((preview.mode === "seam" || preview.selected.length > 1) && !preview.selected.includes(index)) {
|
||
setStatus("请在所选页码范围内定位");
|
||
return;
|
||
}
|
||
preview.page = preview.pages[index];
|
||
preview.surface = surface;
|
||
preview.start = sealPointer(event);
|
||
preview.pointer = event.pointerId;
|
||
preview.box = null;
|
||
sealPosition.Stamp.hidden = true;
|
||
surface.append(sealPosition.Layer);
|
||
el.viewerPanel.setPointerCapture(event.pointerId);
|
||
}
|
||
|
||
function moveSealDrag(event) {
|
||
const preview = sealPreview;
|
||
if (!preview?.start || preview.pointer !== event.pointerId) return;
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
const point = sealPointer(event), start = preview.start, page = preview.page;
|
||
const dx = point.x - start.x, dy = point.y - start.y;
|
||
let width = Math.abs(dx), height = Math.abs(dy);
|
||
const count = preview.mode === "seam" ? preview.selected.length : 1;
|
||
if (preview.ratio) {
|
||
width = Math.min(width, height * preview.ratio / count);
|
||
height = width * count / preview.ratio;
|
||
}
|
||
const x = dx < 0 ? start.x - width : start.x, y = dy < 0 ? start.y - height : start.y;
|
||
preview.box = {x:preview.mode === "seam" ? page.x + page.width - x - width : x, y, width:width * count, height};
|
||
sealPosition.Stamp.hidden = false;
|
||
Object.assign(sealPosition.Stamp.style, {left:`${(x-page.x)/page.width*100}%`, top:`${(y-page.y)/page.height*100}%`, width:`${width/page.width*100}%`, height:`${height/page.height*100}%`});
|
||
Object.assign(sealPosition.Image.style, {width:`${count*100}%`, left:`${preview.mode === "seam" ? -preview.selected.indexOf(page.index)*100 : 0}%`});
|
||
}
|
||
|
||
async function confirmSealPosition(event) {
|
||
const preview = sealPreview;
|
||
if (!preview?.start || preview.pointer !== event.pointerId || preview.confirming) return;
|
||
moveSealDrag(event);
|
||
preview.start = null;
|
||
el.viewerPanel.releasePointerCapture(event.pointerId);
|
||
const box = preview.box;
|
||
if (!box || box.width < 0.01 || box.height < 0.01) { sealPosition.Layer.remove(); return; }
|
||
preview.confirming = true;
|
||
const range = preview.mode === "normal" && preview.selected.length === 1 ? String(preview.page.index + 1) : preview.range;
|
||
try {
|
||
await callWASM("ofdgoSignaturePlacement", {...box, pages:range, placement:preview.mode});
|
||
if (sealPreview !== preview || preview.sequence !== state.openSeq) return;
|
||
el.signPages.value = range;
|
||
for (const [name, key] of [["X", "x"], ["Y", "y"], ["Width", "width"], ["Height", "height"]]) el[`sign${name}`].value = String(box[key]);
|
||
finishSealPosition();
|
||
} catch (error) {
|
||
if (sealPreview === preview) { finishSealPosition(); el.signStatus.textContent = error.message; }
|
||
}
|
||
}
|
||
|
||
function updateSignPlacement() {
|
||
const stamp = Boolean(el.signSeal.files?.length);
|
||
el.signPlacementFields.hidden = !stamp;
|
||
el.signPlacement.disabled = !stamp;
|
||
for (const input of [el.signPages, el.signX, el.signY, el.signWidth, el.signHeight]) {
|
||
input.disabled = !stamp;
|
||
input.required = stamp;
|
||
}
|
||
document.querySelector('label[for="signX"]').textContent = el.signPlacement.value === "seam" ? "右侧内缩" : "横向";
|
||
}
|
||
|
||
async function signDocument(event) {
|
||
event.preventDefault();
|
||
if (!state.doc || document.body.hasAttribute("data-busy")) return;
|
||
if (!el.signCertificate.files?.length || !el.signKey.files?.length || !el.signRoots.files?.length) {
|
||
el.signStatus.textContent = "请选择证书、私钥和信任根";
|
||
return;
|
||
}
|
||
if (el.signMode.value === "replace" && !window.confirm("替换现有签名,是否继续?")) return;
|
||
let openSeq = state.openSeq;
|
||
let key;
|
||
const keyPassword = new TextEncoder().encode(el.signKeyPassword.value);
|
||
el.signKeyPassword.value = "";
|
||
state.exporting = true;
|
||
state.signing = true;
|
||
state.signCanceled = false;
|
||
el.signCancel.disabled = false;
|
||
setBusy(true, "正在签署文档", null, "正在签署文档");
|
||
el.signForm.querySelectorAll("input, select, button[type=submit]").forEach(input => { input.disabled = true; });
|
||
try {
|
||
const name = `${baseFileName()}_签章.ofd`;
|
||
const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({suggestedName:name, types:[{description:"OFD",accept:{"application/ofd":[".ofd"]}}]}) : null;
|
||
if (openSeq !== state.openSeq || state.signCanceled) return;
|
||
if (canvasEditor.input || canvasEditor.crop || canvasEditor.nudge || canvasEditor.nudgeCommit) {
|
||
setBusy(false);
|
||
if (!await canvasEditor.commitNudge() || !await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return;
|
||
openSeq = state.openSeq;
|
||
setBusy(true, "正在签署文档", null, "正在签署文档");
|
||
}
|
||
key = (await securityFiles(el.signKey))[0];
|
||
const options = {
|
||
key, keyPassword, certificate:(await securityFiles(el.signCertificate))[0], roots:await securityFiles(el.signRoots),
|
||
intermediates:await securityFiles(el.signIntermediates), seal:(await securityFiles(el.signSeal))[0] || null,
|
||
pages:el.signPages.value.trim(), placement:el.signPlacement.value,
|
||
x:Number(el.signX.value), y:Number(el.signY.value), width:Number(el.signWidth.value), height:Number(el.signHeight.value),
|
||
mode:el.signMode.value, lock:el.signLock.checked,
|
||
compression: compressionOptions(el.outputCompression.value),
|
||
};
|
||
el.signKey.value = "";
|
||
if (openSeq !== state.openSeq || state.signCanceled || !el.signPanel.open) return;
|
||
const result = await callWASM("ofdgoSaveSigned", null, options, file);
|
||
if (openSeq !== state.openSeq) return;
|
||
if (result.blob) downloadBytes(result.blob, result.mime, name);
|
||
el.signPanel.close();
|
||
setStatus("签署已另存");
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) el.signStatus.textContent = err.name === "AbortError" ? "签署已取消" : err.message;
|
||
} finally {
|
||
key?.fill(0);
|
||
keyPassword.fill(0);
|
||
el.signKey.value = "";
|
||
el.signForm.querySelectorAll("input, select, button[type=submit]").forEach(input => { input.disabled = false; });
|
||
el.signCancel.disabled = false;
|
||
if (state.signCanceled && openSeq === state.openSeq) el.signStatus.textContent = "签署已取消";
|
||
state.signing = false;
|
||
updateSignPlacement();
|
||
state.exporting = false;
|
||
state.exportRequestID = 0;
|
||
el.cancelExportButton.hidden = true;
|
||
if (openSeq === state.openSeq) { setBusy(false); updateControls(); }
|
||
}
|
||
}
|
||
|
||
async function securityFiles(input) {
|
||
return Promise.all(Array.from(input.files || [], async file => new Uint8Array(await file.arrayBuffer())));
|
||
}
|
||
|
||
async function verifyDocument(event) {
|
||
event.preventDefault();
|
||
if (!state.doc || document.body.hasAttribute("data-busy")) return;
|
||
const openSeq = state.openSeq;
|
||
setBusy(true, "正在验证签名", null, "正在验证签名");
|
||
el.verifyForm.inert = true;
|
||
try {
|
||
const options = {
|
||
roots: await securityFiles(el.verifyRoots), certificates: await securityFiles(el.verifyCerts),
|
||
timestampRoots: await securityFiles(el.verifyTimeRoots), tokens: await securityFiles(el.verifyTokens),
|
||
crls: await securityFiles(el.verifyCRLs), ocsp: await securityFiles(el.verifyOCSP),
|
||
requireTimestamp: el.verifyRequireTime.checked, requireRevocation: el.verifyRequireRevocation.checked,
|
||
};
|
||
if (openSeq !== state.openSeq || !el.verifyPanel.open) return;
|
||
const info = await callWASM("ofdgoVerifySignatures", options);
|
||
if (openSeq !== state.openSeq || !el.verifyPanel.open) return;
|
||
Object.assign(state.doc, info);
|
||
renderMeta();
|
||
el.verifyPanel.close();
|
||
setStatus(info.signatureCount ? "验签完成" : "无签名");
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) el.verifyStatus.textContent = err.message;
|
||
} finally {
|
||
el.verifyForm.inert = false;
|
||
if (openSeq === state.openSeq) setBusy(false);
|
||
}
|
||
}
|
||
|
||
function openExportPanel() {
|
||
if (!state.doc || document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
el.exportForm.reset();
|
||
if (state.editing && state.pageSelection.size) {
|
||
el.exportSpecified.checked = true;
|
||
el.exportRange.value = selectedPageRange();
|
||
}
|
||
el.exportPanel.showModal();
|
||
updateExportRange();
|
||
}
|
||
|
||
async function updateExportRange() {
|
||
const specified = el.exportSpecified.checked;
|
||
const value = el.exportRange.value.trim();
|
||
const openSeq = state.openSeq;
|
||
state.exportPages = null;
|
||
el.exportRangeRow.hidden = !specified;
|
||
el.exportRange.removeAttribute("data-invalid");
|
||
el.exportSubmit.disabled = specified;
|
||
el.exportRangeStatus.textContent = specified && value ? "正在校验页码" : "";
|
||
if (!specified || !value) {
|
||
return;
|
||
}
|
||
const current = () => openSeq === state.openSeq && el.exportPanel.open
|
||
&& el.exportSpecified.checked && value === el.exportRange.value.trim();
|
||
try {
|
||
const indices = await callWASM("ofdgoParsePageRange", value);
|
||
if (current()) {
|
||
state.exportPages = indices;
|
||
el.exportSubmit.disabled = false;
|
||
el.exportRangeStatus.textContent = `共${indices.length}页`;
|
||
}
|
||
} catch {
|
||
if (current()) {
|
||
el.exportRange.setAttribute("data-invalid", "true");
|
||
el.exportRangeStatus.textContent = "页码无效";
|
||
}
|
||
}
|
||
}
|
||
|
||
function addBatchFiles(files) {
|
||
let rejected = false;
|
||
for (const file of files) {
|
||
if (!/\.(ofd|pdf)$/i.test(file.name)) { rejected = true; continue; }
|
||
batch.items.push({ file, format: "", pages: "", status: "pending", text: "待转", details: [] });
|
||
}
|
||
if (rejected) batchElements.Status.textContent = "仅支持PDF、OFD";
|
||
renderBatchList();
|
||
}
|
||
|
||
function renderBatchList() {
|
||
const fragment = document.createDocumentFragment();
|
||
for (const item of batch.items) {
|
||
const row = document.createElement("tr");
|
||
const name = row.insertCell();
|
||
const label = document.createElement("span");
|
||
label.className = "convert-name";
|
||
label.textContent = item.file.name;
|
||
label.title = item.file.name;
|
||
name.append(label);
|
||
let detailRow;
|
||
if (item.details.length) {
|
||
detailRow = document.createElement("tr");
|
||
detailRow.className = "convert-details";
|
||
const cell = detailRow.insertCell();
|
||
cell.colSpan = 5;
|
||
const details = document.createElement("details");
|
||
const summary = document.createElement("summary");
|
||
summary.textContent = "提示";
|
||
const messages = document.createElement("ul");
|
||
messages.className = "convert-messages";
|
||
messages.tabIndex = 0;
|
||
for (const message of item.details) {
|
||
const entry = document.createElement("li");
|
||
entry.textContent = message;
|
||
messages.append(entry);
|
||
}
|
||
details.append(summary, messages);
|
||
cell.append(details);
|
||
}
|
||
const reset = () => {
|
||
item.details = [];
|
||
detailRow?.remove();
|
||
batchStatus(item, "pending", "待转");
|
||
batchElements.Status.textContent = "";
|
||
batchElements.Progress.value = 0;
|
||
updateBatchControls();
|
||
};
|
||
const pagesCell = row.insertCell();
|
||
pagesCell.dataset.label = "页码";
|
||
const pages = document.createElement("input");
|
||
pages.type = "text";
|
||
pages.placeholder = "全部";
|
||
pages.title = "如1-3,5;留空为全部";
|
||
pages.value = item.pages;
|
||
pages.disabled = batch.running;
|
||
pages.addEventListener("change", () => { item.pages = pages.value; reset(); });
|
||
pagesCell.append(pages);
|
||
const formatCell = row.insertCell();
|
||
formatCell.dataset.label = "格式";
|
||
const formatControl = document.createElement("span");
|
||
formatControl.className = "convert-format";
|
||
const format = document.createElement("select");
|
||
format.append(new Option("默认", ""), ...batch.formats.map(value => new Option(value.label, value.value)));
|
||
format.value = item.format;
|
||
format.disabled = batch.running;
|
||
format.addEventListener("change", () => { item.format = format.value; reset(); });
|
||
formatControl.append(format);
|
||
formatCell.append(formatControl);
|
||
item.cell = row.insertCell();
|
||
item.cell.textContent = item.text;
|
||
item.cell.dataset.status = item.status;
|
||
item.cell.dataset.label = "状态";
|
||
const action = row.insertCell();
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.className = "icon-button";
|
||
remove.textContent = "×";
|
||
remove.title = "移除";
|
||
remove.disabled = batch.running;
|
||
remove.addEventListener("click", () => { batch.items.splice(batch.items.indexOf(item), 1); renderBatchList(); });
|
||
action.append(remove);
|
||
fragment.append(row);
|
||
if (detailRow) fragment.append(detailRow);
|
||
}
|
||
batchElements.List.replaceChildren(fragment);
|
||
batchElements.Empty.hidden = batch.items.length > 0;
|
||
updateBatchControls();
|
||
}
|
||
|
||
function updateBatchControls() {
|
||
for (const name of ["Add", "Clear", "Format", "Compression", "FontMode", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
|
||
batchElements.FontRow.hidden = !(batch.items.length ? batch.items : [null]).some(item => (item?.format || batchElements.Format.value) === "ofd");
|
||
batchElements.Compression.disabled ||= (batch.items.length ? batch.items : [null]).every(item => (item?.format || batchElements.Format.value) === "txt");
|
||
batchElements.Clear.disabled ||= !batch.items.length;
|
||
batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length;
|
||
batchElements.Start.hidden = batch.running;
|
||
batchElements.Cancel.hidden = !batch.running;
|
||
batchElements.Cancel.disabled = batch.canceled;
|
||
batchElements.Close.textContent = batch.running ? "收起" : "关闭";
|
||
batchElements.Count.textContent = batch.items.length ? `${batch.items.length}个文件` : "";
|
||
batchElements.DPIRow.hidden = !(batch.items.length ? batch.items : [null]).some(batchUsesDPI);
|
||
batchElements.Button.toggleAttribute("data-running", batch.running);
|
||
}
|
||
|
||
function batchUsesDPI(item) {
|
||
const format = item?.format || batchElements.Format.value;
|
||
if (batch.formats.find(value => value.value === format)?.raster) return true;
|
||
return Boolean(item && /\.pdf$/i.test(item.file.name) && format !== "txt"
|
||
&& (format !== "pdf" || item.pages.trim()));
|
||
}
|
||
|
||
function batchStatus(item, status, text) {
|
||
item.status = status;
|
||
item.text = text;
|
||
item.cell.textContent = text;
|
||
item.cell.dataset.status = status;
|
||
}
|
||
|
||
function batchProgress(item, position, count, progress) {
|
||
if (batch.canceled) return;
|
||
const now = performance.now();
|
||
const previous = batch.progress;
|
||
const sameStage = previous?.item === item && previous?.progress.phase === progress.phase;
|
||
if (sameStage && now - batch.progressTime < 80 && progress.completed !== progress.total) return;
|
||
batch.progressTime = now;
|
||
batch.progress = { item, position, count, progress, started: sameStage ? previous.started : now };
|
||
updateBatchProgress();
|
||
}
|
||
|
||
function updateBatchProgress() {
|
||
if (!batch.progress) return;
|
||
const { item, position, count, progress, started } = batch.progress;
|
||
const compressing = progress.phase === "compress" || progress.phase === "write.compress";
|
||
const fontMode = (item?.format || batchElements.Format.value) === "ofd" ? batchElements.FontMode.value : "0";
|
||
const stage = compressing ? "压缩" : progress.phase?.startsWith("write.") ? "写入" : { open: "读取", pages: "解析", convert: "转换", prepare: "准备", fonts: { 1: "嵌入", 2: "外置" }[fontMode] || "整理", "fonts.prepare": "准备", "fonts.scan": "检查", "fonts.commit": "整理", resources: "资源", references: "检查", write: "写入", export: "导出", pack: "打包", commit: "保存" }[progress.phase] || "处理";
|
||
const detail = progress.total > 0 ? `${progress.completed}/${progress.total}` : (compressing || progress.phase === "fonts") ? `已处理 ${progress.completed || 0} 项` : "";
|
||
const seconds = Math.floor((performance.now() - started) / 1000);
|
||
if (item) batchStatus(item, "running", stage);
|
||
batchElements.Status.textContent = `${progress.phase === "pack" ? "" : `文件 ${position + 1}/${count} · `}正在${stage}${detail ? ` · ${detail}` : ""}${seconds ? ` · ${seconds}秒` : ""}`;
|
||
batchElements.Progress.max = progress.total || 1;
|
||
if (progress.total > 0) batchElements.Progress.value = Math.min(progress.completed, progress.total);
|
||
else batchElements.Progress.removeAttribute("value");
|
||
}
|
||
|
||
function batchCall(name, args, onProgress) {
|
||
return new Promise((resolve, reject) => {
|
||
if (batch.workerError) { reject(batch.workerError); return; }
|
||
const id = ++batch.sequence;
|
||
batch.activeID = id;
|
||
batch.requests.set(id, { resolve, reject, onProgress, destination: name === "ofdgoConvertFile" ? args[3] : null });
|
||
try { batch.worker.postMessage({ id, name, args }); }
|
||
catch (err) { batch.requests.delete(id); batch.activeID = 0; reject(err); }
|
||
});
|
||
}
|
||
|
||
function startBatchWorker() {
|
||
batch.workerError = null;
|
||
return new Promise((resolve, reject) => {
|
||
const worker = createEngineWorker();
|
||
batch.worker = worker;
|
||
const fail = async error => {
|
||
batch.workerError = error;
|
||
worker.terminate();
|
||
reject(error);
|
||
const requests = [...batch.requests.values()];
|
||
batch.requests.clear();
|
||
for (const request of requests) {
|
||
if (request.destination && request.outputName) {
|
||
try { await request.destination.removeEntry(request.outputName, { recursive: true }); }
|
||
catch (err) { error = new Error(`${error.message};清理失败:${err.message}`); }
|
||
}
|
||
request.reject(error);
|
||
}
|
||
};
|
||
worker.onerror = event => fail(new Error(event.message || "转换引擎异常"));
|
||
worker.onmessageerror = () => fail(new Error("转换数据传递失败"));
|
||
worker.onmessage = async ({ data }) => {
|
||
if (data.type === "ready") { resolve(); return; }
|
||
if (data.type === "exit") { fail(new Error(data.error)); return; }
|
||
if (data.type === "progress") {
|
||
if (!batch.canceled) batchElements.Status.textContent = { download: "正在下载", compile: "正在编译", start: "正在启动" }[data.phase] || "正在准备";
|
||
return;
|
||
}
|
||
const request = batch.requests.get(data.id);
|
||
if (!request) return;
|
||
if (data.type === "font") {
|
||
try {
|
||
const font = batch.localFonts.get(data.name);
|
||
if (!font) throw new Error("系统字体不可用");
|
||
if (!batch.fontData.has(font)) batch.fontData.set(font, fontManager.read(font));
|
||
const bytes = await batch.fontData.get(font);
|
||
worker.postMessage({ type: "font", request: data.request, bytes });
|
||
} catch (err) {
|
||
worker.postMessage({ type: "font", request: data.request, error: err.message });
|
||
}
|
||
return;
|
||
}
|
||
if (data.type === "output") { request.outputName = data.name; return; }
|
||
if (data.type === "conversion") { request.onProgress?.(data); return; }
|
||
batch.requests.delete(data.id);
|
||
if (batch.activeID === data.id) batch.activeID = 0;
|
||
if (data.ok) request.resolve(data.data);
|
||
else request.reject(Object.assign(new Error(data.error), { code: data.code, name: data.canceled ? "AbortError" : "Error" }));
|
||
};
|
||
});
|
||
}
|
||
|
||
function cancelBatch() {
|
||
batch.canceled = true;
|
||
batchElements.Cancel.disabled = true;
|
||
batch.progress = null;
|
||
batchElements.Status.textContent = "正在取消";
|
||
if (batch.activeID) batch.worker.postMessage({ type: "cancel", id: batch.activeID });
|
||
}
|
||
|
||
function warnBatch(event) {
|
||
if (batch.running) { event.preventDefault(); event.returnValue = ""; }
|
||
}
|
||
|
||
async function runBatch() {
|
||
if (batch.running || !batch.items.length) return;
|
||
const defaultFormat = batch.formats.find(format => format.value === batchElements.Format.value);
|
||
if (!defaultFormat) return;
|
||
const pageRange = item => item.pages.trim() === "全部" ? "" : item.pages.trim();
|
||
const dpi = Number(batchElements.DPI.value);
|
||
const compression = compressionOptions(batchElements.Compression.value);
|
||
const archive = batchElements.Destination.value === "archive";
|
||
const backend = state.renderBackend;
|
||
const fontKey = [fontManager.permission, fontManager.records().map(font => [font.id, font.name, font.enabled, font.checksum])];
|
||
for (const item of batch.items) {
|
||
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
|
||
const key = JSON.stringify([format.value, pageRange(item), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey, compression, format.value === "ofd" ? Number(batchElements.FontMode.value) : 0]);
|
||
if (item.settingsKey !== key) {
|
||
item.status = "pending";
|
||
item.text = "待转";
|
||
item.details = [];
|
||
item.settingsKey = key;
|
||
}
|
||
}
|
||
renderBatchList();
|
||
let jobs = batch.items.filter(item => item.status !== "done" && item.status !== "unchanged");
|
||
if (!jobs.length) {
|
||
jobs = [...batch.items];
|
||
for (const item of jobs) { item.status = "pending"; item.text = "待转"; item.details = []; }
|
||
}
|
||
batch.running = true;
|
||
batch.canceled = false;
|
||
batch.progress = null;
|
||
const progressTimer = window.setInterval(updateBatchProgress, 1000);
|
||
renderBatchList();
|
||
window.addEventListener("beforeunload", warnBatch);
|
||
let destination = null;
|
||
let zipFile = null;
|
||
let storage = null;
|
||
let stagingName = "";
|
||
let packed = false;
|
||
const entries = [];
|
||
try {
|
||
if (archive) {
|
||
zipFile = await saveFilePicker({ suggestedName: "转换结果.zip", types: [{ description: "ZIP", accept: { "application/zip": [".zip"] } }] });
|
||
if (!nativeHost && navigator.storage?.getDirectory) {
|
||
storage = await navigator.storage.getDirectory();
|
||
stagingName = `ofdgo-convert-${crypto.randomUUID()}`;
|
||
destination = await storage.getDirectoryHandle(stagingName, { create: true });
|
||
}
|
||
} else destination = await directoryPicker();
|
||
if (batch.canceled) return;
|
||
if (jobs.some(item => /\.pdf$/i.test(item.file.name) || !["ofd", "txt"].includes(item.format || defaultFormat.value)
|
||
|| (item.format || defaultFormat.value) === "ofd" && batchElements.FontMode.value === "1")) {
|
||
await requestLocalFontsBeforeOpen();
|
||
}
|
||
if (batch.canceled) return;
|
||
batch.localFonts = fontManager.localEntries();
|
||
batch.fontData = new Map();
|
||
batchElements.Status.textContent = "正在准备";
|
||
await startBatchWorker();
|
||
const fonts = await fontManager.files(fontManager.records());
|
||
const names = new Set();
|
||
for (const [position, item] of jobs.entries()) {
|
||
if (batch.canceled) break;
|
||
item.details = [];
|
||
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
|
||
const base = item.file.name.replace(/\.[^.]+$/, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document";
|
||
let unique = base;
|
||
const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase();
|
||
for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`;
|
||
names.add(outputName());
|
||
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, compression, base: unique, credentials: null,
|
||
fontNames: [...batch.localFonts.keys()], fontMode: format.value === "ofd" ? Number(batchElements.FontMode.value) : 0 };
|
||
try {
|
||
for (;;) {
|
||
try {
|
||
const result = await batchCall("ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress));
|
||
for (const file of result.files) entries.push(file);
|
||
item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`);
|
||
for (const font of result.fonts || []) item.details.push(`${font.name}:${font.changed ? options.fontMode === 1 ? "已嵌入" : "已外置" : `未处理 · ${fontBindingError(font.reason)}`}`);
|
||
if (result.unchanged && !result.bytes) {
|
||
batchStatus(item, "unchanged", "原样");
|
||
item.details = ["无需转换,源文件未改动"];
|
||
} else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成");
|
||
break;
|
||
} catch (err) {
|
||
if (batch.canceled || !["credentialsRequired", "invalidCredentials", "pdfPassword"].includes(err.code)) throw err;
|
||
const code = err.code === "pdfPassword" ? options.credentials ? "invalidCredentials" : "credentialsRequired" : err.code;
|
||
options.credentials?.password?.fill(0);
|
||
options.credentials?.key?.fill(0);
|
||
options.credentials?.keyPassword?.fill(0);
|
||
options.credentials = await requestCredentials(code, state.openSeq, err.code === "pdfPassword");
|
||
if (!options.credentials) throw new DOMException("已取消解锁", "AbortError");
|
||
const preservesEncryption = err.code === "pdfPassword" && format.value === "pdf" && compression.mode !== 0 && !options.pages;
|
||
if (!preservesEncryption && !window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError");
|
||
}
|
||
}
|
||
} catch (err) {
|
||
batchStatus(item, err.name === "AbortError" ? "canceled" : "failed", err.name === "AbortError" ? "取消" : "失败");
|
||
item.details = [err.message];
|
||
if (batch.workerError) throw err;
|
||
} finally {
|
||
batch.progress = null;
|
||
options.credentials?.password?.fill(0);
|
||
options.credentials?.key?.fill(0);
|
||
options.credentials?.keyPassword?.fill(0);
|
||
}
|
||
batchElements.Progress.max = jobs.length;
|
||
batchElements.Progress.value = position + 1;
|
||
}
|
||
if (archive && entries.length && !batch.canceled) {
|
||
const result = await batchCall("ofdgoPackFiles", [entries, zipFile, compression], progress => batchProgress(null, 0, 1, progress));
|
||
if (result.blob) downloadBytes(result.blob, "application/zip", "转换结果.zip");
|
||
packed = true;
|
||
for (const item of jobs) if (item.status === "ready") batchStatus(item, "done", "完成");
|
||
}
|
||
const done = jobs.filter(item => item.status === "done").length;
|
||
const failed = jobs.filter(item => item.status === "failed").length;
|
||
const unchanged = batch.items.filter(item => item.status === "unchanged").length;
|
||
batchElements.Status.textContent = `${batch.canceled ? "取消 · " : ""}完成 ${done}${failed ? ` · 失败 ${failed}` : ""}${unchanged ? ` · 原样 ${unchanged}` : ""}`;
|
||
} catch (err) {
|
||
batchElements.Status.textContent = err.name === "AbortError" ? "转换取消" : err.message;
|
||
} finally {
|
||
window.clearInterval(progressTimer);
|
||
batch.progress = null;
|
||
if (!batchElements.Progress.hasAttribute("value")) batchElements.Progress.value = 0;
|
||
if (archive && !packed) for (const item of jobs) if (item.status === "ready") { item.status = "pending"; item.text = "待转"; }
|
||
batch.worker?.terminate();
|
||
batch.worker = null;
|
||
batch.requests.clear();
|
||
batch.activeID = 0;
|
||
batch.fontData?.clear();
|
||
batch.localFonts?.clear();
|
||
if (storage && stagingName) {
|
||
try { await storage.removeEntry(stagingName, { recursive: true }); }
|
||
catch { batchElements.Status.textContent += " · 临时文件清理失败"; }
|
||
}
|
||
batch.running = false;
|
||
window.removeEventListener("beforeunload", warnBatch);
|
||
renderBatchList();
|
||
}
|
||
}
|
||
|
||
function compressionOptions(value) {
|
||
const preset = Number(value) || 0;
|
||
return preset > 2 ? { mode: 2, level: preset - 2 } : { mode: preset };
|
||
}
|
||
|
||
async function exportFile(whole, indices = null, value = el.exportFormat.value) {
|
||
if (!state.doc || document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
const saving = value === "ofd";
|
||
const compression = compressionOptions(el.outputCompression.value);
|
||
const outputOptions = compression.mode ? [compression] : [];
|
||
if (saving && !state.editorInfo && !state.ofdBytes) {
|
||
return;
|
||
}
|
||
if (!saving && state.doc.encryption?.encrypted && !window.confirm("此格式将输出明文,是否继续?")) return;
|
||
const format = saving ? { value: "ofd", label: "OFD", extension: "ofd", mime: "application/ofd" } : exportFormatInfo(value);
|
||
if (!format) {
|
||
return;
|
||
}
|
||
const archive = whole && !saving && format.value !== "pdf" && format.value !== "txt";
|
||
const label = archive ? "ZIP" : format.label;
|
||
const extension = archive ? "zip" : format.extension;
|
||
const mime = archive ? "application/zip" : format.mime;
|
||
let openSeq = state.openSeq;
|
||
const fileName = saving && indices !== null ? `${baseFileName()}_选页.ofd` : whole ? `${baseFileName()}.${extension}` : pageFileName(extension);
|
||
const pageIndex = state.pageIndex;
|
||
const dpi = exportFormatUsesDPI(format.value) ? currentImageDPI() : 0;
|
||
const status = saving ? "正在保存文档" : whole ? STATUS.exporting : STATUS.pageExporting;
|
||
state.exporting = true;
|
||
updateControls();
|
||
setBusy(true, "正在生成文件", null, status);
|
||
try {
|
||
const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({
|
||
suggestedName: fileName,
|
||
types: [{ description: label, accept: { [mime]: [`.${extension}`] } }],
|
||
}) : null;
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
if (canvasEditor.input || canvasEditor.crop || canvasEditor.nudge || canvasEditor.nudgeCommit) {
|
||
setBusy(false);
|
||
if (!await canvasEditor.commitNudge()) return;
|
||
if (!await canvasEditor.commitText()) return;
|
||
if (!await canvasEditor.commitCrop()) return;
|
||
openSeq = state.openSeq;
|
||
setBusy(true, "正在生成文件", null, status);
|
||
}
|
||
const readingSave = saving && !state.editorInfo && indices === null && compression.mode === 0;
|
||
const result = readingSave
|
||
? { label: "OFD", size: state.ofdBytes.byteLength, mime, blob: state.ofdBytes }
|
||
: saving ? await callWASM("ofdgoSaveDocument", indices, ...outputOptions, file) : whole
|
||
? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, ...outputOptions, file)
|
||
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, ...outputOptions, file);
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
if (readingSave && file) {
|
||
const writer = await file.createWritable();
|
||
await writer.write(result.blob);
|
||
await writer.close();
|
||
delete result.blob;
|
||
}
|
||
if (result.blob) {
|
||
downloadBytes(result.blob, result.mime, fileName);
|
||
}
|
||
if (saving && indices === null && state.editorInfo) {
|
||
state.savedRevision = state.editorInfo.revision;
|
||
state.packageDirty = false;
|
||
state.encryptionDirty = false;
|
||
setDirty(false);
|
||
}
|
||
setStatus(`${result.label}已${saving ? "保存" : "导出"}(${formatBytes(result.size)})`);
|
||
return true;
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
const canceled = saving ? "保存已取消" : "导出已取消";
|
||
if (el.batchPagesPanel.open) el.batchPagesStatus.textContent = err.name === "AbortError" ? canceled : err.message;
|
||
if (err.name === "AbortError") {
|
||
setStatus(canceled);
|
||
} else {
|
||
showError(err, false);
|
||
}
|
||
}
|
||
} finally {
|
||
state.exporting = false;
|
||
state.exportRequestID = 0;
|
||
el.cancelExportButton.hidden = true;
|
||
if (openSeq === state.openSeq) {
|
||
setBusy(false);
|
||
updateControls();
|
||
}
|
||
}
|
||
}
|
||
|
||
function cancelExport() {
|
||
if (remoteDownload) {
|
||
remoteDownload.abort();
|
||
return;
|
||
}
|
||
if (!state.exportRequestID || el.cancelExportButton.disabled) {
|
||
return;
|
||
}
|
||
el.cancelExportButton.disabled = true;
|
||
if (state.imageImporting) {
|
||
el.createCancel.disabled = true;
|
||
el.createStatus.textContent = "正在取消操作";
|
||
} else if (state.importing) {
|
||
el.importCancel.disabled = true;
|
||
el.importStatus.textContent = "正在取消操作";
|
||
}
|
||
setProgress("正在取消操作", null);
|
||
wasmWorker.postMessage({ type: "cancel", id: state.exportRequestID });
|
||
}
|
||
|
||
function virtualIndex(offsets, top) {
|
||
let low = 0, high = offsets.length - 1;
|
||
while (low + 1 < high) {
|
||
const mid = (low + high) >>> 1;
|
||
if (offsets[mid] <= top) low = mid;
|
||
else high = mid;
|
||
}
|
||
return low;
|
||
}
|
||
|
||
function createPageWindow(host, viewport, overscan, create, release, observe) {
|
||
host.classList.add("virtual-pages");
|
||
return { host, viewport, overscan, create, release, observe, nodes: new Map(), offsets: new Float64Array(1), origin: 0, total: 0, gap: 0 };
|
||
}
|
||
|
||
function windowInset(view) {
|
||
return view.host.getBoundingClientRect().top - view.viewport.getBoundingClientRect().top
|
||
+ view.viewport.scrollTop - view.viewport.clientTop;
|
||
}
|
||
|
||
function pageWindowTop(view) {
|
||
const inset = windowInset(view);
|
||
if (view.total <= SCROLL_SEGMENT_SIZE) return view.viewport.scrollTop - inset;
|
||
const delta = view.viewport.scrollTop - view.scrollTop;
|
||
if (!delta) return view.logicalTop;
|
||
if (view.relativeScroll) return view.logicalTop + delta;
|
||
const max = view.viewport.scrollHeight - view.viewport.clientHeight;
|
||
return view.viewport.scrollTop / Math.max(1, max) * (max + view.total - SCROLL_SEGMENT_SIZE) - inset;
|
||
}
|
||
|
||
function scrollPageWindowBy(view, delta) {
|
||
if (view) syncPageWindow(view, pageWindowTop(view) + delta);
|
||
}
|
||
|
||
function scrollViewerBy(delta) {
|
||
if (state.pageWindow) scrollPageWindowBy(state.pageWindow, delta);
|
||
else el.viewerPanel.scrollTop += delta;
|
||
}
|
||
|
||
function syncPageWindow(view, target = null) {
|
||
if (!view || view.offsets.length < 2) return;
|
||
const { host, viewport, offsets, nodes } = view;
|
||
const extent = Math.min(SCROLL_SEGMENT_SIZE, view.total);
|
||
host.style.height = `${extent}px`;
|
||
const inset = windowInset(view), height = viewport.clientHeight;
|
||
const maxTop = Math.max(0, viewport.scrollHeight - height);
|
||
const logicalMax = maxTop + view.total - extent;
|
||
const logical = Math.max(-inset, Math.min(logicalMax - inset, target ?? pageWindowTop(view)));
|
||
viewport.scrollTop = logicalMax > 0 ? (logical + inset) * maxTop / logicalMax : 0;
|
||
view.scrollTop = viewport.scrollTop;
|
||
view.logicalTop = logical;
|
||
view.origin = logical - viewport.scrollTop + inset;
|
||
const first = virtualIndex(offsets, Math.max(view.origin, logical - view.overscan));
|
||
const last = virtualIndex(offsets, Math.min(view.origin + extent, logical + height + view.overscan));
|
||
const selection = host === el.svgHost && state.documentSelection ? document.getSelection() : null;
|
||
const selected = selection?.rangeCount && isDocumentSelection(selection.getRangeAt(0));
|
||
let changed = false;
|
||
for (const [index, node] of nodes) {
|
||
if ((index < first || index > last) && view.release(node, index)) {
|
||
node.remove();
|
||
nodes.delete(index);
|
||
changed = true;
|
||
}
|
||
}
|
||
for (let index = first; index <= last; index++) {
|
||
if (nodes.has(index)) continue;
|
||
const node = view.create(state.doc.pages[index]);
|
||
nodes.set(index, node);
|
||
const next = [...host.children].find(child => Number(child.dataset.pageIndex) > index);
|
||
host.insertBefore(node, next || null);
|
||
changed = true;
|
||
node.style.top = `${offsets[index] - view.origin}px`;
|
||
view.observe(node, index);
|
||
}
|
||
for (const [index, node] of nodes) {
|
||
const top = offsets[index] - view.origin;
|
||
const itemHeight = offsets[index + 1] - offsets[index] - view.gap;
|
||
node.style.top = `${Math.max(-itemHeight, Math.min(extent, top))}px`;
|
||
node.style.visibility = top + itemHeight < 0 || top >= extent ? "hidden" : "";
|
||
}
|
||
if (selected && changed) selection.getRangeAt(0).selectNodeContents(host);
|
||
view.anchor = pageWindowAnchor(view);
|
||
}
|
||
|
||
function pageWindowAnchor(view) {
|
||
const top = pageWindowTop(view);
|
||
const index = virtualIndex(view.offsets, top);
|
||
return { index, offset: top - view.offsets[index] };
|
||
}
|
||
|
||
function releaseFlowShell(shell, index) {
|
||
if (shell.contains(document.activeElement) || canvasEditor.drag?.item?.index === index) return false;
|
||
unmountPage(index);
|
||
if (state.selectedPages.has(index)) return false;
|
||
state.pageObserver?.unobserve(shell);
|
||
state.visiblePages.delete(index);
|
||
return true;
|
||
}
|
||
|
||
function createFlowShell(page) {
|
||
const shell = document.createElement("div");
|
||
shell.className = "page-shell";
|
||
shell.dataset.pageIndex = String(page.index);
|
||
const surface = document.createElement("div");
|
||
surface.className = "page-surface";
|
||
const placeholder = document.createElement("div");
|
||
placeholder.className = "page-placeholder";
|
||
placeholder.textContent = `第${page.index + 1}页`;
|
||
shell.append(surface, placeholder);
|
||
layoutPageShell(shell, page);
|
||
return shell;
|
||
}
|
||
|
||
function renderPageFlow() {
|
||
el.svgHost.replaceChildren();
|
||
state.pageWindow = null;
|
||
state.visiblePages.clear();
|
||
el.viewerPanel.classList.remove("single-page-fits-height");
|
||
if (!state.doc) {
|
||
return;
|
||
}
|
||
if (state.pageObserver) {
|
||
state.pageObserver.disconnect();
|
||
}
|
||
el.emptyState.hidden = true;
|
||
el.pageFrame.hidden = false;
|
||
state.pageWindow = createPageWindow(el.svgHost, el.viewerPanel, 600, createFlowShell, releaseFlowShell, observeFlowPage);
|
||
layoutPages();
|
||
}
|
||
|
||
function resetPageFlow(keepCache = false) {
|
||
if (!keepCache) {
|
||
state.pageCache.clear();
|
||
releasePageResources();
|
||
}
|
||
state.selectedPages.clear();
|
||
state.documentSelection = null;
|
||
state.visiblePages.clear();
|
||
resetPageLoading();
|
||
}
|
||
|
||
function resetPageLoading() {
|
||
releaseMediaPlayers();
|
||
for (const task of state.pageInFlight.values()) {
|
||
task.resolve(null);
|
||
}
|
||
state.pageRenderQueue = [];
|
||
state.pageInFlight.clear();
|
||
resetThumbnails();
|
||
if (state.pageObserver) {
|
||
state.pageObserver.disconnect();
|
||
state.pageObserver = null;
|
||
}
|
||
}
|
||
|
||
function observeFlowPage(shell) {
|
||
if (!shell) return;
|
||
const observer = flowPageObserver();
|
||
if (observer) {
|
||
observer.observe(shell);
|
||
} else {
|
||
const index = Number(shell.dataset.pageIndex);
|
||
state.visiblePages.add(index);
|
||
renderFlowPage(index, { priority: 4 });
|
||
}
|
||
}
|
||
|
||
function flowPageObserver() {
|
||
if (!("IntersectionObserver" in window)) {
|
||
return null;
|
||
}
|
||
if (!state.pageObserver) {
|
||
const openSeq = state.openSeq;
|
||
state.pageObserver = new IntersectionObserver((entries) => {
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
for (const entry of entries) {
|
||
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
|
||
if (state.pageWindow && state.pageWindow.nodes.get(index) !== entry.target) continue;
|
||
if (!entry.isIntersecting) {
|
||
state.visiblePages.delete(index);
|
||
unmountPage(index);
|
||
continue;
|
||
}
|
||
state.visiblePages.add(index);
|
||
state.selectedPages.delete(index);
|
||
renderFlowPage(index, { openSeq, priority: 4 });
|
||
}
|
||
trimPageCache();
|
||
}, {
|
||
root: el.viewerPanel,
|
||
rootMargin: "600px 0px",
|
||
});
|
||
}
|
||
return state.pageObserver;
|
||
}
|
||
|
||
function unmountPage(index) {
|
||
const shell = pageShell(index);
|
||
if (!shell) return;
|
||
const selection = document.getSelection();
|
||
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
||
if (canvasEditor.input?.item.index === index || canvasEditor.crop?.item.index === index
|
||
|| range && (!state.documentSelection || !isDocumentSelection(range)) && range.intersectsNode(shell)) {
|
||
state.selectedPages.add(index);
|
||
return;
|
||
}
|
||
releaseMediaPlayers(shell.querySelector(".page-surface"));
|
||
shell.querySelector(".page-surface").replaceChildren();
|
||
shell.classList.remove("rendered");
|
||
state.selectedPages.delete(index);
|
||
}
|
||
|
||
async function renderFlowPage(index, options = {}) {
|
||
const openSeq = options.openSeq || state.openSeq;
|
||
if (openSeq !== state.openSeq) {
|
||
return null;
|
||
}
|
||
try {
|
||
const page = await loadPageData(index, { openSeq, priority: options.priority ?? 2,
|
||
refresh: state.editing && state.pageCache.get(index)?.previewOnly });
|
||
if (openSeq !== state.openSeq) {
|
||
return null;
|
||
}
|
||
if (page) {
|
||
const shell = pageShell(index);
|
||
if (shell && !shell.classList.contains("rendered") && (!state.pageObserver || state.visiblePages.has(index) || index === state.pageIndex)) {
|
||
mountPageSVG(index, page, openSeq);
|
||
}
|
||
updateThumbnail(index, openSeq);
|
||
}
|
||
trimPageCache();
|
||
return page;
|
||
} catch (err) {
|
||
if (openSeq === state.openSeq) {
|
||
markFlowPageError(index, err);
|
||
}
|
||
if (options.throwError && openSeq === state.openSeq) {
|
||
throw err;
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function loadPageData(index, options = {}) {
|
||
const openSeq = options.openSeq || state.openSeq;
|
||
if (openSeq !== state.openSeq) {
|
||
return Promise.resolve(null);
|
||
}
|
||
const key = `${openSeq}:${index}`;
|
||
const priority = options.priority ?? 3;
|
||
const current = state.pageInFlight.get(key);
|
||
if (current) {
|
||
current.priority = Math.min(current.priority, priority);
|
||
return current.promise;
|
||
}
|
||
if (!options.refresh && state.pageCache.has(index)) {
|
||
const page = state.pageCache.get(index);
|
||
state.pageCache.delete(index);
|
||
state.pageCache.set(index, page);
|
||
return Promise.resolve(page);
|
||
}
|
||
let resolve;
|
||
let reject;
|
||
const promise = new Promise((done, fail) => {
|
||
resolve = done;
|
||
reject = fail;
|
||
});
|
||
const task = { key, index, openSeq, priority, refresh: options.refresh, resolve, reject, promise };
|
||
state.pageInFlight.set(key, task);
|
||
state.pageRenderQueue.push(task);
|
||
schedulePageRender();
|
||
return promise;
|
||
}
|
||
|
||
function schedulePageRender() {
|
||
if (state.pageRenderRunning) {
|
||
return;
|
||
}
|
||
state.pageRenderRunning = true;
|
||
requestAnimationFrame(processPageRenderQueue);
|
||
}
|
||
|
||
async function processPageRenderQueue() {
|
||
try {
|
||
await nextFrame();
|
||
while (state.pageRenderQueue.length) {
|
||
let next = 0;
|
||
for (let index = 1; index < state.pageRenderQueue.length; index++) {
|
||
if (comparePageRenderTask(state.pageRenderQueue[index], state.pageRenderQueue[next]) < 0) next = index;
|
||
}
|
||
const [task] = state.pageRenderQueue.splice(next, 1);
|
||
try {
|
||
if (task.openSeq !== state.openSeq) {
|
||
task.resolve(null);
|
||
continue;
|
||
}
|
||
if (!task.refresh && state.pageCache.has(task.index)) {
|
||
task.resolve(state.pageCache.get(task.index));
|
||
continue;
|
||
}
|
||
const page = await callWASM("ofdgoRenderPage", task.index, state.renderBackend, state.renderDPI, displayMode() === "raster");
|
||
if (state.pageInFlight.get(task.key) !== task) {
|
||
task.resolve(null);
|
||
continue;
|
||
}
|
||
const scope = state.composite;
|
||
if (scope?.index === task.index) {
|
||
const objects = await callWASM("ofdgoCompositeObjects", task.index, scope.key);
|
||
if (scope === state.composite && task.openSeq === state.openSeq) scope.objects = objects;
|
||
}
|
||
await loadSVGFonts(page.fonts, task.openSeq);
|
||
if (task.openSeq === state.openSeq && state.pageInFlight.get(task.key) === task) {
|
||
for (const image of page.images) {
|
||
if (!state.svgImages.has(image.name)) {
|
||
const blob = new Blob([image.bytes], { type: image.mime });
|
||
state.svgImages.set(image.name, { url: URL.createObjectURL(blob), size: blob.size });
|
||
}
|
||
}
|
||
page.imageNames = page.images.map((image) => image.name);
|
||
delete page.images;
|
||
if (task.refresh && state.editing) {
|
||
await Promise.all(page.imageNames.map(name => {
|
||
const image = new Image();
|
||
image.src = state.svgImages.get(name).url;
|
||
return image.decode();
|
||
}));
|
||
}
|
||
if (task.openSeq !== state.openSeq || state.pageInFlight.get(task.key) !== task) {
|
||
task.resolve(null);
|
||
continue;
|
||
}
|
||
page.cacheBytes = (page.svg.length + page.text.length + page.annotations.length) * 2;
|
||
page.text = JSON.parse(page.text);
|
||
page.annotations = JSON.parse(page.annotations);
|
||
state.pageCache.set(task.index, page);
|
||
}
|
||
task.resolve(page);
|
||
} catch (err) {
|
||
task.reject(err);
|
||
} finally {
|
||
if (state.pageInFlight.get(task.key) === task) state.pageInFlight.delete(task.key);
|
||
}
|
||
}
|
||
} finally {
|
||
state.pageRenderRunning = false;
|
||
if (!state.pageInFlight.size) releasePageResources();
|
||
}
|
||
}
|
||
|
||
function trimPageCache() {
|
||
const protectedPages = new Set([state.pageIndex, ...state.visiblePages, ...state.visibleThumbnails, ...state.selectedPages]);
|
||
for (const view of [state.pageWindow, state.thumbnailWindow]) {
|
||
for (const index of view?.nodes.keys() || []) protectedPages.add(index);
|
||
}
|
||
for (const task of state.pageInFlight.values()) {
|
||
protectedPages.add(task.index);
|
||
}
|
||
const references = new Map();
|
||
let bytes = 0;
|
||
for (const page of state.pageCache.values()) {
|
||
bytes += page.cacheBytes;
|
||
for (const name of page.imageNames) {
|
||
references.set(name, (references.get(name) || 0) + 1);
|
||
}
|
||
}
|
||
for (const image of state.svgImages.values()) {
|
||
bytes += image.size;
|
||
}
|
||
for (const [index, page] of state.pageCache) {
|
||
if (state.pageCache.size <= PAGE_CACHE_LIMIT && bytes <= PAGE_CACHE_BYTES) {
|
||
break;
|
||
}
|
||
if (protectedPages.has(index)) {
|
||
continue;
|
||
}
|
||
state.pageCache.delete(index);
|
||
bytes -= page.cacheBytes;
|
||
for (const name of page.imageNames) {
|
||
const count = references.get(name) - 1;
|
||
references.set(name, count);
|
||
if (count === 0) {
|
||
const image = state.svgImages.get(name);
|
||
bytes -= image.size;
|
||
URL.revokeObjectURL(image.url);
|
||
state.svgImages.delete(name);
|
||
}
|
||
}
|
||
}
|
||
if (!state.pageInFlight.size) releasePageResources();
|
||
}
|
||
|
||
async function loadSVGFonts(fonts, openSeq) {
|
||
for (const font of fonts) {
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
if (state.svgFonts.has(font.name)) {
|
||
continue;
|
||
}
|
||
const data = await callWASM("ofdgoSVGFontData", font.name);
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
const face = new FontFace(font.name, data.bytes, { weight: String(font.weight), style: font.style });
|
||
await face.load();
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
document.fonts.add(face);
|
||
state.svgFonts.set(font.name, face);
|
||
}
|
||
}
|
||
|
||
function comparePageRenderTask(a, b) {
|
||
return a.priority - b.priority || Math.abs(a.index - state.pageIndex) - Math.abs(b.index - state.pageIndex);
|
||
}
|
||
|
||
function mountPageSVG(index, page, openSeq = state.openSeq) {
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
const shell = pageShell(index);
|
||
if (!shell) {
|
||
return;
|
||
}
|
||
const surface = shell.querySelector(".page-surface");
|
||
const svg = parseSVG(page.svg, `p${openSeq}-${index}`);
|
||
svg.classList.add("ofd-svg");
|
||
surface.replaceChildren(svg);
|
||
surface.append(createTextLayer(page.text));
|
||
if (state.editorInfo && displayMode() === "svg") {
|
||
mountEditorObjects(index, page, surface);
|
||
}
|
||
mountAnnotationNotes(index, surface, page.annotations);
|
||
mountPageLinks(page, surface);
|
||
shell.classList.add("rendered");
|
||
renderSearchHighlights(index);
|
||
if (state.doc?.pages?.[index]) {
|
||
layoutPageShell(shell, state.doc.pages[index]);
|
||
}
|
||
}
|
||
|
||
function releaseMediaPlayers(surface) {
|
||
for (const [id, entry] of state.mediaPlayers || []) {
|
||
if (surface && (entry.surface !== surface || entry.floating)) continue;
|
||
releaseMediaPlayer(id, entry);
|
||
}
|
||
}
|
||
|
||
function releaseMediaPlayer(id, entry) {
|
||
if (state.mediaPlayers?.get(id) !== entry) return;
|
||
state.mediaPlayers.delete(id);
|
||
for (const [requestID, request] of wasmRequests) {
|
||
if (request.mediaID === id) wasmWorker?.postMessage({type: "cancel", id: requestID});
|
||
}
|
||
entry.finish?.();
|
||
closeMediaWindow(entry);
|
||
if (entry.element) {
|
||
entry.element.pause();
|
||
entry.element.removeAttribute("src");
|
||
entry.element.load();
|
||
entry.element.remove();
|
||
}
|
||
if (entry.url) URL.revokeObjectURL(entry.url);
|
||
}
|
||
|
||
function closeMediaWindow(entry) {
|
||
const dialog = entry.dialog;
|
||
if (!dialog) return;
|
||
const restoreFocus = dialog.contains(document.activeElement);
|
||
entry.dialog = null;
|
||
dialog.close();
|
||
dialog.remove();
|
||
if (restoreFocus && entry.opener?.isConnected) entry.opener.focus({ preventScroll: true });
|
||
entry.opener = null;
|
||
}
|
||
|
||
function placeMediaPlayer(id, entry) {
|
||
const box = entry.placement;
|
||
if (!box) return;
|
||
const element = entry.element;
|
||
entry.finish?.();
|
||
if (box.floating) {
|
||
element.removeAttribute("style");
|
||
if (!entry.dialog) {
|
||
const dialog = document.createElement("dialog");
|
||
dialog.className = "form-dialog media-dialog";
|
||
const label = entry.kind === "Video" ? "视频" : "音频";
|
||
const header = document.createElement("div");
|
||
header.className = "form-title";
|
||
const title = document.createElement("span");
|
||
title.textContent = `${label}播放`;
|
||
const close = document.createElement("button");
|
||
close.type = "button";
|
||
close.className = "small-button";
|
||
close.textContent = "关闭";
|
||
close.title = `关闭${label}`;
|
||
close.addEventListener("click", () => releaseMediaPlayer(id, entry));
|
||
dialog.addEventListener("keydown", event => {
|
||
if (event.key !== "Escape") return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
releaseMediaPlayer(id, entry);
|
||
});
|
||
dialog.addEventListener("close", () => {
|
||
if (entry.dialog === dialog) releaseMediaPlayer(id, entry);
|
||
});
|
||
header.append(title, close);
|
||
dialog.append(header, element);
|
||
entry.opener = document.activeElement;
|
||
entry.dialog = dialog;
|
||
document.body.append(dialog);
|
||
dialog.show();
|
||
}
|
||
} else {
|
||
entry.surface.append(element);
|
||
closeMediaWindow(entry);
|
||
element.style.left = `${box.x / box.pageWidth * 100}%`;
|
||
element.style.width = `${box.width / box.pageWidth * 100}%`;
|
||
if (entry.kind === "Video") {
|
||
element.style.width = `calc(${box.width / box.pageWidth * 100}% * var(--surface-scale, 1))`;
|
||
element.style.top = `${box.y / box.pageHeight * 100}%`;
|
||
element.style.height = `calc(${box.height / box.pageHeight * 100}% * var(--surface-scale, 1))`;
|
||
} else {
|
||
element.style.bottom = `${Math.max(0, 100 - (box.y + box.height) / box.pageHeight * 100)}%`;
|
||
}
|
||
}
|
||
entry.placement = null;
|
||
element.currentTime = 0;
|
||
}
|
||
|
||
async function playMediaAction(link, page, surface) {
|
||
const action = link.sound || link.movie;
|
||
const id = action?.resourceID;
|
||
if (!id || !surface || !page) return;
|
||
const operator = link.sound ? "Play" : action.operator || "Play";
|
||
if (!["Play", "Stop", "Pause", "Resume"].includes(operator)) return;
|
||
const players = state.mediaPlayers ||= new Map();
|
||
let entry = players.get(id);
|
||
if (operator === "Stop" || operator === "Pause") {
|
||
if (entry) entry.operation = (entry.operation || 0) + 1;
|
||
if (operator === "Stop") {
|
||
if (entry) releaseMediaPlayer(id, entry);
|
||
} else if (entry) {
|
||
entry.paused = true;
|
||
entry.element?.pause();
|
||
}
|
||
return;
|
||
}
|
||
if (operator === "Resume" && (!entry || entry.element?.ended || !entry.paused && !entry.element?.paused)) return;
|
||
const seq = state.openSeq;
|
||
try {
|
||
if (!entry) {
|
||
entry = { surface };
|
||
players.set(id, entry);
|
||
entry.pending = callWASM("ofdgoExportMedia", id, null).then(data => {
|
||
if (seq !== state.openSeq || players.get(id) !== entry) return;
|
||
if (!entry.floating && !entry.surface.isConnected) {
|
||
releaseMediaPlayer(id, entry);
|
||
return;
|
||
}
|
||
const element = document.createElement(data.kind === "Video" ? "video" : "audio");
|
||
entry.kind = data.kind;
|
||
entry.element = element;
|
||
entry.url = URL.createObjectURL(data.blob);
|
||
element.src = entry.url;
|
||
element.controls = true;
|
||
element.className = "page-media";
|
||
element.addEventListener("error", () => {
|
||
entry.finish?.();
|
||
if (seq === state.openSeq && players.get(id) === entry) setStatus("媒体无法播放");
|
||
});
|
||
placeMediaPlayer(id, entry);
|
||
});
|
||
}
|
||
if (operator === "Play") {
|
||
entry.surface = surface;
|
||
const box = link.movie && link.playbackBox ? link.playbackBox : link;
|
||
entry.floating = !!link.movie && !link.playbackBox || !!link.sound && !(link.width > 0 && link.height > 0);
|
||
entry.placement = { x: box.x, y: box.y, width: box.width, height: box.height, pageWidth: page.width, pageHeight: page.height, floating: entry.floating };
|
||
}
|
||
entry.paused = false;
|
||
const operation = entry.operation = (entry.operation || 0) + 1;
|
||
await entry.pending;
|
||
if (seq !== state.openSeq || players.get(id) !== entry || operation !== entry.operation || !entry.element?.isConnected) return false;
|
||
const element = entry.element;
|
||
if (link.sound) {
|
||
element.volume = Math.max(0, Math.min(100, action.volume ?? 100)) / 100;
|
||
element.loop = !!action.repeat;
|
||
}
|
||
placeMediaPlayer(id, entry);
|
||
let ended;
|
||
if (link.sound && action.synchronous && !action.repeat) {
|
||
ended = new Promise(resolve => {
|
||
entry.finish = event => {
|
||
element.removeEventListener("ended", entry.finish);
|
||
entry.finish = null;
|
||
resolve(event?.type === "ended");
|
||
};
|
||
element.addEventListener("ended", entry.finish);
|
||
});
|
||
}
|
||
try { await element.play(); } catch (err) { entry.finish?.(); throw err; }
|
||
if (ended) return await ended;
|
||
} catch (err) {
|
||
if (players.get(id) === entry && seq === state.openSeq) {
|
||
if (!entry?.element) players.delete(id);
|
||
setStatus(err.message || "媒体无法播放");
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function pageLinkTarget(link, page, surface, openAction) {
|
||
if (link.sound || link.movie) {
|
||
return { href: "#", title: link.sound ? "音频播放" : "视频播放", activate: () => playMediaAction(link, page, surface) };
|
||
}
|
||
if (link.dest) {
|
||
const target = pageIndexByID(state.doc.pages, link.dest.pageID);
|
||
return { href: target < 0 ? "#" : `#page-${target + 1}`, title: target < 0 ? "文档链接" : `第${target + 1}页`, activate: () => navigateDestination(link.dest, openAction) };
|
||
}
|
||
if (link.attachment) {
|
||
return { href: "#", title: link.fileName ? `下载附件:${link.fileName}` : "下载附件", activate: () => {
|
||
if (!link.fileName) { setStatus("附件不存在"); return; }
|
||
return downloadAttachment({ id: link.attachment, fileName: link.fileName });
|
||
} };
|
||
}
|
||
let url;
|
||
try { url = new URL(link.uri); } catch { return null; }
|
||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||
return { href: url.href, title: url.href, external: true, activate: () => { window.open(url.href, "_blank", "noopener,noreferrer"); } };
|
||
}
|
||
|
||
function mountPageLinks(page, surface) {
|
||
const groups = new Map();
|
||
const mounted = new Map();
|
||
for (const [index, link] of page.links.entries()) {
|
||
const target = pageLinkTarget(link, page, surface);
|
||
if (!target) continue;
|
||
target.order = index;
|
||
const key = link.group ? JSON.stringify([link.group, link.x, link.y, link.width, link.height, link.path]) : index;
|
||
if (!groups.has(key)) groups.set(key, { link, targets: [] });
|
||
groups.get(key).targets.push(target);
|
||
}
|
||
for (const { link, targets } of groups.values()) {
|
||
const anchor = document.createElement("a");
|
||
anchor.className = "page-link";
|
||
anchor.href = targets[0].href;
|
||
anchor.title = targets[0].title;
|
||
mounted.set(anchor, { link, targets });
|
||
if (!link.group && targets[0].external) {
|
||
anchor.target = "_blank";
|
||
anchor.rel = "noopener noreferrer";
|
||
} else {
|
||
anchor.addEventListener("click", async event => {
|
||
event.preventDefault();
|
||
if (document.body.hasAttribute("data-busy")) return;
|
||
state.openActionTask = null;
|
||
const openSeq = state.openSeq;
|
||
let actions = targets;
|
||
if (event.detail && link.group) {
|
||
const matches = new Set();
|
||
for (const node of document.elementsFromPoint(event.clientX, event.clientY)) {
|
||
const entry = mounted.get(node.closest(".page-link"));
|
||
if (entry?.link.group === link.group) entry.targets.forEach(target => matches.add(target));
|
||
}
|
||
actions = [...matches].sort((a, b) => a.order - b.order);
|
||
}
|
||
for (const target of actions) {
|
||
if (openSeq !== state.openSeq) break;
|
||
if (await target.activate() === false) break;
|
||
}
|
||
});
|
||
}
|
||
anchor.style.left = `${link.x / page.width * 100}%`;
|
||
anchor.style.top = `${link.y / page.height * 100}%`;
|
||
anchor.style.width = `${link.width / page.width * 100}%`;
|
||
anchor.style.height = `${link.height / page.height * 100}%`;
|
||
if (link.path) {
|
||
anchor.classList.add("page-region-link");
|
||
const region = document.createElementNS(SVG_NAMESPACE, "svg");
|
||
region.setAttribute("viewBox", `${link.x} ${link.y} ${link.width} ${link.height}`);
|
||
const path = document.createElementNS(SVG_NAMESPACE, "path");
|
||
path.setAttribute("d", link.path);
|
||
region.append(path);
|
||
anchor.append(region);
|
||
}
|
||
surface.append(anchor);
|
||
}
|
||
}
|
||
|
||
function destinationPoint(page, x, y) {
|
||
if (state.rotation === 90) return { x: page.height - y, y: x };
|
||
if (state.rotation === 180) return { x: page.width - x, y: page.height - y };
|
||
if (state.rotation === 270) return { x: y, y: page.width - x };
|
||
return { x, y };
|
||
}
|
||
|
||
async function navigateDestination(dest, openAction) {
|
||
if (document.body.hasAttribute("data-busy")) return;
|
||
if (openAction && state.openActionTask !== openAction) return false;
|
||
const index = pageIndexByID(state.doc.pages, dest.pageID);
|
||
if (index < 0) {
|
||
setStatus("目标页面不存在");
|
||
return;
|
||
}
|
||
if (!["XYZ", "Fit", "FitH", "FitV", "FitR"].includes(dest.type)) {
|
||
setStatus("暂不支持此跳转方式");
|
||
return;
|
||
}
|
||
const seq = state.openSeq, scale = state.scale;
|
||
await renderPage(index, { fit: false, scroll: false, openAction });
|
||
if (openAction && state.openActionTask !== openAction) return false;
|
||
if (seq !== state.openSeq || state.pageIndex !== index) return;
|
||
const page = state.doc.pages[index], size = pageViewSize(page), space = pageSpace();
|
||
const width = el.viewerPanel.clientWidth - space * 2, height = el.viewerPanel.clientHeight - space * 2;
|
||
let left = dest.omitLeft ? 0 : (dest.left ?? 0);
|
||
let top = dest.omitTop ? 0 : (dest.top ?? 0);
|
||
if (dest.type === "Fit") {
|
||
fitHeight(false);
|
||
scrollToPage(index);
|
||
return;
|
||
} else if (dest.type === "FitH") {
|
||
setScale(fitWidthScale(page), false);
|
||
left = 0;
|
||
} else if (dest.type === "FitV") {
|
||
setScale(height / (size.height * MM_TO_PX), false);
|
||
top = 0;
|
||
} else if (dest.type === "FitR") {
|
||
const a = destinationPoint(page, left, top), b = destinationPoint(page, dest.right, dest.bottom);
|
||
if (dest.right <= left || dest.bottom <= top) return;
|
||
setScale(Math.min(width / Math.abs(b.x - a.x), height / Math.abs(b.y - a.y)) / MM_TO_PX, false);
|
||
} else {
|
||
setScale(!dest.omitZoom && dest.zoom > 0 ? dest.zoom : scale, false);
|
||
}
|
||
const point = destinationPoint(page, left, top);
|
||
if (dest.type === "FitR") {
|
||
const bottom = destinationPoint(page, dest.right, dest.bottom);
|
||
point.x = Math.min(point.x, bottom.x);
|
||
point.y = Math.min(point.y, bottom.y);
|
||
}
|
||
if (state.pageWindow) syncPageWindow(state.pageWindow, state.pageWindow.offsets[index]);
|
||
const node = pageShell(index), shell = node.getBoundingClientRect(), viewer = el.viewerPanel.getBoundingClientRect();
|
||
el.viewerPanel.scrollLeft += shell.left + (node.clientLeft || 0) - viewer.left - (el.viewerPanel.clientLeft || 0) + point.x * MM_TO_PX * state.scale;
|
||
scrollViewerBy(shell.top + (node.clientTop || 0) - viewer.top - (el.viewerPanel.clientTop || 0) + point.y * MM_TO_PX * state.scale);
|
||
}
|
||
|
||
function createTextLayer(text) {
|
||
const layer = document.createElement("div");
|
||
layer.className = "text-layer";
|
||
const measure = textMeasure ||= document.createElement("canvas").getContext("2d");
|
||
measure.font = "100px sans-serif";
|
||
const widths = new Map();
|
||
for (const run of text.runs || []) {
|
||
const line = document.createElement("span");
|
||
line.className = "text-run";
|
||
const chars = Array.from(run.text);
|
||
let end = 0;
|
||
for (const item of run.spans || []) {
|
||
const breaks = chars.slice(end, item.start).filter((char) => char === "\n").join("");
|
||
if (breaks) {
|
||
line.append(document.createTextNode(breaks));
|
||
}
|
||
const span = document.createElement("span");
|
||
span.textContent = chars.slice(item.start, item.end).join("");
|
||
let width = widths.get(span.textContent);
|
||
if (width === undefined) {
|
||
width = measure.measureText(span.textContent).width || 1;
|
||
widths.set(span.textContent, width);
|
||
}
|
||
const [a, b, c, d, e, f] = item.matrix.map((value) => value * MM_TO_PX);
|
||
span.style.transform = `matrix(${a / width}, ${b / width}, ${c / 100}, ${d / 100}, ${e}, ${f})`;
|
||
line.append(span);
|
||
end = item.end;
|
||
}
|
||
const breaks = chars.slice(end).filter((char) => char === "\n").join("");
|
||
if (breaks) {
|
||
line.append(document.createTextNode(breaks));
|
||
}
|
||
layer.append(line);
|
||
}
|
||
return layer;
|
||
}
|
||
|
||
function syncSelection() {
|
||
const selection = document.getSelection();
|
||
if (state.documentSelection && (!selection.rangeCount || !isDocumentSelection(selection.getRangeAt(0)))) {
|
||
state.documentSelection = null;
|
||
if (state.doc && !document.body.hasAttribute("data-busy")) {
|
||
setStatus(pageStatus(state.pageIndex, state.doc.pageCount));
|
||
}
|
||
}
|
||
for (const index of state.selectedPages) {
|
||
unmountPage(index);
|
||
}
|
||
trimPageCache();
|
||
}
|
||
|
||
function isDocumentSelection(range) {
|
||
return range.startContainer === el.svgHost && range.startOffset === 0
|
||
&& range.endContainer === el.svgHost && range.endOffset === el.svgHost.childNodes.length;
|
||
}
|
||
|
||
async function selectDocumentText() {
|
||
const selection = document.getSelection();
|
||
if (state.documentSelection && selection.rangeCount && isDocumentSelection(selection.getRangeAt(0))) {
|
||
return;
|
||
}
|
||
const openSeq = state.openSeq;
|
||
const current = { text: null };
|
||
const active = () => state.documentSelection === current && openSeq === state.openSeq
|
||
&& selection.rangeCount > 0 && isDocumentSelection(selection.getRangeAt(0));
|
||
state.documentSelection = current;
|
||
const range = document.createRange();
|
||
range.selectNodeContents(el.svgHost);
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
const pages = [];
|
||
const count = state.doc.pageCount;
|
||
try {
|
||
for (let index = 0; index < count; index += 1) {
|
||
setStatus(`正在全选文字 · ${index + 1} / ${count} 页`);
|
||
const text = await callWASM("ofdgoPageTextString", index);
|
||
if (!active()) {
|
||
return;
|
||
}
|
||
if (text) {
|
||
pages.push(text);
|
||
}
|
||
}
|
||
current.text = pages.join("\n");
|
||
setStatus(current.text ? "文字已全选" : "暂无文字");
|
||
} catch (err) {
|
||
if (active()) {
|
||
state.documentSelection = null;
|
||
selection.removeAllRanges();
|
||
showError(err, false);
|
||
}
|
||
}
|
||
}
|
||
|
||
function copyEditorSelection(event) {
|
||
if (!state.editing || !canvasEditor.enabled || !canvasEditor.selected || !el.viewerPanel.contains(event.target)
|
||
|| canvasEditor.input || canvasEditor.crop || canvasEditor.drag || document.body.hasAttribute("data-busy")) return false;
|
||
const items = canvasEditor.items().slice().sort((a, b) => a.order - b.order);
|
||
const cut = event.type === "cut";
|
||
if (state.composite && items.some(item => item.container !== items[0].container)) {
|
||
event.preventDefault();
|
||
setStatus("请选择同一组内的对象");
|
||
return true;
|
||
}
|
||
if (!canEditObject(canvasEditor.selected, "copy") || cut && !canEditObject(canvasEditor.selected, "delete")) {
|
||
event.preventDefault();
|
||
setStatus(cut ? "对象不可剪切" : "对象不可复制");
|
||
return true;
|
||
}
|
||
const index = items[0].index, ids = items.map(item => item.id);
|
||
const clipboard = { token: crypto.randomUUID(), page: state.doc.pages[index].id, scope: state.composite?.key || "", bounds: selectionBounds(items), x: 0, y: 0, cut,
|
||
compound: items.some(item => item.type === "CompositeObject" || item.type === "CompositeGraphicUnit") };
|
||
if (canvasEditor.nudge) {
|
||
clipboard.bounds.x += canvasEditor.nudge.x;
|
||
clipboard.bounds.y += canvasEditor.nudge.y;
|
||
}
|
||
event.clipboardData.setData(OBJECT_CLIPBOARD_TYPE, clipboard.token);
|
||
event.clipboardData.setData("text/plain", items.map(item => item.text || "").filter(Boolean).join("\n"));
|
||
event.preventDefault();
|
||
state.objectClipboard = clipboard;
|
||
const capture = () => {
|
||
const openSeq = state.openSeq, revision = state.editorInfo.revision;
|
||
return callWASM("ofdgoCaptureObjects", index, ids, clipboard.token, clipboard.scope).then(async () => {
|
||
clipboard.cut = false;
|
||
if (cut && openSeq === state.openSeq && revision === state.editorInfo?.revision && index === state.pageIndex && state.objectClipboard === clipboard && clipboard.scope === (state.composite?.key || "")) {
|
||
clipboard.cut = Boolean(await changeDocument("ofdgoDeleteObjects", { index, id: ids, items }));
|
||
}
|
||
return true;
|
||
}, err => {
|
||
if (state.objectClipboard === clipboard) showError(err, false);
|
||
return false;
|
||
});
|
||
};
|
||
clipboard.ready = canvasEditor.nudge ? canvasEditor.commitNudge().then(saved => saved ? capture() : false) : capture();
|
||
return true;
|
||
}
|
||
|
||
function visiblePageBounds(index) {
|
||
const page = state.doc.pages[index], surface = pageShell(index)?.querySelector(".page-surface");
|
||
const full = { x: 0, y: 0, width: page.width, height: page.height };
|
||
if (!surface) return full;
|
||
const rect = surface.getBoundingClientRect(), view = el.viewerPanel.getBoundingClientRect();
|
||
const left = Math.max(rect.left, view.left), top = Math.max(rect.top, view.top);
|
||
const right = Math.min(rect.right, view.left + el.viewerPanel.clientWidth), bottom = Math.min(rect.bottom, view.top + el.viewerPanel.clientHeight);
|
||
if (right <= left || bottom <= top) return full;
|
||
const a = pagePoint(left, top, rect, page, state.rotation), b = pagePoint(right, bottom, rect, page, state.rotation);
|
||
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(a.x - b.x), height: Math.abs(a.y - b.y) };
|
||
}
|
||
|
||
async function pasteEditorContent(event) {
|
||
if (!state.editing || event.target.closest?.("input, textarea, select, [contenteditable]") || formDialogOpen()) return;
|
||
event.preventDefault();
|
||
const token = event.clipboardData.getData(OBJECT_CLIPBOARD_TYPE);
|
||
const files = Array.from(event.clipboardData.files);
|
||
const value = event.clipboardData.getData("text/plain").replace(/\t/g, " ");
|
||
if ((canvasEditor.nudge || canvasEditor.nudgeCommit) && !await canvasEditor.commitNudge()) return;
|
||
if (document.body.hasAttribute("data-busy") || canvasEditor.input || canvasEditor.crop || canvasEditor.drag) return;
|
||
const index = state.pageIndex, page = state.doc.pages[index];
|
||
if (token) {
|
||
const clipboard = state.objectClipboard;
|
||
if (!clipboard || token !== clipboard.token) {
|
||
setStatus("复制内容已失效,请重新复制");
|
||
return;
|
||
}
|
||
const openSeq = state.openSeq;
|
||
if (!await clipboard.ready || openSeq !== state.openSeq || index !== state.pageIndex || state.objectClipboard !== clipboard) return;
|
||
const scope = state.composite?.key || "";
|
||
if (clipboard.compound && (scope || clipboard.scope) && (scope !== clipboard.scope || page.id !== clipboard.page)) {
|
||
setStatus("复合对象请在原范围粘贴");
|
||
return;
|
||
}
|
||
if (!pageCan("insert")) return;
|
||
const visible = visiblePageBounds(index), bounds = clipboard.bounds;
|
||
const offset = (clipboard.targetPage || clipboard.page) === page.id ? { x: clipboard.x + (clipboard.cut ? 0 : 3), y: clipboard.y + (clipboard.cut ? 0 : 3) } : { x: 0, y: 0 };
|
||
for (const [axis, size] of [["x", "width"], ["y", "height"]]) {
|
||
const position = bounds[axis] + offset[axis];
|
||
if (position < visible[axis] || position + bounds[size] > visible[axis] + visible[size]) {
|
||
const margin = Math.min(20, visible[size] / 10);
|
||
offset[axis] = Math.max(visible[axis] + margin, Math.min(position, visible[axis] + visible[size] - margin - bounds[size])) - bounds[axis];
|
||
}
|
||
}
|
||
if (await changeDocument("ofdgoPasteObjects", null, index, token, offset.x, offset.y, state.composite?.key || "")) {
|
||
clipboard.targetPage = page.id;
|
||
clipboard.x = offset.x;
|
||
clipboard.y = offset.y;
|
||
clipboard.cut = false;
|
||
}
|
||
return;
|
||
}
|
||
if (!pageCan("insert")) return;
|
||
const visible = visiblePageBounds(index), margin = Math.min(20, visible.width / 10);
|
||
const x = visible.x + margin, y = visible.y + Math.min(20, visible.height / 10), width = visible.width - margin * 2;
|
||
if (files.length) {
|
||
if (files.length !== 1) {
|
||
setStatus("一次粘贴一张图片");
|
||
return;
|
||
}
|
||
if (!/^image\/(png|jpeg)$/.test(files[0].type)) {
|
||
setStatus("仅支持PNG、JPG图片");
|
||
return;
|
||
}
|
||
await changeDocument("ofdgoInsertImage", null, index, files[0].arrayBuffer().then(bytes => new Uint8Array(bytes)), x, y, width);
|
||
return;
|
||
}
|
||
if (!value.trim()) return;
|
||
const font = state.textFonts[Number(fontPicker.value)];
|
||
if (!font || font.disabled) {
|
||
setStatus("尚未添加字体");
|
||
el.textFontAdd.focus();
|
||
return;
|
||
}
|
||
const style = canvasEditor.selected?.items ? state.textDefaults : currentTextStyle();
|
||
const data = readTextFont(font);
|
||
await changeDocument("ofdgoInsertText", null, index, value, data, x, y, style.size, style.color,
|
||
width, style.wrap, style.align || "left", style.paragraphHeight || 0, style.letterSpacing || 0, ...textIndents(style));
|
||
}
|
||
|
||
function copySelection(event) {
|
||
if (sealPreview) return;
|
||
if (event.target.closest?.("input, textarea, [contenteditable]")) {
|
||
return;
|
||
}
|
||
const selection = document.getSelection();
|
||
if (selection.isCollapsed && copyEditorSelection(event)) return;
|
||
if (selection.isCollapsed || !selection.rangeCount) {
|
||
return;
|
||
}
|
||
const range = selection.getRangeAt(0);
|
||
if (state.documentSelection && isDocumentSelection(range)) {
|
||
event.preventDefault();
|
||
if (state.documentSelection.text === null) {
|
||
setStatus("正在全选文字");
|
||
} else {
|
||
event.clipboardData.setData("text/plain", state.documentSelection.text);
|
||
}
|
||
return;
|
||
}
|
||
if (!el.svgHost.contains(range.startContainer) || !el.svgHost.contains(range.endContainer)) {
|
||
return;
|
||
}
|
||
const lines = [];
|
||
for (const run of el.svgHost.querySelectorAll(".text-run")) {
|
||
if (!range.intersectsNode(run)) {
|
||
continue;
|
||
}
|
||
const part = range.cloneRange();
|
||
if (!run.contains(part.startContainer)) {
|
||
part.setStart(run, 0);
|
||
}
|
||
if (!run.contains(part.endContainer)) {
|
||
part.setEnd(run, run.childNodes.length);
|
||
}
|
||
const text = part.cloneContents().textContent;
|
||
if (text) {
|
||
lines.push(text);
|
||
}
|
||
}
|
||
if (!lines.length) {
|
||
return;
|
||
}
|
||
event.clipboardData.setData("text/plain", lines.join("\n"));
|
||
event.preventDefault();
|
||
}
|
||
|
||
function markFlowPageError(index, err) {
|
||
const shell = pageShell(index);
|
||
if (!shell) {
|
||
return;
|
||
}
|
||
shell.classList.add("error");
|
||
const placeholder = shell.querySelector(".page-placeholder");
|
||
if (placeholder) {
|
||
placeholder.textContent = String(err.message || err);
|
||
}
|
||
}
|
||
|
||
function queueNearbyPages(index, openSeq = state.openSeq) {
|
||
for (let i = Math.max(0, index - 1); i <= Math.min((state.doc?.pageCount || 1) - 1, index + 2); i += 1) {
|
||
renderFlowPage(i, { openSeq, priority: 2 });
|
||
}
|
||
}
|
||
|
||
function pageShell(index) {
|
||
return state.pageWindow?.nodes.get(index) || null;
|
||
}
|
||
|
||
function scrollToPage(index) {
|
||
if (state.pageWindow) syncPageWindow(state.pageWindow, state.pageWindow.offsets[index]);
|
||
const shell = pageShell(index);
|
||
if (shell) {
|
||
if (state.pageWindow) {
|
||
const shellRect = shell.getBoundingClientRect(), viewerRect = el.viewerPanel.getBoundingClientRect();
|
||
const space = state.fitMode === "height" && !state.continuous
|
||
? (el.viewerPanel.clientHeight - shellRect.height) / 2 : pageBlockSpace();
|
||
scrollViewerBy(shellRect.top - viewerRect.top - space);
|
||
} else if (state.fitMode === "height" && !state.continuous) {
|
||
shell.scrollIntoView({ block: "center", inline: "nearest" });
|
||
} else {
|
||
const viewerRect = el.viewerPanel.getBoundingClientRect();
|
||
const shellRect = shell.getBoundingClientRect();
|
||
const top = el.viewerPanel.scrollTop + shellRect.top - viewerRect.top - pageBlockSpace();
|
||
const maxTop = Math.max(0, el.viewerPanel.scrollHeight - el.viewerPanel.clientHeight);
|
||
el.viewerPanel.scrollTop = Math.min(maxTop, Math.max(0, top));
|
||
}
|
||
centerPageInline(shell);
|
||
}
|
||
}
|
||
|
||
function centerPageInline(shell) {
|
||
const viewerRect = el.viewerPanel.getBoundingClientRect();
|
||
const shellRect = shell.getBoundingClientRect();
|
||
el.viewerPanel.scrollLeft += shellRect.left + shellRect.width / 2 - viewerRect.left - el.viewerPanel.clientWidth / 2;
|
||
}
|
||
|
||
function schedulePageSync() {
|
||
if (state.scrollFrame || !state.doc) {
|
||
return;
|
||
}
|
||
state.scrollFrame = requestAnimationFrame(() => {
|
||
state.scrollFrame = 0;
|
||
syncCurrentPageFromScroll();
|
||
});
|
||
}
|
||
|
||
function syncCurrentPageFromScroll() {
|
||
if (state.openActionTask?.navigating) return;
|
||
syncPageWindow(state.pageWindow);
|
||
const shell = pageShellFromView();
|
||
if (!shell) {
|
||
return;
|
||
}
|
||
const nextIndex = Number.parseInt(shell.dataset.pageIndex, 10);
|
||
if (Number.isFinite(nextIndex) && nextIndex !== state.pageIndex) {
|
||
setCurrentPage(nextIndex);
|
||
queueNearbyPages(nextIndex);
|
||
scheduleOpenActions();
|
||
}
|
||
prunePageRenderQueue();
|
||
}
|
||
|
||
function prunePageRenderQueue() {
|
||
state.pageRenderQueue = state.pageRenderQueue.filter(task => {
|
||
if (task.priority < 2 || Math.abs(task.index - state.pageIndex) <= 2
|
||
|| state.pageWindow?.nodes.has(task.index) || state.thumbnailWindow?.nodes.has(task.index)) return true;
|
||
state.pageInFlight.delete(task.key);
|
||
task.resolve(null);
|
||
return false;
|
||
});
|
||
}
|
||
|
||
function pageShellFromView() {
|
||
const rect = el.viewerPanel.getBoundingClientRect();
|
||
const x = rect.left + rect.width / 2;
|
||
if (el.viewerPanel.scrollTop + el.viewerPanel.clientHeight >= el.viewerPanel.scrollHeight - 1) {
|
||
const shell = pageShell(state.pageIndex);
|
||
const bounds = shell?.getBoundingClientRect();
|
||
return bounds && bounds.top >= rect.top - 1 && bounds.bottom <= rect.bottom + 1
|
||
? shell : el.svgHost.lastElementChild;
|
||
}
|
||
if (state.continuous) {
|
||
return pageShellAtPoint(x, rect.top + 1);
|
||
}
|
||
return pageShellAtPoint(x, rect.top + rect.height * 0.45)
|
||
|| pageShellAtPoint(x, rect.top + rect.height * 0.25)
|
||
|| pageShellAtPoint(x, rect.top + rect.height * 0.65);
|
||
}
|
||
|
||
function pageShellAtPoint(x, y) {
|
||
return document.elementFromPoint(x, y)?.closest?.(".page-shell") || null;
|
||
}
|
||
|
||
function setCurrentPage(index) {
|
||
if (index !== state.pageIndex && !state.openActionTask?.navigating) state.openActionTask = null;
|
||
if (state.composite && state.composite.index !== index) resetCompositeScope();
|
||
state.pageIndex = index;
|
||
if (state.editing) state.openActionPage = state.doc?.pages[index]?.id;
|
||
if (state.fitMode === "width") {
|
||
state.scale = fitWidthScale(currentPageInfo());
|
||
el.zoomLabel.textContent = `${Math.round(state.scale * 100)}%`;
|
||
}
|
||
updatePageListCurrent();
|
||
updateControls();
|
||
if (state.doc && !state.exporting) {
|
||
setStatus(pageStatus(index, state.doc.pageCount));
|
||
}
|
||
}
|
||
|
||
function updatePageListCurrent(force = false) {
|
||
if (state.thumbnailWindow) {
|
||
if (!force && state.pageListCurrent === state.pageIndex) return;
|
||
state.pageListCurrent = state.pageIndex;
|
||
if (state.showPages && !el.pageList.hidden) revealThumbnail(state.pageIndex);
|
||
}
|
||
const current = el.pageList.querySelector(".page-list-item[data-current]");
|
||
if (current) {
|
||
if (!force && Number.parseInt(current.dataset.pageIndex, 10) === state.pageIndex) {
|
||
return;
|
||
}
|
||
current.removeAttribute("data-current");
|
||
}
|
||
const next = el.pageList.querySelector(`.page-list-item[data-page-index="${state.pageIndex}"]`);
|
||
if (next) {
|
||
setPageItemCurrent(next, true);
|
||
if (state.showPages && !el.pageList.hidden && !state.thumbnailWindow) {
|
||
const panel = el.navigationContent.getBoundingClientRect();
|
||
const item = next.getBoundingClientRect();
|
||
if (item.top < panel.top) {
|
||
el.navigationContent.scrollTop += item.top - panel.top;
|
||
} else if (item.bottom > panel.bottom) {
|
||
el.navigationContent.scrollTop += Math.min(item.top - panel.top, item.bottom - panel.bottom);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function setPageItemCurrent(item, current) {
|
||
if (current) {
|
||
item.setAttribute("data-current", "page");
|
||
return;
|
||
}
|
||
item.removeAttribute("data-current");
|
||
}
|
||
|
||
function layoutPages() {
|
||
if (!state.doc) {
|
||
return;
|
||
}
|
||
canvasEditor.cancel();
|
||
const view = state.pageWindow;
|
||
if (!view) return;
|
||
const anchor = view.offsets.length > 1 ? pageWindowAnchor(view) : { index: state.pageIndex, offset: 0 };
|
||
const gap = Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0;
|
||
const offsets = new Float64Array(state.doc.pages.length + 1);
|
||
const fittedWidth = state.scale * pageViewSize(currentPageInfo()).width;
|
||
let width = 1;
|
||
for (let index = 0; index < state.doc.pages.length; index++) {
|
||
const size = pageViewSize(state.doc.pages[index]);
|
||
const scale = state.fitMode === "width" ? fittedWidth / size.width : state.scale;
|
||
offsets[index + 1] = offsets[index] + Math.max(1, size.height * MM_TO_PX) * scale + gap;
|
||
width = Math.max(width, Math.max(1, size.width * MM_TO_PX) * scale);
|
||
}
|
||
view.offsets = offsets;
|
||
view.gap = gap;
|
||
view.total = Math.max(0, offsets.at(-1) - gap);
|
||
view.host.style.width = `${width}px`;
|
||
for (const [index, shell] of view.nodes) layoutPageShell(shell, state.doc.pages[index]);
|
||
syncPageWindow(view, offsets[anchor.index] + anchor.offset);
|
||
}
|
||
|
||
function layoutPageShell(shell, page) {
|
||
const width = Math.max(1, page.width * MM_TO_PX);
|
||
const height = Math.max(1, page.height * MM_TO_PX);
|
||
const size = pageViewSize(page);
|
||
const viewWidth = Math.max(1, size.width * MM_TO_PX);
|
||
const viewHeight = Math.max(1, size.height * MM_TO_PX);
|
||
const scale = state.fitMode === "width" ? state.scale * pageViewSize(currentPageInfo()).width / size.width : state.scale;
|
||
shell.style.width = `${viewWidth * scale}px`;
|
||
shell.style.height = `${viewHeight * scale}px`;
|
||
const surface = shell.querySelector(".page-surface");
|
||
if (surface) {
|
||
surface.style.width = `${width}px`;
|
||
surface.style.height = `${height}px`;
|
||
const x = state.rotation === 90 || state.rotation === 180 ? viewWidth : 0;
|
||
const y = state.rotation >= 180 ? viewHeight : 0;
|
||
surface.style.transform = `scale(${scale}) translate(${x}px, ${y}px) rotate(${state.rotation}deg)`;
|
||
surface.style.setProperty("--surface-scale", String(scale));
|
||
surface.style.setProperty("--surface-rotation", `${state.rotation}deg`);
|
||
surface.classList.toggle("sideways", state.rotation % 180 !== 0);
|
||
}
|
||
}
|
||
|
||
function parseSVG(svgText, prefix = "", images = state.svgImages) {
|
||
const template = document.createElement("template");
|
||
template.innerHTML = svgText.replace(/xlink:href="(ofdgo-image-[a-f0-9]{64})"/g, (_, name) => `xlink:href="${images.get(name).url}"`);
|
||
const svg = document.adoptNode(template.content.firstElementChild);
|
||
prefixSVGIds(svg, prefix);
|
||
return svg;
|
||
}
|
||
|
||
function prefixSVGIds(svg, prefix) {
|
||
if (!prefix) {
|
||
return;
|
||
}
|
||
const idMap = new Map();
|
||
for (const node of svg.querySelectorAll("[id]")) {
|
||
const id = node.getAttribute("id");
|
||
if (!id) {
|
||
continue;
|
||
}
|
||
const next = `${prefix}-${id}`;
|
||
idMap.set(id, next);
|
||
node.setAttribute("id", next);
|
||
}
|
||
if (idMap.size === 0) {
|
||
return;
|
||
}
|
||
const replaceRef = (value) => {
|
||
let next = value.replace(/url\(#([^)]+)\)/g, (match, id) => {
|
||
const mapped = idMap.get(id);
|
||
return mapped ? `url(#${mapped})` : match;
|
||
});
|
||
if (next.startsWith("#")) {
|
||
const mapped = idMap.get(next.slice(1));
|
||
if (mapped) {
|
||
next = `#${mapped}`;
|
||
}
|
||
}
|
||
return next;
|
||
};
|
||
const attrs = ["clip-path", "fill", "filter", "href", "marker-end", "marker-mid", "marker-start", "mask", "stroke", "style", "xlink:href"];
|
||
for (const attr of attrs) {
|
||
for (const node of svg.querySelectorAll(`[${attr === "xlink:href" ? "*|href" : attr}*="#"]`)) {
|
||
const value = node.getAttribute(attr);
|
||
if (!value) {
|
||
continue;
|
||
}
|
||
const next = replaceRef(value);
|
||
if (next !== value) {
|
||
node.setAttribute(attr, next);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function focusSearch() {
|
||
if (!state.showPages) {
|
||
toggleSidebar("pages");
|
||
}
|
||
showNavigation(el.searchTab);
|
||
el.searchInput.focus({ preventScroll: true });
|
||
el.searchInput.select();
|
||
}
|
||
|
||
function showNavigation(selected) {
|
||
const scrollTop = el.navigationContent.scrollTop;
|
||
for (const [tab, panel] of [[el.pagesTab, el.pageList], [el.outlinesTab, el.outlineList], [el.annotationsTab, el.annotationsPanel], [el.searchTab, el.searchPanel]]) {
|
||
if (tab.getAttribute("data-selected") === "true") {
|
||
state.navigationScroll.set(tab, scrollTop);
|
||
}
|
||
const active = tab === selected;
|
||
panel.hidden = !active;
|
||
tab.setAttribute("data-selected", String(active));
|
||
tab.tabIndex = active ? 0 : -1;
|
||
}
|
||
el.pageSelectionTools.hidden = !state.editing || !state.pageMultiSelect || selected !== el.pagesTab;
|
||
el.navigationContent.scrollTop = state.navigationScroll.get(selected) || 0;
|
||
if (selected === el.pagesTab) syncThumbnailWindow();
|
||
if (selected === el.annotationsTab) loadAnnotationSummary();
|
||
}
|
||
|
||
async function loadAnnotationSummary() {
|
||
if (!state.doc || !state.ready) return;
|
||
const seq = state.openSeq;
|
||
if (state.annotationSummary?.seq === seq) { renderAnnotationSummary(); return; }
|
||
const summary = {seq, items: [], loading: true};
|
||
state.annotationSummary = summary;
|
||
el.annotationsList.replaceChildren();
|
||
el.annotationsStatus.textContent = "正在读取注解";
|
||
try {
|
||
const items = await callWASM("ofdgoAnnotationInfos");
|
||
if (seq !== state.openSeq || state.annotationSummary !== summary) return;
|
||
summary.items = items || [];
|
||
const labels = {Path: "批注", Highlight: "高亮", Stamp: "印章", Watermark: "水印", Link: "链接"};
|
||
const kind = el.annotationsKind.value;
|
||
el.annotationsKind.replaceChildren(new Option("全部", ""), ...[...new Set(summary.items.map(annotationKind))].map(type => new Option(labels[type] || type, type)));
|
||
el.annotationsKind.value = [...el.annotationsKind.options].some(option => option.value === kind) ? kind : "";
|
||
summary.loading = false;
|
||
renderAnnotationSummary();
|
||
} catch (error) {
|
||
if (seq === state.openSeq && state.annotationSummary === summary) {
|
||
state.annotationSummary = null;
|
||
el.annotationsStatus.textContent = error.message;
|
||
}
|
||
}
|
||
}
|
||
|
||
function annotationKind(annotation) {
|
||
return annotation.type === "Path" && annotation.subtype === "Link" ? "Link" : annotation.type;
|
||
}
|
||
|
||
function renderAnnotationSummary() {
|
||
const summary = state.annotationSummary;
|
||
if (!summary || summary.loading || summary.seq !== state.openSeq) return;
|
||
const query = el.annotationsFilter.value.trim().toLocaleLowerCase();
|
||
const kind = el.annotationsKind.value;
|
||
const labels = {Path: "批注", Highlight: "高亮", Stamp: "印章", Watermark: "水印", Link: "链接"};
|
||
const items = summary.items.filter(item => (!kind || annotationKind(item) === kind) && `${item.remark || ""}\n${item.creator || ""}\n${item.id}`.toLocaleLowerCase().includes(query));
|
||
el.annotationsStatus.textContent = summary.items.length ? `${items.length} / ${summary.items.length}` : "";
|
||
el.annotationsList.replaceChildren(...items.map(item => {
|
||
const row = document.createElement("div");
|
||
row.className = "annotation-summary";
|
||
const target = document.createElement("button");
|
||
target.type = "button";
|
||
target.className = "annotation-summary-target";
|
||
const heading = document.createElement("span"), content = document.createElement("span");
|
||
heading.textContent = `第${item.page}页 · ${labels[annotationKind(item)] || item.type}${item.visible ? "" : " · 隐藏"}`;
|
||
content.textContent = item.remark || item.id;
|
||
target.append(heading, content);
|
||
target.addEventListener("click", () => focusPageRegion(item, "注解", "center"));
|
||
row.append(target);
|
||
appendInfoLine(row, "作者", item.creator);
|
||
appendInfoLine(row, "时间", formatDocumentTime(item.lastModDate));
|
||
if (state.editing) {
|
||
const edit = document.createElement("button");
|
||
edit.type = "button";
|
||
edit.className = "small-button";
|
||
edit.textContent = "修改";
|
||
edit.addEventListener("click", () => {
|
||
if (!state.editing || document.body.hasAttribute("data-busy") || summary.seq !== state.openSeq) return;
|
||
editAnnotationDetails({...item, id: `annotation:${item.id}`, index: item.page - 1, type: "Annotation", fromSummary: true});
|
||
});
|
||
row.append(edit);
|
||
}
|
||
return row;
|
||
}));
|
||
}
|
||
|
||
function renderOutlines(reset = true) {
|
||
const selected = !reset && [el.pagesTab, el.outlinesTab, el.annotationsTab, el.searchTab].find(tab => tab.getAttribute("data-selected") === "true");
|
||
const scrollTop = reset ? 0 : el.navigationContent.scrollTop;
|
||
if (reset) {
|
||
state.navigationScroll.clear();
|
||
state.outlineExpanded.clear();
|
||
state.outlineSelection = null;
|
||
}
|
||
const outlines = state.doc.outlines || [];
|
||
el.pageListTitle.hidden = true;
|
||
el.navigationTabs.hidden = false;
|
||
el.outlinesTab.hidden = outlines.length === 0 && !state.editing;
|
||
el.outlineList.replaceChildren();
|
||
if (state.editing) {
|
||
const tools = document.createElement("div");
|
||
tools.className = "outline-tools";
|
||
for (const [action, label] of [["add", "新增"], ["update", "修改"], ["move", "移动"], ["delete", "删除"]]) {
|
||
const button = document.createElement("button");
|
||
button.className = "button";
|
||
button.type = "button";
|
||
button.textContent = label;
|
||
button.dataset.outlineAction = action;
|
||
button.disabled = action !== "add" && !state.outlineSelection?.length;
|
||
editorClick(button, () => openOutlinePanel(action));
|
||
tools.append(button);
|
||
}
|
||
el.outlineList.append(tools);
|
||
}
|
||
if (outlines.length > 0) {
|
||
el.outlineList.append(createOutlineList(outlines));
|
||
}
|
||
el.navigationContent.scrollTop = scrollTop;
|
||
showNavigation(selected && !selected.hidden ? selected : el.pagesTab);
|
||
}
|
||
|
||
function resetSearch(clearInput = true) {
|
||
state.searchSeq += 1;
|
||
state.searchQuery = "";
|
||
state.searchMatches = [];
|
||
state.searchIndex = -1;
|
||
if (clearInput) {
|
||
el.searchInput.value = "";
|
||
}
|
||
el.searchStatus.textContent = "";
|
||
el.searchResults.replaceChildren();
|
||
updateSearchCount();
|
||
clearSearchHighlights();
|
||
}
|
||
|
||
async function searchDocument() {
|
||
if (!state.doc || document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
const query = el.searchInput.value.trim();
|
||
if (query && query === state.searchQuery) {
|
||
await selectSearchMatch(state.searchIndex + 1);
|
||
return;
|
||
}
|
||
resetSearch(false);
|
||
if (!query) {
|
||
return;
|
||
}
|
||
state.searchQuery = query;
|
||
const seq = state.searchSeq;
|
||
const openSeq = state.openSeq;
|
||
const pageCount = state.doc.pageCount;
|
||
try {
|
||
for (let page = 0; page < pageCount; page += 1) {
|
||
const matches = await callWASM("ofdgoSearchPage", page, query);
|
||
if (seq !== state.searchSeq || openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
el.searchStatus.textContent = `正在搜索文字 · ${page + 1} / ${pageCount} 页`;
|
||
if (!matches?.length) {
|
||
continue;
|
||
}
|
||
for (const match of matches) {
|
||
state.searchMatches.push({ ...match, page });
|
||
}
|
||
renderSearchResults();
|
||
}
|
||
el.searchStatus.textContent = state.searchMatches.length ? "搜索完成" : "暂无结果";
|
||
if (state.searchIndex < 0 && state.searchMatches.length && !el.searchPanel.hidden && state.showPages) {
|
||
await selectSearchMatch(0);
|
||
}
|
||
} catch (err) {
|
||
if (seq === state.searchSeq && openSeq === state.openSeq) {
|
||
state.searchQuery = "";
|
||
el.searchStatus.textContent = "搜索失败";
|
||
showError(err, false);
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateSearchCount() {
|
||
const count = state.searchMatches.length;
|
||
el.searchCount.textContent = `${state.searchIndex + 1} / ${count}`;
|
||
el.searchPrev.disabled = count === 0;
|
||
el.searchNext.disabled = count === 0;
|
||
}
|
||
|
||
function renderSearchResults() {
|
||
const start = Math.floor(Math.max(0, state.searchIndex) / 50) * 50;
|
||
if (el.searchResults.firstElementChild && Number(el.searchResults.firstElementChild.dataset.matchIndex) !== start) {
|
||
el.searchResults.replaceChildren();
|
||
}
|
||
for (let i = start + el.searchResults.childElementCount; i < Math.min(start + 50, state.searchMatches.length); i += 1) {
|
||
const match = state.searchMatches[i];
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "search-result";
|
||
button.dataset.matchIndex = String(i);
|
||
const page = document.createElement("span");
|
||
page.className = "search-page";
|
||
page.textContent = `第${match.page + 1}页`;
|
||
const text = document.createElement("span");
|
||
const mark = document.createElement("mark");
|
||
mark.textContent = match.text;
|
||
text.append(match.before, mark, match.after);
|
||
button.append(page, text);
|
||
button.addEventListener("click", () => selectSearchMatch(i));
|
||
el.searchResults.append(button);
|
||
}
|
||
for (const button of el.searchResults.children) {
|
||
button.setAttribute("data-current", String(Number(button.dataset.matchIndex) === state.searchIndex));
|
||
}
|
||
updateSearchCount();
|
||
}
|
||
|
||
async function selectSearchMatch(index) {
|
||
const count = state.searchMatches.length;
|
||
if (!count || document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
index = (index + count) % count;
|
||
state.searchIndex = index;
|
||
const match = state.searchMatches[index];
|
||
const seq = state.searchSeq;
|
||
const openSeq = state.openSeq;
|
||
clearSearchHighlights();
|
||
renderSearchResults();
|
||
if (!await renderPage(match.page, { fit: false, scroll: false })) {
|
||
return;
|
||
}
|
||
await nextFrame();
|
||
if (seq !== state.searchSeq || openSeq !== state.openSeq || state.searchIndex !== index || state.pageIndex !== match.page) {
|
||
return;
|
||
}
|
||
renderSearchHighlights(match.page);
|
||
const mark = pageShell(match.page).querySelector(".search-highlight");
|
||
if (mark) {
|
||
const box = mark.getBoundingClientRect();
|
||
const viewer = el.viewerPanel.getBoundingClientRect();
|
||
scrollViewerBy(box.top + box.height / 2 - viewer.top - el.viewerPanel.clientHeight / 2);
|
||
if (box.left < viewer.left) {
|
||
el.viewerPanel.scrollLeft += box.left - viewer.left;
|
||
} else if (box.right > viewer.left + el.viewerPanel.clientWidth) {
|
||
el.viewerPanel.scrollLeft += box.right - viewer.left - el.viewerPanel.clientWidth;
|
||
}
|
||
} else {
|
||
scrollToPage(match.page);
|
||
}
|
||
if (state.editing) {
|
||
const node = [...pageShell(match.page).querySelectorAll(".edit-object")].find(node => canvasEditor.nodes.get(node)?.id === match.id);
|
||
const item = node && canvasEditor.nodes.get(node);
|
||
if (item?.type === "TextObject") {
|
||
state.selectObjects = true;
|
||
setPan(false);
|
||
canvasEditor.setTool("");
|
||
canvasEditor.select(item);
|
||
updateControls();
|
||
} else canvasEditor.select(null);
|
||
}
|
||
const button = el.searchResults.querySelector("[data-current=true]");
|
||
const row = button.getBoundingClientRect();
|
||
const panel = el.searchResults.getBoundingClientRect();
|
||
if (row.top < panel.top) {
|
||
el.searchResults.scrollTop += row.top - panel.top;
|
||
} else if (row.bottom > panel.bottom) {
|
||
el.searchResults.scrollTop += row.bottom - panel.bottom;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function clearSearchHighlights() {
|
||
for (const mark of el.svgHost.querySelectorAll(".search-highlight")) {
|
||
mark.remove();
|
||
}
|
||
}
|
||
|
||
function renderSearchHighlights(index) {
|
||
const match = state.searchMatches[state.searchIndex];
|
||
if (!match || match.page !== index) {
|
||
return;
|
||
}
|
||
const surface = pageShell(index).querySelector(".page-surface");
|
||
for (const mark of surface.querySelectorAll(".search-highlight")) {
|
||
mark.remove();
|
||
}
|
||
for (const box of match.boxes || []) {
|
||
const mark = document.createElement("span");
|
||
mark.className = "search-highlight";
|
||
mark.style.left = `${box.X * MM_TO_PX}px`;
|
||
mark.style.top = `${box.Y * MM_TO_PX}px`;
|
||
mark.style.width = `${box.W * MM_TO_PX}px`;
|
||
mark.style.height = `${box.H * MM_TO_PX}px`;
|
||
surface.append(mark);
|
||
}
|
||
}
|
||
|
||
function scheduleOpenActions() {
|
||
if (!state.openActionEnabled || state.openActionFrame) return;
|
||
state.openActionFrame = requestAnimationFrame(() => {
|
||
state.openActionFrame = 0;
|
||
activateOpenActions();
|
||
});
|
||
}
|
||
|
||
async function activateOpenActions() {
|
||
if (!state.openActionEnabled || !state.doc || state.editing || state.exporting || document.body.hasAttribute("data-busy")) return;
|
||
const pageID = state.doc.pages[state.pageIndex]?.id;
|
||
if (!pageID || !state.openActionDocument && state.openActionPage === pageID) return;
|
||
const task = { sequence: state.openSeq, seen: new Set(), navigating: false };
|
||
state.openActionTask = task;
|
||
const current = () => task === state.openActionTask && task.sequence === state.openSeq && !state.editing;
|
||
let documentOpened = state.openActionDocument;
|
||
state.openActionDocument = false;
|
||
state.openActionPage = pageID;
|
||
try {
|
||
while (current()) {
|
||
const index = state.pageIndex;
|
||
const id = state.doc.pages[index]?.id;
|
||
if (!id || !documentOpened && task.seen.has(id)) break;
|
||
if (!documentOpened) {
|
||
task.seen.add(id);
|
||
state.openActionPage = id;
|
||
}
|
||
const page = await renderFlowPage(index, { openSeq: task.sequence, priority: 0, throwError: true });
|
||
if (!page || !current()) break;
|
||
const links = documentOpened ? await callWASM("ofdgoOpenLinks") : await callWASM("ofdgoOpenLinks", index);
|
||
if (!current()) break;
|
||
for (const link of links) {
|
||
if (!current()) break;
|
||
const info = state.doc.pages[state.pageIndex];
|
||
const surface = pageShell(state.pageIndex)?.querySelector(".page-surface");
|
||
const target = pageLinkTarget(link, info, surface, task);
|
||
if (!target) continue;
|
||
if ((target.external || link.attachment) && !window.confirm(link.attachment
|
||
? `文档请求下载附件,是否继续?\n${link.fileName || ""}` : `文档请求打开链接,是否继续?\n${target.href}`)) return;
|
||
task.navigating = !!link.dest;
|
||
if (await target.activate() === false) return;
|
||
if (task.navigating) await nextFrame();
|
||
task.navigating = false;
|
||
}
|
||
if (documentOpened) documentOpened = false;
|
||
else if (state.doc.pages[state.pageIndex]?.id === id) break;
|
||
}
|
||
} catch (err) {
|
||
if (current()) setStatus(err.message || "动作无法执行");
|
||
} finally {
|
||
if (task === state.openActionTask) {
|
||
state.openActionPage = state.doc?.pages[state.pageIndex]?.id;
|
||
state.openActionTask = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function activateOutline(path) {
|
||
if (document.body.hasAttribute("data-busy") || state.editing) return;
|
||
state.openActionTask = null;
|
||
const sequence = state.openSeq;
|
||
const request = ++state.outlineSeq;
|
||
try {
|
||
const links = await callWASM("ofdgoOutlineLinks", path);
|
||
for (const link of links) {
|
||
if (sequence !== state.openSeq || request !== state.outlineSeq || state.editing) break;
|
||
const page = state.doc?.pages[state.pageIndex];
|
||
const surface = pageShell(state.pageIndex)?.querySelector(".page-surface");
|
||
const target = pageLinkTarget(link, page, surface);
|
||
if (target && await target.activate() === false) break;
|
||
}
|
||
} catch (err) {
|
||
if (sequence === state.openSeq && request === state.outlineSeq && !state.editing) setStatus(err.message || "目录无法打开");
|
||
}
|
||
}
|
||
|
||
function createOutlineList(outlines, path = []) {
|
||
const list = document.createElement("ul");
|
||
list.className = "outline-list";
|
||
for (const [index, outline] of outlines.entries()) {
|
||
const current = [...path, index];
|
||
const item = document.createElement("li");
|
||
const hasChildren = outline.children?.length > 0;
|
||
const row = document.createElement(hasChildren ? "summary" : "div");
|
||
if (!hasChildren) {
|
||
row.className = "outline-leaf";
|
||
}
|
||
const link = document.createElement(outline.actionCount || outline.page || state.editing ? "button" : "span");
|
||
link.className = "outline-link";
|
||
link.classList.toggle("selected", state.editing && JSON.stringify(current) === JSON.stringify(state.outlineSelection));
|
||
const title = document.createElement("span");
|
||
title.textContent = outline.title;
|
||
link.append(title);
|
||
if (outline.page) {
|
||
link.type = "button";
|
||
const page = document.createElement("span");
|
||
page.className = "outline-page";
|
||
page.textContent = String(outline.page);
|
||
link.append(page);
|
||
}
|
||
if (outline.actionCount || outline.page || state.editing) {
|
||
link.type = "button";
|
||
link.addEventListener("keydown", async event => {
|
||
if (!state.editing || event.defaultPrevented || event.isComposing || !event.altKey || event.ctrlKey || event.metaKey || event.shiftKey
|
||
|| !["ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||
event.preventDefault();
|
||
const target = index + (event.key === "ArrowUp" ? -1 : 1);
|
||
if (event.repeat || document.body.hasAttribute("data-busy") || target < 0 || target >= outlines.length) return;
|
||
if (await changeDocument("ofdgoMoveOutline", null, current, path, target)) {
|
||
const selected = el.outlineList.querySelector(".outline-link.selected");
|
||
selected?.focus({ preventScroll: true });
|
||
selected?.scrollIntoView({ block: "nearest" });
|
||
}
|
||
});
|
||
link.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
if (state.editing) {
|
||
state.outlineSelection = current;
|
||
for (const node of el.outlineList.querySelectorAll(".outline-link")) node.classList.toggle("selected", node === link);
|
||
for (const button of el.outlineList.querySelectorAll("[data-outline-action]")) button.disabled = false;
|
||
if (outline.page) renderPage(outline.page - 1);
|
||
return;
|
||
}
|
||
activateOutline(current);
|
||
});
|
||
}
|
||
row.append(link);
|
||
if (hasChildren) {
|
||
const details = document.createElement("details");
|
||
const key = JSON.stringify(current);
|
||
details.open = state.outlineExpanded.get(key) ?? outline.expanded;
|
||
details.addEventListener("toggle", () => {
|
||
if (details.isConnected) state.outlineExpanded.set(key, details.open);
|
||
});
|
||
details.append(row, createOutlineList(outline.children, current));
|
||
item.append(details);
|
||
} else {
|
||
item.append(row);
|
||
}
|
||
list.append(item);
|
||
}
|
||
return list;
|
||
}
|
||
|
||
const pageIndexes = new WeakMap();
|
||
|
||
function pageIndexByID(pages, id) {
|
||
let indexes = pageIndexes.get(pages);
|
||
if (!indexes) {
|
||
indexes = new Map(pages.map((page, position) => [referenceIDKey(page.id), {page, position}]));
|
||
pageIndexes.set(pages, indexes);
|
||
}
|
||
const entry = indexes.get(referenceIDKey(id));
|
||
return entry ? entry.page.index ?? entry.position : -1;
|
||
}
|
||
|
||
function referenceIDKey(id) {
|
||
const value = String(id ?? ""), trimmed = value.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, "");
|
||
return /^\d+$/.test(trimmed) && Number(trimmed) > 0 && Number(trimmed) <= 4294967295 ? trimmed.replace(/^0+(?=\d)/, "") : value;
|
||
}
|
||
|
||
function selectedPageIndexes() {
|
||
if (!state.pageSelection.size) return state.pageMultiSelect ? [] : [state.pageIndex];
|
||
return [...state.pageSelection].map(id => pageIndexByID(state.doc.pages, id)).filter(index => index >= 0).sort((a, b) => a - b);
|
||
}
|
||
|
||
function changeSelectedPages(action, direction = 0) {
|
||
const indexes = selectedPageIndexes();
|
||
if (!indexes.length) return;
|
||
if (indexes.length === 1) return changeDocument("ofdgoChangePage", null, action, indexes[0], ...(direction ? [indexes[0] + direction] : []));
|
||
const at = direction < 0 ? indexes[0] - 1 : indexes.at(-1) + 2;
|
||
return changeDocument("ofdgoBatchPages", null, action, selectedPageRange(), ...(direction ? [at] : []));
|
||
}
|
||
|
||
function selectedPageRange(indexes = selectedPageIndexes()) {
|
||
const pages = indexes.map(index => index + 1);
|
||
const ranges = [];
|
||
for (let i = 0; i < pages.length; i++) {
|
||
const first = pages[i];
|
||
while (pages[i + 1] === pages[i] + 1) i++;
|
||
ranges.push(first === pages[i] ? String(first) : `${first}-${pages[i]}`);
|
||
}
|
||
return ranges.join(",");
|
||
}
|
||
|
||
function syncPageSelection() {
|
||
if (!state.editing) state.pageMultiSelect = false;
|
||
el.pageSelectionTools.hidden = !state.editing || !state.pageMultiSelect || el.pageList.hidden;
|
||
el.pageSelectionCount.textContent = `已选${state.pageSelection.size}页`;
|
||
const allSelected = state.pageSelection.size === state.doc?.pageCount;
|
||
el.selectAllPages.textContent = allSelected ? "取消" : "全选";
|
||
for (const button of el.pageList.children) {
|
||
if (state.editing) button.setAttribute("data-pressed", String(state.pageSelection.has(state.doc.pages[Number(button.dataset.pageIndex)].id)));
|
||
else button.removeAttribute("data-pressed");
|
||
}
|
||
}
|
||
|
||
function selectThumbnailPage(event, index) {
|
||
if (document.body.hasAttribute("data-busy")) return;
|
||
if (event.pointerId === state.pageTouchPointer) {
|
||
state.pageTouchPointer = null;
|
||
return;
|
||
}
|
||
if (state.editing) {
|
||
const page = state.doc.pages[index], additive = event.ctrlKey || event.metaKey || state.pageMultiSelect;
|
||
if (!additive) state.pageSelection.clear();
|
||
if (event.shiftKey) {
|
||
let anchor = pageIndexByID(state.doc.pages, state.pageSelectionAnchor);
|
||
if (anchor < 0) anchor = state.pageIndex;
|
||
for (let i = Math.min(anchor, index); i <= Math.max(anchor, index); i++) state.pageSelection.add(state.doc.pages[i].id);
|
||
} else {
|
||
if (additive && state.pageSelection.has(page.id)) state.pageSelection.delete(page.id);
|
||
else state.pageSelection.add(page.id);
|
||
state.pageSelectionAnchor = page.id;
|
||
}
|
||
syncPageSelection();
|
||
updateControls();
|
||
if (additive || event.shiftKey) return;
|
||
}
|
||
return renderPage(index);
|
||
}
|
||
|
||
el.selectAllPages.addEventListener("click", () => {
|
||
if (document.body.hasAttribute("data-busy")) return;
|
||
state.pageSelection = new Set(state.pageSelection.size === state.doc.pageCount ? [] : state.doc.pages.map(page => page.id));
|
||
syncPageSelection();
|
||
updateControls();
|
||
});
|
||
|
||
el.finishPageSelection.addEventListener("click", () => {
|
||
state.pageMultiSelect = false;
|
||
syncPageSelection();
|
||
updateControls();
|
||
});
|
||
|
||
el.pageList.addEventListener("pointerdown", event => {
|
||
cancelPageTouch?.();
|
||
state.pageTouchPointer = null;
|
||
if (!state.editing || event.pointerType !== "touch" || !event.isPrimary || document.body.hasAttribute("data-busy")) return;
|
||
const button = event.target.closest(".page-list-item");
|
||
if (!button || event.target.closest(".thumb-grip")) return;
|
||
const seq = state.openSeq, pointer = event.pointerId;
|
||
const timer = setTimeout(() => {
|
||
if (seq !== state.openSeq || !state.editing || document.body.hasAttribute("data-busy")) return;
|
||
if (!state.pageMultiSelect) state.pageSelection.clear();
|
||
state.pageMultiSelect = true;
|
||
state.pageTouchPointer = pointer;
|
||
state.pageSelection.add(state.doc.pages[Number(button.dataset.pageIndex)].id);
|
||
syncPageSelection();
|
||
updateControls();
|
||
}, 450);
|
||
const finish = next => {
|
||
if (next.pointerId !== pointer) return;
|
||
cancelPageTouch?.();
|
||
};
|
||
cancelPageTouch = () => {
|
||
clearTimeout(timer);
|
||
el.pageList.removeEventListener("pointermove", move);
|
||
el.pageList.removeEventListener("pointerup", finish);
|
||
el.pageList.removeEventListener("pointercancel", finish);
|
||
el.pageList.removeEventListener("lostpointercapture", finish);
|
||
cancelPageTouch = null;
|
||
};
|
||
const move = next => {
|
||
if (Math.hypot(next.clientX - event.clientX, next.clientY - event.clientY) > 8) finish(next);
|
||
};
|
||
el.pageList.addEventListener("pointermove", move);
|
||
el.pageList.addEventListener("pointerup", finish);
|
||
el.pageList.addEventListener("pointercancel", finish);
|
||
el.pageList.addEventListener("lostpointercapture", finish);
|
||
});
|
||
|
||
el.pageList.addEventListener("contextmenu", event => {
|
||
if (state.pageMultiSelect) event.preventDefault();
|
||
});
|
||
|
||
el.pageList.addEventListener("keydown", event => {
|
||
const button = event.target?.closest(".page-list-item");
|
||
if (state.thumbnailWindow && button && !event.altKey && !event.ctrlKey && !event.metaKey && !event.isComposing) {
|
||
const index = Number(button.dataset.pageIndex);
|
||
const next = { ArrowUp: index - 1, ArrowDown: index + 1, Home: 0, End: state.doc.pageCount - 1,
|
||
Tab: index + (event.shiftKey ? -1 : 1) }[event.key];
|
||
if (next >= 0 && next < state.doc.pageCount) {
|
||
event.preventDefault();
|
||
revealThumbnail(next);
|
||
state.thumbnailWindow.nodes.get(next)?.focus({ preventScroll: true });
|
||
return;
|
||
}
|
||
}
|
||
if (!state.editing || event.isComposing || document.body.hasAttribute("data-busy")) return;
|
||
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") {
|
||
state.pageSelection = new Set(state.doc.pages.map(page => page.id));
|
||
} else if (event.key === "Escape") {
|
||
state.pageSelection.clear();
|
||
state.pageSelectionAnchor = null;
|
||
state.pageMultiSelect = false;
|
||
} else return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
syncPageSelection();
|
||
updateControls();
|
||
});
|
||
|
||
function renderPageList() {
|
||
el.pageList.replaceChildren();
|
||
state.thumbnailWindow = null;
|
||
state.pageListCurrent = null;
|
||
state.visibleThumbnails.clear();
|
||
if (!state.doc) {
|
||
return;
|
||
}
|
||
if (state.thumbnailObserver) {
|
||
state.thumbnailObserver.disconnect();
|
||
}
|
||
const ids = new Set(state.doc.pages.map(page => page.id));
|
||
for (const id of state.pageSelection) if (!ids.has(id) || !state.editing) state.pageSelection.delete(id);
|
||
state.thumbnailWindow = createPageWindow(el.pageList, el.navigationContent, 180, createThumbnail, releaseThumbnail, observeThumbnail);
|
||
layoutThumbnails();
|
||
syncPageSelection();
|
||
}
|
||
|
||
function createThumbnail(page) {
|
||
const openSeq = state.openSeq;
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "page-list-item";
|
||
button.dataset.pageIndex = String(page.index);
|
||
if (state.editing) button.setAttribute("data-pressed", String(state.pageSelection.has(page.id)));
|
||
button.title = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
|
||
setPageItemCurrent(button, page.index === state.pageIndex);
|
||
layoutThumbnail(button, page);
|
||
|
||
const thumb = document.createElement("span");
|
||
thumb.className = "thumb-paper";
|
||
setThumbnailContent(thumb, state.pageCache.get(page.index)?.svg, page.index, openSeq);
|
||
|
||
const label = document.createElement("span");
|
||
label.className = "thumb-label";
|
||
label.textContent = `第${page.index + 1}页`;
|
||
|
||
const size = document.createElement("span");
|
||
size.className = "thumb-size";
|
||
size.textContent = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
|
||
|
||
button.append(thumb, label, size);
|
||
if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index);
|
||
button.addEventListener("click", event => selectThumbnailPage(event, page.index));
|
||
button.style.height = `${state.thumbnailWindow.offsets[page.index + 1] - state.thumbnailWindow.offsets[page.index] - 12}px`;
|
||
return button;
|
||
}
|
||
|
||
function releaseThumbnail(button, index) {
|
||
if (state.pageDragIndex === index || button.contains(document.activeElement)) return false;
|
||
state.thumbnailObserver?.unobserve(button);
|
||
state.visibleThumbnails.delete(index);
|
||
return true;
|
||
}
|
||
|
||
function layoutThumbnails() {
|
||
const view = state.thumbnailWindow;
|
||
if (!view) return;
|
||
const visible = !el.pageList.hidden && state.showPages;
|
||
const anchor = visible && view.offsets.length > 1 ? pageWindowAnchor(view) : view.anchor || { index: state.pageIndex, offset: 0 };
|
||
const width = el.navigationContent.clientWidth || view.width || 180;
|
||
view.width = width;
|
||
view.rotation = state.rotation;
|
||
const offsets = new Float64Array(state.doc.pages.length + 1);
|
||
for (let index = 0; index < state.doc.pages.length; index++) {
|
||
const size = pageViewSize(state.doc.pages[index]);
|
||
const ratio = size.width > 0 && size.height > 0 ? size.height / size.width : 1 / 0.707;
|
||
offsets[index + 1] = offsets[index] + Math.max(1, width - 20) * ratio + 70 + 12;
|
||
}
|
||
view.offsets = offsets;
|
||
view.gap = 12;
|
||
view.total = Math.ceil(offsets.at(-1));
|
||
for (const [index, button] of view.nodes) {
|
||
layoutThumbnail(button, state.doc.pages[index]);
|
||
button.style.height = `${offsets[index + 1] - offsets[index] - 12}px`;
|
||
}
|
||
view.pendingTop = offsets[anchor.index] + anchor.offset;
|
||
view.host.style.height = `${Math.min(SCROLL_SEGMENT_SIZE, view.total)}px`;
|
||
if (visible) {
|
||
syncPageWindow(view, view.pendingTop);
|
||
view.pendingTop = null;
|
||
}
|
||
}
|
||
|
||
function syncThumbnailWindow() {
|
||
const view = state.thumbnailWindow;
|
||
if (!view || el.pageList.hidden || !state.showPages) return;
|
||
if (el.navigationContent.clientWidth && (view.width !== el.navigationContent.clientWidth || view.rotation !== state.rotation)) layoutThumbnails();
|
||
syncPageWindow(view, view.pendingTop ?? null);
|
||
view.pendingTop = null;
|
||
prunePageRenderQueue();
|
||
}
|
||
|
||
function revealThumbnail(index) {
|
||
syncThumbnailWindow();
|
||
const view = state.thumbnailWindow;
|
||
if (!view) return;
|
||
const top = pageWindowTop(view);
|
||
const start = view.offsets[index], end = view.offsets[index + 1] - view.gap;
|
||
if (start < top || end > top + el.navigationContent.clientHeight) {
|
||
const target = start < top ? Math.floor(start) : Math.min(start, Math.ceil(end - el.navigationContent.clientHeight));
|
||
syncPageWindow(view, target);
|
||
}
|
||
}
|
||
|
||
function resetThumbnails() {
|
||
state.visibleThumbnails.clear();
|
||
state.thumbnailInFlight.clear();
|
||
if (state.thumbnailObserver) {
|
||
state.thumbnailObserver.disconnect();
|
||
state.thumbnailObserver = null;
|
||
}
|
||
}
|
||
|
||
function pageDropIndex(indexes, target, after) {
|
||
const position = target + Number(after);
|
||
return position - indexes.filter(index => index < position).length;
|
||
}
|
||
|
||
function selectDragPages(index) {
|
||
if (!state.pageSelection.has(state.doc.pages[index].id)) {
|
||
state.pageSelection = new Set([state.doc.pages[index].id]);
|
||
state.pageSelectionAnchor = state.doc.pages[index].id;
|
||
syncPageSelection();
|
||
updateControls();
|
||
}
|
||
return selectedPageIndexes();
|
||
}
|
||
|
||
function enablePageDrag(button, index) {
|
||
const grip = document.createElement("span");
|
||
grip.className = "thumb-grip";
|
||
grip.title = "拖动排序";
|
||
button.append(grip);
|
||
grip.addEventListener("click", event => event.stopPropagation());
|
||
grip.addEventListener("pointerdown", event => {
|
||
if (event.button !== 0 || document.body.hasAttribute("data-busy")) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const seq = state.openSeq, pointer = event.pointerId;
|
||
const indexes = selectDragPages(index);
|
||
state.pageDragIndex = index;
|
||
let x = event.clientX, y = event.clientY, position = null, marker = null, frame = 0, dragging = false;
|
||
const clearMarker = () => { marker?.classList.remove("drop-before", "drop-after"); marker = null; };
|
||
const locate = () => {
|
||
clearMarker();
|
||
position = null;
|
||
const node = document.elementFromPoint(x, y)?.closest(".page-list-item");
|
||
if (!node || !el.pageList.contains(node)) return;
|
||
const rect = node.getBoundingClientRect(), after = y > rect.top + rect.height / 2;
|
||
const target = Number(node.dataset.pageIndex), to = pageDropIndex(indexes, target, after);
|
||
if (indexes.some((index, offset) => index !== to + offset)) {
|
||
position = target + Number(after);
|
||
marker = node;
|
||
marker.classList.add(after ? "drop-after" : "drop-before");
|
||
}
|
||
};
|
||
const tick = () => {
|
||
if (seq !== state.openSeq || !state.editing) { finish(false); return; }
|
||
if (dragging) {
|
||
const rect = el.navigationContent.getBoundingClientRect();
|
||
const delta = y < rect.top + 48 ? -10 : y > rect.bottom - 48 ? 10 : 0;
|
||
if (delta) {
|
||
if (state.thumbnailWindow) scrollPageWindowBy(state.thumbnailWindow, delta);
|
||
else el.navigationContent.scrollTop += delta;
|
||
}
|
||
syncThumbnailWindow();
|
||
locate();
|
||
}
|
||
frame = requestAnimationFrame(tick);
|
||
};
|
||
const move = next => {
|
||
if (next.pointerId !== pointer) return;
|
||
x = next.clientX; y = next.clientY;
|
||
dragging ||= Math.hypot(x - event.clientX, y - event.clientY) > 4;
|
||
button.classList.toggle("reordering", dragging);
|
||
if (dragging) locate();
|
||
};
|
||
const finish = async commit => {
|
||
state.pageDragIndex = null;
|
||
cancelAnimationFrame(frame);
|
||
grip.removeEventListener("pointermove", move);
|
||
grip.removeEventListener("pointerup", up);
|
||
grip.removeEventListener("pointercancel", cancel);
|
||
grip.removeEventListener("lostpointercapture", cancel);
|
||
clearMarker();
|
||
button.classList.remove("reordering");
|
||
if (grip.hasPointerCapture(pointer)) grip.releasePointerCapture(pointer);
|
||
if (commit && dragging && position !== null && seq === state.openSeq && await canvasEditor.commitText() && await canvasEditor.commitCrop()) {
|
||
await changeDocument("ofdgoBatchPages", null, "move", selectedPageRange(indexes), position);
|
||
}
|
||
};
|
||
const up = next => { if (next.pointerId === pointer) finish(true); };
|
||
const cancel = () => finish(false);
|
||
grip.setPointerCapture(pointer);
|
||
grip.addEventListener("pointermove", move);
|
||
grip.addEventListener("pointerup", up);
|
||
grip.addEventListener("pointercancel", cancel);
|
||
grip.addEventListener("lostpointercapture", cancel);
|
||
frame = requestAnimationFrame(tick);
|
||
});
|
||
button.addEventListener("keydown", async event => {
|
||
if (!event.altKey || !["ArrowUp", "ArrowDown"].includes(event.key) || document.body.hasAttribute("data-busy")) return;
|
||
event.preventDefault();
|
||
const indexes = selectDragPages(index), direction = event.key === "ArrowUp" ? -1 : 1;
|
||
const target = direction < 0 ? indexes[0] - 1 : indexes.at(-1) + 1;
|
||
if (target >= 0 && target < state.doc.pageCount && await canvasEditor.commitText() && await canvasEditor.commitCrop()) {
|
||
const focus = (direction < 0 ? target : target - indexes.length + 1) + indexes.indexOf(index);
|
||
if (await changeSelectedPages("move", direction)) {
|
||
revealThumbnail(focus);
|
||
el.pageList.querySelector(`[data-page-index="${focus}"]`)?.focus({ preventScroll: true });
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function layoutThumbnail(button, page) {
|
||
if (page.width <= 0 || page.height <= 0) {
|
||
return;
|
||
}
|
||
const size = pageViewSize(page);
|
||
button.style.setProperty("--thumb-ratio", `${size.width} / ${size.height}`);
|
||
button.style.setProperty("--thumb-width", `${page.width / size.width * 100}%`);
|
||
button.style.setProperty("--thumb-height", `${page.height / size.height * 100}%`);
|
||
button.style.setProperty("--thumb-rotation", `${state.rotation}deg`);
|
||
}
|
||
|
||
function setThumbnailContent(container, svgText, index, openSeq = state.openSeq) {
|
||
const renderKey = `${openSeq}:${index}`;
|
||
if (svgText && container.dataset.renderKey === renderKey) {
|
||
return;
|
||
}
|
||
delete container.dataset.renderKey;
|
||
container.replaceChildren();
|
||
if (!svgText) {
|
||
container.classList.add("pending");
|
||
container.textContent = String(index + 1);
|
||
return;
|
||
}
|
||
try {
|
||
const svg = parseSVG(svgText, `t${openSeq}-${index}`);
|
||
svg.classList.add("thumb-svg");
|
||
container.classList.remove("pending", "error");
|
||
container.append(svg);
|
||
container.dataset.renderKey = renderKey;
|
||
} catch {
|
||
container.classList.add("error");
|
||
container.textContent = String(index + 1);
|
||
}
|
||
}
|
||
|
||
function observeThumbnail(button, index, openSeq = state.openSeq) {
|
||
if (!button) return;
|
||
const observer = thumbnailObserver();
|
||
if (observer) {
|
||
observer.observe(button);
|
||
}
|
||
if (index === state.pageIndex || !observer) {
|
||
if (!observer) state.visibleThumbnails.add(index);
|
||
renderThumbnail(index, openSeq);
|
||
}
|
||
}
|
||
|
||
function thumbnailObserver() {
|
||
if (!("IntersectionObserver" in window)) {
|
||
return null;
|
||
}
|
||
if (!state.thumbnailObserver) {
|
||
const openSeq = state.openSeq;
|
||
state.thumbnailObserver = new IntersectionObserver((entries) => {
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
for (const entry of entries) {
|
||
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
|
||
if (state.thumbnailWindow && state.thumbnailWindow.nodes.get(index) !== entry.target) continue;
|
||
if (!entry.isIntersecting) {
|
||
state.visibleThumbnails.delete(index);
|
||
const thumb = entry.target.querySelector(".thumb-paper");
|
||
if (thumb.dataset.renderKey) {
|
||
setThumbnailContent(thumb, "", index, openSeq);
|
||
}
|
||
continue;
|
||
}
|
||
state.visibleThumbnails.add(index);
|
||
renderThumbnail(index, openSeq);
|
||
}
|
||
trimPageCache();
|
||
}, {
|
||
root: el.navigationContent,
|
||
rootMargin: "180px 0px",
|
||
});
|
||
}
|
||
return state.thumbnailObserver;
|
||
}
|
||
|
||
async function renderThumbnail(index, openSeq = state.openSeq) {
|
||
if (openSeq !== state.openSeq || !state.doc) {
|
||
return;
|
||
}
|
||
if (state.pageCache.has(index)) {
|
||
updateThumbnail(index, openSeq);
|
||
return;
|
||
}
|
||
const key = `${openSeq}:${index}`;
|
||
if (state.thumbnailInFlight.has(key)) {
|
||
return;
|
||
}
|
||
state.thumbnailInFlight.add(key);
|
||
try {
|
||
const page = await loadPageData(index, { openSeq, priority: 5 });
|
||
if (openSeq !== state.openSeq) {
|
||
return;
|
||
}
|
||
if (!page) {
|
||
return;
|
||
}
|
||
updateThumbnail(index, openSeq);
|
||
trimPageCache();
|
||
} catch {
|
||
if (openSeq === state.openSeq) {
|
||
markThumbnailError(index);
|
||
}
|
||
} finally {
|
||
state.thumbnailInFlight.delete(key);
|
||
}
|
||
}
|
||
|
||
function updateThumbnail(index, openSeq = state.openSeq) {
|
||
if (openSeq !== state.openSeq || (state.thumbnailObserver && !state.visibleThumbnails.has(index) && index !== state.pageIndex)) {
|
||
return;
|
||
}
|
||
const thumb = el.pageList.querySelector(`[data-page-index="${index}"] .thumb-paper`);
|
||
if (thumb) {
|
||
setThumbnailContent(thumb, state.pageCache.get(index)?.svg, index, openSeq);
|
||
}
|
||
}
|
||
|
||
function markThumbnailError(index) {
|
||
const thumb = el.pageList.querySelector(`[data-page-index="${index}"] .thumb-paper`);
|
||
if (thumb) {
|
||
delete thumb.dataset.renderKey;
|
||
thumb.classList.add("error");
|
||
thumb.textContent = String(index + 1);
|
||
}
|
||
}
|
||
|
||
function renderMeta(keepDetails = false) {
|
||
renderSecurity();
|
||
el.conversionNotice.hidden = !state.conversionWarnings.length;
|
||
renderMetaContent(el.conversionWarnings, state.conversionWarnings, () => {
|
||
el.conversionWarnings.replaceChildren(...state.conversionWarnings.map(warning => {
|
||
const item = document.createElement("p");
|
||
item.textContent = `第${warning.Page}页 · 位置${warning.Offset}:${warning.Message}`;
|
||
return item;
|
||
}));
|
||
});
|
||
const doc = state.doc || {};
|
||
updateDocumentControls();
|
||
renderMetaContent(el.documentSelect, doc.documents || [], () => {
|
||
el.documentSelect.replaceChildren(...(doc.documents || []).map(entry => {
|
||
const option = document.createElement("option");
|
||
option.value = String(entry.index);
|
||
option.textContent = `${entry.index + 1} · ${entry.title || "未命名"}`;
|
||
return option;
|
||
}));
|
||
});
|
||
el.documentSelect.value = String(doc.documentIndex || 0);
|
||
el.metaPanel.setAttribute("data-busy", String(!!doc.detailsPending));
|
||
document.title = `OFDGo WebUI - ${state.fileName}`;
|
||
el.metaFile.textContent = state.fileName;
|
||
el.metaTitle.textContent = doc.title || "-";
|
||
el.metaAuthor.textContent = doc.author || "-";
|
||
for (const [field, value] of [
|
||
[el.metaSubject, (doc.subject || "").trim()],
|
||
[el.metaCreationDate, formatDocumentTime(doc.creationDate)],
|
||
[el.metaModDate, formatDocumentTime(doc.modDate)],
|
||
[el.metaCreator, [doc.creator, doc.creatorVersion].filter(Boolean).join(" ")],
|
||
]) {
|
||
field.textContent = value;
|
||
field.parentElement.hidden = !value;
|
||
}
|
||
el.metaType.textContent = doc.docType || "-";
|
||
el.metaVersion.textContent = doc.version || "-";
|
||
el.metaFonts.textContent = String(doc.fontCount || 0);
|
||
el.pageTotal.textContent = String(doc.pageCount || 0);
|
||
if (keepDetails && doc.detailsPending) return;
|
||
el.metaSignatures.textContent = doc.detailsPending ? "正在检查签名" : doc.detailsError ? "读取失败" : String(doc.signatureCount || 0);
|
||
renderMetaContent(el.signatureList, [doc.signatures, doc.signatureError], renderSignatures);
|
||
updateStructureControls();
|
||
renderMetaContent(el.docFontList, doc.fonts || [], renderDocumentFonts);
|
||
refreshEditorFonts();
|
||
updateLocalFontButton();
|
||
}
|
||
|
||
function renderMetaContent(node, value, render) {
|
||
const key = JSON.stringify(value);
|
||
if (metaContents.get(node) === key) return;
|
||
render();
|
||
metaContents.set(node, key);
|
||
}
|
||
|
||
function openStructureDialog() {
|
||
if (!state.editing || !state.doc || document.body.hasAttribute("data-busy")) return;
|
||
el.structureEntry.replaceChildren();
|
||
for (const [index, structure] of (state.doc.structures || []).entries()) {
|
||
if (structure.internal) continue;
|
||
el.structureEntry.add(new Option(`${index + 1} · ${structure.root?.name || structure.typeId}`, index));
|
||
}
|
||
el.structureEntry.add(new Option("新建标引", -1));
|
||
el.structureEntry.value = String((state.doc.structures || []).findIndex(structure => !structure.internal));
|
||
el.structureAction.value = "update";
|
||
refreshStructureNodes();
|
||
el.structureDialog.showModal();
|
||
}
|
||
|
||
function refreshStructureNodes() {
|
||
const structure = state.doc.structures?.[Number(el.structureEntry.value)];
|
||
el.structureNodeSelect.replaceChildren();
|
||
const append = (node, path, depth) => {
|
||
if (node.references?.length) return;
|
||
el.structureNodeSelect.add(new Option(`${" ".repeat(depth)}${node.name}`, JSON.stringify(path)));
|
||
for (const [index, child] of (node.children || []).entries()) append(child, [...path, index], depth + 1);
|
||
};
|
||
if (structure?.root) append(structure.root, [], 0);
|
||
refreshStructureFields();
|
||
}
|
||
|
||
function refreshStructureFields() {
|
||
const index = Number(el.structureEntry.value), creating = index < 0;
|
||
const structure = state.doc.structures?.[index];
|
||
let node = structure?.root, canBind = Boolean(structure?.canBind);
|
||
for (const part of JSON.parse(el.structureNodeSelect.value || "[]")) {
|
||
node = node?.children?.[part];
|
||
canBind &&= node?.namespace === structure.root.namespace;
|
||
}
|
||
canBind &&= node?.name !== "ObjectRef";
|
||
el.structureAction.querySelector('[value="bind"]').disabled = !canBind;
|
||
if (!canBind && el.structureAction.value === "bind") el.structureAction.value = "update";
|
||
if (!creating && !node) el.structureAction.value = "delete";
|
||
for (const option of el.structureAction.options) option.disabled = option.value === "bind" ? !canBind : !creating && !node && option.value !== "delete";
|
||
const action = creating ? "create" : el.structureAction.value;
|
||
document.querySelector("#structureNodeRow").hidden = creating || !node;
|
||
document.querySelector("#structureActionRow").hidden = creating;
|
||
document.querySelector("#structureTypeRow").hidden = !creating;
|
||
el.structureType.value = creating ? "custom" : structure?.typeId || "";
|
||
el.structureFields.hidden = action === "bind" || action === "delete";
|
||
el.structureBinding.hidden = action !== "bind";
|
||
el.structureName.value = action === "update" ? node?.name || "" : "";
|
||
el.structureNamespace.value = creating ? "" : node?.namespace || "";
|
||
el.structureText.value = action === "update" ? node?.text || "" : "";
|
||
el.structureAttributes.replaceChildren();
|
||
el.structureReferences.replaceChildren();
|
||
if (action === "update") for (const attr of node?.attributes || []) addStructureAttribute(attr);
|
||
if (action === "bind") {
|
||
const references = [...(node?.references || []), ...(node?.children || []).flatMap(child => child.references || [])];
|
||
for (const ref of references) addStructureReference(ref);
|
||
if (!references.length) addStructureReference();
|
||
}
|
||
for (const control of el.structureFields.querySelectorAll("input,textarea,button")) control.disabled = el.structureFields.hidden;
|
||
el.structureType.disabled = !creating;
|
||
el.structureStatus.textContent = structure?.error || (action === "delete" ? "删除此节点及子节点" : "");
|
||
el.structureSubmit.textContent = ({ create: "新增", add: "新增", update: "修改", bind: "绑定", delete: "删除" })[action];
|
||
}
|
||
|
||
function addStructureAttribute(attribute = {}) {
|
||
const row = document.createElement("div");
|
||
row.className = "structure-attribute";
|
||
for (const [name, label] of [["name", "属性名称"], ["namespace", "命名空间"], ["value", "属性内容"]]) {
|
||
const input = document.createElement("input");
|
||
input.type = "text";
|
||
input.name = name;
|
||
input.value = attribute[name] || "";
|
||
input.placeholder = label;
|
||
input.required = name === "name";
|
||
if (name === "value") input.className = "structure-attribute-value";
|
||
row.append(input);
|
||
}
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.className = "icon-button";
|
||
remove.textContent = "×";
|
||
remove.title = "删除属性";
|
||
remove.addEventListener("click", () => row.remove());
|
||
row.append(remove);
|
||
el.structureAttributes.append(row);
|
||
}
|
||
|
||
function addStructureReference(reference = {}) {
|
||
const row = document.createElement("div");
|
||
row.className = "structure-reference";
|
||
const page = document.createElement("select"), object = document.createElement("select"), ids = document.createElement("input");
|
||
ids.type = "text";
|
||
page.name = "page";
|
||
ids.name = "ids";
|
||
ids.value = (reference.objectIds || []).join(",");
|
||
for (const [index, item] of state.doc.pages.entries()) page.add(new Option(`第${index + 1}页`, item.id));
|
||
if (reference.pageId) page.value = reference.pageId;
|
||
const remove = document.createElement("button"), add = document.createElement("button");
|
||
for (const [button, label, text] of [[remove, "删除引用", "×"], [add, "添加图元", "+"]]) {
|
||
button.type = "button";
|
||
button.className = "icon-button";
|
||
button.textContent = text;
|
||
button.title = label;
|
||
}
|
||
remove.addEventListener("click", () => row.remove());
|
||
add.addEventListener("click", () => {
|
||
if (object.value) ids.value = [...ids.value.split(/[,\s]+/u).filter(Boolean), object.value].join(",");
|
||
});
|
||
for (const [label, control, button] of [["页码", page, remove], ["图元", object, add], ["顺序", ids, null]]) {
|
||
const line = document.createElement("div"), title = document.createElement("label"), unit = document.createElement("div");
|
||
line.className = "form-row";
|
||
title.textContent = label;
|
||
unit.className = "form-unit";
|
||
unit.append(control);
|
||
if (button) unit.append(button);
|
||
line.append(title, unit);
|
||
row.append(line);
|
||
}
|
||
el.structureReferences.append(row);
|
||
const load = async () => {
|
||
const value = page.value, sequence = state.openSeq;
|
||
object.replaceChildren(new Option("读取中", ""));
|
||
add.disabled = true;
|
||
try {
|
||
const index = state.doc.pages.findIndex(item => item.id === value);
|
||
const targets = await callWASM("ofdgoStructureObjects", index);
|
||
if (!row.isConnected || sequence !== state.openSeq || value !== page.value) return;
|
||
object.replaceChildren(new Option("选择", ""));
|
||
for (const target of targets || []) object.add(new Option(`${target.id} · ${(target.text || "图元").slice(0, 60)}`, target.id));
|
||
add.disabled = false;
|
||
} catch (err) {
|
||
if (!row.isConnected || sequence !== state.openSeq || value !== page.value) return;
|
||
object.replaceChildren(new Option("读取失败", ""));
|
||
el.structureStatus.textContent = err.message;
|
||
}
|
||
};
|
||
page.addEventListener("change", () => { ids.value = ""; load(); });
|
||
load();
|
||
}
|
||
|
||
function renderStructures() {
|
||
const structures = state.doc?.structures || [];
|
||
const count = structures.reduce((total, structure) => total + !structure.internal, 0);
|
||
el.structurePanel.hidden = !count && !state.doc?.structureError && !(state.doc && state.editing);
|
||
el.structureManage.hidden = !state.editing;
|
||
el.structureSummary.textContent = count;
|
||
el.structureList.replaceChildren();
|
||
if (state.doc?.structureError) appendInfoLine(el.structureList, "错误", state.doc.structureError, "fail");
|
||
for (const [index, structure] of structures.entries()) {
|
||
if (structure.internal) continue;
|
||
if (structure.root) el.structureList.append(structureNode(structure.root, index, []));
|
||
if (structure.error) appendInfoLine(el.structureList, "错误", structure.error, "fail");
|
||
}
|
||
}
|
||
|
||
function updateStructureControls() {
|
||
renderMetaContent(el.structureList, [state.doc?.structures, state.doc?.structureError, state.doc?.documentIndex, state.doc?.documentVersion, state.openSeq, state.editorInfo?.revision, state.editing], renderStructures);
|
||
el.structureManage.disabled = !state.ready || state.exporting || document.body.hasAttribute("data-busy") || Boolean(state.doc?.detailsPending);
|
||
}
|
||
|
||
function structureNode(node, index, path) {
|
||
const details = document.createElement("details");
|
||
details.className = "structure-node";
|
||
const summary = document.createElement("summary");
|
||
summary.textContent = node.name;
|
||
details.append(summary);
|
||
let loaded = false;
|
||
details.addEventListener("toggle", async () => {
|
||
if (!details.open || loaded) return;
|
||
loaded = true;
|
||
const content = document.createElement("div");
|
||
content.className = "structure-content";
|
||
details.append(content);
|
||
for (const attr of node.attributes || []) appendInfoLine(content, attr.name, attr.value);
|
||
if (node.text?.trim() && !node.references?.length) appendInfoLine(content, "原文", node.text);
|
||
const children = node.children || [];
|
||
const references = node.references?.length || children.some(child => child.references?.length);
|
||
for (const [i, child] of children.entries()) {
|
||
if (!child.references?.length) content.append(structureNode(child, index, [...path, i]));
|
||
}
|
||
if (!references) return;
|
||
const pending = document.createElement("div");
|
||
pending.className = "panel-detail";
|
||
pending.textContent = "读取中";
|
||
content.append(pending);
|
||
const sequence = state.openSeq;
|
||
try {
|
||
const targets = await callWASM("ofdgoStructureTargets", index, path);
|
||
if (sequence !== state.openSeq || !details.isConnected) return;
|
||
pending.remove();
|
||
appendInfoLine(content, "原文", targets.map(target => target.text || "").join(""));
|
||
const pages = new Map();
|
||
for (const target of targets) {
|
||
if (target.error) { appendInfoLine(content, "引用错误", `${target.pageId} · ${target.id}:${target.error}`, "fail"); continue; }
|
||
const b = target.bounds;
|
||
if (!(b.W > 0 && b.H > 0)) continue;
|
||
const region = pages.get(target.page);
|
||
if (region) {
|
||
const right = Math.max(region.x + region.width, b.X + b.W), bottom = Math.max(region.y + region.height, b.Y + b.H);
|
||
region.x = Math.min(region.x, b.X); region.y = Math.min(region.y, b.Y);
|
||
region.width = right - region.x; region.height = bottom - region.y;
|
||
} else pages.set(target.page, { page: target.page, x: b.X, y: b.Y, width: b.W, height: b.H });
|
||
}
|
||
if (pages.size) {
|
||
const line = document.createElement("div");
|
||
line.className = "info-line";
|
||
const label = document.createElement("span");
|
||
label.className = "info-label"; label.textContent = "位置";
|
||
const links = document.createElement("span"); links.className = "info-value structure-links";
|
||
for (const region of pages.values()) {
|
||
const button = document.createElement("button");
|
||
button.type = "button"; button.className = "signature-stamp-link";
|
||
button.textContent = `第${region.page}页`;
|
||
button.addEventListener("click", () => focusPageRegion(region, node.name));
|
||
links.append(button);
|
||
}
|
||
line.append(label, links); content.append(line);
|
||
}
|
||
} catch (err) {
|
||
pending.textContent = "读取失败";
|
||
appendInfoLine(content, "错误", err.message, "fail");
|
||
loaded = false;
|
||
details.addEventListener("toggle", () => { if (!details.open) content.remove(); }, { once: true });
|
||
}
|
||
});
|
||
return details;
|
||
}
|
||
|
||
function renderSignatures() {
|
||
const signatures = state.doc?.signatures || [];
|
||
const showPanel = Boolean(state.doc?.signatureError || signatures.length);
|
||
el.signaturePanel.hidden = !showPanel;
|
||
el.signatureList.replaceChildren();
|
||
el.signatureSummary.textContent = signatureSummary(signatures);
|
||
if (!showPanel) {
|
||
return;
|
||
}
|
||
if (state.doc?.signatureError) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "font-empty";
|
||
empty.textContent = state.doc.signatureError;
|
||
el.signatureList.append(empty);
|
||
return;
|
||
}
|
||
const fragment = document.createDocumentFragment();
|
||
for (const signature of signatures) {
|
||
const row = document.createElement("div");
|
||
row.className = `signature-row ${signature.status}`;
|
||
|
||
const head = document.createElement("div");
|
||
head.className = "signature-head";
|
||
|
||
const name = signatureNameNode(signature);
|
||
|
||
const badges = document.createElement("div");
|
||
badges.className = "signature-badges";
|
||
badges.append(fontBadge(signature.trustedValid ? "可信" : signature.status === "valid" ? "完整" : signature.status === "invalid" ? "异常" : "未验", signature.status));
|
||
|
||
head.append(badges, name);
|
||
row.append(head);
|
||
appendInfoLine(row, "签署人", signature.signer);
|
||
appendInfoLine(row, "时间", formatDocumentTime(signature.signatureDateTime));
|
||
appendInfoLine(row, "章名", signature.sealName);
|
||
appendInfoLine(row, "机构", signatureAgency(signature));
|
||
appendSignatureCheck(row, "信任", signature.certTrustChecked, signature.certTrustOK);
|
||
appendInfoLine(row, "保护文件", signatureReferenceText(signature), signatureReferenceStatus(signature));
|
||
appendInfoLine(row, "错误", signature.error, "fail");
|
||
const checks = document.createElement("details");
|
||
const checksTitle = document.createElement("summary");
|
||
checksTitle.textContent = "校验详情";
|
||
checks.append(checksTitle);
|
||
appendSignatureCheck(checks, "原文", signature.dataHashChecked, signature.dataHashOK);
|
||
appendSignatureCheck(checks, "签名", signature.signedValueChecked, signature.signedValueOK);
|
||
appendSignatureCheck(checks, "证书", signature.certChecked, signature.certOK);
|
||
appendSignaturePolicy(checks, "证书期限", signature.certTimeChecked, signature.certTimeOK);
|
||
appendInfoLine(checks, "信任错误", signature.certTrustError, "fail");
|
||
appendSignaturePolicy(checks, "签署时间", signature.signatureTimeChecked, signature.signatureTimeOK);
|
||
if (signature.type !== "Sign") {
|
||
appendSignatureCheck(checks, "印章", signature.sealChecked, signature.sealOK);
|
||
appendSignaturePolicy(checks, "印章匹配", signature.sealMatchChecked, signature.sealMatchOK);
|
||
appendSignaturePolicy(checks, "印章期限", signature.sealTimeChecked, signature.sealTimeOK);
|
||
if (signature.sealCertTimeChecked) {
|
||
appendInfoLine(checks, "制章证书", signature.sealCertTimeOK ? "有效" : "失效", signature.sealCertTimeOK ? "ok" : "fail");
|
||
}
|
||
}
|
||
appendSignatureCheck(checks, "覆盖", signature.coverageChecked, signature.coverageOK);
|
||
appendInfoLine(checks, "覆盖错误", signature.coverageError, "fail");
|
||
appendInfoLine(checks, "未保护", signature.uncoveredFiles?.join("、"), "fail");
|
||
appendSignatureCheck(checks, "策略", signature.policyChecked, signature.policyOK);
|
||
appendInfoLine(checks, "策略错误", signature.policyError, "fail");
|
||
appendSignatureCheck(checks, "时间戳", signature.timestampChecked, signature.timestampOK);
|
||
appendSignatureCheck(checks, "撤销", signature.revocationChecked, signature.revocationOK);
|
||
row.append(checks);
|
||
for (const timestamp of signature.timestamps || []) {
|
||
const details = document.createElement("details");
|
||
const title = document.createElement("summary");
|
||
title.textContent = timestamp.valid ? "可信时间戳" : "时间戳详情";
|
||
details.append(title);
|
||
appendInfoLine(details, timestamp.valid ? "时间" : "声明时间", formatDocumentTime(timestamp.time));
|
||
appendSignatureCheck(details, "绑定", timestamp.bindingChecked, timestamp.bindingOK);
|
||
appendSignatureCheck(details, "签名", timestamp.signedValueChecked, timestamp.signedValueOK);
|
||
appendSignatureCheck(details, "信任", timestamp.certTrustChecked, timestamp.certTrustOK);
|
||
appendSignatureCheck(details, "时效", timestamp.certTimeChecked, timestamp.certTimeOK);
|
||
appendInfoLine(details, "错误", timestamp.error, "fail");
|
||
row.append(details);
|
||
}
|
||
for (const revocation of signature.revocations || []) {
|
||
const details = document.createElement("details");
|
||
const title = document.createElement("summary");
|
||
title.textContent = "撤销详情";
|
||
details.append(title);
|
||
appendInfoLine(details, "证书", revocation.subject);
|
||
appendInfoLine(details, "来源", revocation.source);
|
||
const valid = revocation.checked && revocation.ok && revocation.status === "good";
|
||
appendInfoLine(details, "状态", !revocation.checked ? "未验" : valid ? "未撤销" : revocation.status === "revoked" ? "已撤销" : "未知", valid ? "ok" : revocation.checked ? "fail" : "");
|
||
appendInfoLine(details, "更新", formatDocumentTime(revocation.thisUpdate));
|
||
appendInfoLine(details, "截止", formatDocumentTime(revocation.nextUpdate));
|
||
appendInfoLine(details, "撤销时间", formatDocumentTime(revocation.revokedAt));
|
||
appendInfoLine(details, "错误", revocation.error, "fail");
|
||
row.append(details);
|
||
}
|
||
const details = document.createElement("details");
|
||
const title = document.createElement("summary");
|
||
title.textContent = "签章信息";
|
||
details.append(title);
|
||
appendInfoLine(details, "编号", signature.id);
|
||
appendInfoLine(details, "文档", signature.docRoot);
|
||
appendInfoLine(details, "算法", signature.signatureMethod);
|
||
appendInfoLine(details, "摘要", signature.digestMethod);
|
||
appendInfoLine(details, "证书主体", signature.signSubject);
|
||
appendInfoLine(details, "颁发者", signature.signIssuer);
|
||
appendInfoLine(details, "序列号", signature.signSerial);
|
||
appendInfoLine(details, "章号", signature.sealId);
|
||
appendInfoLine(details, "章图", signature.sealType);
|
||
appendInfoLine(details, "制章证书", signature.sealSubject);
|
||
appendInfoLine(details, "印章厂商", signature.sealVendor);
|
||
appendInfoLine(details, "组件版本", signature.version);
|
||
row.append(details);
|
||
fragment.append(row);
|
||
}
|
||
el.signatureList.append(fragment);
|
||
}
|
||
|
||
function signatureSummary(signatures) {
|
||
if (!signatures.length) {
|
||
return "0";
|
||
}
|
||
const invalid = signatures.filter((signature) => signature.status === "invalid").length;
|
||
const unchecked = signatures.filter((signature) => signature.status === "unchecked").length;
|
||
const parts = [String(signatures.length)];
|
||
if (invalid) {
|
||
parts.push(`异常 ${invalid}`);
|
||
}
|
||
if (unchecked) {
|
||
parts.push(`未验 ${unchecked}`);
|
||
}
|
||
return parts.length > 1 ? parts.join(" · ") : `${signatures.every(signature => signature.trustedValid) ? "可信" : "完整"} ${signatures.length}`;
|
||
}
|
||
|
||
function signatureNameNode(signature) {
|
||
const stamps = signature.docIndex ? [] : signature.stamps || [];
|
||
const name = document.createElement("div");
|
||
name.className = "signature-name";
|
||
if (!stamps.length) {
|
||
name.textContent = "签名";
|
||
return name;
|
||
}
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "info-name-button";
|
||
button.textContent = "签名";
|
||
button.addEventListener("click", () => focusPageRegion(stamps[0], "签名"));
|
||
name.append(button, signatureStampGroup(stamps));
|
||
return name;
|
||
}
|
||
|
||
function signatureStampGroup(stamps) {
|
||
const group = document.createElement("span");
|
||
group.className = "signature-stamp-group";
|
||
for (const [index, stamp] of stamps.entries()) {
|
||
const item = document.createElement("span");
|
||
item.className = "signature-stamp-item";
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "signature-stamp-link";
|
||
button.textContent = `第${index + 1}处`;
|
||
button.addEventListener("click", () => focusPageRegion(stamp, "签名"));
|
||
item.append(index === 0 ? "(" : "", button, index === stamps.length - 1 ? ")" : "、");
|
||
group.append(item);
|
||
}
|
||
return group;
|
||
}
|
||
|
||
function signatureAgency(signature) {
|
||
if (signature.company && signature.provider) {
|
||
return `${signature.company} · ${signature.provider}`;
|
||
}
|
||
return signature.company || signature.provider || "";
|
||
}
|
||
|
||
function signatureReferenceText(signature) {
|
||
const passed = signature.referencePassed || 0;
|
||
const count = signature.referenceCount || 0;
|
||
if (!count) {
|
||
return "";
|
||
}
|
||
const status = signatureReferenceStatus(signature);
|
||
return `${status === "ok" ? "通过" : status === "fail" ? "失败" : "未验"} ${passed} / ${count}`;
|
||
}
|
||
|
||
function signatureReferenceStatus(signature) {
|
||
const count = signature.referenceCount || 0;
|
||
if (!count) {
|
||
return "";
|
||
}
|
||
if (signature.referencePassed < signature.referenceChecked) {
|
||
return "fail";
|
||
}
|
||
return signature.referenceChecked === count ? "ok" : "";
|
||
}
|
||
|
||
function formatDocumentTime(value) {
|
||
const text = String(value || "").trim().replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/, "$1-$2-$3 $4:$5:$6$7$8").replace("T", " ");
|
||
return /^0001[-/]0?1[-/]0?1(?: 0{1,2}:00:00(?:\.0+)?(?:Z|[+-]00:?00)?)?$/.test(text) ? "" : text;
|
||
}
|
||
|
||
function appendInfoLine(row, label, value, status = "") {
|
||
if (!value && value !== 0) {
|
||
return;
|
||
}
|
||
const line = document.createElement("div");
|
||
line.className = status ? `info-line ${status}` : "info-line";
|
||
|
||
const key = document.createElement("span");
|
||
key.className = "info-label";
|
||
key.textContent = label;
|
||
|
||
const text = document.createElement("span");
|
||
text.className = "info-value";
|
||
text.textContent = String(value);
|
||
|
||
line.append(key, text);
|
||
row.append(line);
|
||
}
|
||
|
||
function appendSignatureCheck(row, label, checked, ok) {
|
||
appendInfoLine(row, label, checked ? ok ? "通过" : "失败" : "未验", checked ? ok ? "ok" : "fail" : "");
|
||
}
|
||
|
||
function appendSignaturePolicy(row, label, checked, ok) {
|
||
if (!checked) {
|
||
return;
|
||
}
|
||
appendSignatureCheck(row, label, checked, ok);
|
||
}
|
||
|
||
async function focusPageRegion(region, label, alignment = "nearest") {
|
||
const pageIndex = region.page - 1;
|
||
if (!state.doc || pageIndex < 0 || document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
const openSeq = state.openSeq;
|
||
if (!await renderPage(pageIndex, { fit: false, scroll: false })) {
|
||
return;
|
||
}
|
||
await nextFrame();
|
||
if (openSeq !== state.openSeq || pageIndex !== state.pageIndex) {
|
||
return;
|
||
}
|
||
highlightPageRegion(region, alignment);
|
||
if (label === "注解") {
|
||
for (const note of pageShell(pageIndex)?.querySelectorAll(".annotation-note") || []) {
|
||
if (note.dataset.annotationId !== String(region.id)) continue;
|
||
window.clearTimeout(note.locateTimer);
|
||
note.classList.add("located");
|
||
note.locateTimer = window.setTimeout(() => note.classList.remove("located"), 1800);
|
||
}
|
||
}
|
||
setStatus(`已定位${label}(第${region.page}页)`);
|
||
}
|
||
|
||
function highlightPageRegion(region, alignment = "nearest") {
|
||
clearRegionHighlights();
|
||
const pageIndex = region.page - 1;
|
||
const shell = pageShell(pageIndex);
|
||
if (!shell || !(region.width > 0 && region.height > 0)) {
|
||
return;
|
||
}
|
||
const mark = document.createElement("div");
|
||
mark.className = "region-highlight";
|
||
mark.style.left = `${region.x * MM_TO_PX}px`;
|
||
mark.style.top = `${region.y * MM_TO_PX}px`;
|
||
mark.style.width = `${region.width * MM_TO_PX}px`;
|
||
mark.style.height = `${region.height * MM_TO_PX}px`;
|
||
shell.querySelector(".page-surface").append(mark);
|
||
if (state.pageWindow) {
|
||
const box = mark.getBoundingClientRect(), viewer = el.viewerPanel.getBoundingClientRect();
|
||
if (alignment === "center") scrollViewerBy(box.top - viewer.top + (box.height - el.viewerPanel.clientHeight) / 2);
|
||
else if (box.top < viewer.top) scrollViewerBy(box.top - viewer.top);
|
||
else if (box.bottom > viewer.top + el.viewerPanel.clientHeight) scrollViewerBy(Math.min(box.top - viewer.top, box.bottom - viewer.top - el.viewerPanel.clientHeight));
|
||
if (box.left < viewer.left) el.viewerPanel.scrollLeft += box.left - viewer.left;
|
||
else if (box.right > viewer.left + el.viewerPanel.clientWidth) el.viewerPanel.scrollLeft += box.right - viewer.left - el.viewerPanel.clientWidth;
|
||
} else mark.scrollIntoView({ block: alignment, inline: "nearest" });
|
||
window.setTimeout(() => mark.remove(), 1800);
|
||
}
|
||
|
||
function clearRegionHighlights() {
|
||
for (const note of el.svgHost.querySelectorAll(".annotation-note.located")) {
|
||
window.clearTimeout(note.locateTimer);
|
||
note.classList.remove("located");
|
||
}
|
||
for (const mark of el.svgHost.querySelectorAll(".region-highlight")) {
|
||
mark.remove();
|
||
}
|
||
}
|
||
|
||
function mountAnnotationNotes(index, surface, annotations) {
|
||
for (const note of surface.querySelectorAll(".annotation-note")) {
|
||
window.clearTimeout(note.locateTimer);
|
||
note.remove();
|
||
}
|
||
surface.onpointermove = surface.onpointerleave = null;
|
||
if (!state.renderAnnotations) return;
|
||
const page = state.doc.pages[index];
|
||
const notes = [];
|
||
for (const annotation of annotations || []) {
|
||
if (!annotation.visible || annotationKind(annotation) === "Link" || annotation.page !== index + 1 || !annotation.remark?.trim()) continue;
|
||
const bounds = annotation.bounds;
|
||
const region = !bounds ? annotation : bounds.W > 0 && bounds.H > 0
|
||
? {x: bounds.X, y: bounds.Y, width: bounds.W, height: bounds.H, contours: annotation.contours || []}
|
||
: {x: annotation.x, y: annotation.y, width: 22 / MM_TO_PX, height: 22 / MM_TO_PX, contours: []};
|
||
const note = document.createElement("button");
|
||
note.type = "button";
|
||
note.className = "annotation-note";
|
||
note.dataset.annotationId = annotation.id;
|
||
note.classList.toggle("no-zoom", !!annotation.noZoom);
|
||
note.classList.toggle("no-rotate", !!annotation.noRotate);
|
||
note.textContent = "\u24d8";
|
||
note.title = "查看注解";
|
||
note.style.left = `${Math.max(22 / MM_TO_PX, Math.min(page.width, region.x + region.width)) * MM_TO_PX}px`;
|
||
note.style.top = `${Math.max(0, Math.min(page.height - 22 / MM_TO_PX, region.y)) * MM_TO_PX}px`;
|
||
note.addEventListener("click", () => {
|
||
if (state.editing || document.body.hasAttribute("data-busy")) return;
|
||
el.annotationDetail.replaceChildren();
|
||
el.annotationFields.hidden = el.annotationSubmit.hidden = true;
|
||
el.annotationDetail.hidden = false;
|
||
el.annotationStatus.textContent = "";
|
||
el.annotationClose.textContent = "关闭";
|
||
const content = document.createElement("div");
|
||
content.textContent = annotation.remark;
|
||
el.annotationDetail.append(content);
|
||
appendInfoLine(el.annotationDetail, "作者", annotation.creator);
|
||
appendInfoLine(el.annotationDetail, "时间", formatDocumentTime(annotation.lastModDate));
|
||
el.annotationNote.showModal();
|
||
});
|
||
surface.append(note);
|
||
notes.push({note, region});
|
||
}
|
||
if (!notes.length) return;
|
||
surface.onpointermove = event => {
|
||
if (event.pointerType === "touch" || state.editing) return;
|
||
const point = pagePoint(event.clientX, event.clientY, surface.getBoundingClientRect(), page, state.rotation);
|
||
const overAction = event.target?.closest?.(".page-link, .page-media");
|
||
const hovered = notes.map(({note, region}) => {
|
||
const rect = note.getBoundingClientRect();
|
||
const overNote = event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
|
||
return !overAction && (overNote || canvasEditor.contains(region, point));
|
||
});
|
||
notes.forEach(({note}, i) => note.classList.toggle("active", hovered[i]));
|
||
};
|
||
surface.onpointerleave = () => {
|
||
for (const {note} of notes) note.classList.remove("active");
|
||
};
|
||
}
|
||
|
||
function renderDocumentFonts() {
|
||
const fonts = state.doc?.fonts || [];
|
||
el.docFontList.replaceChildren();
|
||
el.docFontSummary.textContent = fontSummary(fonts);
|
||
if (!fonts.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "font-empty";
|
||
empty.textContent = "暂无字体";
|
||
el.docFontList.append(empty);
|
||
return;
|
||
}
|
||
const fragment = document.createDocumentFragment();
|
||
for (const font of fonts) {
|
||
const row = document.createElement("div");
|
||
row.className = `doc-font-row ${font.status || "missing"}`;
|
||
|
||
const head = document.createElement("div");
|
||
head.className = "doc-font-head";
|
||
|
||
const name = document.createElement("div");
|
||
name.className = "doc-font-name";
|
||
name.textContent = font.fontName || font.familyName || font.id || "-";
|
||
|
||
const badges = document.createElement("div");
|
||
badges.className = "doc-font-badges";
|
||
badges.append(fontBadge(statusText(font.status), font.status || "missing"));
|
||
if (font.embedded && font.status !== "embedded") {
|
||
badges.append(fontBadge("内嵌", "embedded"));
|
||
}
|
||
const resultText = fontResult(font);
|
||
const metaText = fontMeta(font);
|
||
|
||
head.append(badges, name);
|
||
if (state.editing) {
|
||
const action = document.createElement("button");
|
||
action.type = "button";
|
||
action.className = "small-button font-binding-action";
|
||
action.textContent = font.embedded ? "外置" : "嵌入";
|
||
action.addEventListener("click", () => openFontBinding(font));
|
||
head.append(action);
|
||
}
|
||
row.append(head);
|
||
appendFontDetail(row, metaText);
|
||
appendFontDetail(row, resultText);
|
||
fragment.append(row);
|
||
}
|
||
el.docFontList.append(fragment);
|
||
}
|
||
|
||
async function openFontBinding(font) {
|
||
if (!state.editing || document.body.hasAttribute("data-busy")) return;
|
||
if (!font.embedded && font.status === "matched") {
|
||
await changeDocument("ofdgoChangeFontBinding", null, font.id, 1);
|
||
return;
|
||
}
|
||
fontBinding.font = font;
|
||
fontBinding.sequence++;
|
||
fontBindingElements.Form.inert = false;
|
||
fontBinding.choices = fontManager.editorFonts().filter(choice => !choice.disabled);
|
||
fontBindingElements.Title.textContent = font.embedded ? "外置字体" : "嵌入字体";
|
||
fontBindingElements.Name.textContent = font.fontName || font.familyName || font.id;
|
||
fontBindingElements.SourceRow.hidden = font.embedded;
|
||
fontBindingElements.Source.disabled = font.embedded;
|
||
fontBindingElements.Source.replaceChildren(new Option("选择", ""), ...fontBinding.choices.map((choice, i) => new Option(choice.fullName || choice.name, String(i))), new Option("添加", "file"));
|
||
fontBindingElements.Hint.hidden = !font.embedded;
|
||
fontBindingElements.Status.textContent = "";
|
||
fontBindingElements.Submit.textContent = font.embedded ? "外置" : "嵌入";
|
||
fontBindingElements.Panel.showModal();
|
||
}
|
||
|
||
function fontBindingError(message) {
|
||
if (/requires the original font/.test(message)) return "字形编号需匹配原字体";
|
||
if (/non-character glyph|private character|index-encoded/.test(message)) return "字形映射无法外置";
|
||
if (/unsupported content/.test(message)) return "扩展引用无法确认";
|
||
if (/does not permit/.test(message)) return "字体不允许嵌入";
|
||
if (/no glyph|glyph is unavailable/.test(message)) return "字体缺少所需字形";
|
||
if (/unavailable|not found|no name/.test(message)) return "匹配字体不可用";
|
||
return message;
|
||
}
|
||
|
||
function appendFontDetail(row, text) {
|
||
if (!text) {
|
||
return;
|
||
}
|
||
const detail = document.createElement("div");
|
||
detail.className = "doc-font-detail";
|
||
detail.textContent = text;
|
||
detail.title = text;
|
||
row.append(detail);
|
||
}
|
||
|
||
function fontBadge(text, status) {
|
||
const badge = document.createElement("span");
|
||
badge.className = `font-badge ${status}`;
|
||
badge.textContent = text;
|
||
return badge;
|
||
}
|
||
|
||
function fontSummary(fonts) {
|
||
if (!fonts.length) {
|
||
return "0";
|
||
}
|
||
const missing = fonts.filter((font) => font.status === "missing").length;
|
||
const fallback = fonts.filter((font) => font.status === "fallback").length;
|
||
if (missing || fallback) {
|
||
return `${fonts.length} · 缺失 ${missing} · 回退 ${fallback}`;
|
||
}
|
||
return `共 ${fonts.length}`;
|
||
}
|
||
|
||
function pageStatus(index, pageCount) {
|
||
return `第 ${index + 1} / ${pageCount} 页`;
|
||
}
|
||
|
||
function statusText(status) {
|
||
switch (status) {
|
||
case "pending":
|
||
return "待查";
|
||
case "embedded":
|
||
return "内嵌";
|
||
case "matched":
|
||
return "匹配";
|
||
case "fallback":
|
||
return "回退";
|
||
default:
|
||
return "缺失";
|
||
}
|
||
}
|
||
|
||
function fontResult(font) {
|
||
const parts = [];
|
||
if (font.matched) {
|
||
parts.push(font.embedded || font.status === "embedded" ? `内嵌 ${font.matched}` : `匹配 ${font.matched}`);
|
||
}
|
||
if (font.matchedFace?.fullName) {
|
||
parts.push(font.matchedFace.fullName);
|
||
if (font.matchedFace.index > 0) parts.push(`索引 ${font.matchedFace.index}`);
|
||
}
|
||
if (font.detail) {
|
||
parts.push(font.detail);
|
||
}
|
||
return parts.join(" · ");
|
||
}
|
||
|
||
function fontMeta(font) {
|
||
const parts = [];
|
||
if (font.familyName && font.familyName !== font.fontName) {
|
||
parts.push(`字族 ${font.familyName}`);
|
||
}
|
||
if (font.charset) {
|
||
parts.push(`字集 ${font.charset}`);
|
||
}
|
||
return parts.join(" · ");
|
||
}
|
||
|
||
function setContinuous(continuous) {
|
||
state.continuous = continuous;
|
||
el.pageFrame.classList.toggle("continuous", state.continuous);
|
||
el.continuousButton.setAttribute("data-pressed", String(state.continuous));
|
||
}
|
||
|
||
function toggleContinuous() {
|
||
const anchor = state.fitMode === "free" ? scaleAnchor() : null;
|
||
setContinuous(!state.continuous);
|
||
if (state.pageWindow) {
|
||
updateFitSpace();
|
||
layoutPages();
|
||
}
|
||
clearRegionHighlights();
|
||
if (state.fitMode === "free") {
|
||
restoreScaleAnchor(anchor);
|
||
} else {
|
||
applyFit(false);
|
||
scrollToPage(state.pageIndex);
|
||
}
|
||
}
|
||
|
||
function rotatePages() {
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
state.rotation = (state.rotation + 90) % 360;
|
||
clearRegionHighlights();
|
||
el.viewerPanel.classList.remove("single-page-fits-height");
|
||
layoutPages();
|
||
layoutThumbnails();
|
||
if (state.fitMode === "free") {
|
||
updateFitSpace();
|
||
} else {
|
||
applyFit(false);
|
||
}
|
||
scrollToPage(state.pageIndex);
|
||
}
|
||
|
||
function pageViewSize(page) {
|
||
return state.rotation % 180 ? { width: page.height, height: page.width } : page;
|
||
}
|
||
|
||
function togglePan() {
|
||
if (document.body.hasAttribute("data-busy")) {
|
||
return;
|
||
}
|
||
setPan(!state.panMode);
|
||
}
|
||
|
||
function setPan(enabled) {
|
||
endPan();
|
||
state.panMode = enabled;
|
||
el.viewerPanel.classList.toggle("pan-mode", state.panMode);
|
||
el.panButton.setAttribute("data-pressed", String(state.panMode));
|
||
updateEditorTools();
|
||
}
|
||
|
||
function startPan(event) {
|
||
if (!state.panMode || !state.doc || event.pointerType !== "mouse" || event.button !== 0 || document.body.hasAttribute("data-busy") || event.target.closest("a, button")) {
|
||
return;
|
||
}
|
||
const rect = el.viewerPanel.getBoundingClientRect();
|
||
if (event.clientX >= rect.left + el.viewerPanel.clientWidth || event.clientY >= rect.top + el.viewerPanel.clientHeight) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
state.pan = { id: event.pointerId, x: event.clientX + el.viewerPanel.scrollLeft,
|
||
y: event.clientY + (state.pageWindow ? pageWindowTop(state.pageWindow) : el.viewerPanel.scrollTop) };
|
||
el.viewerPanel.setPointerCapture(event.pointerId);
|
||
el.viewerPanel.classList.add("panning");
|
||
canvasEditor.focus();
|
||
}
|
||
|
||
function movePan(event) {
|
||
const pan = state.pan;
|
||
if (!pan || event.pointerId !== pan.id) {
|
||
return;
|
||
}
|
||
el.viewerPanel.scrollLeft = pan.x - event.clientX;
|
||
if (state.pageWindow) syncPageWindow(state.pageWindow, pan.y - event.clientY);
|
||
else el.viewerPanel.scrollTop = pan.y - event.clientY;
|
||
}
|
||
|
||
function endPan(event) {
|
||
const pan = state.pan;
|
||
if (!pan || (event && event.pointerId !== pan.id)) {
|
||
return;
|
||
}
|
||
state.pan = null;
|
||
el.viewerPanel.classList.remove("panning");
|
||
if (el.viewerPanel.hasPointerCapture(pan.id)) {
|
||
el.viewerPanel.releasePointerCapture(pan.id);
|
||
}
|
||
}
|
||
|
||
function fitWidth(updateStatus = true) {
|
||
const page = currentPageInfo();
|
||
if (!page) {
|
||
return;
|
||
}
|
||
setScale(fitWidthScale(page), updateStatus, "width");
|
||
if (updateStatus) {
|
||
scrollToPage(state.pageIndex);
|
||
}
|
||
}
|
||
|
||
function fitWidthScale(page) {
|
||
page = pageViewSize(page);
|
||
const space = pageSpace();
|
||
const width = Math.max(1, page.width * MM_TO_PX);
|
||
const frameWidth = Number.parseFloat(getComputedStyle(el.pageFrame).width) || el.viewerPanel.clientWidth;
|
||
let available = Math.max(1, frameWidth - space * 2);
|
||
if (!viewerHasVerticalScrollbar()) {
|
||
const limit = Math.max(1, el.viewerPanel.clientHeight - space * 2);
|
||
let height = Math.max(1, page.height * MM_TO_PX) * (available / width);
|
||
if (state.continuous) {
|
||
height = 0;
|
||
for (const item of state.doc.pages) {
|
||
const size = pageViewSize(item);
|
||
height += available * size.height / size.width;
|
||
if (height > limit) break;
|
||
}
|
||
}
|
||
if (height > limit) {
|
||
available = Math.max(1, available - scrollbarWidth());
|
||
}
|
||
}
|
||
return Math.min(4, available / width);
|
||
}
|
||
|
||
function fitHeight(updateStatus = true) {
|
||
const page = currentPageInfo();
|
||
if (!page) {
|
||
return;
|
||
}
|
||
const space = pageSpace();
|
||
let availableWidth = Math.max(1, el.viewerPanel.clientWidth - space * 2);
|
||
let availableHeight = Math.max(1, el.viewerPanel.clientHeight - space * 2);
|
||
const size = pageViewSize(page);
|
||
const width = Math.max(1, size.width * MM_TO_PX);
|
||
const height = Math.max(1, size.height * MM_TO_PX);
|
||
if (state.continuous) {
|
||
availableWidth = Math.max(1, el.viewerPanel.offsetWidth);
|
||
availableHeight = Math.max(1, el.viewerPanel.offsetHeight);
|
||
const contentWidth = state.doc.pages.reduce((max, item) => Math.max(max, pageViewSize(item).width), 0) * MM_TO_PX;
|
||
const contentHeight = state.doc.pages.reduce((total, item) => total + pageViewSize(item).height, 0) * MM_TO_PX;
|
||
const scale = Math.min(availableWidth / width, availableHeight / height);
|
||
const needsVerticalScrollbar = scale > availableHeight / contentHeight;
|
||
const needsHorizontalScrollbar = scale > availableWidth / contentWidth;
|
||
if (needsVerticalScrollbar || needsHorizontalScrollbar) {
|
||
const scrollbar = scrollbarWidth();
|
||
availableWidth = Math.max(1, availableWidth - (needsVerticalScrollbar ? scrollbar : 0));
|
||
availableHeight = Math.max(1, availableHeight - (needsHorizontalScrollbar ? scrollbar : 0));
|
||
const nextScale = Math.min(availableWidth / width, availableHeight / height);
|
||
if (!needsVerticalScrollbar && nextScale > availableHeight / contentHeight) {
|
||
availableWidth = Math.max(1, availableWidth - scrollbar);
|
||
}
|
||
if (!needsHorizontalScrollbar && nextScale > availableWidth / contentWidth) {
|
||
availableHeight = Math.max(1, availableHeight - scrollbar);
|
||
}
|
||
}
|
||
}
|
||
setScale(Math.min(availableWidth / width, availableHeight / height), updateStatus, "height");
|
||
if (updateStatus) {
|
||
scrollToPage(state.pageIndex);
|
||
}
|
||
}
|
||
|
||
function applyFit(updateStatus = true) {
|
||
if (state.fitMode === "height") {
|
||
fitHeight(updateStatus);
|
||
return;
|
||
}
|
||
if (state.fitMode === "width") {
|
||
fitWidth(updateStatus);
|
||
} else {
|
||
updateControls();
|
||
}
|
||
}
|
||
|
||
function setScale(nextScale, updateStatus = true, fitMode = "free") {
|
||
const anchor = updateStatus && fitMode === "free" ? scaleAnchor() : null;
|
||
const scale = Math.min(4, fitMode === "free" ? Math.max(0.2, nextScale) : nextScale);
|
||
const layoutChanged = scale !== state.scale || fitMode !== state.fitMode;
|
||
state.fitMode = fitMode;
|
||
state.scale = scale;
|
||
if (layoutChanged) {
|
||
clearRegionHighlights();
|
||
layoutPages();
|
||
}
|
||
updateFitSpace();
|
||
if (state.pageWindow && state.pageWindow.gap !== (Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0)) layoutPages();
|
||
if (layoutChanged) {
|
||
restoreScaleAnchor(anchor);
|
||
}
|
||
el.zoomLabel.textContent = `${Math.round(state.scale * 100)}%`;
|
||
if (updateStatus && state.doc && !state.exporting) {
|
||
setStatus(`第 ${state.pageIndex + 1} / ${state.doc.pageCount} 页`);
|
||
}
|
||
updateControls();
|
||
}
|
||
|
||
function scaleAnchor(viewY = 0.45) {
|
||
const shell = pageShellFromView() || pageShell(state.pageIndex);
|
||
if (!shell) {
|
||
return null;
|
||
}
|
||
const viewerRect = el.viewerPanel.getBoundingClientRect();
|
||
const shellRect = shell.getBoundingClientRect();
|
||
return {
|
||
index: Number.parseInt(shell.dataset.pageIndex, 10),
|
||
viewY,
|
||
x: (viewerRect.left + viewerRect.width / 2 - shellRect.left) / Math.max(1, shellRect.width),
|
||
y: (viewerRect.top + viewerRect.height * viewY - shellRect.top) / Math.max(1, shellRect.height),
|
||
};
|
||
}
|
||
|
||
function restoreScaleAnchor(anchor) {
|
||
if (!anchor) {
|
||
return;
|
||
}
|
||
if (state.fitMode === "height" || (state.fitMode === "width" && anchor.y <= 0)) {
|
||
scrollToPage(anchor.index);
|
||
return;
|
||
}
|
||
if (state.pageWindow) {
|
||
const view = state.pageWindow;
|
||
const height = view.offsets[anchor.index + 1] - view.offsets[anchor.index] - view.gap;
|
||
syncPageWindow(view, view.offsets[anchor.index] + height * anchor.y - el.viewerPanel.clientHeight * anchor.viewY);
|
||
}
|
||
const shell = pageShell(anchor.index);
|
||
if (!shell) {
|
||
return;
|
||
}
|
||
const viewerRect = el.viewerPanel.getBoundingClientRect();
|
||
const shellRect = shell.getBoundingClientRect();
|
||
el.viewerPanel.scrollLeft += shellRect.left + shellRect.width * anchor.x - viewerRect.left - viewerRect.width / 2;
|
||
scrollViewerBy(shellRect.top + shellRect.height * anchor.y - viewerRect.top - viewerRect.height * anchor.viewY);
|
||
}
|
||
|
||
function currentPageInfo() {
|
||
return state.doc?.pages?.[state.pageIndex] || null;
|
||
}
|
||
|
||
function pageSpace() {
|
||
return Number.parseFloat(getComputedStyle(el.pageFrame).paddingLeft) || 0;
|
||
}
|
||
|
||
function pageBlockSpace() {
|
||
return Number.parseFloat(getComputedStyle(el.pageFrame).paddingTop) || pageSpace();
|
||
}
|
||
|
||
function viewerHasVerticalScrollbar() {
|
||
return el.viewerPanel.scrollHeight > el.viewerPanel.clientHeight;
|
||
}
|
||
|
||
function scrollbarWidth() {
|
||
const probe = document.createElement("div");
|
||
const content = document.createElement("div");
|
||
probe.style.position = "absolute";
|
||
probe.style.width = "100px";
|
||
probe.style.height = "100px";
|
||
probe.style.overflow = "scroll";
|
||
probe.style.scrollbarWidth = getComputedStyle(el.viewerPanel).scrollbarWidth;
|
||
probe.style.left = "-9999px";
|
||
probe.append(content);
|
||
document.body.append(probe);
|
||
const width = 100 - Number.parseFloat(getComputedStyle(content).width);
|
||
probe.remove();
|
||
return width;
|
||
}
|
||
|
||
function updateFitSpace() {
|
||
const page = currentPageInfo();
|
||
if (!page || state.fitMode === "free") {
|
||
el.viewerPanel.classList.remove("single-page-fits-height");
|
||
el.pageFrame.style.removeProperty("--fit-space");
|
||
el.pageFrame.style.removeProperty("--fit-gap");
|
||
return;
|
||
}
|
||
const shell = pageShell(state.pageIndex);
|
||
const height = shell ? shell.getBoundingClientRect().height : Math.max(1, pageViewSize(page).height * MM_TO_PX * state.scale);
|
||
const base = pageSpace();
|
||
el.viewerPanel.classList.toggle("single-page-fits-height", state.doc.pageCount === 1 && height <= el.viewerPanel.clientHeight - base * 2);
|
||
const space = state.continuous ? 0 : Math.max(base, (el.viewerPanel.clientHeight - height) / 2);
|
||
const gap = space > base ? space + 1 : space;
|
||
el.pageFrame.style.setProperty("--fit-space", `${space}px`);
|
||
el.pageFrame.style.setProperty("--fit-gap", `${gap}px`);
|
||
}
|
||
|
||
function updateInvoiceControl(disabled) {
|
||
const doc = state.doc, openSeq = state.openSeq;
|
||
el.invoiceButton.disabled = disabled;
|
||
el.invoiceButton.hidden = !doc || !state.editing;
|
||
if (!doc || state.editing) return;
|
||
let cached = invoiceDocuments.get(doc);
|
||
if (cached?.openSeq !== openSeq) cached = null;
|
||
if (!cached && !disabled) {
|
||
cached = {openSeq, available: false, data: null};
|
||
invoiceDocuments.set(doc, cached);
|
||
callWASM("ofdgoInvoices").then(data => {
|
||
cached.data = data;
|
||
cached.available = !!(data.report?.invoices?.length || data.report?.issues?.some(issue => issue.code !== "missing-data"));
|
||
}).catch(() => { cached.available = true; }).finally(() => {
|
||
if (state.doc === doc && state.openSeq === openSeq) updateDocumentControls();
|
||
});
|
||
}
|
||
el.invoiceButton.hidden = !cached?.available;
|
||
}
|
||
|
||
function updateDocumentControls() {
|
||
const count = state.doc?.documents?.length || 0, index = managedDocumentIndex(), manager = state.documentManagement;
|
||
const busy = document.body.hasAttribute("data-busy");
|
||
const disabled = !state.doc || !state.ready || state.exporting || busy;
|
||
el.documentManageButton.hidden = !state.editing;
|
||
el.documentManageButton.disabled = disabled;
|
||
resourceElements.ManageButton.disabled = disabled;
|
||
settingsElements.Button.disabled = disabled;
|
||
updateInvoiceControl(disabled);
|
||
updateResourceControls();
|
||
el.documentRow.hidden = !count || !state.editing && count < 2;
|
||
el.documentSelect.disabled = disabled;
|
||
const actionsDisabled = disabled || !state.editing || Boolean(manager?.loading || manager?.failed);
|
||
el.documentList.inert = busy || Boolean(manager?.loading);
|
||
el.documentAddButton.disabled = actionsDisabled;
|
||
el.documentImportButton.disabled = actionsDisabled;
|
||
el.infoButton.disabled = actionsDisabled || Boolean(manager && !manager.entries);
|
||
el.documentDeleteButton.disabled = actionsDisabled || count <= 1;
|
||
el.documentUpButton.disabled = actionsDisabled || index === 0;
|
||
el.documentDownButton.disabled = actionsDisabled || index >= count - 1;
|
||
updateVersionControls();
|
||
}
|
||
|
||
function updateControls() {
|
||
renderSecurity();
|
||
updateStructureControls();
|
||
const hasDoc = Boolean(state.doc);
|
||
updateDocumentControls();
|
||
updateDisplayControls();
|
||
updateEditorTools();
|
||
const pageCount = state.doc ? state.doc.pageCount : 0;
|
||
el.prevButton.disabled = !hasDoc || state.pageIndex <= 0;
|
||
el.nextButton.disabled = !hasDoc || state.pageIndex >= pageCount - 1;
|
||
el.pageInput.disabled = !hasDoc;
|
||
el.pageInput.max = String(pageCount || 1);
|
||
el.pageInput.value = String(hasDoc ? state.pageIndex + 1 : 0);
|
||
el.pageControl.style.setProperty("--page-width", `${String(pageCount || 1).length + 0.5}ch`);
|
||
el.zoomOutButton.disabled = !hasDoc || state.scale <= 0.2;
|
||
el.zoomInButton.disabled = !hasDoc || state.scale >= 4;
|
||
el.fitButton.disabled = !hasDoc;
|
||
el.fitHeightButton.disabled = !hasDoc;
|
||
el.rotateButton.disabled = !hasDoc;
|
||
el.panButton.disabled = !hasDoc;
|
||
el.continuousButton.disabled = !hasDoc;
|
||
el.fitButton.toggleAttribute("data-pressed", hasDoc && state.fitMode === "width");
|
||
el.fitHeightButton.toggleAttribute("data-pressed", hasDoc && state.fitMode === "height");
|
||
updateAnnotationButton();
|
||
el.exportFormat.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
|
||
el.outputCompression.disabled = !hasDoc || state.exporting;
|
||
el.exportPageButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
|
||
el.exportButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
|
||
updateDPIControl();
|
||
}
|
||
|
||
function updateEditorTools() {
|
||
el.viewerPanel.classList.toggle("editing", state.editing);
|
||
el.editButton.disabled = !state.doc || !state.ready || state.exporting || document.body.hasAttribute("data-busy");
|
||
el.editButton.setAttribute("data-pressed", String(state.editing));
|
||
el.editButton.title = state.editing ? "阅读" : "编辑";
|
||
el.editNotice.textContent = state.editorInfo?.editWarnings?.join(" · ") || "";
|
||
el.editNotice.hidden = !state.editing || !el.editNotice.textContent;
|
||
el.editorTools.hidden = !state.editing;
|
||
const pagesDisabled = !state.editing || !state.ready || state.exporting;
|
||
el.insertTextButton.disabled = el.insertImageButton.disabled = pagesDisabled || !pageCan("insert");
|
||
el.saveButton.disabled = !state.doc || !state.ready || state.exporting || (!state.editorInfo && !state.ofdBytes);
|
||
el.addPageButton.disabled = pagesDisabled;
|
||
const selectedPages = state.editing ? selectedPageIndexes() : [state.pageIndex];
|
||
const selectionDisabled = pagesDisabled || !selectedPages.length;
|
||
el.batchPagesButton.disabled = selectionDisabled;
|
||
el.copyPageButton.disabled = selectionDisabled || selectedPages.some(index => !pageCan("copy", index));
|
||
el.pageSettingsButton.disabled = selectionDisabled || selectedPages.some(index => !pageCan("resize", index));
|
||
el.deletePageButton.disabled = selectionDisabled || selectedPages.some(index => !pageCan("delete", index)) || selectedPages.length >= state.doc?.pageCount;
|
||
const moveDisabled = selectionDisabled || selectedPages.some(index => !pageCan("move", index));
|
||
el.movePagePrevButton.disabled = moveDisabled || selectedPages[0] === 0;
|
||
el.movePageNextButton.disabled = moveDisabled || selectedPages.at(-1) === state.doc?.pageCount - 1;
|
||
const enabled = state.editing && state.selectObjects && !state.panMode && !sealPreview && !resourceManager.pick;
|
||
canvasEditor.setEnabled(enabled);
|
||
el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting;
|
||
el.redoButton.disabled = !state.editorInfo?.canRedo || !state.ready || state.exporting;
|
||
updateObjectControls(canvasEditor.selected);
|
||
}
|
||
|
||
function updateObjectControls(item, reset = false) {
|
||
el.compositeBackButton.hidden = !state.composite;
|
||
el.compositeBackButton.disabled = !state.ready || state.exporting;
|
||
el.selectObjectButton.hidden = Boolean(state.composite);
|
||
const members = item?.items || (item ? [item] : []);
|
||
const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting;
|
||
el.deleteObjectButton.disabled = disabled || !canEditObject(item, "delete");
|
||
el.copyObjectButton.disabled = disabled || !canEditObject(item, "copy") || Boolean(state.composite && members.some(member => member.container !== members[0].container));
|
||
el.objectStyleButton.disabled = disabled || !canEditObject(item, "transform");
|
||
const cropping = Boolean(canvasEditor.crop);
|
||
el.objectBoundsButton.disabled = disabled || cropping || !canEditObject(item, "transform");
|
||
el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange");
|
||
el.objectRotate.disabled = el.objectFlip.disabled = disabled || cropping || !canEditObject(item, state.composite ? "transform" : "arrange");
|
||
el.cropImageButton.disabled = disabled || item.type !== "ImageObject" || !canEditObject(item, "cropImage");
|
||
el.saveImageButton.disabled = disabled || !item.imageResource;
|
||
el.imageFit.disabled = el.cropImageButton.disabled || cropping || Boolean(item.scoped && !canEditObject(item, "fitImage"));
|
||
el.cropImageButton.textContent = cropping ? "完成" : "裁剪";
|
||
el.cropImageButton.setAttribute("data-pressed", String(cropping));
|
||
el.resetCropButton.textContent = cropping ? "取消" : "还原";
|
||
el.resetCropButton.disabled = canEditObject(item, "resetCrop") ? disabled : el.cropImageButton.disabled || !cropping && (item.scoped ? !item.cropped : !item.imageBounds || ["x", "y", "width", "height"].every(key => Math.abs(item[key] - item.imageBounds[key]) < 1e-9));
|
||
el.objectDistribute.disabled = el.objectAlign.disabled || !item.items || item.items.length < 3;
|
||
el.editObjectButton.disabled = disabled || (item.items ? !item.items.every(member => member.type === "TextObject" && !member.richText)
|
||
: !canEditObject(item, "enter") && (item.type === "PathObject"
|
||
|| !(item.type === "TextObject" && canEditObject(item, "rewriteText")) && !canEditObject(item, item.type === "TextObject" ? "textContent" : item.type === "ImageObject" ? "replaceImage" : "update")));
|
||
el.editObjectButton.textContent = canEditObject(item, "enter") ? "进入" : item?.type === "ImageObject" ? "替换" : "修改";
|
||
el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
|
||
const selection = selectedText(item);
|
||
const text = selection ? currentTextStyle() : state.textDefaults;
|
||
const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && (!selection || item.items.some(member => member.richText))) || Boolean(canvasEditor.input?.composing || canvasEditor.input?.saving);
|
||
fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont"));
|
||
el.textSize.disabled = textDisabled || text !== state.textDefaults && !item.draft && (!canEditObject(item, "reflow") || Boolean(item?.items && !item.selectionIDs) && !canEditObject(item, "layoutKnown"));
|
||
el.textColor.disabled = textDisabled || text !== state.textDefaults && !item.draft && !item.richText && !canEditObject(item, "paint");
|
||
el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting;
|
||
const formatsDisabled = textDisabled || Boolean(item?.items || item?.scoped) || Boolean(item && !item.draft && (!selection || !canEditObject(item, "textContent")));
|
||
for (const button of textFormatButtons) {
|
||
button.disabled = formatsDisabled;
|
||
const key = button.dataset.textFormat;
|
||
button.setAttribute("data-pressed", text[key] === undefined && (canvasEditor.input?.rich || item?.richText) ? "mixed" : String(Boolean(text[key])));
|
||
}
|
||
textBackground.disabled = formatsDisabled;
|
||
textBackground.classList.toggle("mixed-color", Boolean((canvasEditor.input?.rich || item?.richText) && text.background === undefined));
|
||
if (text.background) textBackground.value = text.background;
|
||
document.querySelector("#textHighlight").style.setProperty("--highlight-color", textBackground.value);
|
||
const richHistory = canvasEditor.input?.rich || canvasEditor.input?.history;
|
||
if (richHistory) {
|
||
el.undoButton.disabled = textDisabled || richHistory.index === 0;
|
||
el.redoButton.disabled = textDisabled || richHistory.index === richHistory.entries.length - 1;
|
||
} else {
|
||
el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting;
|
||
el.redoButton.disabled = !state.editorInfo?.canRedo || !state.ready || state.exporting;
|
||
}
|
||
el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items || item?.draft) || Boolean(canvasEditor.input);
|
||
const reflow = document.querySelector("#textReflow"), positioned = canvasEditor.input?.source && (item?.selectionIDs || !canEditObject(item, "layoutKnown"));
|
||
reflow.hidden = !positioned;
|
||
el.textWrap.hidden = !!positioned;
|
||
reflow.disabled = textDisabled || !canEditObject(item, "reflow") || !!canvasEditor.input?.reflow;
|
||
el.paragraphButton.disabled = el.textAlign.disabled;
|
||
const layoutKnown = text === state.textDefaults || Boolean(item?.draft) || canEditObject(item, "layoutKnown");
|
||
el.textAlign.value = layoutKnown ? text.align || "left" : "";
|
||
el.textLineHeight.placeholder = layoutKnown ? "自动" : "原文";
|
||
el.textSpacing.placeholder = layoutKnown ? "0" : "原文";
|
||
el.textWrap.setAttribute("data-pressed", String(Boolean(text.wrap)));
|
||
if (reset || document.activeElement !== el.textLineHeight) {
|
||
el.textLineHeight.value = text.paragraphHeight ? String(displayPoints(text.paragraphHeight)) : "";
|
||
}
|
||
if (reset || document.activeElement !== el.textSpacing) {
|
||
el.textSpacing.value = text.letterSpacing ? String(displayPoints(text.letterSpacing)) : "";
|
||
}
|
||
updateTextFonts(item);
|
||
if (reset || el.textSize.disabled || document.activeElement !== el.textSize) {
|
||
el.textSize.value = text.size === undefined ? "" : displayPoints(text.size);
|
||
}
|
||
el.textSize.placeholder = text.size === undefined ? "混合" : "";
|
||
el.textSize.title = text.size === undefined ? "字号不同" : `字号${displayPoints(text.size)}pt`;
|
||
el.textColor.classList.toggle("mixed-color", Boolean(selection && !text.color));
|
||
el.textColor.title = selection && !text.color ? "文字颜色不同" : "文字颜色";
|
||
if (reset || el.textColor.disabled || document.activeElement !== el.textColor) {
|
||
el.textColor.value = text.color || "#000000";
|
||
}
|
||
const path = selectedPath(item);
|
||
for (const [input, key] of [[el.shapeFill, "fill"], [el.shapeStroke, "stroke"]]) {
|
||
input.indeterminate = Boolean(path?.items && path[key] === undefined);
|
||
if (path) input.checked = Boolean(path[key]);
|
||
}
|
||
for (const [input, key, label] of [[el.shapeFillColor, "fillColor", "填充颜色"], [el.shapeStrokeColor, "strokeColor", "描边颜色"]]) {
|
||
const mixed = Boolean(path?.items && path[key] === undefined);
|
||
input.classList.toggle("mixed-color", mixed);
|
||
input.title = mixed ? `${label}不同` : label;
|
||
if (path && (reset || document.activeElement !== input)) input.value = path[key] || "#000000";
|
||
}
|
||
el.shapeWidth.placeholder = path?.items && path.lineWidth === undefined ? "混合" : "";
|
||
if (path && canEditObject(path, "paint")) {
|
||
if (reset || document.activeElement !== el.shapeWidth) {
|
||
el.shapeWidth.value = path.lineWidth === undefined ? "" : displayPoints(path.lineWidth);
|
||
}
|
||
} else if (!item && !el.shapeWidth.value) {
|
||
el.shapeWidth.value = "1";
|
||
}
|
||
updateDrawingControls();
|
||
const orders = members.map(member => member.position).sort((a, b) => a - b);
|
||
const count = members[0]?.count || 0;
|
||
const canRaise = orders.some((order, i) => order !== count - orders.length + i);
|
||
const canLower = orders.some((order, i) => order !== i);
|
||
el.objectOrder.disabled = disabled || cropping || count <= orders.length || !canEditObject(item, "order")
|
||
|| members.some(member => member.container !== members[0].container);
|
||
for (const option of el.objectOrder.options) {
|
||
option.disabled = el.objectOrder.disabled || (["up", "top"].includes(option.value) ? !canRaise : !canLower);
|
||
}
|
||
}
|
||
|
||
function updateAnnotationButton() {
|
||
el.annotationButton.setAttribute("data-pressed", String(state.renderAnnotations));
|
||
}
|
||
|
||
async function callWASM(name, ...args) {
|
||
if (state.wasmExited) {
|
||
scheduleWASMRecovery();
|
||
throw new Error(state.wasmRecovering ? STATUS.recovering : "引擎运行中断");
|
||
}
|
||
if (!state.ready) {
|
||
throw new Error("引擎尚未就绪");
|
||
}
|
||
return new Promise((resolve, reject) => {
|
||
const id = ++wasmRequestID;
|
||
if (name === "ofdgoResources" && resourceElements.Panel.open) resourceManager.requestID = id;
|
||
wasmRequests.set(id, { resolve, reject, openSeq: state.openSeq });
|
||
if (name === "ofdgoExportMedia") wasmRequests.get(id).mediaID = args[0];
|
||
if (name === "ofdgoConvertPDF") {
|
||
const request = wasmRequests.get(id);
|
||
request.localFonts = fontManager.localEntries();
|
||
request.fontData = new Map();
|
||
args.push([...request.localFonts.keys()]);
|
||
}
|
||
if (name === "ofdgoImportDocuments" || name === "ofdgoImportImages" || name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoExportResource" || name === "ofdgoExportVersion" || name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") {
|
||
state.exportRequestID = id;
|
||
el.cancelExportButton.hidden = false;
|
||
el.cancelExportButton.disabled = false;
|
||
}
|
||
try {
|
||
const transfer = [];
|
||
if (name === "ofdgoOpen" && !nativeHost) {
|
||
args[0] = args[0].slice();
|
||
transfer.push(args[0].buffer);
|
||
}
|
||
if (name === "ofdgoLoadImport" && args[0] && !nativeHost) transfer.push(args[0].buffer);
|
||
wasmWorker.postMessage({ id, name, args }, transfer);
|
||
} catch (err) {
|
||
wasmRequests.delete(id);
|
||
reject(err);
|
||
}
|
||
});
|
||
}
|
||
|
||
function showError(err, empty = !state.doc) {
|
||
const message = String(err.message || err);
|
||
setStatus(message);
|
||
if (empty) {
|
||
setEmpty(message);
|
||
}
|
||
}
|
||
|
||
function setBusy(busy, text = "", percent = 0, status = "") {
|
||
document.body.toggleAttribute("data-busy", busy);
|
||
updateStructureControls();
|
||
updateDocumentControls();
|
||
renderSecurity();
|
||
updateDisplayControls();
|
||
el.editButton.disabled = busy || Boolean(sealPreview || resourceManager.pick) || !state.doc || !state.ready || state.exporting;
|
||
el.createForm.inert = busy && !state.imageImporting;
|
||
updateCreateOptions();
|
||
el.insertForm.inert = busy;
|
||
el.pageForm.inert = busy;
|
||
el.paragraphForm.inert = busy;
|
||
el.infoForm.inert = busy;
|
||
el.documentForm.inert = busy;
|
||
el.versionForm.inert = busy;
|
||
el.attachmentForm.inert = busy;
|
||
el.annotationForm.inert = busy;
|
||
el.importForm.inert = busy && !state.importing;
|
||
for (const input of [el.importFile, el.importRange, el.importPosition, el.importOutlines]) input.disabled = busy;
|
||
updateImportControls(busy);
|
||
el.batchPagesForm.inert = busy;
|
||
el.objectStyleForm.inert = busy;
|
||
el.objectBoundsForm.inert = busy;
|
||
el.outlineForm.inert = busy;
|
||
el.structureForm.inert = busy;
|
||
el.outlineList.inert = busy;
|
||
el.navigationTabs.inert = busy;
|
||
el.pageList.inert = busy;
|
||
el.editorTools.inert = busy || Boolean(sealPreview || resourceManager.pick);
|
||
el.fontList.inert = busy;
|
||
el.docFontList.inert = busy;
|
||
if (!busy) {
|
||
el.progressPanel.hidden = true;
|
||
if (state.fontSyncPending) {
|
||
window.setTimeout(syncUserFonts, 0);
|
||
}
|
||
if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0);
|
||
return;
|
||
}
|
||
el.exportPanel.close();
|
||
endPan();
|
||
el.progressPanel.hidden = !text;
|
||
setProgress(text, percent, status);
|
||
}
|
||
|
||
function setProgress(text = "", percent = 0, status = "") {
|
||
if (text) {
|
||
el.progressLabel.textContent = text;
|
||
}
|
||
if (status) {
|
||
setStatus(status);
|
||
}
|
||
el.progressBar.parentElement.hidden = percent === null;
|
||
if (percent === null) {
|
||
return;
|
||
}
|
||
const value = Math.max(0, Math.min(100, percent));
|
||
el.progressBar.style.width = `${value}%`;
|
||
}
|
||
|
||
function setStatus(text) {
|
||
el.statusText.textContent = text;
|
||
}
|
||
|
||
function setEmpty(text) {
|
||
el.emptyState.hidden = false;
|
||
el.pageFrame.hidden = true;
|
||
el.emptyState.textContent = text;
|
||
}
|
||
|
||
function formatSize(value) {
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
return "-";
|
||
}
|
||
return value.toFixed(1);
|
||
}
|
||
|
||
function downloadBytes(bytes, mime, name) {
|
||
if (nativeHost) return nativeHost.download(bytes, mime, name).catch(error => { if (error.name !== "AbortError") showError(error, false); });
|
||
const blob = new Blob([bytes], { type: mime });
|
||
const link = document.createElement("a");
|
||
const url = URL.createObjectURL(blob);
|
||
link.href = url;
|
||
link.download = name;
|
||
document.body.append(link);
|
||
link.click();
|
||
link.remove();
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
}
|
||
|
||
function baseFileName() {
|
||
const base = (state.fileName || state.doc?.title || "ofdgo").replace(/\.[^.]+$/, "");
|
||
return base.replace(/[\\/:*?"<>|]+/g, "_").trim() || "ofdgo";
|
||
}
|
||
|
||
function pageFileName(extension) {
|
||
const page = String(state.pageIndex + 1).padStart(String(state.doc?.pageCount || 1).length, "0");
|
||
return `${baseFileName()}_${page}.${extension}`;
|
||
}
|
||
|
||
function exportFormatInfo(value) {
|
||
return state.exportFormats.find((format) => format.value === value) || null;
|
||
}
|
||
|
||
function exportFormatUsesDPI(value) {
|
||
return value === "png" || value === "jpg";
|
||
}
|
||
|
||
function updateDPIControl() {
|
||
el.imageDPI.disabled = state.exporting || !state.doc || document.body.hasAttribute("data-busy") || (displayMode() !== "raster" && !exportFormatUsesDPI(el.exportFormat.value));
|
||
el.imageDPI.title = `输出精度:${currentImageDPI()} DPI`;
|
||
el.dpiValue.textContent = String(currentImageDPI());
|
||
const format = exportFormatInfo(el.exportFormat.value);
|
||
el.formatValue.textContent = format?.label || "";
|
||
el.exportFormat.title = format ? `导出格式:${format.label}` : "导出格式";
|
||
}
|
||
|
||
function currentImageDPI() {
|
||
return Number.parseFloat(el.imageDPI.value) || DEFAULT_IMAGE_DPI;
|
||
}
|
||
|
||
function formatBytes(size) {
|
||
if (!Number.isFinite(size) || size <= 0) {
|
||
return "0 KB";
|
||
}
|
||
if (size < 1024 * 1024) {
|
||
return `${Math.round(size / 1024)} KB`;
|
||
}
|
||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
function nextFrame() {
|
||
return new Promise((resolve) => requestAnimationFrame(resolve));
|
||
}
|
||
|
||
function waitForPaint() {
|
||
return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
}
|