feat(界面设计): 统一全部下拉框样式与交互
build.yaml / build (push) Successful in 46s

This commit is contained in:
xiaoqidun committed 2026-10-30 20:35:34 +08:00
1 parent 86e4d7d6ae
commit 2350504cfe
4 files changed
+254 -21

No files matched your search

+20 -7
View File
@@ -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 {
+4 -2
View File
@@ -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) {
+4 -12
View File
@@ -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) {
+226
View File
@@ -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();
}
}