From 5fce4f0a712b4903dfed08f443e7a9b33c8ee7fb Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sat, 31 Oct 2026 13:30:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=95=8C=E9=9D=A2=E8=AE=BE=E8=AE=A1):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=96=B0=E5=BB=BA=E6=96=B9=E5=90=91=E3=80=81?= =?UTF-8?q?=E7=BA=B8=E5=BC=A0=E4=B8=8E=E5=9B=BE=E7=89=87=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 12 +- assets/webui/ofdgo.css | 104 ++++++++++++++ assets/webui/ofdgo.js | 293 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 394 insertions(+), 15 deletions(-) diff --git a/assets/webui/index.html b/assets/webui/index.html index 9c8bd62..17debce 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -346,7 +346,7 @@
- +

新建文档

@@ -354,8 +354,14 @@ - - + + +
+
方向
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 358a455..132d8df 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1657,6 +1657,110 @@ button:not(:disabled):hover { gap: 12px; } +.create-dialog { + width: 360px; +} + +.create-dialog.create-images { + width: 600px; +} + +.create-image-actions { + display: flex; + align-items: center; + gap: 8px; +} + +#createImageCount { + margin-left: auto; + color: var(--text-muted); +} + +#createImageQueue { + margin-bottom: 12px; +} + +.create-image-list { + max-height: min(24dvh, 216px); + overflow: auto; + overscroll-behavior: contain; + border: 1px solid var(--line); + border-radius: var(--radius-control); + margin-bottom: 8px; +} + +.create-image-entry { + position: relative; + display: grid; + grid-template-columns: 3ch 48px minmax(0, 1fr); + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: 0; + border-bottom: 1px solid var(--line); + text-align: left; +} + +.create-image-entry:last-child { + border-bottom: 0; +} + +.create-image-entry[data-selected="true"] { + background: var(--selected-fill); + color: var(--accent-strong); +} + +.create-image-entry:focus-visible { + outline-offset: -2px; +} + +.create-image-entry[data-drop]::after { + content: ""; + position: absolute; + inset-inline: 0; + height: 2px; + background: var(--accent); +} + +.create-image-entry[data-drop="before"]::after { top: 0; } +.create-image-entry[data-drop="after"]::after { bottom: 0; } + +.create-image-preview { + display: grid; + place-items: center; + width: 48px; + height: 48px; + overflow: hidden; + background: var(--well-fill); + color: var(--text-muted); + font-size: 11px; +} + +.create-image-preview img { + display: block; + max-width: 100%; + max-height: 100%; + background: #fff; +} + +.create-image-details { + display: grid; + min-width: 0; + gap: 4px; +} + +.create-image-details > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.create-image-details > time { + color: var(--text-muted); + font-size: 12px; +} + .encryption-current { display: flex; align-items: center; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 3777c48..c7fa165 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -92,6 +92,7 @@ function createDocumentState() { createInsert: false, createPackage: false, createDocumentAt: null, + createImageQueue: null, documentManagement: null, versionManagement: null, infoDocumentIndex: null, @@ -190,6 +191,8 @@ function createDocumentState() { } let state = createDocumentState(); +let createImagePicker = null; +const createPaperSizes = { a4: [210, 297], a3: [297, 420], a5: [148, 210] }; const el = { appearanceButton: document.querySelector("#appearanceButton"), @@ -502,6 +505,16 @@ const el = { createTitle: document.querySelector("#createTitle"), createMode: document.querySelector("#createMode"), createImages: document.querySelector("#createImages"), + createImageQueue: document.querySelector("#createImageQueue"), + createImageList: document.querySelector("#createImageList"), + createImageAdd: document.querySelector("#createImageAdd"), + createImageCount: document.querySelector("#createImageCount"), + createImageOrder: document.querySelector("#createImageOrder"), + createImageUp: document.querySelector("#createImageUp"), + createImageDown: document.querySelector("#createImageDown"), + createImageRemove: document.querySelector("#createImageRemove"), + createPortrait: document.querySelector("#createPortrait"), + createLandscape: document.querySelector("#createLandscape"), createSize: document.querySelector("#createSize"), createMargin: document.querySelector("#createMargin"), createPosition: document.querySelector("#createPosition"), @@ -1664,9 +1677,82 @@ el.createCancel.addEventListener("click", () => state.imageImporting ? cancelExp 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.createPanel.addEventListener("close", () => { releaseCreateImages(documentTab()); el.createImages.value = ""; el.createImageList.replaceChildren(); }); +el.createMode.addEventListener("change", () => { + el.createSize.value = el.createMode.value === "images" ? "image" : "a4"; + applyCreatePaper(); +}); +el.createSize.addEventListener("change", applyCreatePaper); +for (const input of [el.createWidth, el.createHeight]) input.addEventListener("input", () => updateCreateDirection(true)); +for (const radio of [el.createPortrait, el.createLandscape]) radio.addEventListener("change", () => { + [el.createWidth.value, el.createHeight.value] = [el.createHeight.value, el.createWidth.value]; + updateCreateDirection(); +}); +el.createImageAdd.addEventListener("click", () => { + createImagePicker = { tab: documentTab(), queue: state.createImageQueue }; + el.createImages.click(); +}); +el.createImages.addEventListener("change", addCreateImages); +el.createImages.addEventListener("cancel", () => { createImagePicker = null; }); +el.createImageOrder.addEventListener("change", () => { state.createImageQueue.order = el.createImageOrder.value; sortCreateImages(); }); +el.createImageUp.addEventListener("click", () => moveCreateImage(-1)); +el.createImageDown.addEventListener("click", () => moveCreateImage(1)); +el.createImageRemove.addEventListener("click", removeCreateImage); +el.createImageList.addEventListener("click", event => { + const row = event.target.closest("[data-image-id]"); + if (row) { state.createImageQueue.selected = Number(row.dataset.imageId); updateCreateImageSelection(); } +}); +el.createImageList.addEventListener("keydown", event => { + const row = event.target.closest("[data-image-id]"), queue = state.createImageQueue; + if (!row || event.ctrlKey || event.metaKey || event.shiftKey) return; + const index = queue.entries.findIndex(entry => entry.id === Number(row.dataset.imageId)); + if (["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) { + event.preventDefault(); + queue.selected = queue.entries[index].id; + if (event.altKey && event.key.startsWith("Arrow")) moveCreateImage(event.key === "ArrowUp" ? -1 : 1); + else { + const next = event.key === "Home" ? 0 : event.key === "End" ? queue.entries.length - 1 : Math.max(0, Math.min(queue.entries.length - 1, index + (event.key === "ArrowUp" ? -1 : 1))); + queue.selected = queue.entries[next].id; + updateCreateImageSelection(); + } + focusCreateImage(); + } else if (event.key === "Delete" && !event.altKey) { + event.preventDefault(); + queue.selected = queue.entries[index].id; + removeCreateImage(); + focusCreateImage(); + } +}); +el.createImageList.addEventListener("dragstart", event => { + const row = event.target.closest("[data-image-id]"); + if (!row || state.imageImporting) { event.preventDefault(); return; } + state.createImageQueue.drag = Number(row.dataset.imageId); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("application/x-ofdgo-image", row.dataset.imageId); +}); +el.createImageList.addEventListener("dragover", event => { + if (state.createImageQueue?.drag == null || !event.dataTransfer.types.includes("application/x-ofdgo-image")) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + const row = event.target.closest("[data-image-id]"); + for (const item of el.createImageList.children) delete item.dataset.drop; + if (row) row.dataset.drop = event.clientY < row.getBoundingClientRect().top + row.offsetHeight / 2 ? "before" : "after"; +}); +el.createImageList.addEventListener("drop", event => { + const queue = state.createImageQueue, row = event.target.closest("[data-image-id]"); + if (queue?.drag == null || !event.dataTransfer.types.includes("application/x-ofdgo-image")) return; + event.preventDefault(); + const from = queue.entries.findIndex(entry => entry.id === queue.drag); + let to = row ? queue.entries.findIndex(entry => entry.id === Number(row.dataset.imageId)) + (row.dataset.drop === "after" ? 1 : 0) : queue.entries.length; + if (to > from) to--; + queue.selected = queue.drag; + queue.drag = null; + reorderCreateImage(from, to); +}); +el.createImageList.addEventListener("dragend", () => { + if (state.createImageQueue) state.createImageQueue.drag = null; + for (const row of el.createImageList.children) delete row.dataset.drop; +}); el.createForm.addEventListener("submit", createDocument); editorClick(el.insertTextButton, toggleTextTool); editorClick(el.insertImageButton, () => openInsertPanel()); @@ -2860,6 +2946,10 @@ function detachDocumentView() { state.searchRunning = false; } tab.localFonts = fontManager.localFonts; + if (state.createImageQueue) { + state.createImageQueue.observer?.disconnect(); + state.createImageQueue.drag = null; + } tab.status = el.statusText.textContent; advanceDocumentSequence(); state.pageSeq++; @@ -2932,6 +3022,7 @@ function attachDocumentTab(tab) { el.ofdInput.value = ""; canvasEditor.pendingSelection = tab.view?.selection || null; documentDialogs?.setOwner(tab); + if (el.createPanel.open && state.createImageQueue) renderCreateImages(); workspace.resourceView = resourceElements.Panel.open ? resourceManager : null; pageView.setContinuous(state.continuous); pageView.updatePanControl(); @@ -3053,6 +3144,7 @@ async function mountDocumentTab(tab) { async function releaseDocumentTab(tab) { if (tab.closed) return; + releaseCreateImages(tab); const clipboard = workspace.objectClipboard; if (clipboard?.tab === tab) { const prepared = await prepareObjectClipboard(clipboard, tab); @@ -3734,14 +3826,18 @@ function openCreatePanel(insert = false, packageDocument = false) { return; } el.createForm.reset(); + releaseCreateImages(documentTab()); + state.createImageQueue = { entries: [], selected: null, next: 0, order: "name-asc", drag: null, observer: null, task: Promise.resolve() }; state.createInsert = insert === true; state.createPackage = packageDocument === true; el.createTitle.textContent = state.createInsert ? "插入页面" : state.createPackage ? "新增文档" : "新建文档"; state.createDocumentAt = state.createPackage ? managedDocumentIndex() + 1 : null; el.createMode.value = state.createInsert ? "images" : "blank"; + el.createSize.value = state.createInsert ? "image" : "a4"; el.createSubmit.textContent = state.createInsert ? "插入" : state.createPackage ? "新增" : "新建"; el.createCancel.disabled = false; updateCreateOptions(); + renderCreateImages(); el.createStatus.textContent = ""; el.createPanel.showModal(); } @@ -3753,21 +3849,187 @@ function updateCreateOptions() { 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]]) { + for (const [id, input] of [["createImagesRow", el.createImages], ["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; + el.createSize.disabled = busy; + el.createSize.querySelector('[value="image"]').hidden = !images; + el.createSize.querySelector('[value="image"]').disabled = !images; + el.createImageQueue.hidden = !images || !state.createImageQueue?.entries.length; + el.createPanel.classList.toggle("create-images", images); + el.createImageAdd.disabled = el.createImageOrder.disabled = busy; + const dimensions = !images || el.createSize.value !== "image"; + toggle("createDimensions", dimensions); + toggle("createDirectionRow", dimensions); + el.createWidth.disabled = el.createHeight.disabled = busy || !dimensions; toggle("createPositionRow", Boolean(state.createInsert)); el.createPosition.disabled = busy || !state.createInsert; - el.createSubmit.disabled = busy; + el.createSubmit.disabled = busy || images && !state.createImageQueue?.entries.length; + updateCreateDirection(); + updateCreateImageSelection(); +} + +function applyCreatePaper() { + const paper = createPaperSizes[el.createSize.value]; + if (paper) { + const [width, height] = el.createLandscape.checked ? [...paper].reverse() : paper; + el.createWidth.value = width; + el.createHeight.value = height; + } + updateCreateOptions(); +} + +function updateCreateDirection(matchPaper = false) { + const width = Number(el.createWidth.value), height = Number(el.createHeight.value); + const valid = width > 0 && height > 0; + el.createPortrait.checked = valid && width < height; + el.createLandscape.checked = valid && width > height; + el.createPortrait.disabled = el.createLandscape.disabled = !valid || width === height || el.createWidth.disabled; + if (matchPaper) el.createSize.value = Object.entries(createPaperSizes).find(([, [w, h]]) => Math.min(width, height) === w && Math.max(width, height) === h)?.[0] || "custom"; +} + +function releaseCreateImages(tab) { + const queue = tab.state.createImageQueue; + if (!queue) return; + queue.observer?.disconnect(); + for (const entry of queue.entries) if (entry.url) URL.revokeObjectURL(entry.url); + queue.entries.length = 0; + tab.state.createImageQueue = null; +} + +function addCreateImages() { + const { tab, queue } = createImagePicker || { tab: documentTab(), queue: state.createImageQueue }; + createImagePicker = null; + const files = [...el.createImages.files]; + el.createImages.value = ""; + if (tab.closed || !queue || tab.state.createImageQueue !== queue) return; + for (const file of files) queue.entries.push({ id: queue.next++, file, url: null, previewed: false }); + sortCreateImages(queue); + if (isForegroundDocument(tab)) updateCreateOptions(); +} + +function sortCreateImages(queue = state.createImageQueue) { + const order = queue.order; + if (order !== "custom") queue.entries.sort((a, b) => { + const value = order.startsWith("name") ? a.file.name.localeCompare(b.file.name, undefined, { numeric: true }) : order.startsWith("time") ? a.file.lastModified - b.file.lastModified : a.id - b.id; + return (order.endsWith("desc") ? -value : value) || a.id - b.id; + }); + if (queue === state.createImageQueue) renderCreateImages(); +} + +function updateCreateImageSelection() { + const queue = state.createImageQueue; + const index = queue?.entries.findIndex(entry => entry.id === queue.selected) ?? -1; + el.createImageUp.disabled = state.imageImporting || index <= 0; + el.createImageDown.disabled = state.imageImporting || index < 0 || index === queue.entries.length - 1; + el.createImageRemove.disabled = state.imageImporting || index < 0; + for (const row of el.createImageList.children) { + row.dataset.selected = String(Number(row.dataset.imageId) === queue?.selected); + row.tabIndex = row.dataset.selected === "true" ? 0 : -1; + row.disabled = state.imageImporting; + row.draggable = !state.imageImporting; + } +} + +function focusCreateImage() { + (el.createImageList.querySelector('[data-selected="true"]') || el.createImageAdd).focus(); +} + +function reorderCreateImage(from, to) { + const queue = state.createImageQueue; + if (from < 0 || to < 0 || to >= queue.entries.length) return; + if (from !== to) { + queue.entries.splice(to, 0, queue.entries.splice(from, 1)[0]); + queue.order = "custom"; + } + renderCreateImages(); +} + +function moveCreateImage(direction) { + const queue = state.createImageQueue, index = queue.entries.findIndex(entry => entry.id === queue.selected); + reorderCreateImage(index, index + direction); +} + +function removeCreateImage() { + const queue = state.createImageQueue, index = queue.entries.findIndex(entry => entry.id === queue.selected); + if (index < 0) return; + const [entry] = queue.entries.splice(index, 1); + if (entry.url) URL.revokeObjectURL(entry.url); + queue.selected = queue.entries[Math.min(index, queue.entries.length - 1)]?.id ?? null; + renderCreateImages(); + updateCreateOptions(); +} + +function renderCreateImages() { + const tab = documentTab(), queue = state.createImageQueue; + queue.observer?.disconnect(); + const top = el.createImageList.scrollTop; + const focused = el.createImageList.contains(document.activeElement); + el.createImageList.replaceChildren(); + el.createImageOrder.value = queue.order; + el.createImageCount.textContent = String(queue.entries.length); + if (!queue.entries.some(entry => entry.id === queue.selected)) queue.selected = queue.entries[0]?.id ?? null; + queue.observer = new IntersectionObserver(items => { + for (const item of items) if (item.isIntersecting && isForegroundDocument(tab)) { + queue.observer.unobserve(item.target); + const entry = queue.entries.find(entry => entry.id === Number(item.target.dataset.imageId)); + queue.task = queue.task.then(() => loadCreateImagePreview(tab, queue, entry)); + } + }, { root: el.createImageList, rootMargin: "64px" }); + for (const [index, entry] of queue.entries.entries()) { + const row = document.createElement("button"), number = document.createElement("span"), preview = document.createElement("span"), details = document.createElement("span"); + row.type = "button"; + row.className = "create-image-entry"; + row.dataset.imageId = entry.id; + row.title = entry.file.name; + number.textContent = String(index + 1); + preview.className = "create-image-preview"; + preview.textContent = entry.file.name.split(".").at(-1).toUpperCase(); + details.className = "create-image-details"; + const name = document.createElement("span"), time = document.createElement("time"); + name.textContent = entry.file.name; + time.textContent = new Date(entry.file.lastModified).toLocaleString(); + details.append(name, time); + row.append(number, preview, details); + el.createImageList.append(row); + if (entry.url) showCreateImagePreview(entry, row); + else if (!entry.previewed) queue.observer.observe(row); + } + el.createImageList.scrollTop = top; + updateCreateImageSelection(); + if (focused) focusCreateImage(); +} + +function showCreateImagePreview(entry, row) { + const image = document.createElement("img"); + image.src = entry.url; + image.draggable = false; + row.querySelector(".create-image-preview").replaceChildren(image); +} + +async function loadCreateImagePreview(tab, queue, entry) { + if (!entry || entry.previewed || tab.state.createImageQueue !== queue || !queue.entries.includes(entry)) return; + entry.previewed = true; + let bitmap; + try { + bitmap = await createImageBitmap(entry.file, { resizeWidth: 64 }); + const canvas = document.createElement("canvas"), scale = Math.min(64 / bitmap.width, 64 / bitmap.height, 1); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height); + const blob = await new Promise(resolve => canvas.toBlob(resolve)); + if (tab.state.createImageQueue !== queue || !queue.entries.includes(entry)) return; + entry.url = URL.createObjectURL(blob); + if (isForegroundDocument(tab)) { + const current = el.createImageList.querySelector(`[data-image-id="${entry.id}"]`); + if (current) showCreateImagePreview(entry, current); + } + } catch {} finally { bitmap?.close(); } } function imagePageRequest() { - const files = [...el.createImages.files].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + const files = state.createImageQueue.entries.map(entry => entry.file); const size = el.createSize.value; return [files, { Width: size === "image" ? 0 : Number(el.createWidth.value), @@ -3778,14 +4040,21 @@ function imagePageRequest() { async function createDocument(event) { event.preventDefault(); + const owner = documentTab(); 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(); + Number(el.createWidth.value), Number(el.createHeight.value), state.createDocumentAt)) { + releaseCreateImages(owner); + documentDialogs.closeFor(owner, el.createPanel); + } 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(); + if (await changeDocument("ofdgoImportImages", null, ...imagePageRequest(), at, "", state.renderAnnotations)) { + releaseCreateImages(owner); + documentDialogs.closeFor(owner, el.createPanel); + } return; } if (workspace.closing) {