feat(import-window): replace modal CSS with window frame CSS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Saberlve 2026-04-15 15:35:55 +08:00
parent 5dee76ecd1
commit 05a5e99ee0

View File

@ -144,74 +144,168 @@
border-radius: 16px; border-radius: 16px;
color: var(--muted); color: var(--muted);
} }
.modal-shell[hidden] { .window-shell[hidden] {
display: none; display: none;
} }
.modal-shell { .window-shell {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 40; z-index: 40;
pointer-events: none;
} }
.modal-overlay { .window-shell:not([hidden]) {
position: absolute; pointer-events: auto;
}
.window-overlay {
position: fixed;
inset: 0; inset: 0;
border-radius: 0; z-index: 39;
background: rgba(24, 28, 22, 0.42); background: rgba(24, 28, 22, 0.42);
border: 0;
} }
.modal-card { .window-frame {
position: relative; position: fixed;
z-index: 1; z-index: 40;
width: min(1100px, calc(100vw - 2rem)); width: var(--fw-width, 90vw);
max-height: calc(100vh - 2rem); height: var(--fw-height, 75vh);
margin: 1rem auto; left: var(--fw-left, calc((100vw - var(--fw-width, 90vw)) / 2));
top: var(--fw-top, calc((100vh - var(--fw-height, 75vh)) / 2));
min-width: 40rem;
min-height: 30rem;
border-radius: 16px;
box-shadow: 0 24px 64px rgba(35, 31, 21, 0.22);
display: grid; display: grid;
grid-template-rows: auto auto 1fr auto; grid-template-rows: auto 1fr auto;
overflow: hidden;
background: var(--surface); background: var(--surface);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 24px 60px rgba(35, 31, 21, 0.2);
overflow: hidden;
} }
.modal-header, .window-frame.maximized {
.modal-action-bar { width: 100vw;
height: 100vh;
left: 0 !important;
top: 0 !important;
border-radius: 0;
--fw-width: 100vw;
--fw-height: 100vh;
}
.window-frame:not(.maximized):not(.minimized) {
resize: both;
overflow: auto;
}
.window-minimized-bar {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 50;
height: 2.5rem;
width: 14rem;
border-radius: 8px;
background: var(--surface);
border: 1px solid var(--line);
box-shadow: 0 8px 24px rgba(35, 31, 21, 0.14);
display: none;
align-items: center;
padding: 0 0.75rem;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
color: var(--ink);
}
.window-minimized-bar:not([hidden]) {
display: flex;
}
.window-minimized-bar:hover {
background: var(--panel);
}
.window-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 1rem;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: rgba(255, 253, 248, 0.98);
cursor: grab;
} }
.modal-action-bar { .window-toolbar:active {
border-top: 1px solid var(--line); cursor: grabbing;
border-bottom: 0;
position: sticky;
bottom: 0;
z-index: 2;
background: var(--surface);
} }
.modal-header-actions { .window-toolbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.modal-body { .window-body {
display: grid; display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.2fr); grid-template-columns: 22rem minmax(0, 1fr);
min-height: 24rem;
}
.modal-pane {
display: grid;
gap: 0.6rem;
padding: 1rem 1.25rem;
min-height: 0; min-height: 0;
} }
.modal-scroll { .window-pane {
display: grid;
gap: 0.6rem;
min-height: 0;
padding: 1rem 1.25rem;
}
.window-pane.collections {
border-right: 1px solid var(--line);
}
.window-scroll {
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
} }
.collection-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.75rem;
align-items: center;
padding: 0.75rem 0.9rem;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.72);
}
.collection-item input[type="checkbox"] {
width: auto;
margin: 0;
}
.item-row-body {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.title-button {
flex: 1;
min-width: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--ink);
text-align: left;
font-weight: 600;
}
.title-button:hover {
transform: none;
background: transparent;
}
.details-button {
width: 2.2rem;
min-width: 2.2rem;
padding: 0.45rem 0;
}
.collection-item .meta {
margin-left: auto;
}
.item-year {
flex: 0 0 auto;
min-width: 3rem;
text-align: right;
}
.preview-popover { .preview-popover {
position: absolute; position: fixed;
max-width: 28rem; max-width: 28rem;
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
border: 1px solid var(--line); border: 1px solid var(--line);
@ -219,6 +313,7 @@
background: rgba(255, 253, 248, 0.98); background: rgba(255, 253, 248, 0.98);
box-shadow: 0 18px 40px rgba(35, 31, 21, 0.16); box-shadow: 0 18px 40px rgba(35, 31, 21, 0.16);
color: var(--ink); color: var(--ink);
z-index: 60;
} }
.tabs { .tabs {
display: flex; display: flex;
@ -248,17 +343,18 @@
border-right: 0; border-right: 0;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.modal-card { .window-frame {
width: calc(100vw - 1rem); width: 100vw;
margin: 0.5rem auto; height: 100vh;
max-height: calc(100vh - 1rem); left: 0 !important;
top: 0 !important;
border-radius: 0;
} }
.modal-body { .window-body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: minmax(12rem, 1fr) minmax(12rem, 1fr); grid-template-rows: minmax(12rem, 1fr) minmax(12rem, 1fr);
} }
.modal-header, .window-toolbar {
.modal-action-bar {
flex-wrap: wrap; flex-wrap: wrap;
} }
} }
@ -298,35 +394,32 @@
<button id="open-import-modal-button" type="button">导入文献</button> <button id="open-import-modal-button" type="button">导入文献</button>
</div> </div>
<div id="import-modal" class="modal-shell" hidden aria-hidden="true"> <div id="import-modal" class="window-shell" hidden aria-hidden="true">
<button id="import-modal-overlay" class="modal-overlay" type="button" aria-label="关闭导入弹窗"></button> <div id="import-modal-overlay" class="window-overlay" type="button" aria-label="关闭导入弹窗"></div>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="import-modal-title"> <section class="window-frame" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
<header class="modal-header"> <div class="window-toolbar">
<button id="close-import-modal-button" type="button" class="secondary">返回主页面</button>
<div> <div>
<h3 id="import-modal-title">Import From Zotero</h3> <h3 id="import-modal-title">Import From Zotero</h3>
<p id="import-modal-project-label" class="meta">未选择项目</p> <p id="import-modal-project-label" class="meta">未选择项目</p>
</div> </div>
<div class="modal-header-actions"> <div class="window-toolbar-actions">
<div id="modal-selected-count" class="meta">已选 0 篇</div> <div id="modal-selected-count" class="meta">已选 0 篇</div>
<button id="close-import-modal-button" type="button" class="secondary">关闭</button> <button id="modal-clear-selection-button" type="button" class="danger">清空选择</button>
</div> <button id="modal-import-selected-items-button" type="button">导入所选</button>
</header> </div>
<div id="collection-status" class="status"></div> </div>
<div class="modal-body"> <div class="window-body">
<div class="modal-pane"> <div class="window-pane collections">
<small>Collections</small> <small>Collections</small>
<div id="modal-collection-tree" class="modal-scroll tree-list"></div> <div id="collection-status" class="status"></div>
</div> <div id="modal-collection-tree" class="window-scroll tree-list"></div>
<div class="modal-pane"> </div>
<small>Collection Items</small> <div class="window-pane items">
<div id="modal-collection-items" class="modal-scroll collection-items-list"></div> <small>Collection Items</small>
<div id="modal-collection-items" class="window-scroll collection-items-list"></div>
</div> </div>
</div> </div>
<footer class="modal-action-bar">
<button id="modal-select-descendants-button" type="button" class="secondary">全选当前目录及子目录</button>
<button id="modal-clear-selection-button" type="button" class="danger">清空选择</button>
<button id="modal-import-selected-items-button" type="button">导入所选到当前项目</button>
</footer>
<div id="item-preview-popover" class="preview-popover" hidden></div> <div id="item-preview-popover" class="preview-popover" hidden></div>
</section> </section>
</div> </div>
@ -379,6 +472,7 @@
</main> </main>
<script> <script>
const ABSTRACT_PREVIEW_DELAY_MS = 3000;
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal"; const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";
function readImportSessionState() { function readImportSessionState() {
@ -420,6 +514,9 @@
loadingCollectionKey: null, loadingCollectionKey: null,
nextCollectionItemsRequestToken: 0, nextCollectionItemsRequestToken: 0,
activeCollectionItemsRequestToken: 0, activeCollectionItemsRequestToken: 0,
hoveredItemKey: null,
hoverTimerId: null,
previewItemKey: null,
}; };
const elements = { const elements = {
@ -435,9 +532,9 @@
collectionTree: document.getElementById("modal-collection-tree"), collectionTree: document.getElementById("modal-collection-tree"),
collectionItems: document.getElementById("modal-collection-items"), collectionItems: document.getElementById("modal-collection-items"),
selectedCount: document.getElementById("modal-selected-count"), selectedCount: document.getElementById("modal-selected-count"),
selectDescendantsButton: document.getElementById("modal-select-descendants-button"),
clearSelectionButton: document.getElementById("modal-clear-selection-button"), clearSelectionButton: document.getElementById("modal-clear-selection-button"),
importSelectedItemsButton: document.getElementById("modal-import-selected-items-button"), importSelectedItemsButton: document.getElementById("modal-import-selected-items-button"),
previewPopover: document.getElementById("item-preview-popover"),
currentProjectLabel: document.getElementById("current-project-label"), currentProjectLabel: document.getElementById("current-project-label"),
cardList: document.getElementById("card-list"), cardList: document.getElementById("card-list"),
cardDetail: document.getElementById("card-detail"), cardDetail: document.getElementById("card-detail"),
@ -511,7 +608,7 @@
function renderCards(cards) { function renderCards(cards) {
state.currentCards = cards || []; state.currentCards = cards || [];
if (!state.currentCards.length) { if (!state.currentCards.length) {
elements.cardList.innerHTML = '<div class="empty">当前项目还没有卡片。先导入 Zotero 选中文献。</div>'; elements.cardList.innerHTML = '<div class="empty">当前项目还没有卡片。先从 Zotero 导入文献。</div>';
elements.cardDetail.innerHTML = '<div class="empty">导入后点击卡片查看详情。</div>'; elements.cardDetail.innerHTML = '<div class="empty">导入后点击卡片查看详情。</div>';
return; return;
} }
@ -584,6 +681,74 @@
elements.selectedCount.textContent = `已选 ${state.selectedItemKeys.size} 篇`; elements.selectedCount.textContent = `已选 ${state.selectedItemKeys.size} 篇`;
} }
function clearAbstractPreviewTimer() {
if (state.hoverTimerId !== null) {
window.clearTimeout(state.hoverTimerId);
state.hoverTimerId = null;
}
}
function cancelAbstractPreview() {
clearAbstractPreviewTimer();
state.hoveredItemKey = null;
state.previewItemKey = null;
elements.previewPopover.hidden = true;
elements.previewPopover.setAttribute("aria-hidden", "true");
elements.previewPopover.textContent = "";
elements.previewPopover.style.top = "";
elements.previewPopover.style.left = "";
}
function positionAbstractPreview(target) {
const preview = elements.previewPopover;
const targetRect = target.getBoundingClientRect();
const maxWidth = Math.min(448, Math.max(260, window.innerWidth - 32));
preview.style.maxWidth = `${maxWidth}px`;
const previewRect = preview.getBoundingClientRect();
let left = targetRect.right + 12;
if (left + previewRect.width > window.innerWidth - 12) {
left = Math.max(12, targetRect.left - previewRect.width - 12);
}
let top = targetRect.top;
if (top + previewRect.height > window.innerHeight - 12) {
top = Math.max(12, window.innerHeight - previewRect.height - 12);
}
preview.style.left = `${Math.round(left)}px`;
preview.style.top = `${Math.round(top)}px`;
}
function showAbstractPreview(item, target) {
clearAbstractPreviewTimer();
const abstract = typeof item.abstract === "string" ? item.abstract.trim() : "";
if (!abstract) {
cancelAbstractPreview();
return;
}
state.previewItemKey = item.item_key;
elements.previewPopover.textContent = abstract;
elements.previewPopover.hidden = false;
elements.previewPopover.setAttribute("aria-hidden", "false");
positionAbstractPreview(target);
}
function scheduleAbstractPreview(item, target) {
if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
return;
}
const abstract = typeof item.abstract === "string" ? item.abstract.trim() : "";
if (!abstract) {
return;
}
cancelAbstractPreview();
state.hoveredItemKey = item.item_key;
state.hoverTimerId = window.setTimeout(() => {
state.hoverTimerId = null;
if (state.hoveredItemKey === item.item_key) {
showAbstractPreview(item, target);
}
}, ABSTRACT_PREVIEW_DELAY_MS);
}
function resetCollectionItemsLoadingState(clearItems = false) { function resetCollectionItemsLoadingState(clearItems = false) {
if (clearItems) { if (clearItems) {
state.visibleCollectionItems = []; state.visibleCollectionItems = [];
@ -611,7 +776,6 @@
syncCurrentProjectLabels(); syncCurrentProjectLabels();
elements.importSelectedItemsButton.disabled = !hasProject || !hasSelection; elements.importSelectedItemsButton.disabled = !hasProject || !hasSelection;
elements.importSelectedItemsButton.title = !hasProject ? "请先选择项目" : (hasSelection ? "" : "请先选择文献"); elements.importSelectedItemsButton.title = !hasProject ? "请先选择项目" : (hasSelection ? "" : "请先选择文献");
elements.selectDescendantsButton.disabled = !state.collectionItemsLoaded || !state.visibleCollectionItems.length;
elements.clearSelectionButton.disabled = !state.selectedItemKeys.size; elements.clearSelectionButton.disabled = !state.selectedItemKeys.size;
renderSelectedCount(); renderSelectedCount();
} }
@ -701,7 +865,7 @@
button.style.textAlign = "left"; button.style.textAlign = "left";
button.innerHTML = ` button.innerHTML = `
<strong>${node.name}</strong> <strong>${node.name}</strong>
<div class="meta">${node.direct_item_count} 篇直接文献 · ${node.descendant_item_count} 篇含子目录</div> <div class="meta">${node.direct_item_count} 篇</div>
`; `;
button.addEventListener("click", () => { button.addEventListener("click", () => {
loadCollectionItems(node.collection_key).catch((error) => { loadCollectionItems(node.collection_key).catch((error) => {
@ -726,6 +890,7 @@
} }
function renderCollectionItems() { function renderCollectionItems() {
cancelAbstractPreview();
if (!state.selectedCollectionKey) { if (!state.selectedCollectionKey) {
elements.collectionItems.innerHTML = '<div class="empty">先选择一个 collection。</div>'; elements.collectionItems.innerHTML = '<div class="empty">先选择一个 collection。</div>';
updateImportActionState(); updateImportActionState();
@ -739,27 +904,49 @@
return; return;
} }
if (!state.visibleCollectionItems.length) { if (!state.visibleCollectionItems.length) {
elements.collectionItems.innerHTML = '<div class="empty">当前目录及子目录下没有可导入文献。</div>'; elements.collectionItems.innerHTML = '<div class="empty">当前目录下没有可导入文献。</div>';
updateImportActionState(); updateImportActionState();
return; return;
} }
elements.collectionItems.innerHTML = ""; elements.collectionItems.innerHTML = "";
for (const item of state.visibleCollectionItems) { for (const item of state.visibleCollectionItems) {
const row = document.createElement("label"); const row = document.createElement("div");
row.className = "project-item"; row.className = "collection-item";
row.style.display = "grid"; const hasAbstract = typeof item.abstract === "string" && item.abstract.trim().length > 0;
row.style.gridTemplateColumns = "auto 1fr";
row.style.gap = "0.75rem";
row.style.alignItems = "start";
row.innerHTML = ` row.innerHTML = `
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} /> <input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
<div> <div class="item-row-body">
<strong>${item.title}</strong> <button type="button" class="title-button">${item.title}</button>
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div> <div class="meta item-year">${item.year || "-"}</div>
<button
type="button"
class="secondary details-button"
aria-label="${hasAbstract ? "查看摘要" : "暂无摘要"}"
${hasAbstract ? "" : "disabled"}
>i</button>
</div> </div>
`; `;
row.querySelector("input").addEventListener("change", () => toggleItemSelection(item.item_key)); const checkbox = row.querySelector("input");
const titleButton = row.querySelector(".title-button");
const detailsButton = row.querySelector(".details-button");
checkbox.addEventListener("change", () => {
cancelAbstractPreview();
toggleItemSelection(item.item_key);
});
titleButton.addEventListener("mouseenter", () => scheduleAbstractPreview(item, titleButton));
titleButton.addEventListener("mouseleave", cancelAbstractPreview);
detailsButton.addEventListener("click", (event) => {
event.preventDefault();
if (!hasAbstract) {
return;
}
if (state.previewItemKey === item.item_key && !elements.previewPopover.hidden) {
cancelAbstractPreview();
return;
}
showAbstractPreview(item, detailsButton);
});
elements.collectionItems.appendChild(row); elements.collectionItems.appendChild(row);
} }
updateImportActionState(); updateImportActionState();
@ -784,6 +971,7 @@
} }
async function loadCollectionItems(collectionKey) { async function loadCollectionItems(collectionKey) {
cancelAbstractPreview();
if (!state.collectionItemsLoaded && state.loadingCollectionKey === collectionKey) { if (!state.collectionItemsLoaded && state.loadingCollectionKey === collectionKey) {
return; return;
} }
@ -809,7 +997,7 @@
renderCollectionItems(); renderCollectionItems();
setStatus(elements.collectionStatus, "正在加载文献..."); setStatus(elements.collectionStatus, "正在加载文献...");
try { try {
const payload = await api(`/api/zotero/collections/${collectionKey}/items`); const payload = await api(`/api/zotero/collections/${collectionKey}/items?include_descendants=false`);
if (state.activeCollectionItemsRequestToken !== requestToken || state.selectedCollectionKey !== collectionKey) { if (state.activeCollectionItemsRequestToken !== requestToken || state.selectedCollectionKey !== collectionKey) {
return; return;
} }
@ -820,7 +1008,7 @@
const visibleCount = state.visibleCollectionItems.length; const visibleCount = state.visibleCollectionItems.length;
setStatus( setStatus(
elements.collectionStatus, elements.collectionStatus,
visibleCount ? `当前目录及子目录共 ${visibleCount} 篇文献。` : "当前目录及子目录下没有可导入文献。" visibleCount ? `当前目录共 ${visibleCount} 篇文献。` : "当前目录下没有可导入文献。"
); );
} catch (error) { } catch (error) {
if (state.activeCollectionItemsRequestToken === requestToken) { if (state.activeCollectionItemsRequestToken === requestToken) {
@ -832,6 +1020,7 @@
} }
function toggleItemSelection(itemKey) { function toggleItemSelection(itemKey) {
cancelAbstractPreview();
if (state.selectedItemKeys.has(itemKey)) { if (state.selectedItemKeys.has(itemKey)) {
state.selectedItemKeys.delete(itemKey); state.selectedItemKeys.delete(itemKey);
} else { } else {
@ -842,16 +1031,8 @@
updateImportActionState(); updateImportActionState();
} }
function selectVisibleItems() {
for (const item of state.visibleCollectionItems) {
state.selectedItemKeys.add(item.item_key);
}
persistImportSessionState();
renderCollectionItems();
updateImportActionState();
}
function clearSelectedItems() { function clearSelectedItems() {
cancelAbstractPreview();
state.selectedItemKeys.clear(); state.selectedItemKeys.clear();
persistImportSessionState(); persistImportSessionState();
renderCollectionItems(); renderCollectionItems();
@ -859,6 +1040,7 @@
} }
async function importSelectedItems() { async function importSelectedItems() {
cancelAbstractPreview();
if (!state.currentProjectId || !state.selectedItemKeys.size) { if (!state.currentProjectId || !state.selectedItemKeys.size) {
updateImportActionState(); updateImportActionState();
return; return;
@ -882,6 +1064,7 @@
elements.importModal.hidden = false; elements.importModal.hidden = false;
elements.importModal.setAttribute("aria-hidden", "false"); elements.importModal.setAttribute("aria-hidden", "false");
document.body.classList.toggle("modal-open", true); document.body.classList.toggle("modal-open", true);
cancelAbstractPreview();
updateImportActionState(); updateImportActionState();
if (!state.collectionTree.length) { if (!state.collectionTree.length) {
try { try {
@ -909,6 +1092,7 @@
elements.importModal.hidden = true; elements.importModal.hidden = true;
elements.importModal.setAttribute("aria-hidden", "true"); elements.importModal.setAttribute("aria-hidden", "true");
document.body.classList.toggle("modal-open", false); document.body.classList.toggle("modal-open", false);
cancelAbstractPreview();
} }
async function loadProjects() { async function loadProjects() {
@ -979,7 +1163,7 @@
}); });
elements.closeImportModalButton.addEventListener("click", closeImportModal); elements.closeImportModalButton.addEventListener("click", closeImportModal);
elements.importModalOverlay.addEventListener("click", closeImportModal); elements.importModalOverlay.addEventListener("click", closeImportModal);
elements.selectDescendantsButton.addEventListener("click", selectVisibleItems); elements.collectionItems.addEventListener("scroll", cancelAbstractPreview);
elements.clearSelectionButton.addEventListener("click", clearSelectedItems); elements.clearSelectionButton.addEventListener("click", clearSelectedItems);
elements.importSelectedItemsButton.addEventListener("click", () => { elements.importSelectedItemsButton.addEventListener("click", () => {
importSelectedItems().catch((error) => { importSelectedItems().catch((error) => {