book(B1): drag-reorder + duplicate on Pages overview

- Tiles are now draggable; grabbing one and dropping on another
  swaps it into that position. Visual feedback: source tile fades
  to 50% opacity while dragging; the hovered drop target shows an
  accent-coloured outline.
- New `moveBookPage(from, to)` splices the manifest array and
  pushes a single history entry; nothing else changes (anchors
  reference entry ids, so reordering doesn't break refs).
- Per-tile hover overlay now has two actions instead of one:
  - `⎘` Duplicate — clones the entry with a fresh id, drops any
    anchor (the duplicate sits inserted-position by default), and
    appends "copy" to the label.
  - `×` Delete — unchanged, plus a soft danger hover state.
- `attachBookPagesDnd()` is idempotent (guards via
  `data-dnd-attached`) so re-rendering the grid doesn't pile up
  listeners.
This commit is contained in:
thePR0M3TH3AN committed 2026-06-02 14:49:36 -04:00
1 parent 382165d21b
commit 2df533a93d
1 file changed
+112 -7
+112 -7
View File
@@ -1534,14 +1534,26 @@
position: absolute; top: 6px; left: 8px;
font-size: .72rem; color: var(--muted); font-weight: 600;
}
.bookPageTileDelete {
.bookPageTileActions {
position: absolute; top: 4px; right: 4px;
width: 22px; height: 22px; border-radius: 0;
border: 1px solid var(--line); background: #fff;
cursor: pointer; font-size: .9rem; line-height: 1;
display: flex; gap: 4px;
opacity: 0; transition: opacity .12s;
}
.bookPageTile:hover .bookPageTileDelete { opacity: 1; }
.bookPageTile:hover .bookPageTileActions { opacity: 1; }
.bookPageTileAction {
width: 22px; height: 22px; padding: 0; border-radius: 0;
border: 1px solid var(--line); background: #fff;
cursor: pointer; font-size: .85rem; line-height: 1;
}
.bookPageTileAction.danger:hover {
background: var(--warn, #fee2e2);
border-color: var(--warn, #fca5a5);
}
.bookPageTile.dragging { opacity: .5; }
.bookPageTile.dragover {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.bookPageTileLabel {
font-size: .8rem; color: var(--muted); text-align: center;
padding: 0 8px;
@@ -8273,6 +8285,41 @@
syncInputs();
render();
}
// Duplicate a designed-page entry. The duplicate gets its own
// fresh manifest id; the underlying ref (if any) stays the same
// so two tiles point at one page event. Authors can break the
// share later by "Make local copy" in Phase D2.
function duplicateBookPage(entryId) {
const book = state.book;
if (!book) return;
const idx = book.pages.findIndex(p => p.id === entryId);
if (idx < 0) return;
pushHistory('book: duplicate page');
const copy = JSON.parse(JSON.stringify(book.pages[idx]));
copy.id = makeBookEntryId();
copy.label = copy.label ? copy.label + ' copy' : '';
// Anchors on the original copy don't translate cleanly — clear
// them so the duplicate just sits at its inserted position.
copy.anchor = null;
book.pages.splice(idx + 1, 0, copy);
renderBookPagesGrid();
syncInputs();
render();
}
// Reorder pages within the manifest. Drag-and-drop in the grid
// calls this. `to` is the destination index AFTER the source has
// been removed, matching the deck-sorter's contract.
function moveBookPage(from, to) {
const book = state.book;
if (!book) return;
if (from === to || from < 0 || from >= book.pages.length) return;
pushHistory('book: reorder pages');
const [moved] = book.pages.splice(from, 1);
book.pages.splice(to, 0, moved);
renderBookPagesGrid();
syncInputs();
render();
}
// Render the Pages overview grid. Each tile is a placeholder
// (the actual rendered-page thumbnail lands in Phase B once the
@@ -8307,6 +8354,8 @@
const tile = document.createElement('div');
tile.className = `bookPageTile ${p.type === 'reflow' ? 'reflowSegment' : 'designedPage'}`;
tile.dataset.entryId = p.id;
tile.dataset.index = String(idx);
tile.draggable = true;
const numEl = document.createElement('div');
numEl.className = 'bookPageTileNum';
if ((numbering?.enabled !== false) && !p.skipNumber && idx >= firstNumberedIdx) {
@@ -8316,16 +8365,32 @@
numEl.textContent = '—';
}
tile.appendChild(numEl);
// Per-tile action group (top-right). Visible on hover only —
// matches the deck sorter's intent of "tiles read as artwork,
// chrome stays out of the way until you point at them".
const actions = document.createElement('div');
actions.className = 'bookPageTileActions';
const dup = document.createElement('button');
dup.type = 'button';
dup.className = 'bookPageTileAction';
dup.title = 'Duplicate';
dup.textContent = '⎘';
dup.addEventListener('click', (e) => {
e.stopPropagation();
duplicateBookPage(p.id);
});
actions.appendChild(dup);
const del = document.createElement('button');
del.type = 'button';
del.className = 'bookPageTileDelete';
del.className = 'bookPageTileAction danger';
del.title = 'Remove from book';
del.textContent = '×';
del.addEventListener('click', (e) => {
e.stopPropagation();
deleteBookPage(p.id);
});
tile.appendChild(del);
actions.appendChild(del);
tile.appendChild(actions);
const label = document.createElement('div');
label.className = 'bookPageTileLabel';
if (p.type === 'reflow') {
@@ -8336,6 +8401,46 @@
tile.appendChild(label);
grid.appendChild(tile);
});
attachBookPagesDnd();
}
// Drag-and-drop reorder on the Pages grid. Mirrors the deck
// sorter's pattern. `_bookPageDragFrom` carries the source index
// across the dragstart → dragover → drop chain.
let _bookPageDragFrom = null;
function attachBookPagesDnd() {
const grid = document.getElementById('bookPagesGrid');
if (!grid || grid.dataset.dndAttached === '1') return;
grid.dataset.dndAttached = '1';
grid.addEventListener('dragstart', (e) => {
const tile = e.target.closest('.bookPageTile');
if (!tile) return;
_bookPageDragFrom = Number(tile.dataset.index);
tile.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
grid.addEventListener('dragend', (e) => {
const tile = e.target.closest('.bookPageTile');
if (tile) tile.classList.remove('dragging');
grid.querySelectorAll('.dragover').forEach(el => el.classList.remove('dragover'));
_bookPageDragFrom = null;
});
grid.addEventListener('dragover', (e) => {
const tile = e.target.closest('.bookPageTile');
if (!tile || _bookPageDragFrom === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
grid.querySelectorAll('.dragover').forEach(el => el.classList.remove('dragover'));
tile.classList.add('dragover');
});
grid.addEventListener('drop', (e) => {
const tile = e.target.closest('.bookPageTile');
if (!tile || _bookPageDragFrom === null) return;
e.preventDefault();
const to = Number(tile.dataset.index);
moveBookPage(_bookPageDragFrom, to);
_bookPageDragFrom = null;
});
}
function setBookSubView(view) {