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",
"version": "0.0.0",
"dependencies": {
"highlight.js": "^11.11.1",
"pagedjs": "^0.4.3",
"vue": "^3.5.24"
},
@ -1182,6 +1183,15 @@
"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": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View file

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

View file

@ -1,116 +1,193 @@
<script setup>
import PagedJsWrapper from './components/PagedJsWrapper.vue';
import { onMounted, ref, nextTick } from 'vue';
import { Previewer } from 'pagedjs';
import EditorPanel from './components/EditorPanel.vue';
import StylesheetViewer from './components/StylesheetViewer.vue';
import ElementPopup from './components/ElementPopup.vue';
import { onMounted, ref, watch } from 'vue';
const marginTop = ref(20);
let sourceHTML = '';
let editorUIElement = null;
// Main state
const previewFrame = ref(null);
const stylesheetContent = ref('');
const aboutFontSize = ref(2);
const aboutFontSizeUnit = ref('rem');
const renderPaged = async () => {
console.log('renderPaged called, marginTop:', marginTop.value);
// Popup state
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');
// 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 {
size: A4;
margin: ${marginTop.value}mm 15mm 26mm 15mm;
}
`;
// PagedJS print rules
const printStyles = `
h2 { break-before: page; }
// Detach UI before PagedJS processes body
if (editorUIElement && editorUIElement.parentNode) {
editorUIElement.remove();
}
// 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 = () => {
marginTop.value += 5;
console.log('Button clicked, new margin:', marginTop.value);
renderPaged();
};
onMounted(async () => {
await nextTick();
// Store source HTML before first render
const source = document.getElementById('print-source');
if (source) {
sourceHTML = source.innerHTML;
console.log('Source HTML stored:', sourceHTML.substring(0, 100));
}
// Store UI element reference
editorUIElement = document.getElementById('editor-ui');
renderPaged();
});
</script>
<template>
<!-- Source content -->
<div id="print-source">
<PagedJsWrapper />
</div>
<!-- Editor UI overlay -->
<div id="editor-ui">
<button id="increase-margin" @click="increaseMargin">+</button>
</div>
</template>
<style>
/* PagedJS print styles */
h2 {
break-before: page;
@page {
size: A4;
margin: 20mm 15mm 26mm 15mm;
}
@page {
@bottom-center {
content: string(title);
}
@bottom-center { content: string(title); }
}
.chapter > h2 {
string-set: title content(text);
.chapter > h2 { string-set: title content(text); }
`;
// CSS parsing utilities
const extractCssBlock = (css, selector) => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(`${escaped}\\s*{[^}]*}`, 'gi'));
return match ? match[0] : '';
};
const extractCssValue = (css, selector, property) => {
const regex = new RegExp(`${selector}\\s*{[^}]*${property}:\\s*([\\d.]+)(px|rem|em)`, 'i');
const match = css.match(regex);
return match ? { value: parseFloat(match[1]), unit: match[2] } : null;
};
const updateCssValue = (selector, property, value, unit) => {
const regex = new RegExp(`(${selector}\\s*{[^}]*${property}:\\s*)[\\d.]+(px|rem|em)`, 'gi');
stylesheetContent.value = stylesheetContent.value.replace(regex, `$1${value}${unit}`);
};
// Iframe style injection
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);
}
styleElement.textContent = stylesheetContent.value;
};
// 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>
<template>
<div id="content-source" style="display: none">
<PagedJsWrapper />
</div>
<EditorPanel
:fontSize="aboutFontSize"
:unit="aboutFontSizeUnit"
@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>
<style>
#preview-frame {
position: fixed;
top: 0;
left: 250px;
width: calc(100% - 600px);
height: 100vh;
border: none;
}
</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>
<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>
Lorem ipsum dolor sit amet consectetur adipiscing elit. Duis nibh tortor
</p>
@ -12,7 +39,7 @@
</section>
<section class="chapter">
<h2>Chapter 2</h2>
<h2 id="chapter-2">Chapter 2</h2>
<p>consectetur adipiscing elit</p>
</section>
@ -22,8 +49,6 @@
</section>
</template>
<script setup>
</script>
<script setup></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>