geoproject-app/src/App.vue
isUnknown 12595c5454 feat: add purple highlight for content elements
- Add --color-purple variable (#7136ff)
- Hover: dashed purple outline on content elements
- Selected: purple background with 30% opacity
- ElementPopup: purple label and instance count
- Track hovered and selected elements separately from pages
- Clear element selection when popup closes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 14:15:06 +01:00

517 lines
15 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 { onMounted, ref, watch, computed, provide } from 'vue';
import { useStylesheetStore } from './stores/stylesheet';
import Coloris from '@melloware/coloris';
const stylesheetStore = useStylesheetStore();
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
const PAGE_HIGHLIGHT_COLOR = '#ff8a50';
const ELEMENT_HIGHLIGHT_COLOR = '#7136ff';
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;
});
// 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
);
};
// 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.style.outline = '';
}
// Add highlight to new page (only if not already selected)
if (foundPage && !selectedPages.value.includes(foundPage)) {
foundPage.style.outline = `2px solid ${PAGE_HIGHLIGHT_COLOR}50`;
}
hoveredPage.value = foundPage;
}
// If not near page edge, check for content element hover
if (!foundPage) {
const contentElement = getContentElement(event.target);
if (contentElement !== hoveredElement.value) {
// Remove highlight from previous element (only if not selected)
if (hoveredElement.value && hoveredElement.value !== selectedElement.value) {
hoveredElement.value.style.outline = '';
}
// Add highlight to new element (only if not already selected)
if (contentElement && contentElement !== selectedElement.value) {
contentElement.style.outline = `2px dashed ${ELEMENT_HIGHLIGHT_COLOR}`;
}
hoveredElement.value = contentElement;
}
} else {
// Clear element hover when hovering page edge
if (hoveredElement.value && hoveredElement.value !== selectedElement.value) {
hoveredElement.value.style.outline = '';
hoveredElement.value = null;
}
}
};
// Clear selection highlight from all selected pages
const clearSelectedPages = () => {
selectedPages.value.forEach((page) => {
page.style.outline = '';
});
selectedPages.value = [];
};
// Content elements that can trigger ElementPopup
const CONTENT_ELEMENTS = [
'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
'IMG', 'FIGURE', 'FIGCAPTION',
'UL', 'OL', 'LI',
'BLOCKQUOTE', 'PRE', 'CODE',
'TABLE', 'THEAD', 'TBODY', 'TR', 'TH', 'TD',
'A', 'SPAN', 'STRONG', 'EM', 'B', 'I', 'U',
'ARTICLE', 'SECTION', 'ASIDE', 'HEADER', 'FOOTER', 'NAV'
];
// 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.style.outline = '';
selectedElement.value.style.backgroundColor = '';
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.style.outline = `2px solid ${PAGE_HIGHLIGHT_COLOR}`;
});
selectedPages.value = sameTemplatePages;
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();
// Clear previous element selection
clearSelectedElement();
// Select the new element
selectedElement.value = contentElement;
contentElement.style.outline = '';
contentElement.style.backgroundColor = `${ELEMENT_HIGHLIGHT_COLOR}4D`; // 30% opacity
// Get count of similar elements
const doc = event.target.ownerDocument;
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();
}
);
// 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(() => renderPreview(true));
</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" />
<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;
}
/* 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>