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

23 KiB
Raw Blame History

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):

.modal-shell[hidden] {
  display: none;
}
.modal-shell {
  position: fixed;
  inset: 0;
  z-index: 40;
}

New:

.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):

.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:

.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:

.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:

.window-toolbar {
  cursor: grab;
}
.window-toolbar:active {
  cursor: grabbing;
}
  • Step 6: Commit
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):

<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:

<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
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:

const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";

New:

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:

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:

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):

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):

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
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:

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():

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:

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 openImportModalopenImportWindow and closeImportModalcloseImportWindow

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:

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:

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():

elements.openImportModalButton.addEventListener("click", () => {
  openImportWindow();
});

The element openImportModalButton should be kept in elements for this reference.

  • Step 8: Update Esc key handler

Old:

document.addEventListener("keydown", (event) => {
  if (event.key === "Escape" && state.isImportModalOpen) {
    closeImportModal();
  }
});

New:

document.addEventListener("keydown", (event) => {
  if (event.key === "Escape" && state.isImportWindowOpen) {
    closeImportWindow();
  }
});
  • Step 9: Update syncCurrentProjectLabels and updateImportActionState

Update all references:

  • elements.importModalProjectLabelelements.windowProjectLabel

  • elements.collectionStatuselements.windowCollectionStatus

  • elements.collectionTreeelements.windowCollectionTree

  • elements.collectionItemselements.windowCollectionItems

  • elements.selectedCountelements.windowSelectedCount

  • elements.clearSelectionButtonelements.windowClearSelectionButton

  • elements.importSelectedItemsButtonelements.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
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:

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:

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
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -v

Expected: All tests pass.

  • Step 4: Commit
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:nonedisplay:flex in CSS. Maximized removes rounded corners. Drag is bounded to viewport. Import button disabled when no project selected is unchanged.