geoproject-app/src/App.vue
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

732 lines
20 KiB
Vue

<script setup>
import PagedJsWrapper from './components/PagedJsWrapper.vue';
import EditorPanel from './components/editor/EditorPanel.vue';
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, onUnmounted, ref, watch, computed, provide } from 'vue';
import { useStylesheetStore } from './stores/stylesheet';
import { useNarrativeStore } from './stores/narrative';
import Coloris from '@melloware/coloris';
const stylesheetStore = useStylesheetStore();
const narrativeStore = useNarrativeStore();
const previewFrame1 = ref(null);
const previewFrame2 = ref(null);
const elementPopup = ref(null);
const pagePopup = ref(null);
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
let savedScrollPercentage = 0;
const currentFrameIndex = ref(1); // 1 or 2, which iframe is currently visible
const isTransitioning = ref(false);
const activeFrame = computed(() => {
return currentFrameIndex.value === 1
? previewFrame1.value
: previewFrame2.value;
});
// Handle keyboard shortcuts (Cmd/Ctrl+S for save)
const handleKeyboardShortcut = (event) => {
// Check for Cmd+S (Mac) or Ctrl+S (Windows/Linux)
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();
}
}
};
// 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);
// Add keyboard shortcut listener to iframe (for when focus is inside iframe)
hiddenFrame.contentDocument.addEventListener('keydown', handleKeyboardShortcut);
// 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);
};
onMounted(async () => {
// Load narrative data (narrativeUrl constructed from location, always present)
await narrativeStore.loadNarrative(location.href + '.json');
// Initialize stylesheet with custom CSS
if (narrativeStore.data) {
await stylesheetStore.initializeFromNarrative(narrativeStore.data);
}
// Add keyboard shortcut listener to document (for when focus is outside iframe)
document.addEventListener('keydown', handleKeyboardShortcut);
// Render preview after data is loaded
renderPreview(true);
});
onUnmounted(() => {
// Clean up keyboard shortcut listener
document.removeEventListener('keydown', handleKeyboardShortcut);
});
</script>
<template>
<div id="content-source" style="display: none">
<PagedJsWrapper />
</div>
<EditorPanel />
<iframe
ref="previewFrame1"
class="preview-frame"
:class="{ shifted: activeTab.length > 0 }"
></iframe>
<iframe
ref="previewFrame2"
class="preview-frame"
:class="{ shifted: activeTab.length > 0 }"
></iframe>
<PreviewLoader :isLoading="isTransitioning" :shifted="activeTab.length > 0" />
<SaveButton />
<ElementPopup
ref="elementPopup"
:iframeRef="activeFrame"
@close="handleElementPopupClose"
/>
<PagePopup
ref="pagePopup"
:iframeRef="activeFrame"
@close="handlePagePopupClose"
/>
<button class="print-btn" @click="printPreview" title="Imprimer">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M17 2H7V6H17V2ZM19 8H5C3.34 8 2 9.34 2 11V17H6V21H18V17H22V11C22 9.34 20.66 8 19 8ZM16 19H8V14H16V19ZM19 12C18.45 12 18 11.55 18 11C18 10.45 18.45 10 19 10C19.55 10 20 10.45 20 11C20 11.55 19.55 12 19 12Z"
/>
</svg>
</button>
</template>
<style>
.preview-frame {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
border: none;
margin-left: 0;
transform: scale(1) translateY(0);
height: 100vh;
transition: all 0.2s ease-in-out var(--curve);
}
.preview-frame.shifted {
margin-left: 17.55rem;
transform: scale(0.65) translateY(-40vh);
height: 155vh;
}
.preview-frame:nth-of-type(1) {
z-index: 1;
opacity: 1;
}
.preview-frame:nth-of-type(2) {
z-index: 0;
opacity: 0;
}
.print-btn {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
border: none;
background: var(--color-page-highlight);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
z-index: 1000;
}
.print-btn:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
}
.print-btn svg {
width: 1.5rem;
height: 1.5rem;
}
/* Coloris button - ensure it's clickable and visible */
:deep(.clr-field button) {
pointer-events: auto !important;
cursor: pointer !important;
position: relative;
z-index: 1;
}
/* Hide UI elements when printing */
@media print {
#editor-panel,
#element-popup,
#page-popup,
.preview-loader,
.print-btn {
display: none !important;
}
.preview-frame {
position: static !important;
margin-left: 0 !important;
transform: none !important;
width: 100% !important;
height: auto !important;
}
.preview-frame:not(:first-of-type) {
display: none !important;
}
}
</style>