feat: implement page numbers, running titles, and smooth iframe transitions

Add functional page number and running title toggles with proper positioning
for left/right pages. Implement smooth crossfade transitions between iframe
reloads to eliminate visual flicker during PagedJS rendering.

Page Numbers & Running Titles:
- Page numbers toggle: adds counter(page) to @bottom-left (left pages) or
  @bottom-right (right pages)
- Running title toggle: adds string(title) from h2 chapter titles
- Combined positioning: both elements appear side-by-side in same margin box
  - Left pages: "1 Chapter Title" in @bottom-left
  - Right pages: "Chapter Title 2" in @bottom-right
- Automatic CSS rule management: adds/removes @page:left, @page:right, and
  string-set rules based on checkbox state
- Bidirectional sync: checkboxes reflect existing CSS state on load

Smooth Iframe Transitions:
- Dual iframe system: two iframes alternate as visible/hidden
- Crossfade technique: hidden iframe loads new content while visible remains
  displayed, then smooth 300ms opacity transition
- Scroll preservation: saves scroll percentage from visible iframe, restores
  to hidden iframe after PagedJS render
- Collision prevention: isTransitioning flag prevents overlapping renders
- Active frame tracking: computed property ensures ElementPopup always
  references the visible iframe

Technical details:
- Uses srcdoc to inject HTML with dynamic CSS
- Z-index and opacity manipulation for layering
- CSS transitions (opacity 0.3s ease-in-out)
- Automatic frame swapping after transition completes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
isUnknown 2025-12-04 15:27:23 +01:00
parent 9af36fb422
commit 07e5764ccd
2 changed files with 166 additions and 20 deletions

View file

@ -372,7 +372,7 @@ watch(pageNumbers, (enabled) => {
if (isUpdatingFromStore) return;
immediateUpdate(() => {
// TODO: implement page numbers toggle
updatePageFooters();
});
});
@ -380,10 +380,85 @@ watch(runningTitle, (enabled) => {
if (isUpdatingFromStore) return;
immediateUpdate(() => {
// TODO: implement running title toggle
updatePageFooters();
});
});
const updatePageFooters = () => {
let currentCss = stylesheetStore.content;
// Remove existing @page:left and @page:right rules
currentCss = currentCss.replace(/@page:left\s*\{[^}]*\}/g, '');
currentCss = currentCss.replace(/@page:right\s*\{[^}]*\}/g, '');
// Remove old @page @bottom-center rule if exists
currentCss = currentCss.replace(/@page\s*\{[^}]*@bottom-center[^}]*\}/g, (match) => {
return match.replace(/@bottom-center\s*\{[^}]*\}/g, '');
});
// Remove string-set rule if running title is disabled
if (!runningTitle.value) {
currentCss = currentCss.replace(/\.chapter\s*>\s*h2\s*\{[^}]*string-set:[^}]*\}\s*/g, '');
} else if (!currentCss.includes('string-set: title')) {
// Add the string-set rule for h2 titles if running title is enabled
const stringSetRule = '\n.chapter > h2 {\n string-set: title content(text);\n}\n';
currentCss += stringSetRule;
}
// Build new rules based on checkboxes
let leftPageRule = '';
let rightPageRule = '';
if (pageNumbers.value || runningTitle.value) {
// Left pages: page number bottom-left, running title right next to it (bottom-left-corner)
let leftBottomLeft = '';
let leftBottomCenter = '';
if (pageNumbers.value && runningTitle.value) {
// Page number on the left, title right next to it
leftBottomLeft = ' @bottom-left {\n content: counter(page) " " string(title);\n }\n';
} else if (pageNumbers.value) {
leftBottomLeft = ' @bottom-left {\n content: counter(page);\n }\n';
} else if (runningTitle.value) {
leftBottomLeft = ' @bottom-left {\n content: string(title);\n }\n';
}
if (leftBottomLeft || leftBottomCenter) {
leftPageRule = `@page:left {\n${leftBottomLeft}${leftBottomCenter}}\n\n`;
}
// Right pages: title on the left, page number on the right (next to it)
let rightBottomRight = '';
if (pageNumbers.value && runningTitle.value) {
// Title on the left of page number
rightBottomRight = ' @bottom-right {\n content: string(title) " " counter(page);\n }\n';
} else if (pageNumbers.value) {
rightBottomRight = ' @bottom-right {\n content: counter(page);\n }\n';
} else if (runningTitle.value) {
rightBottomRight = ' @bottom-right {\n content: string(title);\n }\n';
}
if (rightBottomRight) {
rightPageRule = `@page:right {\n${rightBottomRight}}\n\n`;
}
}
// Insert the new rules after the main @page rule
const pageRuleMatch = currentCss.match(/@page\s*\{[^}]*\}/);
if (pageRuleMatch) {
const insertPosition = pageRuleMatch.index + pageRuleMatch[0].length;
currentCss =
currentCss.slice(0, insertPosition) +
'\n\n' +
leftPageRule +
rightPageRule +
currentCss.slice(insertPosition);
}
stylesheetStore.content = currentCss;
};
const syncFromStore = () => {
isUpdatingFromStore = true;
@ -421,6 +496,22 @@ const syncFromStore = () => {
if (bgMatch) {
background.value.value = bgMatch[1].trim();
}
// Check for page numbers and running title in @page:left and @page:right
const leftPageMatch = stylesheetStore.content.match(/@page:left\s*\{[^}]*\}/);
const rightPageMatch = stylesheetStore.content.match(/@page:right\s*\{[^}]*\}/);
// Check if page numbers exist (counter(page) in either left or right)
const hasPageNumbers =
(leftPageMatch && leftPageMatch[0].includes('counter(page)')) ||
(rightPageMatch && rightPageMatch[0].includes('counter(page)'));
pageNumbers.value = hasPageNumbers;
// Check if running title exists (string(title) in either left or right)
const hasRunningTitle =
(leftPageMatch && leftPageMatch[0].includes('string(title)')) ||
(rightPageMatch && rightPageMatch[0].includes('string(title)'));
runningTitle.value = hasRunningTitle;
} finally {
isUpdatingFromStore = false;
}