feat(book): JSON export — whole book + cover-only

Two new payload builders:

- bookPayload() — captures spec, pages, masters, cover metadata,
  every layer that targets a book surface. Hooked into downloadProject
  so Save Project (JSON) in book mode now writes a real, round-
  trippable book file instead of falling through to coverTemplatePayload
  (which silently wrote a malformed DVD-cover JSON for books).

- bookCoverPayload() — cover-only payload with the three cover panels
  + the spec fields the cover needs to lay out correctly (trim, bleed,
  safe, spine width snapshotted at export so the file stays portable
  between books with different page counts). Surfaced via a new
  '💾 Cover JSON' button in the page-edit bar that only shows in
  cover-edit mode.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-06-04 14:50:29 -04:00
1 parent 9b5d6fa459
commit 6c33571329
1 file changed
+107
+107
View File
@@ -4921,6 +4921,7 @@
<button type="button" id="bookMasterAddTextFrame" title="Add a reflow text-frame to this master" style="display:none">+ Text frame</button> <button type="button" id="bookMasterAddTextFrame" title="Add a reflow text-frame to this master" style="display:none">+ Text frame</button>
<button type="button" id="bookPageEditPublish" title="Publish this page as a casewrap-page event">Publish</button> <button type="button" id="bookPageEditPublish" title="Publish this page as a casewrap-page event">Publish</button>
<button type="button" id="bookPageEditProofPdf" title="Export just this page as a PDF (single-page proof for the printer)">📄 Proof PDF</button> <button type="button" id="bookPageEditProofPdf" title="Export just this page as a PDF (single-page proof for the printer)">📄 Proof PDF</button>
<button type="button" id="bookCoverExportJsonBtn" title="Download just the cover spread as a portable JSON file" style="display:none">💾 Cover JSON</button>
<button type="button" id="bookPageEditGatingBtn" title="Set premium / paywall on this page" style="display:none">⚡ Free</button> <button type="button" id="bookPageEditGatingBtn" title="Set premium / paywall on this page" style="display:none">⚡ Free</button>
<span class="hint" id="bookPageEditPublishStatus" style="margin-left:6px;font-size:.74rem"></span> <span class="hint" id="bookPageEditPublishStatus" style="margin-left:6px;font-size:.74rem"></span>
</div> </div>
@@ -12497,6 +12498,8 @@
if (prev) prev.style.display = 'none'; if (prev) prev.style.display = 'none';
if (next) next.style.display = 'none'; if (next) next.style.display = 'none';
if (tfBtn) tfBtn.style.display = 'none'; if (tfBtn) tfBtn.style.display = 'none';
const coverExportBtn = document.getElementById('bookCoverExportJsonBtn');
if (coverExportBtn) coverExportBtn.style.display = '';
if (pubBtn) { if (pubBtn) {
const visTag = state.visibility === 'private' ? ' 🔒' : ''; const visTag = state.visibility === 'private' ? ' 🔒' : '';
const ref = book.cover?.ref; const ref = book.cover?.ref;
@@ -12507,6 +12510,11 @@
} }
return; return;
} }
// Editing a page / master → hide the cover-only export button.
{
const coverExportBtn = document.getElementById('bookCoverExportJsonBtn');
if (coverExportBtn) coverExportBtn.style.display = 'none';
}
if (editingMaster) { if (editingMaster) {
const m = book.masters.find(x => x.id === book.editingMasterId); const m = book.masters.find(x => x.id === book.editingMasterId);
if (label) label.textContent = `Master: ${m?.label || 'Untitled'}`; if (label) label.textContent = `Master: ${m?.label || 'Untitled'}`;
@@ -25918,6 +25926,90 @@
}; };
} }
// Whole-book payload — spec + pages + masters + cover metadata +
// every layer that belongs to any book surface (pages, masters,
// cover panels). Transient UI fields (editingCover / editingEntryId
// / editingMasterId / editingChapterId) are excluded; restore opens
// the book at the Pages overview.
function bookPayload() {
const book = state.book || {};
return {
version: SCHEMA_VERSION,
templateMode: 'book',
templateType: 'book',
meta: state.meta,
ui: {
zoom: state.zoom,
showGuides: state.showGuides,
showBleed: state.showBleed,
showCrops: state.showCrops,
printGuides: state.printGuides,
printCrops: state.printCrops,
},
book: {
spec: book.spec || {},
pages: book.pages || [],
masters: book.masters || [],
cover: book.cover || {},
},
layerGroups: { ...ensureLayerGroups() },
layers: nonGeneratedLayers().filter(l => {
const t = String(l.target || '');
return t.startsWith('book-page:')
|| t.startsWith('book-master:')
|| t === 'book-cover-front'
|| t === 'book-spine'
|| t === 'book-cover-back';
}),
};
}
// Cover-only payload — just the three cover panels + the spec
// fields the cover needs to render correctly (trim, bleed, safe,
// spine width snapshotted at export so the file is portable). Use
// it to move a cover spread between books or stash it as a
// standalone artifact.
function bookCoverPayload() {
const spec = state.book?.spec || {};
return {
version: SCHEMA_VERSION,
templateMode: 'book-cover',
templateType: 'book-cover',
meta: state.meta,
ui: {
zoom: state.zoom,
showGuides: state.showGuides,
showBleed: state.showBleed,
showCrops: state.showCrops,
},
bookCover: {
trimW: Number(spec.trimW) || 0,
trimH: Number(spec.trimH) || 0,
bleed: Number(spec.bleed) || 0,
safe: Number(spec.safe) || 0.125,
coverHasBack: !!spec.coverHasBack,
coverHasSpine: !!spec.coverHasSpine,
// Snapshot the derived spine width so the cover is
// self-contained — re-importing into a book with a
// different page count won't shift it unless the user opts
// in by toggling spineFromPages.
spineWidth: bookSpineWidthInches(),
spineFromPages: !!spec.spineFromPages,
spineWManual: Number(spec.spineWManual) || 0,
paperThickness: Number(spec.paperThickness) || 0,
pageCountAtExport: bookPageCount(),
bookSize: spec.bookSize || 'Custom',
},
layerGroups: { ...ensureLayerGroups() },
layers: nonGeneratedLayers().filter(l => {
const t = String(l.target || '');
return t === 'book-cover-front'
|| t === 'book-spine'
|| t === 'book-cover-back';
}),
};
}
function exportPayload() { function exportPayload() {
const templateType = templateTypeForMode(); const templateType = templateTypeForMode();
return { return {
@@ -27966,6 +28058,11 @@
} else if (state.templateMode === 'pixelart') { } else if (state.templateMode === 'pixelart') {
const pa = state.pixelArt || defaultPixelArt(32, 32); const pa = state.pixelArt || defaultPixelArt(32, 32);
triggerDownload(pixelArtPayload(), projectFilename('pixel_art', `${pa.width}x${pa.height}`)); triggerDownload(pixelArtPayload(), projectFilename('pixel_art', `${pa.width}x${pa.height}`));
} else if (state.templateMode === 'book') {
const spec = state.book?.spec || {};
const variant = filenamePart(spec.bookSize, 'custom')
|| `${Number(spec.trimW) || 0}x${Number(spec.trimH) || 0}`;
triggerDownload(bookPayload(), projectFilename('book', variant));
} else if (isCanvasMode(state.templateMode)) { } else if (isCanvasMode(state.templateMode)) {
const ca = state.customArt || { width: 1920, height: 1080 }; const ca = state.customArt || { width: 1920, height: 1080 };
const stem = state.templateMode === 'slide' ? 'slide' : 'custom_art'; const stem = state.templateMode === 'slide' ? 'slide' : 'custom_art';
@@ -37146,6 +37243,16 @@
const id = state.book?.editingEntryId; const id = state.book?.editingEntryId;
if (id) openBookPageGatingDialog(id); if (id) openBookPageGatingDialog(id);
}); });
on('bookCoverExportJsonBtn', 'click', () => {
if (!state.book?.editingCover) return;
const titlePart = filenamePart(state.meta?.title, 'untitled');
const spec = state.book?.spec || {};
const sizePart = filenamePart(spec.bookSize, '')
|| `${Number(spec.trimW) || 0}x${Number(spec.trimH) || 0}`;
const filename = [titlePart, 'book_cover', sizePart].filter(Boolean).join('_') + '.json';
triggerDownload(bookCoverPayload(), filename);
toastSuccess?.('Cover spread saved.');
});
on('bookPageEditProofPdf', 'click', async () => { on('bookPageEditProofPdf', 'click', async () => {
const book = state.book; const book = state.book;
const entryId = book?.editingEntryId; const entryId = book?.editingEntryId;