Merge branch 'feature/import-floating-window' into master

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Saberlve 2026-04-15 16:18:08 +08:00
commit 4829dce3a6
2 changed files with 273 additions and 279 deletions

View File

@ -156,13 +156,6 @@
.window-shell:not([hidden]) { .window-shell:not([hidden]) {
pointer-events: auto; pointer-events: auto;
} }
.window-overlay {
position: fixed;
inset: 0;
z-index: 39;
background: rgba(24, 28, 22, 0.42);
border: 0;
}
.window-frame { .window-frame {
position: fixed; position: fixed;
z-index: 40; z-index: 40;
@ -256,56 +249,8 @@
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: fixed; position: absolute;
max-width: 28rem; max-width: 28rem;
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
border: 1px solid var(--line); border: 1px solid var(--line);
@ -313,7 +258,6 @@
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;
@ -343,18 +287,17 @@
border-right: 0; border-right: 0;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.window-frame { .modal-card {
width: 100vw; width: calc(100vw - 1rem);
height: 100vh; margin: 0.5rem auto;
left: 0 !important; max-height: calc(100vh - 1rem);
top: 0 !important;
border-radius: 0;
} }
.window-body { .modal-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);
} }
.window-toolbar { .modal-header,
.modal-action-bar {
flex-wrap: wrap; flex-wrap: wrap;
} }
} }
@ -394,35 +337,44 @@
<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="window-shell" hidden aria-hidden="true"> <div id="import-window-shell" class="window-shell" hidden aria-hidden="true">
<div id="import-modal-overlay" class="window-overlay" type="button" aria-label="关闭导入弹窗"></div> <section id="import-window" class="window-frame" role="dialog" aria-modal="true" aria-labelledby="import-window-title">
<section class="window-frame" role="dialog" aria-modal="true" aria-labelledby="import-modal-title"> <div class="window-toolbar" id="import-window-toolbar">
<div class="window-toolbar"> <div style="display:flex;align-items:center;gap:0.6rem">
<button id="close-import-modal-button" type="button" class="secondary">返回主页面</button> <span style="font-size:1.2rem;line-height:1"></span>
<div> <div>
<h3 id="import-modal-title">Import From Zotero</h3> <h3 id="import-window-title">Import From Zotero</h3>
<p id="import-modal-project-label" class="meta">未选择项目</p> <p id="import-window-project-label" class="meta">未选择项目</p>
</div>
</div> </div>
<div class="window-toolbar-actions"> <div class="window-toolbar-actions">
<div id="modal-selected-count" class="meta">已选 0 篇</div> <div id="window-selected-count" class="meta">已选 0 篇</div>
<button id="modal-clear-selection-button" type="button" class="danger">清空选择</button> <button id="window-clear-selection-button" type="button" class="danger">清空选择</button>
<button id="modal-import-selected-items-button" type="button">导入所选</button> <button id="window-import-selected-items-button" type="button">导入所选</button>
<button id="window-minimize-button" type="button" class="secondary" title="最小化" style="width:auto;padding:0.55rem 0.75rem">_</button>
<button id="window-maximize-button" type="button" class="secondary" title="最大化" style="width:auto;padding:0.55rem 0.75rem"></button>
<button id="window-close-button" type="button" class="secondary" title="关闭" style="width:auto;padding:0.55rem 0.75rem">×</button>
</div> </div>
</div> </div>
<div class="window-body"> <div class="window-body">
<div class="window-pane collections"> <div class="window-pane collections">
<small>Collections</small> <small>Collections</small>
<div id="collection-status" class="status"></div> <div id="window-collection-status" class="status"></div>
<div id="modal-collection-tree" class="window-scroll tree-list"></div> <div id="window-collection-tree" class="window-scroll tree-list"></div>
</div> </div>
<div class="window-pane items"> <div class="window-pane items">
<small>Collection Items</small> <small>Collection Items</small>
<div id="modal-collection-items" class="window-scroll collection-items-list"></div> <div id="window-collection-items" class="window-scroll collection-items-list"></div>
</div> </div>
</div> </div>
<div id="item-preview-popover" class="preview-popover" hidden></div> <div id="item-preview-popover" class="preview-popover" hidden></div>
</section> </section>
</div> </div>
<div id="import-window-minimized-bar" class="window-minimized-bar" hidden>
<span style="flex:1;text-align:left">导入文献</span>
<button id="window-restore-button" type="button" class="secondary" style="width:auto;padding:0.3rem 0.5rem;font-size:0.8rem"></button>
</div>
</section> </section>
<section class="panel"> <section class="panel">
@ -472,9 +424,22 @@
</main> </main>
<script> <script>
const ABSTRACT_PREVIEW_DELAY_MS = 3000; const WINDOW_SESSION_STORAGE_KEY = "zotero-kb.import-window";
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal"; const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";
function readWindowSessionState() {
try {
const raw = window.sessionStorage.getItem(WINDOW_SESSION_STORAGE_KEY);
if (!raw) return {};
const payload = JSON.parse(raw);
return typeof payload === "object" && payload ? payload : {};
} catch (_error) {
return {};
}
}
const windowSessionState = readWindowSessionState();
function readImportSessionState() { function readImportSessionState() {
try { try {
const raw = window.sessionStorage.getItem(IMPORT_SESSION_STORAGE_KEY); const raw = window.sessionStorage.getItem(IMPORT_SESSION_STORAGE_KEY);
@ -490,27 +455,12 @@
const importSessionState = readImportSessionState(); const importSessionState = readImportSessionState();
const WINDOW_SESSION_STORAGE_KEY = "zotero-kb.import-window";
function readWindowSessionState() {
try {
const raw = window.sessionStorage.getItem(WINDOW_SESSION_STORAGE_KEY);
if (!raw) return {};
const payload = JSON.parse(raw);
return typeof payload === "object" && payload ? payload : {};
} catch (_error) {
return {};
}
}
const windowSessionState = readWindowSessionState();
const state = { const state = {
projects: [], projects: [],
currentProjectId: null, currentProjectId: null,
currentCards: [], currentCards: [],
collectionTree: [], collectionTree: [],
isImportModalOpen: false, isImportWindowOpen: false,
isImportWindowOpen: false, isImportWindowOpen: false,
windowX: typeof windowSessionState.x === "number" ? windowSessionState.x : null, windowX: typeof windowSessionState.x === "number" ? windowSessionState.x : null,
windowY: typeof windowSessionState.y === "number" ? windowSessionState.y : null, windowY: typeof windowSessionState.y === "number" ? windowSessionState.y : null,
@ -541,9 +491,6 @@
loadingCollectionKey: null, loadingCollectionKey: null,
nextCollectionItemsRequestToken: 0, nextCollectionItemsRequestToken: 0,
activeCollectionItemsRequestToken: 0, activeCollectionItemsRequestToken: 0,
hoveredItemKey: null,
hoverTimerId: null,
previewItemKey: null,
}; };
const elements = { const elements = {
@ -553,12 +500,12 @@
importWindowShell: document.getElementById("import-window-shell"), importWindowShell: document.getElementById("import-window-shell"),
importWindow: document.getElementById("import-window"), importWindow: document.getElementById("import-window"),
windowToolbar: document.getElementById("import-window-toolbar"), windowToolbar: document.getElementById("import-window-toolbar"),
openImportModalButton: document.getElementById("open-import-modal-button"),
windowMinimizeButton: document.getElementById("window-minimize-button"), windowMinimizeButton: document.getElementById("window-minimize-button"),
windowMaximizeButton: document.getElementById("window-maximize-button"), windowMaximizeButton: document.getElementById("window-maximize-button"),
windowCloseButton: document.getElementById("window-close-button"), windowCloseButton: document.getElementById("window-close-button"),
windowRestoreButton: document.getElementById("window-restore-button"), windowRestoreButton: document.getElementById("window-restore-button"),
importWindowMinimizedBar: document.getElementById("import-window-minimized-bar"), importWindowMinimizedBar: document.getElementById("import-window-minimized-bar"),
openImportModalButton: document.getElementById("open-import-modal-button"),
windowProjectLabel: document.getElementById("import-window-project-label"), windowProjectLabel: document.getElementById("import-window-project-label"),
windowSelectedCount: document.getElementById("window-selected-count"), windowSelectedCount: document.getElementById("window-selected-count"),
windowClearSelectionButton: document.getElementById("window-clear-selection-button"), windowClearSelectionButton: document.getElementById("window-clear-selection-button"),
@ -566,7 +513,6 @@
windowCollectionStatus: document.getElementById("window-collection-status"), windowCollectionStatus: document.getElementById("window-collection-status"),
windowCollectionTree: document.getElementById("window-collection-tree"), windowCollectionTree: document.getElementById("window-collection-tree"),
windowCollectionItems: document.getElementById("window-collection-items"), windowCollectionItems: document.getElementById("window-collection-items"),
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"),
@ -616,6 +562,110 @@
); );
} }
function applyWindowPositionAndSize() {
const fw = elements.importWindow;
if (!fw) return;
fw.style.left = state.windowX !== null ? `${state.windowX}px` : "";
fw.style.top = state.windowY !== null ? `${state.windowY}px` : "";
fw.style.width = state.windowWidth !== null ? `${state.windowWidth}px` : "";
fw.style.height = state.windowHeight !== null ? `${state.windowHeight}px` : "";
}
function initWindowFromSession() {
const fw = elements.importWindow;
const bar = elements.importWindowMinimizedBar;
if (state.isWindowMinimized) {
fw.classList.add("minimized");
fw.classList.remove("maximized");
bar.hidden = false;
} else if (state.isWindowMaximized) {
fw.classList.add("maximized");
fw.classList.remove("minimized");
fw.style.width = "100vw";
fw.style.height = "100vh";
fw.style.left = "0";
fw.style.top = "0";
bar.hidden = true;
} else {
if (state.windowX !== null) {
applyWindowPositionAndSize();
}
fw.classList.remove("minimized", "maximized");
bar.hidden = true;
}
}
function startWindowDrag(event) {
const fw = elements.importWindow;
if (!fw || fw.classList.contains("maximized") || fw.classList.contains("minimized")) return;
event.preventDefault();
state.isWindowDragging = true;
state.windowDragStartX = event.clientX;
state.windowDragStartY = event.clientY;
state.windowDragStartLeft = parseFloat(fw.style.left) || 0;
state.windowDragStartTop = parseFloat(fw.style.top) || 0;
fw.style.transition = "none";
}
function moveWindowDrag(event) {
if (!state.isWindowDragging) return;
event.preventDefault();
const fw = elements.importWindow;
const dx = event.clientX - state.windowDragStartX;
const dy = event.clientY - state.windowDragStartY;
let newLeft = state.windowDragStartLeft + dx;
let newTop = state.windowDragStartTop + dy;
const maxLeft = window.innerWidth - (parseFloat(fw.style.width) || fw.offsetWidth);
const maxTop = window.innerHeight - (parseFloat(fw.style.height) || fw.offsetHeight);
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
fw.style.left = `${newLeft}px`;
fw.style.top = `${newTop}px`;
state.windowX = newLeft;
state.windowY = newTop;
}
function endWindowDrag() {
if (!state.isWindowDragging) return;
state.isWindowDragging = false;
persistWindowSessionState();
}
function minimizeWindow() {
state.isWindowMinimized = true;
state.isWindowMaximized = false;
elements.importWindow.classList.add("minimized");
elements.importWindow.classList.remove("maximized");
elements.importWindowMinimizedBar.hidden = false;
persistWindowSessionState();
}
function maximizeWindow() {
state.isWindowMaximized = !state.isWindowMaximized;
if (state.isWindowMaximized) {
state.isWindowMinimized = false;
elements.importWindow.classList.add("maximized");
elements.importWindow.classList.remove("minimized");
elements.importWindowMinimizedBar.hidden = true;
elements.importWindow.style.width = "100vw";
elements.importWindow.style.height = "100vh";
elements.importWindow.style.left = "0";
elements.importWindow.style.top = "0";
} else {
elements.importWindow.classList.remove("maximized");
applyWindowPositionAndSize();
}
persistWindowSessionState();
}
function restoreWindow() {
state.isWindowMinimized = false;
elements.importWindow.classList.remove("minimized");
elements.importWindowMinimizedBar.hidden = true;
applyWindowPositionAndSize();
persistWindowSessionState();
}
function setStatus(target, message, isError = false) { function setStatus(target, message, isError = false) {
target.textContent = message || ""; target.textContent = message || "";
target.classList.toggle("error", Boolean(isError)); target.classList.toggle("error", Boolean(isError));
@ -661,7 +711,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;
} }
@ -731,75 +781,7 @@
} }
function renderSelectedCount() { function renderSelectedCount() {
elements.selectedCount.textContent = `已选 ${state.selectedItemKeys.size} 篇`; elements.windowSelectedCount.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) {
@ -816,20 +798,20 @@
if (currentProject) { if (currentProject) {
const label = `当前项目:${currentProject.name}`; const label = `当前项目:${currentProject.name}`;
elements.currentProjectLabel.textContent = label; elements.currentProjectLabel.textContent = label;
elements.importModalProjectLabel.textContent = label; elements.windowProjectLabel.textContent = label;
return; return;
} }
elements.currentProjectLabel.textContent = "当前未选择项目。"; elements.currentProjectLabel.textContent = "当前未选择项目。";
elements.importModalProjectLabel.textContent = "未选择项目"; elements.windowProjectLabel.textContent = "未选择项目";
} }
function updateImportActionState() { function updateImportActionState() {
const hasProject = Boolean(state.currentProjectId); const hasProject = Boolean(state.currentProjectId);
const hasSelection = state.selectedItemKeys.size > 0; const hasSelection = state.selectedItemKeys.size > 0;
syncCurrentProjectLabels(); syncCurrentProjectLabels();
elements.importSelectedItemsButton.disabled = !hasProject || !hasSelection; elements.windowImportSelectedItemsButton.disabled = !hasProject || !hasSelection;
elements.importSelectedItemsButton.title = !hasProject ? "请先选择项目" : (hasSelection ? "" : "请先选择文献"); elements.windowImportSelectedItemsButton.title = !hasProject ? "请先选择项目" : (hasSelection ? "" : "请先选择文献");
elements.clearSelectionButton.disabled = !state.selectedItemKeys.size; elements.windowClearSelectionButton.disabled = !state.selectedItemKeys.size;
renderSelectedCount(); renderSelectedCount();
} }
@ -873,11 +855,11 @@
function renderCollectionTree() { function renderCollectionTree() {
if (!state.collectionTree.length) { if (!state.collectionTree.length) {
elements.collectionTree.innerHTML = '<div class="empty">暂无 collection。</div>'; elements.windowCollectionTree.innerHTML = '<div class="empty">暂无 collection。</div>';
return; return;
} }
elements.collectionTree.innerHTML = ""; elements.windowCollectionTree.innerHTML = "";
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
function appendNode(node, depth) { function appendNode(node, depth) {
@ -918,11 +900,11 @@
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} 篇</div> <div class="meta">${node.direct_item_count} 篇直接文献 · ${node.descendant_item_count} 篇含子目录</div>
`; `;
button.addEventListener("click", () => { button.addEventListener("click", () => {
loadCollectionItems(node.collection_key).catch((error) => { loadCollectionItems(node.collection_key).catch((error) => {
setStatus(elements.collectionStatus, error.message, true); setStatus(elements.windowCollectionStatus, error.message, true);
}); });
}); });
row.appendChild(button); row.appendChild(button);
@ -939,74 +921,51 @@
for (const node of state.collectionTree) { for (const node of state.collectionTree) {
appendNode(node, 0); appendNode(node, 0);
} }
elements.collectionTree.appendChild(fragment); elements.windowCollectionTree.appendChild(fragment);
} }
function renderCollectionItems() { function renderCollectionItems() {
cancelAbstractPreview();
if (!state.selectedCollectionKey) { if (!state.selectedCollectionKey) {
elements.collectionItems.innerHTML = '<div class="empty">先选择一个 collection。</div>'; elements.windowCollectionItems.innerHTML = '<div class="empty">先选择一个 collection。</div>';
updateImportActionState(); updateImportActionState();
return; return;
} }
if (!state.collectionItemsLoaded) { if (!state.collectionItemsLoaded) {
elements.collectionItems.innerHTML = state.loadingCollectionKey === state.selectedCollectionKey elements.windowCollectionItems.innerHTML = state.loadingCollectionKey === state.selectedCollectionKey
? '<div class="empty">正在加载当前 collection 文献。</div>' ? '<div class="empty">正在加载当前 collection 文献。</div>'
: '<div class="empty">当前 collection 文献尚未加载。</div>'; : '<div class="empty">当前 collection 文献尚未加载。</div>';
updateImportActionState(); updateImportActionState();
return; return;
} }
if (!state.visibleCollectionItems.length) { if (!state.visibleCollectionItems.length) {
elements.collectionItems.innerHTML = '<div class="empty">当前目录下没有可导入文献。</div>'; elements.windowCollectionItems.innerHTML = '<div class="empty">当前目录及子目录下没有可导入文献。</div>';
updateImportActionState(); updateImportActionState();
return; return;
} }
elements.collectionItems.innerHTML = ""; elements.windowCollectionItems.innerHTML = "";
for (const item of state.visibleCollectionItems) { for (const item of state.visibleCollectionItems) {
const row = document.createElement("div"); const row = document.createElement("label");
row.className = "collection-item"; row.className = "project-item";
const hasAbstract = typeof item.abstract === "string" && item.abstract.trim().length > 0; row.style.display = "grid";
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 class="item-row-body"> <div>
<button type="button" class="title-button">${item.title}</button> <strong>${item.title}</strong>
<div class="meta item-year">${item.year || "-"}</div> <div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
<button
type="button"
class="secondary details-button"
aria-label="${hasAbstract ? "查看摘要" : "暂无摘要"}"
${hasAbstract ? "" : "disabled"}
>i</button>
</div> </div>
`; `;
const checkbox = row.querySelector("input"); row.querySelector("input").addEventListener("change", () => toggleItemSelection(item.item_key));
const titleButton = row.querySelector(".title-button"); elements.windowCollectionItems.appendChild(row);
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);
} }
updateImportActionState(); updateImportActionState();
} }
async function loadCollectionTree() { async function loadCollectionTree() {
setStatus(elements.collectionStatus, "正在加载 collection..."); setStatus(elements.windowCollectionStatus, "正在加载 collection...");
const payload = await api("/api/zotero/collections/tree"); const payload = await api("/api/zotero/collections/tree");
state.collectionTree = Array.isArray(payload.collections) ? payload.collections : []; state.collectionTree = Array.isArray(payload.collections) ? payload.collections : [];
normalizeSelectedCollectionKey(); normalizeSelectedCollectionKey();
@ -1020,11 +979,10 @@
state.visibleCollectionItems = []; state.visibleCollectionItems = [];
resetCollectionItemsLoadingState(); resetCollectionItemsLoadingState();
renderCollectionItems(); renderCollectionItems();
setStatus(elements.collectionStatus, ""); setStatus(elements.windowCollectionStatus, "");
} }
async function loadCollectionItems(collectionKey) { async function loadCollectionItems(collectionKey) {
cancelAbstractPreview();
if (!state.collectionItemsLoaded && state.loadingCollectionKey === collectionKey) { if (!state.collectionItemsLoaded && state.loadingCollectionKey === collectionKey) {
return; return;
} }
@ -1033,7 +991,7 @@
resetCollectionItemsLoadingState(true); resetCollectionItemsLoadingState(true);
renderCollectionTree(); renderCollectionTree();
renderCollectionItems(); renderCollectionItems();
setStatus(elements.collectionStatus, state.selectedCollectionKey ? "已切换到可用 collection。" : ""); setStatus(elements.windowCollectionStatus, state.selectedCollectionKey ? "已切换到可用 collection。" : "");
if (state.selectedCollectionKey) { if (state.selectedCollectionKey) {
await loadCollectionItems(state.selectedCollectionKey); await loadCollectionItems(state.selectedCollectionKey);
} }
@ -1048,9 +1006,9 @@
state.nextCollectionItemsRequestToken = requestToken; state.nextCollectionItemsRequestToken = requestToken;
state.activeCollectionItemsRequestToken = requestToken; state.activeCollectionItemsRequestToken = requestToken;
renderCollectionItems(); renderCollectionItems();
setStatus(elements.collectionStatus, "正在加载文献..."); setStatus(elements.windowCollectionStatus, "正在加载文献...");
try { try {
const payload = await api(`/api/zotero/collections/${collectionKey}/items?include_descendants=false`); const payload = await api(`/api/zotero/collections/${collectionKey}/items`);
if (state.activeCollectionItemsRequestToken !== requestToken || state.selectedCollectionKey !== collectionKey) { if (state.activeCollectionItemsRequestToken !== requestToken || state.selectedCollectionKey !== collectionKey) {
return; return;
} }
@ -1060,8 +1018,8 @@
renderCollectionItems(); renderCollectionItems();
const visibleCount = state.visibleCollectionItems.length; const visibleCount = state.visibleCollectionItems.length;
setStatus( setStatus(
elements.collectionStatus, elements.windowCollectionStatus,
visibleCount ? `当前目录共 ${visibleCount} 篇文献。` : "当前目录下没有可导入文献。" visibleCount ? `当前目录及子目录共 ${visibleCount} 篇文献。` : "当前目录及子目录下没有可导入文献。"
); );
} catch (error) { } catch (error) {
if (state.activeCollectionItemsRequestToken === requestToken) { if (state.activeCollectionItemsRequestToken === requestToken) {
@ -1073,7 +1031,6 @@
} }
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 {
@ -1084,8 +1041,16 @@
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();
@ -1093,12 +1058,11 @@
} }
async function importSelectedItems() { async function importSelectedItems() {
cancelAbstractPreview();
if (!state.currentProjectId || !state.selectedItemKeys.size) { if (!state.currentProjectId || !state.selectedItemKeys.size) {
updateImportActionState(); updateImportActionState();
return; return;
} }
setStatus(elements.collectionStatus, "正在导入..."); setStatus(elements.windowCollectionStatus, "正在导入...");
const payload = await api(`/api/projects/${state.currentProjectId}/imports/item-keys`, { const payload = await api(`/api/projects/${state.currentProjectId}/imports/item-keys`, {
method: "POST", method: "POST",
body: JSON.stringify({ item_keys: Array.from(state.selectedItemKeys) }), body: JSON.stringify({ item_keys: Array.from(state.selectedItemKeys) }),
@ -1107,23 +1071,24 @@
persistImportSessionState(); persistImportSessionState();
renderCollectionItems(); renderCollectionItems();
renderCards(payload.project_view?.cards || []); renderCards(payload.project_view?.cards || []);
setStatus(elements.collectionStatus, "导入完成。"); setStatus(elements.windowCollectionStatus, "导入完成。");
updateImportActionState(); updateImportActionState();
closeImportModal(); closeImportWindow();
} }
async function openImportModal() { async function openImportWindow() {
state.isImportModalOpen = true; state.isImportWindowOpen = true;
elements.importModal.hidden = false; elements.importWindowShell.hidden = false;
elements.importModal.setAttribute("aria-hidden", "false"); elements.importWindowShell.setAttribute("aria-hidden", "false");
document.body.classList.toggle("modal-open", true); document.body.classList.toggle("modal-open", true);
cancelAbstractPreview(); cancelAbstractPreview();
updateImportActionState(); updateImportActionState();
initWindowFromSession();
if (!state.collectionTree.length) { if (!state.collectionTree.length) {
try { try {
await loadCollectionTree(); await loadCollectionTree();
} catch (error) { } catch (error) {
setStatus(elements.collectionStatus, error.message, true); setStatus(elements.windowCollectionStatus, error.message, true);
} }
} else if ( } else if (
state.selectedCollectionKey && state.selectedCollectionKey &&
@ -1132,7 +1097,7 @@
try { try {
await loadCollectionItems(state.selectedCollectionKey); await loadCollectionItems(state.selectedCollectionKey);
} catch (error) { } catch (error) {
setStatus(elements.collectionStatus, error.message, true); setStatus(elements.windowCollectionStatus, error.message, true);
} }
} else { } else {
renderCollectionTree(); renderCollectionTree();
@ -1140,10 +1105,10 @@
} }
} }
function closeImportModal() { function closeImportWindow() {
state.isImportModalOpen = false; state.isImportWindowOpen = false;
elements.importModal.hidden = true; elements.importWindowShell.hidden = true;
elements.importModal.setAttribute("aria-hidden", "true"); elements.importWindowShell.setAttribute("aria-hidden", "true");
document.body.classList.toggle("modal-open", false); document.body.classList.toggle("modal-open", false);
cancelAbstractPreview(); cancelAbstractPreview();
} }
@ -1212,20 +1177,28 @@
elements.refreshProjectsButton.addEventListener("click", loadProjects); elements.refreshProjectsButton.addEventListener("click", loadProjects);
elements.openImportModalButton.addEventListener("click", () => { elements.openImportModalButton.addEventListener("click", () => {
openImportModal(); openImportWindow();
}); });
elements.closeImportModalButton.addEventListener("click", closeImportModal); elements.windowCloseButton.addEventListener("click", closeImportWindow);
elements.importModalOverlay.addEventListener("click", closeImportModal); elements.windowMinimizeButton.addEventListener("click", minimizeWindow);
elements.collectionItems.addEventListener("scroll", cancelAbstractPreview); elements.windowMaximizeButton.addEventListener("click", maximizeWindow);
elements.clearSelectionButton.addEventListener("click", clearSelectedItems); elements.windowRestoreButton.addEventListener("click", restoreWindow);
elements.importSelectedItemsButton.addEventListener("click", () => { elements.importWindowMinimizedBar.addEventListener("click", (e) => {
if (e.target === elements.windowRestoreButton) return;
restoreWindow();
});
elements.windowToolbar.addEventListener("pointerdown", startWindowDrag);
document.addEventListener("pointermove", moveWindowDrag);
document.addEventListener("pointerup", endWindowDrag);
elements.windowClearSelectionButton.addEventListener("click", clearSelectedItems);
elements.windowImportSelectedItemsButton.addEventListener("click", () => {
importSelectedItems().catch((error) => { importSelectedItems().catch((error) => {
setStatus(elements.collectionStatus, error.message, true); setStatus(elements.windowCollectionStatus, error.message, true);
}); });
}); });
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportModalOpen) { if (event.key === "Escape" && state.isImportWindowOpen) {
closeImportModal(); closeImportWindow();
} }
}); });
@ -1280,7 +1253,7 @@
elements.recommendTab.addEventListener("click", () => activateTab("recommend")); elements.recommendTab.addEventListener("click", () => activateTab("recommend"));
elements.planTab.addEventListener("click", () => activateTab("plan")); elements.planTab.addEventListener("click", () => activateTab("plan"));
closeImportModal(); closeImportWindow();
updateImportActionState(); updateImportActionState();
loadProjects().catch((error) => { loadProjects().catch((error) => {
setStatus(elements.projectStatus, error.message, true); setStatus(elements.projectStatus, error.message, true);

View File

@ -26,21 +26,42 @@ def test_index_contains_base_page_forms(tmp_path) -> None:
assert 'id="plan-form"' in html assert 'id="plan-form"' in html
def test_index_contains_import_modal_controls(tmp_path) -> None: def test_index_contains_import_window_controls(tmp_path) -> None:
html = _get_index_html(tmp_path) html = _get_index_html(tmp_path)
assert 'id="open-import-modal-button"' in html assert 'id="open-import-modal-button"' in html
assert 'id="import-modal"' in html assert 'id="import-window-shell"' in html
assert 'id="close-import-modal-button"' in html assert 'id="window-minimize-button"' in html
assert 'id="modal-collection-tree"' in html assert 'id="window-maximize-button"' in html
assert 'id="modal-collection-items"' in html assert 'id="window-close-button"' in html
assert 'id="modal-selected-count"' in html assert 'id="window-restore-button"' in html
assert 'id="modal-select-descendants-button"' in html assert 'id="import-window-minimized-bar"' in html
assert 'id="modal-clear-selection-button"' in html assert 'id="import-window-project-label"' in html
assert 'id="modal-import-selected-items-button"' in html assert 'id="window-collection-tree"' in html
assert 'id="window-collection-items"' in html
assert 'id="window-selected-count"' in html
assert 'id="window-clear-selection-button"' in html
assert 'id="window-import-selected-items-button"' in html
assert 'id="item-preview-popover"' in html assert 'id="item-preview-popover"' in html
assert 'isImportModalOpen: false' in html assert 'isImportWindowOpen: false' in html
assert "function openImportModal()" in html assert "function openImportWindow()" in html
assert "function closeImportModal()" in html assert "function closeImportWindow()" in html
assert "function updateImportActionState()" in html assert "function updateImportActionState()" in html
assert 'document.body.classList.toggle("modal-open"' in html assert 'document.body.classList.toggle("modal-open"' in html
def test_index_window_frame_css(tmp_path) -> None:
html = _get_index_html(tmp_path)
assert ".window-frame" in html
assert ".window-shell" in html
assert ".window-toolbar" in html
assert ".window-body" in html
assert ".window-minimized-bar" in html
assert '"zotero-kb.import-window"' in html
assert "startWindowDrag" in html
assert "minimizeWindow" in html
assert "maximizeWindow" in html
assert "restoreWindow" in html
assert "initWindowFromSession" in html
assert "persistWindowSessionState" in html