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 = ` - - -
- - - diff --git a/src/composables/useIframeInteractions.js b/src/composables/useIframeInteractions.js new file mode 100644 index 0000000..6ff88c5 --- /dev/null +++ b/src/composables/useIframeInteractions.js @@ -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, + }; +} diff --git a/src/composables/useKeyboardShortcuts.js b/src/composables/useKeyboardShortcuts.js new file mode 100644 index 0000000..0a007a7 --- /dev/null +++ b/src/composables/useKeyboardShortcuts.js @@ -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 + }; +} diff --git a/src/composables/usePreviewRenderer.js b/src/composables/usePreviewRenderer.js new file mode 100644 index 0000000..331b995 --- /dev/null +++ b/src/composables/usePreviewRenderer.js @@ -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 = ` + + + + + +