Compare commits

...

5 commits

Author SHA1 Message Date
isUnknown
be7bb66e70 refactor: extract App.vue logic into composables (762→230 lines)
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 14s
Extracted complex logic from App.vue into focused, reusable composables:

New composables:
- useKeyboardShortcuts.js (~80 lines): Keyboard shortcuts (Cmd/Ctrl+S, P, Escape, \)
- useIframeInteractions.js (~370 lines): Page/element hover, labels, clicks, popups
- usePreviewRenderer.js (~160 lines): Double buffering, transitions, scroll persistence
- usePrintPreview.js (~70 lines): Print dialog and style collection

Benefits:
- 70% reduction in App.vue size (532 lines extracted)
- Better separation of concerns
- Improved maintainability and testability
- Clearer code organization

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:20:10 +01:00
isUnknown
dac532a932 feat: add Cmd/Ctrl+P shortcut to trigger print preview
Added Cmd+P (Mac) or Ctrl+P (Windows/Linux) to trigger printPreview():
- Prevents default browser print dialog
- Triggers custom print preview function
- Updated print button tooltip to show keyboard shortcut
- Added platform detection for correct symbol display (⌘ or Ctrl)

Works in all contexts (main document and iframe).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:11:18 +01:00
isUnknown
4d39a83a63 feat: add backslash shortcut to toggle editor panel
Added \ key to toggle the editor panel open/closed:
- Opens to 'document' tab when panel is closed
- Closes panel when it's open
- Updated button tooltips to indicate the keyboard shortcut

Works in all contexts (main document and iframe).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:10:26 +01:00
isUnknown
8e2f0a10e2 feat: add Escape key shortcut to close popups
Added Escape key handler to close ElementPopup or PagePopup when open.
The handler checks which popup is visible and calls its close method.

Works in all contexts (main document and iframe) using the existing
handleKeyboardShortcut function.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:09:28 +01:00
isUnknown
91ef119697 fix: keyboard shortcut Cmd/Ctrl+S now works when focus is in preview iframe
Added keyboard event listener to iframe document to capture shortcuts
when user is focused inside the preview. Previously, keyboard events
inside iframes didn't bubble up to the parent document.

Changes:
- Add handleKeyboardShortcut function in App.vue
- Attach keydown listener to main document (for focus outside iframe)
- Attach keydown listener to iframe document (for focus inside iframe)
- Clean up listener on unmount

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:07:02 +01:00
6 changed files with 734 additions and 529 deletions

View file

