zotero-kb/docs/superpowers/plans/2026-04-15-import-floating-window.md
2026-04-16 13:34:39 +08:00

706 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 147154):
```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 155173):
```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:342370`
- [ ] **Step 1: Replace the modal HTML with window shell + window frame + minimized bar**
Old (lines 342370):
```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:419498`
- [ ] **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 ~467498):
```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 13
- Minimize/maximize/close buttons → Task 4 Steps 45
- 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 912
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.