706 lines
23 KiB
Markdown
706 lines
23 KiB
Markdown
# Import Floating Window Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the full-screen import modal with a draggable, resizable, minimizable floating window while preserving all existing import behaviors.
|
||
|
||
**Architecture:** All UI code lives in a single `index.html` file (template served by FastAPI). The change modifies CSS classes (modal → window frame), adds window control HTML elements, adds drag/resize/minimize/maximize JavaScript, and persists window state to `sessionStorage`.
|
||
|
||
**Tech Stack:** Vanilla JS, CSS custom properties, FastAPI serving as single-page app.
|
||
|
||
**Files:**
|
||
- Modify: `src/zotero_kb/templates/index.html`
|
||
- Modify: `tests/test_ui.py`
|
||
|
||
---
|
||
|
||
## Task 1: Replace Modal CSS with Window Frame CSS
|
||
|
||
**Files:**
|
||
- Modify: `src/zotero_kb/templates/index.html:147-206`
|
||
|
||
- [ ] **Step 1: Replace `.modal-shell` CSS with `.window-shell`**
|
||
|
||
Old (lines 147–154):
|
||
```css
|
||
.modal-shell[hidden] {
|
||
display: none;
|
||
}
|
||
.modal-shell {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 40;
|
||
}
|
||
```
|
||
|
||
New:
|
||
```css
|
||
.window-shell[hidden] {
|
||
display: none;
|
||
}
|
||
.window-shell {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 40;
|
||
pointer-events: none;
|
||
}
|
||
.window-shell:not([hidden]) {
|
||
pointer-events: auto;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Replace `.modal-overlay` CSS with `.window-frame` base**
|
||
|
||
Old (lines 155–173):
|
||
```css
|
||
.modal-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 0;
|
||
background: rgba(24, 28, 22, 0.42);
|
||
}
|
||
.modal-card {
|
||
position: relative;
|
||
z-index: 1;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
display: grid;
|
||
grid-template-rows: auto auto 1fr;
|
||
background: var(--surface);
|
||
border: 0;
|
||
border-radius: 0;
|
||
box-shadow: none;
|
||
overflow: hidden;
|
||
}
|
||
```
|
||
|
||
New:
|
||
```css
|
||
.window-frame {
|
||
position: fixed;
|
||
z-index: 40;
|
||
width: var(--fw-width, 90vw);
|
||
height: var(--fw-height, 75vh);
|
||
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;
|
||
grid-template-rows: auto 1fr auto;
|
||
overflow: hidden;
|
||
background: var(--surface);
|
||
border: 1px solid var(--line);
|
||
}
|
||
|
||
.window-frame.maximized {
|
||
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;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add `.window-minimized-bar` CSS**
|
||
|
||
After `.window-frame.maximized` block, add:
|
||
```css
|
||
.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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update `.modal-toolbar` to `.window-toolbar`**
|
||
|
||
Find `.modal-toolbar` (line ~174) and `.modal-toolbar-actions` — rename to `.window-toolbar` and `.window-toolbar-actions`. Find `.modal-body` and `.modal-pane` — rename to `.window-body` and `.window-pane`. Find `.modal-scroll` — rename to `.window-scroll`.
|
||
|
||
- [ ] **Step 5: Add drag cursor CSS**
|
||
|
||
In the existing `button:hover` block or nearby, add:
|
||
```css
|
||
.window-toolbar {
|
||
cursor: grab;
|
||
}
|
||
.window-toolbar:active {
|
||
cursor: grabbing;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/zotero_kb/templates/index.html
|
||
git commit -m "feat(import-window): replace modal CSS with window frame CSS"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Replace Modal HTML Structure with Window Frame
|
||
|
||
**Files:**
|
||
- Modify: `src/zotero_kb/templates/index.html:342–370`
|
||
|
||
- [ ] **Step 1: Replace the modal HTML with window shell + window frame + minimized bar**
|
||
|
||
Old (lines 342–370):
|
||
```html
|
||
<div id="import-modal" class="modal-shell" hidden aria-hidden="true">
|
||
<button id="import-modal-overlay" class="modal-overlay" type="button" aria-label="关闭导入弹窗"></button>
|
||
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
|
||
<div class="modal-toolbar">
|
||
<button id="close-import-modal-button" type="button" class="secondary">返回主页面</button>
|
||
<div>
|
||
<h3 id="import-modal-title">Import From Zotero</h3>
|
||
<p id="import-modal-project-label" class="meta">未选择项目</p>
|
||
</div>
|
||
<div class="modal-toolbar-actions">
|
||
<div id="modal-selected-count" class="meta">已选 0 篇</div>
|
||
<button id="modal-clear-selection-button" type="button" class="danger">清空选择</button>
|
||
<button id="modal-import-selected-items-button" type="button">导入所选</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="modal-pane collections">
|
||
<small>Collections</small>
|
||
<div id="collection-status" class="status"></div>
|
||
<div id="modal-collection-tree" class="modal-scroll tree-list"></div>
|
||
</div>
|
||
<div class="modal-pane items">
|
||
<small>Collection Items</small>
|
||
<div id="modal-collection-items" class="modal-scroll collection-items-list"></div>
|
||
</div>
|
||
</div>
|
||
<div id="item-preview-popover" class="preview-popover" hidden></div>
|
||
</section>
|
||
</div>
|
||
```
|
||
|
||
New:
|
||
```html
|
||
<div id="import-window-shell" class="window-shell" hidden aria-hidden="true">
|
||
<section id="import-window" class="window-frame" role="dialog" aria-modal="true" aria-labelledby="import-window-title">
|
||
<div class="window-toolbar" id="import-window-toolbar">
|
||
<div style="display:flex;align-items:center;gap:0.6rem">
|
||
<span style="font-size:1.2rem;line-height:1">☰</span>
|
||
<div>
|
||
<h3 id="import-window-title">Import From Zotero</h3>
|
||
<p id="import-window-project-label" class="meta">未选择项目</p>
|
||
</div>
|
||
</div>
|
||
<div class="window-toolbar-actions">
|
||
<div id="window-selected-count" class="meta">已选 0 篇</div>
|
||
<button id="window-clear-selection-button" type="button" class="danger">清空选择</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 class="window-body">
|
||
<div class="window-pane collections">
|
||
<small>Collections</small>
|
||
<div id="window-collection-status" class="status"></div>
|
||
<div id="window-collection-tree" class="window-scroll tree-list"></div>
|
||
</div>
|
||
<div class="window-pane items">
|
||
<small>Collection Items</small>
|
||
<div id="window-collection-items" class="window-scroll collection-items-list"></div>
|
||
</div>
|
||
</div>
|
||
<div id="item-preview-popover" class="preview-popover" hidden></div>
|
||
</section>
|
||
</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>
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/zotero_kb/templates/index.html
|
||
git commit -m "feat(import-window): replace modal HTML with window frame structure"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Rename JS State Keys and Element References
|
||
|
||
**Files:**
|
||
- Modify: `src/zotero_kb/templates/index.html:419–498`
|
||
|
||
- [ ] **Step 1: Rename `IMPORT_SESSION_STORAGE_KEY`**
|
||
|
||
Old:
|
||
```js
|
||
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";
|
||
```
|
||
|
||
New:
|
||
```js
|
||
const WINDOW_SESSION_STORAGE_KEY = "zotero-kb.import-window";
|
||
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";
|
||
```
|
||
|
||
- [ ] **Step 2: Add `readWindowSessionState` function**
|
||
|
||
After `readImportSessionState()` function (~line 434), add:
|
||
```js
|
||
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();
|
||
```
|
||
|
||
- [ ] **Step 3: Add window state to the `state` object**
|
||
|
||
In the `state` object (~line 438), add after `isImportModalOpen`:
|
||
```js
|
||
isImportWindowOpen: false,
|
||
windowX: typeof windowSessionState.x === "number" ? windowSessionState.x : null,
|
||
windowY: typeof windowSessionState.y === "number" ? windowSessionState.y : null,
|
||
windowWidth: typeof windowSessionState.width === "number" ? windowSessionState.width : null,
|
||
windowHeight: typeof windowSessionState.height === "number" ? windowSessionState.height : null,
|
||
isWindowMinimized: Boolean(windowSessionState.isMinimized),
|
||
isWindowMaximized: Boolean(windowSessionState.isMaximized),
|
||
isWindowDragging: false,
|
||
windowDragStartX: 0,
|
||
windowDragStartY: 0,
|
||
windowDragStartLeft: 0,
|
||
windowDragStartTop: 0,
|
||
```
|
||
|
||
- [ ] **Step 4: Rename `elements.importModal` and `importModalOverlay` references**
|
||
|
||
Update the `elements` object (lines ~467–498):
|
||
```js
|
||
importWindowShell: document.getElementById("import-window-shell"),
|
||
importWindow: document.getElementById("import-window"),
|
||
windowToolbar: document.getElementById("import-window-toolbar"),
|
||
windowMinimizeButton: document.getElementById("window-minimize-button"),
|
||
windowMaximizeButton: document.getElementById("window-maximize-button"),
|
||
windowCloseButton: document.getElementById("window-close-button"),
|
||
windowRestoreButton: document.getElementById("window-restore-button"),
|
||
importWindowMinimizedBar: document.getElementById("import-window-minimized-bar"),
|
||
windowProjectLabel: document.getElementById("import-window-project-label"),
|
||
windowSelectedCount: document.getElementById("window-selected-count"),
|
||
windowClearSelectionButton: document.getElementById("window-clear-selection-button"),
|
||
windowImportSelectedItemsButton: document.getElementById("window-import-selected-items-button"),
|
||
windowCollectionStatus: document.getElementById("window-collection-status"),
|
||
windowCollectionTree: document.getElementById("window-collection-tree"),
|
||
windowCollectionItems: document.getElementById("window-collection-items"),
|
||
```
|
||
|
||
Remove: `importModalOverlay`, `openImportModalButton`, `closeImportModalButton`, `importModalProjectLabel`, `collectionStatus`, `collectionTree`, `collectionItems`, `selectedCount`, `clearSelectionButton`, `importSelectedItemsButton`.
|
||
|
||
- [ ] **Step 5: Add `persistWindowSessionState` function**
|
||
|
||
After `persistImportSessionState()` function (~line 509):
|
||
```js
|
||
function persistWindowSessionState() {
|
||
const fw = elements.importWindow;
|
||
if (!fw) return;
|
||
const style = window.getComputedStyle(fw);
|
||
const width = parseFloat(style.width);
|
||
const height = parseFloat(style.height);
|
||
const left = parseFloat(style.left);
|
||
const top = parseFloat(style.top);
|
||
window.sessionStorage.setItem(
|
||
WINDOW_SESSION_STORAGE_KEY,
|
||
JSON.stringify({
|
||
x: left,
|
||
y: top,
|
||
width: width,
|
||
height: height,
|
||
isMinimized: state.isWindowMinimized,
|
||
isMaximized: state.isWindowMaximized,
|
||
})
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/zotero_kb/templates/index.html
|
||
git commit -m "feat(import-window): add window state management to JS"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Implement Drag, Minimize, Maximize, and Close Logic
|
||
|
||
**Files:**
|
||
- Modify: `src/zotero_kb/templates/index.html`
|
||
|
||
- [ ] **Step 1: Add window apply position/size helper**
|
||
|
||
After `persistWindowSessionState()`, add:
|
||
```js
|
||
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;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add drag handlers**
|
||
|
||
After `initWindowFromSession()`:
|
||
```js
|
||
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;
|
||
// boundary clamp
|
||
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();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Wire up drag event listeners**
|
||
|
||
Find where `elements.projectForm.addEventListener` is set up (~line 1079). Before that block, add:
|
||
```js
|
||
elements.windowToolbar.addEventListener("pointerdown", startWindowDrag);
|
||
document.addEventListener("pointermove", moveWindowDrag);
|
||
document.addEventListener("pointerup", endWindowDrag);
|
||
elements.windowMinimizeButton.addEventListener("click", minimizeWindow);
|
||
elements.windowMaximizeButton.addEventListener("click", maximizeWindow);
|
||
elements.windowCloseButton.addEventListener("click", closeImportWindow);
|
||
elements.windowRestoreButton.addEventListener("click", restoreWindow);
|
||
elements.importWindowMinimizedBar.addEventListener("click", (e) => {
|
||
if (e.target === elements.windowRestoreButton) return;
|
||
restoreWindow();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Rename `openImportModal` → `openImportWindow` and `closeImportModal` → `closeImportWindow`**
|
||
|
||
In the JS, rename:
|
||
- `function openImportModal()` → `function openImportWindow()`
|
||
- `function closeImportModal()` → `function closeImportWindow()`
|
||
- `openImportModalButton` → (removed, not needed as separate element)
|
||
- `closeImportModalButton` → (removed)
|
||
|
||
Find all call sites and update them. The `Esc` key handler should call `closeImportWindow`.
|
||
|
||
- [ ] **Step 5: Update the open function to use window state**
|
||
|
||
The `openImportWindow` function should call `initWindowFromSession()` instead of the modal open logic:
|
||
```js
|
||
state.isImportWindowOpen = true;
|
||
elements.importWindowShell.hidden = false;
|
||
elements.importWindowShell.setAttribute("aria-hidden", "false");
|
||
document.body.classList.toggle("modal-open", true);
|
||
cancelAbstractPreview();
|
||
updateImportActionState();
|
||
initWindowFromSession();
|
||
```
|
||
|
||
- [ ] **Step 6: Update the close function**
|
||
|
||
`closeImportWindow` should:
|
||
```js
|
||
state.isImportWindowOpen = false;
|
||
elements.importWindowShell.hidden = true;
|
||
elements.importWindowShell.setAttribute("aria-hidden", "true");
|
||
document.body.classList.toggle("modal-open", false);
|
||
cancelAbstractPreview();
|
||
// Note: does NOT clear window position/size/minimized state
|
||
```
|
||
|
||
- [ ] **Step 7: Update the button that opens the window**
|
||
|
||
The `导入文献` button (`open-import-modal-button`) handler should call `openImportWindow()`:
|
||
```js
|
||
elements.openImportModalButton.addEventListener("click", () => {
|
||
openImportWindow();
|
||
});
|
||
```
|
||
|
||
The element `openImportModalButton` should be kept in `elements` for this reference.
|
||
|
||
- [ ] **Step 8: Update Esc key handler**
|
||
|
||
Old:
|
||
```js
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape" && state.isImportModalOpen) {
|
||
closeImportModal();
|
||
}
|
||
});
|
||
```
|
||
|
||
New:
|
||
```js
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape" && state.isImportWindowOpen) {
|
||
closeImportWindow();
|
||
}
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 9: Update `syncCurrentProjectLabels` and `updateImportActionState`**
|
||
|
||
Update all references:
|
||
- `elements.importModalProjectLabel` → `elements.windowProjectLabel`
|
||
- `elements.collectionStatus` → `elements.windowCollectionStatus`
|
||
- `elements.collectionTree` → `elements.windowCollectionTree`
|
||
- `elements.collectionItems` → `elements.windowCollectionItems`
|
||
- `elements.selectedCount` → `elements.windowSelectedCount`
|
||
- `elements.clearSelectionButton` → `elements.windowClearSelectionButton`
|
||
- `elements.importSelectedItemsButton` → `elements.windowImportSelectedItemsButton`
|
||
|
||
- [ ] **Step 10: Update `renderCollectionTree`, `renderCollectionItems`, `renderSelectedCount`, `setStatus` call sites**
|
||
|
||
Update all `elements.collectionTree`, `elements.collectionItems`, `elements.collectionStatus`, `elements.selectedCount` references to use the renamed `window*` variants.
|
||
|
||
- [ ] **Step 11: Update `closeImportWindow` call inside `importSelectedItems`**
|
||
|
||
When import succeeds, call `closeImportWindow()` instead of `closeImportModal()`.
|
||
|
||
- [ ] **Step 12: Update `openImportWindow` call inside `loadCollectionTree` and `loadCollectionItems`**
|
||
|
||
There are call sites inside `openImportModal()` (now `openImportWindow()`) that call `loadCollectionTree()` and `loadCollectionItems()`. These remain — just the function name changed.
|
||
|
||
- [ ] **Step 13: Commit**
|
||
|
||
```bash
|
||
git add src/zotero_kb/templates/index.html
|
||
git commit -m "feat(import-window): implement drag, minimize, maximize, close logic"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Update Tests
|
||
|
||
**Files:**
|
||
- Modify: `tests/test_ui.py`
|
||
|
||
- [ ] **Step 1: Update `test_index_contains_import_modal_controls`**
|
||
|
||
Replace the old test with:
|
||
```python
|
||
def test_index_contains_import_window_controls(tmp_path) -> None:
|
||
html = _get_index_html(tmp_path)
|
||
|
||
assert 'id="open-import-modal-button"' in html
|
||
assert 'id="import-window-shell"' in html
|
||
assert 'id="import-window"' in html
|
||
assert 'id="window-minimize-button"' in html
|
||
assert 'id="window-maximize-button"' in html
|
||
assert 'id="window-close-button"' in html
|
||
assert 'id="window-restore-button"' in html
|
||
assert 'id="import-window-minimized-bar"' in html
|
||
assert 'id="window-project-label"' 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 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
|
||
assert "isImportWindowOpen: false" in html
|
||
assert "function openImportWindow()" in html
|
||
assert "function closeImportWindow()" in html
|
||
assert "function updateImportActionState()" in html
|
||
assert "function scheduleAbstractPreview(item, target)" in html
|
||
assert "function cancelAbstractPreview()" in html
|
||
assert "function showAbstractPreview(item, target)" in html
|
||
assert "details-button" in html
|
||
assert "?include_descendants=false" in html
|
||
assert "window.classList.add(\"minimized\")" in html
|
||
assert "window.classList.add(\"maximized\")" in html
|
||
assert "WINDOW_SESSION_STORAGE_KEY" in html
|
||
```
|
||
|
||
- [ ] **Step 2: Add new test for window CSS classes**
|
||
|
||
After the above test, add:
|
||
```python
|
||
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
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to verify**
|
||
|
||
```bash
|
||
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -v
|
||
```
|
||
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add tests/test_ui.py
|
||
git commit -m "test(import-window): update UI tests for floating window"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Checklist
|
||
|
||
1. **Spec coverage:** Each requirement in `2026-04-15-import-floating-window-design.md` is implemented:
|
||
- Window attributes (90vw×75vh, centered, rounded, shadow) → Task 1
|
||
- Toolbar drag → Task 4 Steps 1–3
|
||
- Minimize/maximize/close buttons → Task 4 Steps 4–5
|
||
- Minimized bar → Task 2 HTML + Task 4 Step 5
|
||
- Session state persistence → Task 3 Steps 2, 5, Task 4 Step 3
|
||
- Esc closes window → Task 4 Step 8
|
||
- All existing behaviors unchanged → Task 4 Steps 9–12
|
||
|
||
2. **Placeholder scan:** No "TBD", "TODO", or vague language. All code is concrete.
|
||
|
||
3. **Type consistency:** All renamed JS state keys and element references use consistent `window*` prefix throughout. The `state` object uses `isWindowMinimized`, `isWindowMaximized`, `isWindowDragging` etc. consistently across all tasks.
|
||
|
||
4. **Spec completeness check:** `window-minimized-bar` has `hidden` attribute in HTML and `display:none` → `display:flex` in CSS. Maximized removes rounded corners. Drag is bounded to viewport. Import button disabled when no project selected is unchanged.
|