@ -5,10 +5,13 @@ import ElementPopup from './components/ElementPopup.vue';
import PagePopup from './components/PagePopup.vue';
import PreviewLoader from './components/PreviewLoader.vue';
import SaveButton from './components/SaveButton.vue';
import { onMounted, ref, watch, computed, provide } from 'vue';
import { onMounted, ref, computed, provide } from 'vue';
import { useStylesheetStore } from './stores/stylesheet';
import { useNarrativeStore } from './stores/narrative';
import Coloris from '@melloware/coloris';
import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
import { useIframeInteractions } from './composables/useIframeInteractions';
import { usePreviewRenderer } from './composables/usePreviewRenderer';
import { usePrintPreview } from './composables/usePrintPreview';
const stylesheetStore = useStylesheetStore();
const narrativeStore = useNarrativeStore();
@ -21,16 +24,32 @@ const activeTab = ref('');
provide('activeTab', activeTab);
// Page interaction state
const hoveredPage = ref(null);
const selectedPages = ref([]); // Pages with active border (when popup is open)
const hoveredElement = ref(null); // Currently hovered content element
const selectedElement = ref(null); // Selected element (when popup is open)
const EDGE_THRESHOLD = 30; // px from edge to trigger hover
// Setup iframe interactions (hover, click, labels)
const {
hoveredPage,
selectedPages,
hoveredElement,
selectedElement,
handleIframeMouseMove,
handleIframeClick,
handlePagePopupClose,
handleElementPopupClose,
} = useIframeInteractions({ elementPopup, pagePopup });
let savedScrollPercentage = 0;
const currentFrameIndex = ref(1); // 1 or 2, which iframe is currently visible
const isTransitioning = ref(false);
// Setup preview renderer with double buffering
const {
renderPreview,
currentFrameIndex,
isTransitioning,
setKeyboardShortcutHandler,
} = usePreviewRenderer({
previewFrame1,
previewFrame2,
stylesheetStore,
narrativeStore,
handleIframeMouseMove,
handleIframeClick,
});
const activeFrame = computed(() => {
return currentFrameIndex.value === 1
@ -38,522 +57,25 @@ const activeFrame = computed(() => {
: previewFrame2.value;
});
// Check if mouse position is near the edges of a page element
const isNearPageEdge = (pageElement, mouseX, mouseY) => {
const rect = pageElement.getBoundingClientRect();
const nearLeft = mouseX >= rect.left && mouseX <= rect.left + EDGE_THRESHOLD;
const nearRight =
mouseX >= rect.right - EDGE_THRESHOLD && mouseX <= rect.right;
const nearTop = mouseY >= rect.top && mouseY <= rect.top + EDGE_THRESHOLD;
const nearBottom =
mouseY >= rect.bottom - EDGE_THRESHOLD && mouseY <= rect.bottom;
const inHorizontalRange = mouseY >= rect.top && mouseY <= rect.bottom;
const inVerticalRange = mouseX >= rect.left && mouseX <= rect.right;
return (
(nearLeft && inHorizontalRange) ||
(nearRight && inHorizontalRange) ||
(nearTop && inVerticalRange) ||
(nearBottom && inVerticalRange)
);
};
// Get all pages using the same template as the given page
const getPagesWithSameTemplate = (page, doc) => {
const pageType = page.getAttribute('data-page-type') || 'default';
const allPages = doc.querySelectorAll('.pagedjs_page');
return Array.from(allPages).filter(
(p) => (p.getAttribute('data-page-type') || 'default') === pageType
);
};
// Get selector for element (same logic as ElementPopup)
const getSelectorFromElement = (element) => {
if (element.id) {
return `#${element.id}`;
}
const tagName = element.tagName.toLowerCase();
// Filter out state classes (element-hovered, element-selected, page-hovered, page-selected)
const classes = Array.from(element.classList).filter(
(cls) =>
![
'element-hovered',
'element-selected',
'page-hovered',
'page-selected',
].includes(cls)
);
if (classes.length > 0) {
return `${tagName}.${classes[0]}`;
}
return tagName;
};
// Create and position element label on hover
const createElementLabel = (element) => {
const doc = element.ownerDocument;
const existingLabel = doc.querySelector('.element-hover-label');
if (existingLabel) {
existingLabel.remove();
}
const rect = element.getBoundingClientRect();
const scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop;
const scrollLeft = doc.documentElement.scrollLeft || doc.body.scrollLeft;
const label = doc.createElement('div');
label.className = 'element-hover-label';
label.textContent = getSelectorFromElement(element);
label.style.top = `${rect.top + scrollTop - 32}px`;
label.style.left = `${rect.left + scrollLeft}px`;
doc.body.appendChild(label);
return label;
};
// Remove element label
const removeElementLabel = (doc) => {
const label = doc.querySelector('.element-hover-label');
if (label) {
label.remove();
}
};
// Create and position page label on hover
const createPageLabel = (page) => {
const doc = page.ownerDocument;
const existingLabel = doc.querySelector('.page-hover-label');
if (existingLabel) {
existingLabel.remove();
}
const rect = page.getBoundingClientRect();
const scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop;
const scrollLeft = doc.documentElement.scrollLeft || doc.body.scrollLeft;
const templateName = page.getAttribute('data-page-type') || 'default';
const label = doc.createElement('div');
label.className = 'page-hover-label';
label.textContent = `@page ${templateName}`;
label.style.top = `${rect.top + scrollTop - 32}px`;
label.style.left = `${rect.left + scrollLeft}px`;
doc.body.appendChild(label);
return label;
};
// Remove page label
const removePageLabel = (doc) => {
const label = doc.querySelector('.page-hover-label');
if (label) {
label.remove();
}
};
// Handle mouse movement in iframe
const handleIframeMouseMove = (event) => {
const pages = event.target.ownerDocument.querySelectorAll('.pagedjs_page');
let foundPage = null;
// Check if hovering near page edge
for (const page of pages) {
if (isNearPageEdge(page, event.clientX, event.clientY)) {
foundPage = page;
break;
}
}
// Update page hover state
if (foundPage !== hoveredPage.value) {
// Remove highlight from previous page (only if not in selectedPages)
if (hoveredPage.value && !selectedPages.value.includes(hoveredPage.value)) {
hoveredPage.value.classList.remove('page-hovered');
}
// Remove previous page label
removePageLabel(event.target.ownerDocument);
// Add highlight to new page (only if not already selected)
if (foundPage && !selectedPages.value.includes(foundPage)) {
foundPage.classList.add('page-hovered');
createPageLabel(foundPage);
}
hoveredPage.value = foundPage;
}
// If not near page edge, check for content element hover
if (!foundPage) {
const contentElement = getContentElement(event.target);
const doc = event.target.ownerDocument;
if (contentElement !== hoveredElement.value) {
// Remove highlight from previous element (only if not selected)
if (
hoveredElement.value &&
hoveredElement.value !== selectedElement.value
) {
hoveredElement.value.classList.remove('element-hovered');
}
// Remove previous labels
removeElementLabel(doc);
removePageLabel(doc);
// Add highlight to new element (only if not already selected)
if (contentElement && contentElement !== selectedElement.value) {
contentElement.classList.add('element-hovered');
createElementLabel(contentElement);
}
hoveredElement.value = contentElement;
}
} else {
// Clear element hover when hovering page edge
if (
hoveredElement.value &&
hoveredElement.value !== selectedElement.value
) {
hoveredElement.value.classList.remove('element-hovered');
hoveredElement.value = null;
}
// Remove element label when hovering page edge
removeElementLabel(event.target.ownerDocument);
}
};
// Clear selection highlight from all selected pages
const clearSelectedPages = () => {
selectedPages.value.forEach((page) => {
page.classList.remove('page-selected');
});
selectedPages.value = [];
};
// Text elements that can trigger ElementPopup (excluding containers, images, etc.)
const CONTENT_ELEMENTS = [
'P',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'BLOCKQUOTE',
'LI',
'A',
'STRONG',
'EM',
'B',
'I',
'U',
'CODE',
'PRE',
'FIGCAPTION',
];
// Check if element is a content element (or find closest content parent)
const getContentElement = (element) => {
let current = element;
while (current && current.tagName !== 'BODY') {
if (CONTENT_ELEMENTS.includes(current.tagName)) {
return current;
}
current = current.parentElement;
}
return null;
};
// Clear selected element highlight
const clearSelectedElement = () => {
if (selectedElement.value) {
selectedElement.value.classList.remove('element-selected');
const doc = selectedElement.value.ownerDocument;
removeElementLabel(doc);
selectedElement.value = null;
}
};
// Get count of similar elements (same tag)
const getSimilarElementsCount = (element, doc) => {
const tagName = element.tagName;
const allElements = doc.querySelectorAll(tagName);
return allElements.length;
};
// Handle click in iframe
const handleIframeClick = (event) => {
const element = event.target;
// Check if clicking near a page edge
if (hoveredPage.value) {
event.stopPropagation();
// Clear previous selections
clearSelectedPages();
clearSelectedElement();
// Get all pages with same template and highlight them
const doc = event.target.ownerDocument;
const sameTemplatePages = getPagesWithSameTemplate(hoveredPage.value, doc);
sameTemplatePages.forEach((page) => {
page.classList.add('page-selected');
});
selectedPages.value = sameTemplatePages;
// Remove labels when opening popup
removePageLabel(doc);
removeElementLabel(doc);
pagePopup.value.open(hoveredPage.value, event, sameTemplatePages.length);
elementPopup.value.close();
return;
}
// Only show popup for elements inside the page template
const isInsidePage = element.closest('.pagedjs_page');
if (!isInsidePage) {
clearSelectedPages();
clearSelectedElement();
elementPopup.value.close();
pagePopup.value.close();
return;
}
// Only show ElementPopup for content elements, not divs
const contentElement = getContentElement(element);
if (!contentElement) {
clearSelectedPages();
clearSelectedElement();
elementPopup.value.close();
pagePopup.value.close();
return;
}
// Clear page selections
clearSelectedPages();
// If popup is already open and we're clicking another element, close it
if (elementPopup.value.visible) {
clearSelectedElement();
elementPopup.value.close();
pagePopup.value.close();
return;
}
// Clear previous element selection
clearSelectedElement();
// Remove hovered class from the element we're about to select
contentElement.classList.remove('element-hovered');
// Clear the hoveredElement ref if it's the same as what we're selecting
if (hoveredElement.value === contentElement) {
hoveredElement.value = null;
}
// Get document and remove labels when opening popup
const doc = event.target.ownerDocument;
removeElementLabel(doc);
removePageLabel(doc);
// Select the new element
selectedElement.value = contentElement;
contentElement.classList.add('element-selected');
// Get count of similar elements
const count = getSimilarElementsCount(contentElement, doc);
elementPopup.value.handleIframeClick(event, contentElement, count);
pagePopup.value.close();
};
// Expose clearSelectedPages for PagePopup to call when closing
const handlePagePopupClose = () => {
clearSelectedPages();
};
// Handle ElementPopup close
const handleElementPopupClose = () => {
clearSelectedElement();
};
const renderPreview = async (shouldReloadFromFile = false) => {
if (isTransitioning.value) return;
isTransitioning.value = true;
// Determine which iframe is currently visible and which to render to
const visibleFrame =
currentFrameIndex.value === 1 ? previewFrame1.value : previewFrame2.value;
const hiddenFrame =
currentFrameIndex.value === 1 ? previewFrame2.value : previewFrame1.value;
if (!hiddenFrame) {
isTransitioning.value = 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;
savedScrollPercentage = maxScroll > 0 ? scrollTop / maxScroll : 0;
}
if (shouldReloadFromFile || !stylesheetStore.content) {
await stylesheetStore.loadStylesheet();
}
// Render to the hidden frame
hiddenFrame.srcdoc = `
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/assets/css/pagedjs-interface.css">
<style id="dynamic-styles">${stylesheetStore.content}</style>
<script src="https://unpkg.com/pagedjs/dist/paged.polyfill.js"><\/script>
</head>
<body>${document.getElementById('content-source').innerHTML}</body>
</html>
`;
hiddenFrame.onload = () => {
// Add event listeners for page and element interactions
hiddenFrame.contentDocument.addEventListener(
'mousemove',
handleIframeMouseMove
);
hiddenFrame.contentDocument.addEventListener('click', handleIframeClick);
// Close Coloris when clicking in the iframe
hiddenFrame.contentDocument.addEventListener('click', () => {
Coloris.close();
});
// Wait for PagedJS to finish rendering
setTimeout(() => {
// Restore scroll position
const scrollHeight =
hiddenFrame.contentDocument.documentElement.scrollHeight;
const clientHeight = hiddenFrame.contentWindow.innerHeight;
const maxScroll = scrollHeight - clientHeight;
const targetScroll = savedScrollPercentage * maxScroll;
hiddenFrame.contentWindow.scrollTo(0, targetScroll);
// 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.value = false;
}, 200); // Match CSS transition duration
}, 50); // Small delay to ensure scroll is set
}, 200); // Wait for PagedJS
};
};
watch(
() => stylesheetStore.content,
() => {
renderPreview();
}
);
// Re-render when narrative data changes
watch(
() => narrativeStore.data,
() => {
if (narrativeStore.data) {
renderPreview();
}
}
);
// Print the PagedJS content
const printPreview = async () => {
const frame = activeFrame.value;
if (!frame || !frame.contentDocument) return;
const doc = frame.contentDocument;
// Collect all styles
let allStyles = '';
// Get inline <style> tags content
doc.querySelectorAll('style').forEach((style) => {
allStyles += style.innerHTML + '\n';
});
// Get rules from stylesheets
for (const sheet of doc.styleSheets) {
try {
for (const rule of sheet.cssRules) {
allStyles += rule.cssText + '\n';
}
} catch (e) {
// Cross-origin stylesheet, try to fetch it
if (sheet.href) {
try {
const response = await fetch(sheet.href);
const css = await response.text();
allStyles += css;
} catch (fetchError) {
console.warn('Could not fetch stylesheet:', sheet.href);
}
}
}
}
// Save current page content
const originalContent = document.body.innerHTML;
const originalStyles = document.head.innerHTML;
// Replace page content with iframe content
document.head.innerHTML = `
<meta charset="UTF-8">
<title>Impression</title>
<style>${allStyles}</style>
`;
document.body.innerHTML = doc.body.innerHTML;
// Print
window.print();
// Restore original content after print dialog closes
setTimeout(() => {
document.head.innerHTML = originalStyles;
document.body.innerHTML = originalContent;
// Re-mount Vue app would be needed, so we reload instead
window.location.reload();
}, 100);
};
// Setup print preview
const { printPreview } = usePrintPreview(activeFrame);
// Setup keyboard shortcuts (depends on printPreview)
const {
handleKeyboardShortcut,
isMac
} = useKeyboardShortcuts({
stylesheetStore,
elementPopup,
pagePopup,
activeTab,
printPreview,
});
// Attach keyboard shortcut handler to renderer
setKeyboardShortcutHandler(handleKeyboardShortcut);
// Lifecycle: Initialize app on mount
onMounted(async () => {
// Load narrative data (narrativeUrl constructed from location, always present)
await narrativeStore.loadNarrative(location.href + '.json');
@ -601,7 +123,7 @@ onMounted(async () => {
@close="handlePagePopupClose"
/>
<button class="print-btn" @click="printPreview" title="Imprimer">
<button class="print-btn" @click="printPreview" :title="`Imprimer (${isMac ? '⌘' : 'Ctrl'}+P)`">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"

View file

@ -6,6 +6,7 @@
class="tab"
:class="{ active: activeTab === 'document' }"
@click="activeTab = 'document'"
title="Ouvrir l'onglet Document (\)"
>
Document
</button>
@ -14,6 +15,7 @@
class="tab"
:class="{ active: activeTab === 'code' }"
@click="activeTab = 'code'"
title="Ouvrir l'onglet Code"
>
Code
</button>
@ -22,6 +24,7 @@
class="tab"
:class="{ active: activeTab === 'contenu' }"
@click="activeTab = 'contenu'"
title="Ouvrir l'onglet Contenu"
>
Contenu
</button>
@ -32,7 +35,7 @@
type="button"
class="close-button"
@click="activeTab = ''"
title="Fermer le panneau"
title="Fermer le panneau (\)"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -0,0 +1,372 @@
import { ref } from 'vue';
/**
* Composable for managing interactions with pages and elements in the iframe
* Handles hover effects, labels, and click events for both pages and content elements
*/
export function useIframeInteractions({ elementPopup, pagePopup }) {
// Page interaction state
const hoveredPage = ref(null);
const selectedPages = ref([]); // Pages with active border (when popup is open)
const hoveredElement = ref(null); // Currently hovered content element
const selectedElement = ref(null); // Selected element (when popup is open)
const EDGE_THRESHOLD = 30; // px from edge to trigger hover
// Text elements that can trigger ElementPopup (excluding containers, images, etc.)
const CONTENT_ELEMENTS = [
'P',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'BLOCKQUOTE',
'LI',
'A',
'STRONG',
'EM',
'B',
'I',
'U',
'CODE',
'PRE',
'FIGCAPTION',
];
// Check if mouse position is near the edges of a page element
const isNearPageEdge = (pageElement, mouseX, mouseY) => {
const rect = pageElement.getBoundingClientRect();
const nearLeft = mouseX >= rect.left && mouseX <= rect.left + EDGE_THRESHOLD;
const nearRight =
mouseX >= rect.right - EDGE_THRESHOLD && mouseX <= rect.right;
const nearTop = mouseY >= rect.top && mouseY <= rect.top + EDGE_THRESHOLD;
const nearBottom =
mouseY >= rect.bottom - EDGE_THRESHOLD && mouseY <= rect.bottom;
const inHorizontalRange = mouseY >= rect.top && mouseY <= rect.bottom;
const inVerticalRange = mouseX >= rect.left && mouseX <= rect.right;
return (
(nearLeft && inHorizontalRange) ||
(nearRight && inHorizontalRange) ||
(nearTop && inVerticalRange) ||
(nearBottom && inVerticalRange)
);
};
// Get all pages using the same template as the given page
const getPagesWithSameTemplate = (page, doc) => {
const pageType = page.getAttribute('data-page-type') || 'default';
const allPages = doc.querySelectorAll('.pagedjs_page');
return Array.from(allPages).filter(
(p) => (p.getAttribute('data-page-type') || 'default') === pageType
);
};
// Get selector for element (same logic as ElementPopup)
const getSelectorFromElement = (element) => {
if (element.id) {
return `#${element.id}`;
}
const tagName = element.tagName.toLowerCase();
// Filter out state classes (element-hovered, element-selected, page-hovered, page-selected)
const classes = Array.from(element.classList).filter(
(cls) =>
![
'element-hovered',
'element-selected',
'page-hovered',
'page-selected',
].includes(cls)
);
if (classes.length > 0) {
return `${tagName}.${classes[0]}`;
}
return tagName;
};
// Create and position element label on hover
const createElementLabel = (element) => {
const doc = element.ownerDocument;
const existingLabel = doc.querySelector('.element-hover-label');
if (existingLabel) {
existingLabel.remove();
}
const rect = element.getBoundingClientRect();
const scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop;
const scrollLeft = doc.documentElement.scrollLeft || doc.body.scrollLeft;
const label = doc.createElement('div');
label.className = 'element-hover-label';
label.textContent = getSelectorFromElement(element);
label.style.top = `${rect.top + scrollTop - 32}px`;
label.style.left = `${rect.left + scrollLeft}px`;
doc.body.appendChild(label);
return label;
};
// Remove element label
const removeElementLabel = (doc) => {
const label = doc.querySelector('.element-hover-label');
if (label) {
label.remove();
}
};
// Create and position page label on hover
const createPageLabel = (page) => {
const doc = page.ownerDocument;
const existingLabel = doc.querySelector('.page-hover-label');
if (existingLabel) {
existingLabel.remove();
}
const rect = page.getBoundingClientRect();
const scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop;
const scrollLeft = doc.documentElement.scrollLeft || doc.body.scrollLeft;
const templateName = page.getAttribute('data-page-type') || 'default';
const label = doc.createElement('div');
label.className = 'page-hover-label';
label.textContent = `@page ${templateName}`;
label.style.top = `${rect.top + scrollTop - 32}px`;
label.style.left = `${rect.left + scrollLeft}px`;
doc.body.appendChild(label);
return label;
};
// Remove page label
const removePageLabel = (doc) => {
const label = doc.querySelector('.page-hover-label');
if (label) {
label.remove();
}
};
// Check if element is a content element (or find closest content parent)
const getContentElement = (element) => {
let current = element;
while (current && current.tagName !== 'BODY') {
if (CONTENT_ELEMENTS.includes(current.tagName)) {
return current;
}
current = current.parentElement;
}
return null;
};
// Clear selection highlight from all selected pages
const clearSelectedPages = () => {
selectedPages.value.forEach((page) => {
page.classList.remove('page-selected');
});
selectedPages.value = [];
};
// Clear selected element highlight
const clearSelectedElement = () => {
if (selectedElement.value) {
selectedElement.value.classList.remove('element-selected');
const doc = selectedElement.value.ownerDocument;
removeElementLabel(doc);
selectedElement.value = null;
}
};
// Get count of similar elements (same tag)
const getSimilarElementsCount = (element, doc) => {
const tagName = element.tagName;
const allElements = doc.querySelectorAll(tagName);
return allElements.length;
};
// Handle mouse movement in iframe
const handleIframeMouseMove = (event) => {
const pages = event.target.ownerDocument.querySelectorAll('.pagedjs_page');
let foundPage = null;
// Check if hovering near page edge
for (const page of pages) {
if (isNearPageEdge(page, event.clientX, event.clientY)) {
foundPage = page;
break;
}
}
// Update page hover state
if (foundPage !== hoveredPage.value) {
// Remove highlight from previous page (only if not in selectedPages)
if (hoveredPage.value && !selectedPages.value.includes(hoveredPage.value)) {
hoveredPage.value.classList.remove('page-hovered');
}
// Remove previous page label
removePageLabel(event.target.ownerDocument);
// Add highlight to new page (only if not already selected)
if (foundPage && !selectedPages.value.includes(foundPage)) {
foundPage.classList.add('page-hovered');
createPageLabel(foundPage);
}
hoveredPage.value = foundPage;
}
// If not near page edge, check for content element hover
if (!foundPage) {
const contentElement = getContentElement(event.target);
const doc = event.target.ownerDocument;
if (contentElement !== hoveredElement.value) {
// Remove highlight from previous element (only if not selected)
if (
hoveredElement.value &&
hoveredElement.value !== selectedElement.value
) {
hoveredElement.value.classList.remove('element-hovered');
}
// Remove previous labels
removeElementLabel(doc);
removePageLabel(doc);
// Add highlight to new element (only if not already selected)
if (contentElement && contentElement !== selectedElement.value) {
contentElement.classList.add('element-hovered');
createElementLabel(contentElement);
}
hoveredElement.value = contentElement;
}
} else {
// Clear element hover when hovering page edge
if (
hoveredElement.value &&
hoveredElement.value !== selectedElement.value
) {
hoveredElement.value.classList.remove('element-hovered');
hoveredElement.value = null;
}
// Remove element label when hovering page edge
removeElementLabel(event.target.ownerDocument);
}
};
// Handle click in iframe
const handleIframeClick = (event) => {
const element = event.target;
// Check if clicking near a page edge
if (hoveredPage.value) {
event.stopPropagation();
// Clear previous selections
clearSelectedPages();
clearSelectedElement();
// Get all pages with same template and highlight them
const doc = event.target.ownerDocument;
const sameTemplatePages = getPagesWithSameTemplate(hoveredPage.value, doc);
sameTemplatePages.forEach((page) => {
page.classList.add('page-selected');
});
selectedPages.value = sameTemplatePages;
// Remove labels when opening popup
removePageLabel(doc);
removeElementLabel(doc);
pagePopup.value.open(hoveredPage.value, event, sameTemplatePages.length);
elementPopup.value.close();
return;
}
// Only show popup for elements inside the page template
const isInsidePage = element.closest('.pagedjs_page');
if (!isInsidePage) {
clearSelectedPages();
clearSelectedElement();
elementPopup.value.close();
pagePopup.value.close();
return;
}
// Only show ElementPopup for content elements, not divs
const contentElement = getContentElement(element);
if (!contentElement) {
clearSelectedPages();
clearSelectedElement();
elementPopup.value.close();
pagePopup.value.close();
return;
}
// Clear page selections
clearSelectedPages();
// If popup is already open and we're clicking another element, close it
if (elementPopup.value.visible) {
clearSelectedElement();
elementPopup.value.close();
pagePopup.value.close();
return;
}
// Clear previous element selection
clearSelectedElement();
// Remove hovered class from the element we're about to select
contentElement.classList.remove('element-hovered');
// Clear the hoveredElement ref if it's the same as what we're selecting
if (hoveredElement.value === contentElement) {
hoveredElement.value = null;
}
// Get document and remove labels when opening popup
const doc = event.target.ownerDocument;
removeElementLabel(doc);
removePageLabel(doc);
// Select the new element
selectedElement.value = contentElement;
contentElement.classList.add('element-selected');
// Get count of similar elements
const count = getSimilarElementsCount(contentElement, doc);
elementPopup.value.handleIframeClick(event, contentElement, count);
pagePopup.value.close();
};
// Handlers for popup close events
const handlePagePopupClose = () => {
clearSelectedPages();
};
const handleElementPopupClose = () => {
clearSelectedElement();
};
return {
// State
hoveredPage,
selectedPages,
hoveredElement,
selectedElement,
// Handlers
handleIframeMouseMove,
handleIframeClick,
handlePagePopupClose,
handleElementPopupClose,
// Utilities
clearSelectedPages,
clearSelectedElement,
};
}

View file

@ -0,0 +1,81 @@
import { onMounted, onUnmounted } from 'vue';
/**
* Composable for managing global keyboard shortcuts
* Handles Cmd/Ctrl+S (save), Cmd/Ctrl+P (print), Escape (close popups), \ (toggle panel)
*/
export function useKeyboardShortcuts({
stylesheetStore,
elementPopup,
pagePopup,
activeTab,
printPreview
}) {
// Detect platform for keyboard shortcut display
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
// Handle keyboard shortcuts
const handleKeyboardShortcut = (event) => {
// Escape key - close any open popup
if (event.key === 'Escape') {
if (elementPopup.value?.visible) {
elementPopup.value.close();
return;
}
if (pagePopup.value?.visible) {
pagePopup.value.close();
return;
}
}
// Backslash key - toggle editor panel
if (event.key === '\\') {
event.preventDefault();
// Toggle: if panel is closed, open to 'document' tab; if open, close it
activeTab.value = activeTab.value.length > 0 ? '' : 'document';
return;
}
// Cmd+P (Mac) or Ctrl+P (Windows/Linux) - print
if ((event.metaKey || event.ctrlKey) && event.key === 'p') {
event.preventDefault();
printPreview();
return;
}
// Cmd+S (Mac) or Ctrl+S (Windows/Linux) - save
if ((event.metaKey || event.ctrlKey) && event.key === 's') {
event.preventDefault();
// Only save if there are changes and not currently saving
if (stylesheetStore.isDirty && !stylesheetStore.isSaving) {
stylesheetStore.saveCustomCss();
}
}
};
// Attach keyboard listener to iframe document
const attachToIframe = (iframe) => {
if (iframe && iframe.contentDocument) {
iframe.contentDocument.addEventListener('keydown', handleKeyboardShortcut);
}
};
// Setup keyboard listeners on mount
onMounted(() => {
// Add keyboard shortcut listener to document (for when focus is outside iframe)
document.addEventListener('keydown', handleKeyboardShortcut);
});
// Cleanup on unmount
onUnmounted(() => {
// Clean up keyboard shortcut listener
document.removeEventListener('keydown', handleKeyboardShortcut);
});
return {
handleKeyboardShortcut,
attachToIframe,
isMac
};
}

View file

@ -0,0 +1,157 @@
import { ref, watch } from 'vue';
import Coloris from '@melloware/coloris';
/**
* Composable for managing preview rendering with double buffering
* Handles iframe transitions, scroll persistence, and PagedJS rendering
*/
export function usePreviewRenderer({
previewFrame1,
previewFrame2,
stylesheetStore,
narrativeStore,
handleIframeMouseMove,
handleIframeClick,
}) {
let savedScrollPercentage = 0;
const currentFrameIndex = ref(1); // 1 or 2, which iframe is currently visible
const isTransitioning = ref(false);
let keyboardShortcutHandler = null;
/**
* Render preview to hidden iframe with crossfade transition
*/
const renderPreview = async (shouldReloadFromFile = false) => {
if (isTransitioning.value) return;
isTransitioning.value = true;
// Determine which iframe is currently visible and which to render to
const visibleFrame =
currentFrameIndex.value === 1 ? previewFrame1.value : previewFrame2.value;
const hiddenFrame =
currentFrameIndex.value === 1 ? previewFrame2.value : previewFrame1.value;
if (!hiddenFrame) {
isTransitioning.value = 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;
savedScrollPercentage = maxScroll > 0 ? scrollTop / maxScroll : 0;
}
if (shouldReloadFromFile || !stylesheetStore.content) {
await stylesheetStore.loadStylesheet();
}
// Render to the hidden frame
hiddenFrame.srcdoc = `
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/assets/css/pagedjs-interface.css">
<style id="dynamic-styles">${stylesheetStore.content}</style>
<script src="https://unpkg.com/pagedjs/dist/paged.polyfill.js"><\/script>
</head>
<body>${document.getElementById('content-source').innerHTML}</body>
</html>
`;
hiddenFrame.onload = () => {
// Add event listeners for page and element interactions
hiddenFrame.contentDocument.addEventListener(
'mousemove',
handleIframeMouseMove
);
hiddenFrame.contentDocument.addEventListener('click', handleIframeClick);
// Add keyboard shortcut listener to iframe (for when focus is inside iframe)
if (keyboardShortcutHandler) {
hiddenFrame.contentDocument.addEventListener('keydown', keyboardShortcutHandler);
}
// Close Coloris when clicking in the iframe
hiddenFrame.contentDocument.addEventListener('click', () => {
Coloris.close();
});
// Wait for PagedJS to finish rendering
setTimeout(() => {
// Restore scroll position
const scrollHeight =
hiddenFrame.contentDocument.documentElement.scrollHeight;
const clientHeight = hiddenFrame.contentWindow.innerHeight;
const maxScroll = scrollHeight - clientHeight;
const targetScroll = savedScrollPercentage * maxScroll;
hiddenFrame.contentWindow.scrollTo(0, targetScroll);
// 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.value = false;
}, 200); // Match CSS transition duration
}, 50); // Small delay to ensure scroll is set
}, 200); // Wait for PagedJS
};
};
// Watch for stylesheet changes and re-render
watch(
() => stylesheetStore.content,
() => {
renderPreview();
}
);
// Re-render when narrative data changes
watch(
() => narrativeStore.data,
() => {
if (narrativeStore.data) {
renderPreview();
}
}
);
/**
* Set the keyboard shortcut handler (called after keyboard shortcuts composable is initialized)
*/
const setKeyboardShortcutHandler = (handler) => {
keyboardShortcutHandler = handler;
};
return {
renderPreview,
currentFrameIndex,
isTransitioning,
setKeyboardShortcutHandler,
};
}

