diff --git a/src/App.vue b/src/App.vue index 7f40662..2027125 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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, onUnmounted, 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,568 +57,25 @@ const activeFrame = computed(() => { : previewFrame2.value; }); -// 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(); - } - } -}; - -// 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 = ` - - - - - -