From 40a2fb305e95b017a590fa080de56ba720c11aed Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 31 Aug 2026 00:57:08 -0500 Subject: [PATCH] feat(renderer): add fluid chapter reorder motion --- src/renderer/book/chapter-list.ts | 98 ++++-- src/renderer/book/editor.ts | 3 +- src/renderer/book/exports.ts | 6 +- src/renderer/book/full-book.ts | 4 +- src/renderer/book/ui.ts | 16 +- src/renderer/book/welcome.ts | 6 +- src/renderer/dialog.ts | 8 +- src/renderer/index.css | 76 ++++- src/renderer/index.html | 4 + src/renderer/theme.ts | 2 +- src/renderer/themes.css | 510 ++++++++++++++++++++++++++++++ 11 files changed, 695 insertions(+), 38 deletions(-) diff --git a/src/renderer/book/chapter-list.ts b/src/renderer/book/chapter-list.ts index 3570505..f7fd273 100644 --- a/src/renderer/book/chapter-list.ts +++ b/src/renderer/book/chapter-list.ts @@ -134,6 +134,14 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { } function renderChapterList() { + const previousPositions = animateNextRender + ? new Map( + Array.from(chapterList.querySelectorAll(".chapter-row"), (row) => [ + row.dataset.id ?? "", + row.getBoundingClientRect().top, + ]) + ) + : null; chapterList.innerHTML = ""; for (const ch of state.ordered) { const li = document.createElement("li"); @@ -190,6 +198,26 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { }); chapterList.appendChild(li); } + if (!previousPositions) return; + animateNextRender = false; + const rows = Array.from(chapterList.querySelectorAll(".chapter-row")); + for (const row of rows) { + const oldTop = previousPositions.get(row.dataset.id ?? ""); + if (oldTop === undefined) continue; + const delta = oldTop - row.getBoundingClientRect().top; + if (!delta) continue; + row.classList.add("reordering"); + row.style.transform = `translateY(${delta}px)`; + } + requestAnimationFrame(() => { + for (const row of rows) row.style.transform = ""; + }); + setTimeout(() => { + for (const row of rows) { + row.classList.remove("reordering"); + row.style.transform = ""; + } + }, 420); } async function create() { @@ -305,6 +333,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { let dragStartY = 0; let dragMoved = false; let dragJustHappened = false; + let animateNextRender = false; + let dragGhost: HTMLElement | null = null; + let dragPlaceholder: HTMLElement | null = null; + let dragOffsetY = 0; chapterList.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; // left button only @@ -327,15 +359,38 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { const src = chapterList.querySelector( `li.chapter-row[data-id="${draggingId}"]` ) as HTMLElement | null; - src?.classList.add("dragging"); + if (src) { + const rect = src.getBoundingClientRect(); + dragOffsetY = e.clientY - rect.top; + dragGhost = src.cloneNode(true) as HTMLElement; + dragGhost.classList.add("drag-ghost"); + dragGhost.style.width = `${rect.width}px`; + dragGhost.style.left = `${rect.left}px`; + dragGhost.style.top = `${rect.top}px`; + document.body.appendChild(dragGhost); + + dragPlaceholder = document.createElement("li"); + dragPlaceholder.className = "chapter-drop-placeholder"; + dragPlaceholder.style.height = `${rect.height}px`; + src.replaceWith(dragPlaceholder); + } document.body.classList.add("dragging-active"); } + if (dragGhost) dragGhost.style.top = `${e.clientY - dragOffsetY}px`; const overEl = document.elementFromPoint(e.clientX, e.clientY); const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null; - chapterList - .querySelectorAll(".chapter-row") - .forEach((n) => n.classList.remove("drop-target")); - if (overLi && overLi.dataset.id !== draggingId) overLi.classList.add("drop-target"); + chapterList.querySelectorAll(".chapter-row").forEach((n) => { + n.classList.remove("drop-target", "drop-before", "drop-after"); + }); + if (overLi && overLi.dataset.id !== draggingId) { + overLi.classList.add("drop-target"); + const before = e.clientY < overLi.getBoundingClientRect().top + overLi.offsetHeight / 2; + overLi.classList.add(before ? "drop-before" : "drop-after"); + if (dragPlaceholder) { + if (before) chapterList.insertBefore(dragPlaceholder, overLi); + else chapterList.insertBefore(dragPlaceholder, overLi.nextSibling); + } + } }); document.addEventListener("pointerup", (e) => { @@ -347,23 +402,26 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { return; } dragJustHappened = true; - const overEl = document.elementFromPoint(e.clientX, e.clientY); - const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null; - const targetId = overLi?.dataset.id ?? null; - const src = chapterList.querySelector( - `li.chapter-row[data-id="${draggingId}"]` - ) as HTMLElement | null; - src?.classList.remove("dragging"); - chapterList - .querySelectorAll(".chapter-row") - .forEach((n) => n.classList.remove("drop-target")); - if (targetId && targetId !== draggingId) { + const targetIndex = dragPlaceholder + ? Array.from(chapterList.children).indexOf(dragPlaceholder) + : -1; + dragGhost?.remove(); + dragGhost = null; + dragPlaceholder?.remove(); + dragPlaceholder = null; + chapterList.querySelectorAll(".chapter-row").forEach((n) => { + n.classList.remove("drop-target", "drop-before", "drop-after"); + }); + let reordered = false; + if (targetIndex >= 0) { const from = state.ordered.findIndex((c) => c.id === draggingId); - const to = state.ordered.findIndex((c) => c.id === targetId); - if (from >= 0 && to >= 0) { + if (from >= 0 && targetIndex !== from) { + const to = targetIndex; state.ordered = moveInList(state.ordered, from, to); state.meta!.chapterOrder = state.ordered.map((c) => c.id); + animateNextRender = true; renderChapterList(); + reordered = true; // Selecting the dragged row must go through selectChapter: assigning // selectedId directly would pair the id with whatever document is // still in the editor, and the next autosave would write that @@ -372,6 +430,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { void persistOrder(); } } + // The source row was replaced by the gap while dragging. Restore the list + // even when the pointer is released over its original slot or outside a + // valid destination. + if (!reordered) renderChapterList(); draggingId = null; }); diff --git a/src/renderer/book/editor.ts b/src/renderer/book/editor.ts index 9e85ed4..baacf57 100644 --- a/src/renderer/book/editor.ts +++ b/src/renderer/book/editor.ts @@ -17,6 +17,7 @@ import { markdownToPlain, openEl, setActive, + showView, showToast, } from "./ui.js"; import { @@ -127,7 +128,7 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature { clearTimers(); $("fullBookBtn").classList.remove("active"); $("fullBookView").classList.add("hidden"); - $("chapterView").classList.remove("hidden"); + showView($("chapterView")); $("editorPlaceholder").classList.add("hidden"); } diff --git a/src/renderer/book/exports.ts b/src/renderer/book/exports.ts index cb243a9..7c2c13f 100644 --- a/src/renderer/book/exports.ts +++ b/src/renderer/book/exports.ts @@ -131,8 +131,10 @@ export function createExportsFeature(ctx: BookCtx): ExportsFeature { e.stopPropagation(); const wasOpen = !exportMenu.classList.contains("hidden"); closeExportMenu(); - if (!wasOpen) openEl(exportMenu); - exportMenuToggle.classList.toggle("open", wasOpen); + if (!wasOpen) { + openEl(exportMenu); + exportMenuToggle.classList.add("open"); + } }); exportMenu.addEventListener("click", (e) => { diff --git a/src/renderer/book/full-book.ts b/src/renderer/book/full-book.ts index bf2da1d..9d01ad6 100644 --- a/src/renderer/book/full-book.ts +++ b/src/renderer/book/full-book.ts @@ -17,7 +17,7 @@ import type { BookMeta } from "../../main/project.js"; import type { WikilinkTarget } from "../wikilinks.js"; import type { BookCtx } from "./types.js"; import { alertMessage } from "../dialog.js"; -import { $, countWords } from "./ui.js"; +import { $, countWords, showView } from "./ui.js"; import { createTiptapEditor, setEditorDoc, @@ -195,7 +195,7 @@ export function createFullBookFeature(ctx: BookCtx): FullBookFeature { $("fullBookBtn").classList.add("active"); $("chapterView").classList.add("hidden"); $("editorPlaceholder").classList.add("hidden"); - fullBookView.classList.remove("hidden"); + showView(fullBookView); await renderFullBook(); fullBookEditor.commands.focus(); } diff --git a/src/renderer/book/ui.ts b/src/renderer/book/ui.ts index da1e513..5a37883 100644 --- a/src/renderer/book/ui.ts +++ b/src/renderer/book/ui.ts @@ -23,6 +23,16 @@ export function openEl(el: HTMLElement) { el.classList.remove("hidden", "closing"); } +// Reveal a primary surface with a short continuity cue. The surface remains +// usable before the animation starts, and the class is removed so later +// updates do not replay it accidentally. +export function showView(el: HTMLElement) { + el.classList.remove("hidden", "view-enter"); + void el.offsetWidth; + el.classList.add("view-enter"); + el.addEventListener("animationend", () => el.classList.remove("view-enter"), { once: true }); +} + export function closeEl(el: HTMLElement | null) { if (!el || el.classList.contains("hidden")) return; el.classList.add("closing"); @@ -35,7 +45,11 @@ export function closeEl(el: HTMLElement | null) { el.removeEventListener("animationend", onEnd); delete (el as unknown as { __close?: () => void }).__close; }; - const onEnd = () => finish(); + const onEnd = (event: AnimationEvent) => { + // The modal animation bubbles through its backdrop; only the backdrop's + // own exit should decide when the overlay is safe to hide. + if (event.target === el) finish(); + }; el.addEventListener("animationend", onEnd); const timer = setTimeout(finish, 420); (el as unknown as { __close?: () => void }).__close = () => { diff --git a/src/renderer/book/welcome.ts b/src/renderer/book/welcome.ts index 0c2bee1..ce8800b 100644 --- a/src/renderer/book/welcome.ts +++ b/src/renderer/book/welcome.ts @@ -10,7 +10,7 @@ import type { BookCtx } from "./types.js"; import type { ChapterEntry, Recent } from "./types.js"; import type { BookMeta } from "../../main/project.js"; import { alertMessage, promptInput } from "../dialog.js"; -import { $, showUndoToast } from "./ui.js"; +import { $, showUndoToast, showView } from "./ui.js"; export interface WelcomeFeature { showWelcome(): Promise; @@ -37,7 +37,7 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { const homeBtn = $("homeBtn") as HTMLButtonElement | null; async function showWelcome() { - welcomeEl.classList.remove("hidden"); + showView(welcomeEl); chapterView.classList.add("hidden"); fullBookView.classList.add("hidden"); editorPlaceholder.classList.add("hidden"); @@ -69,7 +69,7 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { chapterView.classList.add("hidden"); fullBookView.classList.add("hidden"); fullBookBtn.classList.remove("active"); - editorPlaceholder.classList.remove("hidden"); + showView(editorPlaceholder); } function setCurrentBook(data: { path: string; title: string }) { diff --git a/src/renderer/dialog.ts b/src/renderer/dialog.ts index 4b812cd..186fc7f 100644 --- a/src/renderer/dialog.ts +++ b/src/renderer/dialog.ts @@ -1,5 +1,7 @@ // In-app modal dialog used in place of window.prompt/confirm/alert, // which Electron does not support (prompt() throws). +import { closeEl, openEl } from "./book/ui.js"; + type Mode = "prompt" | "confirm" | "alert"; function el(id: string): HTMLElement { @@ -29,7 +31,7 @@ function open(mode: Mode, title: string, body: string, value: string): Promise { export function alertMessage(title: string, body?: string): Promise { return open("alert", title, body ?? "", "").then(() => undefined); -} \ No newline at end of file +} diff --git a/src/renderer/index.css b/src/renderer/index.css index 31dcf2d..feb6830 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -395,7 +395,9 @@ html, body { :root[data-theme="dark-mode"] #addChapterBtn, :root[data-theme="neon"] #addChapterBtn, :root[data-theme="meridian"] #addChapterBtn, -:root[data-theme="impeccable-dark"] #addChapterBtn { +:root[data-theme="impeccable-dark"] #addChapterBtn, +:root[data-theme="cosmic"] #addChapterBtn, +:root[data-theme="cosmic-stardust"] #addChapterBtn { color: #ffffff; } @@ -403,7 +405,9 @@ html, body { :root[data-theme="dark-mode"] #homeBtn .home-label, :root[data-theme="neon"] #homeBtn .home-label, :root[data-theme="meridian"] #homeBtn .home-label, -:root[data-theme="impeccable-dark"] #homeBtn .home-label { +:root[data-theme="impeccable-dark"] #homeBtn .home-label, +:root[data-theme="cosmic"] #homeBtn .home-label, +:root[data-theme="cosmic-stardust"] #homeBtn .home-label { color: #ffffff; } @@ -422,7 +426,11 @@ html, body { :root[data-theme="meridian"] .brand-icon-light, :root[data-theme="meridian"] .welcome-brand-light, :root[data-theme="impeccable-dark"] .brand-icon-light, -:root[data-theme="impeccable-dark"] .welcome-brand-light { +:root[data-theme="impeccable-dark"] .welcome-brand-light, +:root[data-theme="cosmic"] .brand-icon-light, +:root[data-theme="cosmic"] .welcome-brand-light, +:root[data-theme="cosmic-stardust"] .brand-icon-light, +:root[data-theme="cosmic-stardust"] .welcome-brand-light { display: none; } :root[data-theme="dark-mode"] .brand-icon-dark, @@ -432,7 +440,11 @@ html, body { :root[data-theme="meridian"] .brand-icon-dark, :root[data-theme="meridian"] .welcome-brand-dark, :root[data-theme="impeccable-dark"] .brand-icon-dark, -:root[data-theme="impeccable-dark"] .welcome-brand-dark { +:root[data-theme="impeccable-dark"] .welcome-brand-dark, +:root[data-theme="cosmic"] .brand-icon-dark, +:root[data-theme="cosmic"] .welcome-brand-dark, +:root[data-theme="cosmic-stardust"] .brand-icon-dark, +:root[data-theme="cosmic-stardust"] .welcome-brand-dark { display: inline-block; } @@ -582,10 +594,50 @@ body.dragging-active * { cursor: grabbing; } +.drag-ghost { + position: fixed; + z-index: 1001; + pointer-events: none; + opacity: 1; + background: var(--color-panel); + border: 1px solid var(--color-accent); + box-shadow: 0 10px 24px var(--color-shadow-strong); + transform: rotate(1deg) scale(1.02); + transition: transform 140ms var(--ease-out), box-shadow 140ms var(--ease-out); +} + +.chapter-drop-placeholder { + list-style: none; + margin: 0; + border: 1px dashed var(--color-accent); + border-radius: var(--radius); + background: color-mix(in srgb, var(--color-accent) 10%, transparent); +} + .chapter-row.drop-target { border-color: var(--color-accent); - background: var(--color-accent); - color: var(--color-on-accent); + background: color-mix(in srgb, var(--color-accent) 18%, transparent); +} + +/* Keep the destination edge visible while dragging. The FLIP pass carries the + rows into their new positions after release. */ +.chapter-row.drop-before { + background-image: linear-gradient(var(--color-accent), var(--color-accent)); + background-size: 100% 3px; + background-position: top; + background-repeat: no-repeat; +} + +.chapter-row.drop-after { + background-image: linear-gradient(var(--color-accent), var(--color-accent)); + background-size: 100% 3px; + background-position: bottom; + background-repeat: no-repeat; +} + +.chapter-row.reordering { + transition: transform 360ms var(--ease-out), background var(--motion-fast) var(--ease-out), + color var(--motion-fast) var(--ease-out), border-color var(--motion-fast) var(--ease-out); } .chapter-row:focus-visible { @@ -1771,6 +1823,10 @@ body.focus-mode .editor { from { opacity: 0; transform: scale(0.985); } to { opacity: 1; transform: scale(1); } } +@keyframes viewIn { + from { opacity: 0.72; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} /* Dialogs / modals */ .modal-backdrop:not(.hidden) .modal { @@ -1803,6 +1859,12 @@ body.focus-mode .editor { animation: rootIn var(--motion-window) var(--ease-out) both; } +/* Primary navigation changes keep the manuscript's spatial continuity without + delaying input or hiding the destination before it is ready. */ +.view-enter { + animation: viewIn var(--motion-base) var(--ease-out) both; +} + /* List item entrances */ .recent-list .recent-item { animation: listIn var(--motion-base) var(--ease-out) both; @@ -1865,7 +1927,7 @@ body:not(.dragging-active) .chapter-row { scroll-behavior: auto !important; } .modal, .modal-backdrop, .dropdown-menu, .color-popover, - .recent-item, .chapter-row, #root { + .recent-item, .chapter-row, .view-enter, #root { transform: none !important; animation: none !important; } diff --git a/src/renderer/index.html b/src/renderer/index.html index b3ea9bc..4c854cf 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -39,6 +39,10 @@ + + + + diff --git a/src/renderer/theme.ts b/src/renderer/theme.ts index b9e13aa..82f636c 100644 --- a/src/renderer/theme.ts +++ b/src/renderer/theme.ts @@ -17,7 +17,7 @@ const LIGHT = "light"; // Themes with a real entry in themes.css + the selector. Anything else that // might linger in saved settings (a removed theme, a typo) falls back to // Light so the