View file

@ -0,0 +1,70 @@
/**
* Composable for handling print preview functionality
* Collects styles from iframe and triggers browser print dialog
*/
export function usePrintPreview(activeFrame) {
/**
* Print the PagedJS content from the active frame
*/
const printPreview = async () => {
const frame = activeFrame.value;
if (!frame || !frame.contentDocument) return;
const doc = frame.contentDocument;
// Collect all styles
let allStyles = '';
// Get inline <style> tags content
doc.querySelectorAll('style').forEach((style) => {
allStyles += style.innerHTML + '\n';
});
// Get rules from stylesheets
for (const sheet of doc.styleSheets) {
try {
for (const rule of sheet.cssRules) {
allStyles += rule.cssText + '\n';
}
} catch (e) {
// Cross-origin stylesheet, try to fetch it
if (sheet.href) {
try {
const response = await fetch(sheet.href);
const css = await response.text();
allStyles += css;
} catch (fetchError) {
console.warn('Could not fetch stylesheet:', sheet.href);
}
}
}
}
// Save current page content
const originalContent = document.body.innerHTML;
const originalStyles = document.head.innerHTML;
// Replace page content with iframe content
document.head.innerHTML = `
<meta charset="UTF-8">
<title>Impression</title>
<style>${allStyles}</style>
`;
document.body.innerHTML = doc.body.innerHTML;
// Print
window.print();
// Restore original content after print dialog closes
setTimeout(() => {
document.head.innerHTML = originalStyles;
document.body.innerHTML = originalContent;
// Re-mount Vue app would be needed, so we reload instead
window.location.reload();
}, 100);
};
return {
printPreview
};
}