feat: iframe-based PagedJS preview with reactive CSS editor

- Isolate PagedJS in iframe to avoid DOM/CSS conflicts
- Add EditorPanel for global CSS controls
- Add StylesheetViewer with highlight.js syntax highlighting
- Add ElementPopup for element-specific CSS editing
- CSS modifications update preview reactively
- Support px/rem/em units

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
isUnknown 2025-11-24 16:51:55 +01:00
parent dc0ae26464
commit f51c77cefe
9 changed files with 541 additions and 109 deletions

10
package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "geoproject", "name": "geoproject",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"highlight.js": "^11.11.1",
"pagedjs": "^0.4.3", "pagedjs": "^0.4.3",
"vue": "^3.5.24" "vue": "^3.5.24"
}, },
@ -1182,6 +1183,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View file

@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"highlight.js": "^11.11.1",
"pagedjs": "^0.4.3", "pagedjs": "^0.4.3",
"vue": "^3.5.24" "vue": "^3.5.24"
}, },

View file

@ -1,116 +1,193 @@
<script setup> <script setup>
import PagedJsWrapper from './components/PagedJsWrapper.vue'; import PagedJsWrapper from './components/PagedJsWrapper.vue';
import { onMounted, ref, nextTick } from 'vue'; import EditorPanel from './components/EditorPanel.vue';
import { Previewer } from 'pagedjs'; import StylesheetViewer from './components/StylesheetViewer.vue';
import ElementPopup from './components/ElementPopup.vue';
import { onMounted, ref, watch } from 'vue';
const marginTop = ref(20); // Main state
let sourceHTML = ''; const previewFrame = ref(null);
let editorUIElement = null; const stylesheetContent = ref('');
const aboutFontSize = ref(2);
const aboutFontSizeUnit = ref('rem');
const renderPaged = async () => { // Popup state
console.log('renderPaged called, marginTop:', marginTop.value); const popupVisible = ref(false);
const popupPosition = ref({ x: 0, y: 0 });
const popupSelector = ref('');
const popupElementCss = ref('');
const popupFontSize = ref(null);
const popupFontSizeUnit = ref('rem');
// PagedJS print rules
const printStyles = `
h2 { break-before: page; }
// Update @page margin dynamically
let styleEl = document.getElementById('dynamic-page-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'dynamic-page-style';
document.head.appendChild(styleEl);
}
styleEl.textContent = `
@page { @page {
size: A4; size: A4;
margin: ${marginTop.value}mm 15mm 26mm 15mm; margin: 20mm 15mm 26mm 15mm;
} }
@page {
@bottom-center { content: string(title); }
}
.chapter > h2 { string-set: title content(text); }
`; `;
// Detach UI before PagedJS processes body // CSS parsing utilities
if (editorUIElement && editorUIElement.parentNode) { const extractCssBlock = (css, selector) => {
editorUIElement.remove(); const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} const match = css.match(new RegExp(`${escaped}\\s*{[^}]*}`, 'gi'));
return match ? match[0] : '';
// Remove previous render
const existingPages = document.querySelector('.pagedjs_pages');
if (existingPages) existingPages.remove();
// Restore source content to body
const printSource = document.getElementById('print-source');
if (printSource && sourceHTML) {
printSource.innerHTML = sourceHTML;
printSource.style.display = 'block';
}
// Render
const paged = new Previewer();
try {
const flow = await paged.preview();
console.log('Rendered', flow.total, 'pages.');
} catch (e) {
console.error('PagedJS error:', e);
}
// Re-inject UI after PagedJS transforms the DOM
if (editorUIElement) {
document.body.appendChild(editorUIElement);
// Apply styles inline since PagedJS removes stylesheets
editorUIElement.style.cssText = `
position: fixed !important;
bottom: 1rem !important;
left: 1rem !important;
z-index: 9999 !important;
background: white;
padding: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
`;
}
}; };
const increaseMargin = () => { const extractCssValue = (css, selector, property) => {
marginTop.value += 5; const regex = new RegExp(`${selector}\\s*{[^}]*${property}:\\s*([\\d.]+)(px|rem|em)`, 'i');
console.log('Button clicked, new margin:', marginTop.value); const match = css.match(regex);
renderPaged(); return match ? { value: parseFloat(match[1]), unit: match[2] } : null;
}; };
onMounted(async () => { const updateCssValue = (selector, property, value, unit) => {
await nextTick(); const regex = new RegExp(`(${selector}\\s*{[^}]*${property}:\\s*)[\\d.]+(px|rem|em)`, 'gi');
// Store source HTML before first render stylesheetContent.value = stylesheetContent.value.replace(regex, `$1${value}${unit}`);
const source = document.getElementById('print-source'); };
if (source) {
sourceHTML = source.innerHTML; // Iframe style injection
console.log('Source HTML stored:', sourceHTML.substring(0, 100)); const injectStylesToIframe = () => {
const iframe = previewFrame.value;
if (!iframe?.contentDocument) return;
let styleElement = iframe.contentDocument.getElementById('dynamic-styles');
if (!styleElement) {
styleElement = iframe.contentDocument.createElement('style');
styleElement.id = 'dynamic-styles';
iframe.contentDocument.head.appendChild(styleElement);
} }
// Store UI element reference styleElement.textContent = stylesheetContent.value;
editorUIElement = document.getElementById('editor-ui'); };
renderPaged();
// Popup handlers
const handleIframeClick = (event) => {
const element = event.target;
if (element.tagName === 'BODY' || element.tagName === 'HTML') {
popupVisible.value = false;
return;
}
const selector = element.id
? `#${element.id}`
: `.${element.className.split(' ')[0]}`;
popupSelector.value = selector;
popupElementCss.value = extractCssBlock(stylesheetContent.value, selector);
const fontSizeData = extractCssValue(stylesheetContent.value, selector, 'font-size');
popupFontSize.value = fontSizeData?.value ?? null;
popupFontSizeUnit.value = fontSizeData?.unit ?? 'rem';
const rect = element.getBoundingClientRect();
const iframeRect = previewFrame.value.getBoundingClientRect();
popupPosition.value = {
x: iframeRect.left + rect.left,
y: iframeRect.top + rect.bottom + 5
};
popupVisible.value = true;
};
const closePopup = () => {
popupVisible.value = false;
};
const updatePopupFontSize = (newValue) => {
updateCssValue(popupSelector.value, 'font-size', newValue, popupFontSizeUnit.value);
popupFontSize.value = newValue;
popupElementCss.value = extractCssBlock(stylesheetContent.value, popupSelector.value);
};
// Watchers
watch(aboutFontSize, (newVal) => {
updateCssValue('.about', 'font-size', newVal, aboutFontSizeUnit.value);
}); });
watch(stylesheetContent, injectStylesToIframe);
// Initial render
const renderPreview = async () => {
const iframe = previewFrame.value;
if (!iframe) return;
const response = await fetch('/assets/css/stylesheet.css');
stylesheetContent.value = await response.text();
const initialFontSize = extractCssValue(stylesheetContent.value, '.about', 'font-size');
if (initialFontSize) {
aboutFontSize.value = initialFontSize.value;
aboutFontSizeUnit.value = initialFontSize.unit;
}
const contentSource = document.getElementById('content-source');
const iframeDoc = iframe.contentDocument;
iframeDoc.open();
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/assets/css/pagedjs-interface.css">
<style id="dynamic-styles">${stylesheetContent.value}</style>
<style>${printStyles}</style>
<script src="https://unpkg.com/pagedjs/dist/paged.polyfill.js"><\/script>
</head>
<body>${contentSource.innerHTML}</body>
</html>
`);
iframeDoc.close();
iframe.onload = () => {
iframe.contentDocument.addEventListener('click', handleIframeClick);
};
};
onMounted(renderPreview);
</script> </script>
<template> <template>
<!-- Source content --> <div id="content-source" style="display: none">
<div id="print-source">
<PagedJsWrapper /> <PagedJsWrapper />
</div> </div>
<!-- Editor UI overlay --> <EditorPanel
<div id="editor-ui"> :fontSize="aboutFontSize"
<button id="increase-margin" @click="increaseMargin">+</button> :unit="aboutFontSizeUnit"
</div> @update:fontSize="aboutFontSize = $event"
/>
<iframe ref="previewFrame" id="preview-frame"></iframe>
<StylesheetViewer :stylesheet="stylesheetContent" />
<ElementPopup
:visible="popupVisible"
:position="popupPosition"
:selector="popupSelector"
:elementCss="popupElementCss"
:currentFontSize="popupFontSize"
:fontSizeUnit="popupFontSizeUnit"
@close="closePopup"
@update:fontSize="updatePopupFontSize"
/>
</template> </template>
<style> <style>
/* PagedJS print styles */ #preview-frame {
h2 { position: fixed;
break-before: page; top: 0;
} left: 250px;
width: calc(100% - 600px);
@page { height: 100vh;
@bottom-center { border: none;
content: string(title);
}
}
.chapter > h2 {
string-set: title content(text);
} }
</style> </style>

View file

@ -0,0 +1,19 @@
/* PagedJS print styles */
@page {
size: A4;
margin: 20mm 15mm 26mm 15mm;
}
h2 {
break-before: page;
}
@page {
@bottom-center {
content: string(title);
}
}
.chapter > h2 {
string-set: title content(text);
}

View file

@ -0,0 +1,72 @@
<template>
<aside id="editor-panel">
<h3>Éditeur</h3>
<div class="control">
<label>Font-size .about</label>
<input
type="number"
step="0.1"
v-model.number="localFontSize"
@input="updateFontSize"
/>
<span>{{ unit }}</span>
</div>
</aside>
</template>
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
fontSize: Number,
unit: String,
});
const emit = defineEmits(['update:fontSize']);
const localFontSize = ref(props.fontSize);
watch(
() => props.fontSize,
(newVal) => {
localFontSize.value = newVal;
}
);
const updateFontSize = () => {
emit('update:fontSize', localFontSize.value);
};
</script>
<style scoped>
#editor-panel {
position: fixed;
top: 0;
left: 0;
width: 250px;
height: 100vh;
background: #f5f5f5;
padding: 1rem;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
overflow-y: auto;
}
h3 {
margin-top: 0;
}
.control {
margin-bottom: 1rem;
}
.control label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
}
.control input {
width: 80px;
padding: 0.25rem;
}
</style>

View file

@ -0,0 +1,152 @@
<template>
<div
v-if="visible"
id="element-popup"
:style="{ top: position.y + 'px', left: position.x + 'px' }"
>
<div class="popup-header">
<span>{{ selector }}</span>
<button class="close-btn" @click="$emit('close')">×</button>
</div>
<div class="popup-body">
<div class="popup-controls">
<div class="control" v-if="currentFontSize !== null">
<label>font-size</label>
<input
type="number"
step="0.1"
:value="currentFontSize"
@input="$emit('update:fontSize', parseFloat($event.target.value))"
/>
<span>{{ fontSizeUnit }}</span>
</div>
<p v-else class="no-styles">Aucun style éditable</p>
</div>
<div class="popup-css">
<pre><code class="hljs language-css" v-html="highlightedCss"></code></pre>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import hljs from 'highlight.js/lib/core';
import css from 'highlight.js/lib/languages/css';
import 'highlight.js/styles/github.css';
hljs.registerLanguage('css', css);
const props = defineProps({
visible: Boolean,
position: Object,
selector: String,
elementCss: String,
currentFontSize: Number,
fontSizeUnit: String
});
defineEmits(['close', 'update:fontSize']);
const highlightedCss = computed(() => {
if (!props.elementCss) return '<span class="no-css">Aucun style défini</span>';
return hljs.highlight(props.elementCss, { language: 'css' }).value;
});
</script>
<style scoped>
#element-popup {
position: fixed;
background: white;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
min-width: 400px;
max-width: 500px;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #eee;
font-size: 0.875rem;
font-weight: bold;
background: #f5f5f5;
}
.close-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0;
}
.popup-body {
display: flex;
gap: 1px;
background: #eee;
}
.popup-controls {
flex: 1;
padding: 0.75rem;
background: white;
}
.popup-css {
flex: 1;
padding: 0.75rem;
background: #1e1e1e;
max-height: 200px;
overflow-y: auto;
}
.popup-css pre {
margin: 0;
font-size: 0.75rem;
line-height: 1.4;
}
.popup-css code {
background: transparent;
color: #fff;
}
.control {
margin-bottom: 0.5rem;
}
.control label {
display: block;
font-size: 0.75rem;
margin-bottom: 0.25rem;
color: #666;
}
.control input {
width: 60px;
padding: 0.25rem;
font-size: 0.875rem;
}
.control span {
font-size: 0.75rem;
color: #666;
margin-left: 0.25rem;
}
.no-styles {
font-size: 0.75rem;
color: #999;
margin: 0;
}
.no-css {
color: #666;
font-style: italic;
}
</style>

View file

@ -1,6 +1,33 @@
<template> <template>
<section class="chapter"> <section class="chapter">
<h2>About</h2> <p class="about">
Accumsan arcu tristique purus eros pellentesque rutrum hendrerit phasellus
euismod maximus rutrum vivamus dolor erat sollicitudin ut quam metus
gravida proin nisl lacus sed lacus. Morbi fusce eros euismod varius ex
ipsum erat lacus arcu nunc cursus a scelerisque tristique ipsum congue
adipiscing suspendisse facilisis dolor morbi nulla orci massa. Vivamus nec
nisl amet eros consectetur ut consectetur phasellus maecenas morbi felis
pellentesque pellentesque ipsum ut a arcu sem facilisis eros tempus eu
euismod sollicitudin. Nisl facilisis tempus tempus placerat lorem sed leo
sit a leo tempus amet tristique felis gravida morbi congue aliquam nunc
maximus ipsum ex nisl a. Leo felis leo gravida fusce lacus orci
condimentum morbi eros amet portaest sit quam a hendrerit fusce quam
tristique arcu id maximus nunc fusce suspendisse.
</p>
<p class="about">
Accumsan arcu tristique purus eros pellentesque rutrum hendrerit phasellus
euismod maximus rutrum vivamus dolor erat sollicitudin ut quam metus
gravida proin nisl lacus sed lacus. Morbi fusce eros euismod varius ex
ipsum erat lacus arcu nunc cursus a scelerisque tristique ipsum congue
adipiscing suspendisse facilisis dolor morbi nulla orci massa. Vivamus nec
nisl amet eros consectetur ut consectetur phasellus maecenas morbi felis
pellentesque pellentesque ipsum ut a arcu sem facilisis eros tempus eu
euismod sollicitudin. Nisl facilisis tempus tempus placerat lorem sed leo
sit a leo tempus amet tristique felis gravida morbi congue aliquam nunc
maximus ipsum ex nisl a. Leo felis leo gravida fusce lacus orci
condimentum morbi eros amet portaest sit quam a hendrerit fusce quam
tristique arcu id maximus nunc fusce suspendisse.
</p>
<p> <p>
Lorem ipsum dolor sit amet consectetur adipiscing elit. Duis nibh tortor Lorem ipsum dolor sit amet consectetur adipiscing elit. Duis nibh tortor
</p> </p>
@ -12,7 +39,7 @@
</section> </section>
<section class="chapter"> <section class="chapter">
<h2>Chapter 2</h2> <h2 id="chapter-2">Chapter 2</h2>
<p>consectetur adipiscing elit</p> <p>consectetur adipiscing elit</p>
</section> </section>
@ -22,8 +49,6 @@
</section> </section>
</template> </template>
<script setup> <script setup></script>
</script>
<style> <style></style>
</style>

View file

@ -0,0 +1,22 @@
<template>
<aside id="side-panel">
<button @click="fontSize++">Action ({{ fontSize }}px)</button>
</aside>
</template>
<script setup>
const fontSize = defineModel('fontSize');
</script>
<style>
#side-panel {
position: fixed;
top: 0;
right: 0;
width: 300px;
height: 100vh;
background: white;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
padding: 1rem;
}
</style>

View file

@ -0,0 +1,54 @@
<template>
<aside id="stylesheet-viewer">
<h3>Stylesheet</h3>
<pre><code class="hljs language-css" v-html="highlightedCss"></code></pre>
</aside>
</template>
<script setup>
import { computed } from 'vue';
import hljs from 'highlight.js/lib/core';
import css from 'highlight.js/lib/languages/css';
import 'highlight.js/styles/github.css';
hljs.registerLanguage('css', css);
const props = defineProps({
stylesheet: String
});
const highlightedCss = computed(() => {
if (!props.stylesheet) return '';
return hljs.highlight(props.stylesheet, { language: 'css' }).value;
});
</script>
<style scoped>
#stylesheet-viewer {
position: fixed;
top: 0;
right: 0;
width: 350px;
height: 100vh;
background: #1e1e1e;
padding: 1rem;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
overflow-y: auto;
color: #fff;
}
h3 {
margin-top: 0;
color: #fff;
}
pre {
margin: 0;
font-size: 0.75rem;
line-height: 1.4;
}
code {
background: transparent;
}
</style>