From 2350504cfe2bc7eaafb50acfe5078993f4195f92 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Fri, 30 Oct 2026 20:35:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=95=8C=E9=9D=A2=E8=AE=BE=E8=AE=A1):=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=85=A8=E9=83=A8=E4=B8=8B=E6=8B=89=E6=A1=86?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E4=B8=8E=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.css | 27 +++-- assets/webui/ofdgo.js | 6 +- assets/webui/ofdgo_font.js | 16 +-- assets/webui/ofdgo_util.js | 226 +++++++++++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 21 deletions(-) diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index f047e9d..74e0783 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -626,7 +626,8 @@ a:focus-visible { mask: var(--dropdown-arrow) center / contain no-repeat; } -.font-listbox { +.font-listbox, +.dropdown-list { position: fixed; margin: 0; padding: 4px; @@ -636,10 +637,13 @@ a:focus-visible { box-shadow: var(--shadow-flyout); overflow-y: auto; overflow-x: hidden; + overscroll-behavior: contain; font-size: 14px; + border-radius: var(--radius-control); } -.font-listbox .font-option { +.font-listbox .font-option, +.dropdown-list .dropdown-option { display: flex; align-items: baseline; justify-content: space-between; @@ -649,7 +653,13 @@ a:focus-visible { cursor: pointer; } -.font-listbox .font-option > span:first-child { +.dropdown-group { + padding: 6px 8px; + color: var(--text-muted); +} + +.font-listbox .font-option > span:first-child, +.dropdown-list .dropdown-option > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -662,21 +672,25 @@ a:focus-visible { font-size: 12px; } -.font-listbox .font-option:not([data-disabled="true"]):not([data-selected="true"]):hover { +.font-listbox .font-option:not([data-disabled="true"]):not([data-selected="true"]):hover, +.dropdown-list .dropdown-option:not([data-disabled="true"]):not([data-selected="true"]):hover { background: var(--hover); } -.font-listbox [data-selected="true"] { +.font-listbox [data-selected="true"], +.dropdown-list [data-selected="true"] { background: var(--selected); color: var(--accent-strong); } .font-listbox [data-disabled="true"], +.dropdown-list [data-disabled="true"], .font-search-empty { color: var(--text-muted); } -.font-listbox [data-disabled="true"] { +.font-listbox [data-disabled="true"], +.dropdown-list [data-disabled="true"] { cursor: default; } @@ -4746,7 +4760,6 @@ audio.page-media { .editor-number, .font-picker, -.font-listbox, .page-control, .format-select, .order-select { diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 9541701..83bc42d 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -2,7 +2,7 @@ import { SVG_NAMESPACE, CanvasEditor, canEditObject, editTextValue, richTextMode import { FontManager, FontPicker } from "./ofdgo_font.js"; import { nativeHost, createEngineWorker, engineLimits, documentBytes, saveFilePicker, directoryPicker } from "./ofdgo_host.js"; import { PrintDialog } from "./ofdgo_print.js"; -import { DocumentDialogs } from "./ofdgo_util.js"; +import { DocumentDialogs, SelectPicker } from "./ofdgo_util.js"; import { MM_TO_PX, SCROLL_SEGMENT_SIZE, createPageView } from "./ofdgo_view.js"; const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)"); @@ -705,7 +705,8 @@ const fontManager = new FontManager({ onChange: scheduleFontSync, onPermissionChange: updateFontPermissionHint, }); -const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts, groupTextFonts); +const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, () => { selectPicker.close(); loadEditorFonts(); }, groupTextFonts); +const selectPicker = new SelectPicker(() => fontPicker.prepareCommit()); const textFormatButtons = [...document.querySelectorAll("[data-text-format]")]; const textBackground = document.querySelector("#textBackground"); const editorInputs = new Map(); @@ -2872,6 +2873,7 @@ function detachDocumentView() { resourceManager.sequence++; settings.sequence++; fontBinding.sequence++; + selectPicker.close(); fontPicker.close(); fontPicker.changing = null; for (const [input, control] of editorInputs) { diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index dee398b..a22f58a 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -1,3 +1,5 @@ +import { bindDropdownPointer, positionDropdown } from "./ofdgo_util.js"; + const DATABASE = "ofdgo"; export class FontPicker { @@ -14,13 +16,7 @@ export class FontPicker { if (this.open) this.close(); else { input.focus(); input.select(); this.show(); } }); - list.addEventListener("pointerdown", event => { - if (event.target.closest(".font-option")) event.preventDefault(); - }); - list.addEventListener("click", event => { - const option = event.target.closest(".font-option"); - if (option) this.choose(Number(option.dataset.index)); - }); + bindDropdownPointer(list, index => this.choose(index)); window.addEventListener("resize", () => this.close()); } @@ -133,11 +129,7 @@ export class FontPicker { position() { if (!this.open) return; - const rect = this.input.parentElement.getBoundingClientRect(); - if (rect.right <= 0 || rect.left >= window.innerWidth) { this.close(); return; } - const width = Math.min(rect.width, window.innerWidth - 16); - Object.assign(this.list.style, { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`, - top: `${rect.bottom + 4}px`, width: `${width}px`, maxHeight: `${Math.max(40, Math.min(280, window.innerHeight - rect.bottom - 12))}px` }); + if (!positionDropdown(this.list, this.input.parentElement)) this.close(); } activate(index) { diff --git a/assets/webui/ofdgo_util.js b/assets/webui/ofdgo_util.js index 3b0fc6a..31727d1 100644 --- a/assets/webui/ofdgo_util.js +++ b/assets/webui/ofdgo_util.js @@ -322,3 +322,229 @@ export class DocumentDialogs { if (restore) for (const item of this.current().entries.get(panel)?.snapshot?.nodes || []) { item.node.scrollTop = item.scrollTop; item.node.scrollLeft = item.scrollLeft; } } } + +export function positionDropdown(list, anchor, fit = false) { + const rect = anchor.getBoundingClientRect(), viewport = window.visualViewport; + const left = viewport?.offsetLeft || 0, top = viewport?.offsetTop || 0; + const width = viewport?.width || window.innerWidth, height = viewport?.height || window.innerHeight; + if (rect.right <= left || rect.left >= left + width || rect.bottom <= top || rect.top >= top + height) return false; + const below = top + height - rect.bottom - 12, above = rect.top - top - 12; + const limit = Math.min(280, Math.max(below, above)); + if (limit < 20) return false; + const minimum = Math.min(rect.width, width - 16); + Object.assign(list.style, { width: fit ? "max-content" : `${minimum}px`, minWidth: `${minimum}px`, maxWidth: `${width - 16}px`, maxHeight: `${limit}px` }); + const size = list.getBoundingClientRect(); + Object.assign(list.style, { left: `${Math.max(left + 8, Math.min(rect.left, left + width - size.width - 8))}px`, + top: `${below >= size.height || below >= above ? rect.bottom + 4 : rect.top - size.height - 4}px` }); + return true; +} + +export function bindDropdownPointer(list, choose) { + list.addEventListener("pointerdown", event => { + if (event.pointerType !== "touch" && event.target.closest("[data-index]")) event.preventDefault(); + }); + list.addEventListener("mousedown", event => { if (event.target.closest("[data-index]")) event.preventDefault(); }); + list.addEventListener("click", event => { + const option = event.target.closest("[data-index]"); + if (option) choose(Number(option.dataset.index)); + }); +} + +export class SelectPicker { + constructor(onOpen) { + Object.assign(this, { onOpen, select: null, options: [], active: -1, selected: -1, query: "", queryTime: 0, frame: 0, touchClick: false }); + this.list = document.createElement("div"); + this.list.className = "dropdown-list"; + this.list.setAttribute("popover", "manual"); + bindDropdownPointer(this.list, index => this.choose(index)); + window.addEventListener("pointerdown", event => { + if (event.button !== 0) return; + this.touchClick = false; + const select = event.target.closest("select"); + if (select && !select.matches(":disabled")) event.preventDefault(); + if (this.select && event.target !== this.select && !this.list.contains(event.target)) this.close(); + }, true); + window.addEventListener("pointerup", event => { + const select = event.target.closest("select"); + if (event.pointerType !== "touch" || !select || select.matches(":disabled")) return; + event.preventDefault(); + if (this.select === select) this.close(); + else this.show(select); + this.touchClick = true; + }, true); + window.addEventListener("mousedown", event => { + if (event.button === 0 && event.target.closest("select")) event.preventDefault(); + }, true); + window.addEventListener("click", event => { + const select = event.target.closest("select"); + if (!select || select.matches(":disabled")) return; + event.preventDefault(); + if (this.touchClick) { this.touchClick = false; return; } + if (this.select === select) this.close(); + else this.show(select); + }, true); + window.addEventListener("keydown", event => this.keyDown(event), true); + window.addEventListener("focusin", event => { + if (this.select && event.target !== this.select && !this.list.contains(event.target)) this.close(); + }); + window.addEventListener("scroll", event => { + if (this.select && !this.list.contains(event.target)) this.position(); + }, true); + window.addEventListener("resize", () => this.close()); + window.addEventListener("blur", () => this.close()); + window.addEventListener("pagehide", () => this.close()); + document.addEventListener("close", event => { + if (this.select && event.target.contains(this.select)) this.close(); + }, true); + document.addEventListener("reset", event => { + if (this.select?.form === event.target) this.close(); + }, true); + document.addEventListener("change", event => { + if (event.target === this.select) this.render(); + }, true); + } + + show(select) { + this.close(); + this.onOpen?.(); + select.focus({ preventScroll: true }); + if (!select.isConnected || select.matches(":disabled")) return; + this.select = select; + (select.closest("dialog") || document.body).append(this.list); + this.render(); + if (!this.select) return; + this.list.showPopover(); + this.position(); + if (!this.select) return; + this.activate(this.active); + this.observer = new MutationObserver(() => this.render()); + this.observer.observe(select, { subtree: true, childList: true, characterData: true, attributes: true }); + this.watch(); + } + + render() { + const select = this.select; + if (!select) return; + const previous = this.options[this.active]?.option, changed = this.selected !== select.selectedIndex; + this.selected = select.selectedIndex; + this.options = [...select.options].filter(option => !option.hidden && !option.parentElement.hidden).map(option => ({ + option, disabled: option.disabled || option.parentElement.tagName === "OPTGROUP" && option.parentElement.disabled, + })); + if (!this.options.length) { this.close(); return; } + this.list.replaceChildren(); + let group = null; + for (const [index, { option, disabled }] of this.options.entries()) { + if (option.parentElement.tagName === "OPTGROUP" && group !== option.parentElement) { + group = option.parentElement; + const heading = document.createElement("div"); + heading.className = "dropdown-group"; + heading.textContent = group.label; + this.list.append(heading); + } + const row = document.createElement("div"); + row.className = "dropdown-option"; + row.dataset.index = String(index); + row.dataset.disabled = String(disabled); + const label = document.createElement("span"); + label.textContent = option.label; + row.append(label); + row.title = option.title; + this.list.append(row); + } + const selected = this.options.findIndex(({ option, disabled }) => !disabled && option === (changed ? select.selectedOptions[0] : previous)); + this.activate(selected < 0 ? this.options.findIndex(({ disabled }) => !disabled) : selected); + if (this.list.matches(":popover-open")) this.position(); + } + + watch() { + const select = this.select; + if (!select) return; + if (!select.isConnected || select.matches(":disabled") || !select.getClientRects().length || select.closest("[hidden], [inert], dialog:not([open])")) { + this.close(); + return; + } + if (this.selected !== select.selectedIndex) this.render(); + if (this.select) this.frame = requestAnimationFrame(() => this.watch()); + } + + position() { + if (this.select && !positionDropdown(this.list, this.select, true)) this.close(); + } + + activate(index) { + this.active = index; + for (const row of this.list.querySelectorAll(".dropdown-option")) { + row.dataset.selected = String(Number(row.dataset.index) === index); + if (row.dataset.selected === "true" && this.list.matches(":popover-open")) row.scrollIntoView({ block: "nearest" }); + } + } + + choose(index) { + const select = this.select, item = this.options[index]; + if (!select || select.matches(":disabled") || !item || item.disabled || !select.contains(item.option)) return; + const changed = select.selectedIndex !== item.option.index; + select.selectedIndex = item.option.index; + this.close(); + if (changed) { + select.dispatchEvent(new Event("input", { bubbles: true })); + select.dispatchEvent(new Event("change", { bubbles: true })); + } + } + + close() { + if (!this.select) return; + this.observer?.disconnect(); + this.observer = null; + cancelAnimationFrame(this.frame); + this.frame = 0; + this.list.hidePopover(); + this.list.remove(); + this.list.replaceChildren(); + this.select = null; + this.options = []; + this.active = this.selected = -1; + this.query = ""; + this.queryTime = 0; + } + + keyDown(event) { + if (event.defaultPrevented || event.isComposing) return; + this.touchClick = false; + if (event.ctrlKey || event.metaKey || event.key === "Tab") { this.close(); return; } + const select = event.target.closest("select"); + if (!select || select.matches(":disabled") || event.altKey && !["ArrowDown", "ArrowUp"].includes(event.key)) return; + const key = event.key, open = this.select === select; + if (key === "Escape") { + if (!open) return; + this.close(); + } else if (key === "F4" || event.altKey) { + if (open) this.close(); + else this.show(select); + } else if (["Enter", " "].includes(key)) { + if (open) this.choose(this.active); + else this.show(select); + } else if (["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"].includes(key)) { + if (!open) this.show(select); + if (this.select && (open || !["ArrowDown", "ArrowUp"].includes(key))) { + const available = this.options.flatMap(({ disabled }, index) => disabled ? [] : [index]); + const at = available.indexOf(this.active), direction = ["ArrowUp", "PageUp"].includes(key) ? -1 : 1; + const step = key.startsWith("Page") ? Math.max(1, Math.floor(this.list.clientHeight / (this.list.querySelector(".dropdown-option")?.offsetHeight || 32)) - 1) : 1; + const next = key === "Home" ? 0 : key === "End" ? available.length - 1 : Math.max(0, Math.min(available.length - 1, at + direction * step)); + this.activate(available[next] ?? -1); + } + } else if (key.length === 1) { + if (!open) this.show(select); + const now = performance.now(); + this.query = (now - this.queryTime < 700 ? this.query : "") + key.toLocaleLowerCase(); + this.queryTime = now; + const repeated = [...this.query].every(value => value === this.query[0]), query = repeated ? this.query[0] : this.query; + const start = repeated ? this.active + 1 : Math.max(0, this.active); + for (let offset = 0; offset < this.options.length; offset++) { + const index = (start + offset) % this.options.length, item = this.options[index]; + if (!item.disabled && item.option.label.trim().toLocaleLowerCase().startsWith(query)) { this.activate(index); break; } + } + } else return; + event.preventDefault(); + event.stopImmediatePropagation(); + } +}