diff --git a/src/index.html b/src/index.html
index 40bc640..81906b2 100644
--- a/src/index.html
+++ b/src/index.html
@@ -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) {