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:
parent
9af36fb422
commit
07e5764ccd
2 changed files with 166 additions and 20 deletions
91
src/App.vue
91
src/App.vue
|
|
@ -2,23 +2,40 @@
|
||||||
import PagedJsWrapper from './components/PagedJsWrapper.vue';
|
import PagedJsWrapper from './components/PagedJsWrapper.vue';
|
||||||
import EditorPanel from './components/editor/EditorPanel.vue';
|
import EditorPanel from './components/editor/EditorPanel.vue';
|
||||||
import ElementPopup from './components/ElementPopup.vue';
|
import ElementPopup from './components/ElementPopup.vue';
|
||||||
import { onMounted, ref, watch } from 'vue';
|
import { onMounted, ref, watch, computed } from 'vue';
|
||||||
import { useStylesheetStore } from './stores/stylesheet';
|
import { useStylesheetStore } from './stores/stylesheet';
|
||||||
|
|
||||||
const stylesheetStore = useStylesheetStore();
|
const stylesheetStore = useStylesheetStore();
|
||||||
const previewFrame = ref(null);
|
const previewFrame1 = ref(null);
|
||||||
|
const previewFrame2 = ref(null);
|
||||||
const elementPopup = ref(null);
|
const elementPopup = ref(null);
|
||||||
|
|
||||||
let savedScrollPercentage = 0;
|
let savedScrollPercentage = 0;
|
||||||
|
const currentFrameIndex = ref(1); // 1 or 2, which iframe is currently visible
|
||||||
|
let isTransitioning = false;
|
||||||
|
|
||||||
|
const activeFrame = computed(() => {
|
||||||
|
return currentFrameIndex.value === 1 ? previewFrame1.value : previewFrame2.value;
|
||||||
|
});
|
||||||
|
|
||||||
const renderPreview = async (shouldReloadFromFile = false) => {
|
const renderPreview = async (shouldReloadFromFile = false) => {
|
||||||
const iframe = previewFrame.value;
|
if (isTransitioning) return;
|
||||||
if (!iframe) return;
|
isTransitioning = true;
|
||||||
|
|
||||||
if (iframe.contentWindow && iframe.contentDocument) {
|
// Determine which iframe is currently visible and which to render to
|
||||||
const scrollTop = iframe.contentWindow.scrollY || 0;
|
const visibleFrame = currentFrameIndex.value === 1 ? previewFrame1.value : previewFrame2.value;
|
||||||
const scrollHeight = iframe.contentDocument.documentElement.scrollHeight;
|
const hiddenFrame = currentFrameIndex.value === 1 ? previewFrame2.value : previewFrame1.value;
|
||||||
const clientHeight = iframe.contentWindow.innerHeight;
|
|
||||||
|
if (!hiddenFrame) {
|
||||||
|
isTransitioning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save scroll position from visible frame
|
||||||
|
if (visibleFrame && visibleFrame.contentWindow && visibleFrame.contentDocument) {
|
||||||
|
const scrollTop = visibleFrame.contentWindow.scrollY || 0;
|
||||||
|
const scrollHeight = visibleFrame.contentDocument.documentElement.scrollHeight;
|
||||||
|
const clientHeight = visibleFrame.contentWindow.innerHeight;
|
||||||
const maxScroll = scrollHeight - clientHeight;
|
const maxScroll = scrollHeight - clientHeight;
|
||||||
|
|
||||||
savedScrollPercentage = maxScroll > 0 ? scrollTop / maxScroll : 0;
|
savedScrollPercentage = maxScroll > 0 ? scrollTop / maxScroll : 0;
|
||||||
|
|
@ -28,7 +45,8 @@ const renderPreview = async (shouldReloadFromFile = false) => {
|
||||||
await stylesheetStore.loadStylesheet();
|
await stylesheetStore.loadStylesheet();
|
||||||
}
|
}
|
||||||
|
|
||||||
iframe.srcdoc = `
|
// Render to the hidden frame
|
||||||
|
hiddenFrame.srcdoc = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -40,20 +58,45 @@ const renderPreview = async (shouldReloadFromFile = false) => {
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
iframe.onload = () => {
|
hiddenFrame.onload = () => {
|
||||||
iframe.contentDocument.addEventListener(
|
hiddenFrame.contentDocument.addEventListener(
|
||||||
'click',
|
'click',
|
||||||
elementPopup.value.handleIframeClick
|
elementPopup.value.handleIframeClick
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Wait for PagedJS to finish rendering
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const scrollHeight = iframe.contentDocument.documentElement.scrollHeight;
|
// Restore scroll position
|
||||||
const clientHeight = iframe.contentWindow.innerHeight;
|
const scrollHeight = hiddenFrame.contentDocument.documentElement.scrollHeight;
|
||||||
|
const clientHeight = hiddenFrame.contentWindow.innerHeight;
|
||||||
const maxScroll = scrollHeight - clientHeight;
|
const maxScroll = scrollHeight - clientHeight;
|
||||||
const targetScroll = savedScrollPercentage * maxScroll;
|
const targetScroll = savedScrollPercentage * maxScroll;
|
||||||
|
|
||||||
iframe.contentWindow.scrollTo(0, targetScroll);
|
hiddenFrame.contentWindow.scrollTo(0, targetScroll);
|
||||||
}, 500);
|
|
||||||
|
// Start crossfade transition
|
||||||
|
setTimeout(() => {
|
||||||
|
// Make hidden frame visible (it's already behind)
|
||||||
|
hiddenFrame.style.opacity = '1';
|
||||||
|
hiddenFrame.style.zIndex = '1';
|
||||||
|
|
||||||
|
// Fade out visible frame
|
||||||
|
if (visibleFrame) {
|
||||||
|
visibleFrame.style.opacity = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// After fade completes, swap the frames
|
||||||
|
setTimeout(() => {
|
||||||
|
if (visibleFrame) {
|
||||||
|
visibleFrame.style.zIndex = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap current frame
|
||||||
|
currentFrameIndex.value = currentFrameIndex.value === 1 ? 2 : 1;
|
||||||
|
isTransitioning = false;
|
||||||
|
}, 300); // Match CSS transition duration
|
||||||
|
}, 100); // Small delay to ensure scroll is set
|
||||||
|
}, 500); // Wait for PagedJS
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -74,18 +117,30 @@ onMounted(() => renderPreview(true));
|
||||||
|
|
||||||
<EditorPanel />
|
<EditorPanel />
|
||||||
|
|
||||||
<iframe ref="previewFrame" id="preview-frame"></iframe>
|
<iframe ref="previewFrame1" class="preview-frame"></iframe>
|
||||||
|
<iframe ref="previewFrame2" class="preview-frame"></iframe>
|
||||||
|
|
||||||
<ElementPopup ref="elementPopup" :iframeRef="previewFrame" />
|
<ElementPopup ref="elementPopup" :iframeRef="activeFrame" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#preview-frame {
|
.preview-frame {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
border: none;
|
border: none;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame:nth-of-type(1) {
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame:nth-of-type(2) {
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -372,7 +372,7 @@ watch(pageNumbers, (enabled) => {
|
||||||
if (isUpdatingFromStore) return;
|
if (isUpdatingFromStore) return;
|
||||||
|
|
||||||
immediateUpdate(() => {
|
immediateUpdate(() => {
|
||||||
// TODO: implement page numbers toggle
|
updatePageFooters();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -380,10 +380,85 @@ watch(runningTitle, (enabled) => {
|
||||||
if (isUpdatingFromStore) return;
|
if (isUpdatingFromStore) return;
|
||||||
|
|
||||||
immediateUpdate(() => {
|
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 = () => {
|
const syncFromStore = () => {
|
||||||
isUpdatingFromStore = true;
|
isUpdatingFromStore = true;
|
||||||
|
|
||||||
|
|
@ -421,6 +496,22 @@ const syncFromStore = () => {
|
||||||
if (bgMatch) {
|
if (bgMatch) {
|
||||||
background.value.value = bgMatch[1].trim();
|
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 {
|
} finally {
|
||||||
isUpdatingFromStore = false;
|
isUpdatingFromStore = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue