Merge iframe: complete web-to-print architecture with Pinia

Merges the iframe branch which implements a clean, scalable architecture:
- Iframe-based PagedJS preview with reactive CSS editing
- Pinia store for centralized stylesheet management
- Autonomous components (EditorPanel, ElementPopup) with no prop drilling
- CSS parsing utilities with proper selector escaping
- Clean separation of concerns with functional domain organization

🤖 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 18:20:47 +01:00
commit 913e41190c
10 changed files with 383 additions and 160 deletions

136
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"pagedjs": "^0.4.3", "pagedjs": "^0.4.3",
"pinia": "^3.0.4",
"vue": "^3.5.24" "vue": "^3.5.24"
}, },
"devDependencies": { "devDependencies": {
@ -920,6 +921,39 @@
"@vue/shared": "3.5.25" "@vue/shared": "3.5.25"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.25", "version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz",
@ -970,12 +1004,36 @@
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/birpc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz",
"integrity": "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/clear-cut": { "node_modules/clear-cut": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/clear-cut/-/clear-cut-2.0.2.tgz", "resolved": "https://registry.npmjs.org/clear-cut/-/clear-cut-2.0.2.tgz",
"integrity": "sha512-WVgn/gSejQ+0aoR8ucbKIdo6icduPZW6AbWwyUmAUgxy63rUYjwa5rj/HeoNPhf0/XPrl82X8bO/hwBkSmsFtg==", "integrity": "sha512-WVgn/gSejQ+0aoR8ucbKIdo6icduPZW6AbWwyUmAUgxy63rUYjwa5rj/HeoNPhf0/XPrl82X8bO/hwBkSmsFtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"license": "MIT",
"dependencies": {
"is-what": "^5.2.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/core-js": { "node_modules/core-js": {
"version": "2.6.12", "version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
@ -1192,6 +1250,24 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"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",
@ -1207,6 +1283,12 @@
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -1244,6 +1326,12 @@
"event-emitter": "^0.3.5" "event-emitter": "^0.3.5"
} }
}, },
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -1263,6 +1351,27 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -1297,6 +1406,12 @@
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
@ -1357,6 +1472,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/superjson": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz",
"integrity": "sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==",
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View file

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"pagedjs": "^0.4.3", "pagedjs": "^0.4.3",
"pinia": "^3.0.4",
"vue": "^3.5.24" "vue": "^3.5.24"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,7 +1,7 @@
.about {
font-size: 1rem;
}
#chapter-2 { #chapter-2 {
font-size: 2rem; font-size: 2rem;
} }
p {
font-size: 1rem;
}

View file

@ -4,22 +4,16 @@ import EditorPanel from './components/EditorPanel.vue';
import StylesheetViewer from './components/StylesheetViewer.vue'; import StylesheetViewer from './components/StylesheetViewer.vue';
import ElementPopup from './components/ElementPopup.vue'; import ElementPopup from './components/ElementPopup.vue';
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { useStylesheetStore } from './stores/stylesheet';
// Main state // ============================================================================
const previewFrame = ref(null); // Store
const stylesheetContent = ref(''); // ============================================================================
const aboutFontSize = ref(2); const stylesheetStore = useStylesheetStore();
const aboutFontSizeUnit = ref('rem');
// Popup state // ============================================================================
const popupVisible = ref(false); // PagedJS configuration
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 = ` const printStyles = `
h2 { break-before: page; } h2 { break-before: page; }
@ -35,25 +29,11 @@ h2 { break-before: page; }
.chapter > h2 { string-set: title content(text); } .chapter > h2 { string-set: title content(text); }
`; `;
// CSS parsing utilities // ============================================================================
const extractCssBlock = (css, selector) => { // Iframe preview
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // ============================================================================
const match = css.match(new RegExp(`${escaped}\\s*{[^}]*}`, 'gi')); const previewFrame = ref(null);
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 injectStylesToIframe = () => {
const iframe = previewFrame.value; const iframe = previewFrame.value;
if (!iframe?.contentDocument) return; if (!iframe?.contentDocument) return;
@ -64,93 +44,39 @@ const injectStylesToIframe = () => {
styleElement.id = 'dynamic-styles'; styleElement.id = 'dynamic-styles';
iframe.contentDocument.head.appendChild(styleElement); iframe.contentDocument.head.appendChild(styleElement);
} }
styleElement.textContent = stylesheetContent.value; styleElement.textContent = stylesheetStore.content;
}; };
// Popup handlers const elementPopup = ref(null);
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 renderPreview = async () => {
const iframe = previewFrame.value; const iframe = previewFrame.value;
if (!iframe) return; if (!iframe) return;
const response = await fetch('/assets/css/stylesheet.css'); await stylesheetStore.loadStylesheet();
stylesheetContent.value = await response.text();
const initialFontSize = extractCssValue(stylesheetContent.value, '.about', 'font-size'); iframe.srcdoc = `
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> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="stylesheet" href="/assets/css/pagedjs-interface.css"> <link rel="stylesheet" href="/assets/css/pagedjs-interface.css">
<style id="dynamic-styles">${stylesheetContent.value}</style> <style id="dynamic-styles">${stylesheetStore.content}</style>
<style>${printStyles}</style> <style>${printStyles}</style>
<script src="https://unpkg.com/pagedjs/dist/paged.polyfill.js"><\/script> <script src="https://unpkg.com/pagedjs/dist/paged.polyfill.js"><\/script>
</head> </head>
<body>${contentSource.innerHTML}</body> <body>${document.getElementById('content-source').innerHTML}</body>
</html> </html>
`); `;
iframeDoc.close();
iframe.onload = () => { iframe.onload = () => {
iframe.contentDocument.addEventListener('click', handleIframeClick); iframe.contentDocument.addEventListener('click', elementPopup.value.handleIframeClick);
}; };
}; };
// ============================================================================
// Lifecycle
// ============================================================================
watch(() => stylesheetStore.content, injectStylesToIframe);
onMounted(renderPreview); onMounted(renderPreview);
</script> </script>
@ -159,26 +85,13 @@ onMounted(renderPreview);
<PagedJsWrapper /> <PagedJsWrapper />
</div> </div>
<EditorPanel <EditorPanel />
:fontSize="aboutFontSize"
:unit="aboutFontSizeUnit"
@update:fontSize="aboutFontSize = $event"
/>
<iframe ref="previewFrame" id="preview-frame"></iframe> <iframe ref="previewFrame" id="preview-frame"></iframe>
<StylesheetViewer :stylesheet="stylesheetContent" /> <StylesheetViewer :stylesheet="stylesheetStore.content" />
<ElementPopup <ElementPopup ref="elementPopup" :iframeRef="previewFrame" />
:visible="popupVisible"
:position="popupPosition"
:selector="popupSelector"
:elementCss="popupElementCss"
:currentFontSize="popupFontSize"
:fontSizeUnit="popupFontSizeUnit"
@close="closePopup"
@update:fontSize="updatePopupFontSize"
/>
</template> </template>
<style> <style>

View file

@ -2,39 +2,36 @@
<aside id="editor-panel"> <aside id="editor-panel">
<h3>Éditeur</h3> <h3>Éditeur</h3>
<div class="control"> <div class="control">
<label>Font-size .about</label> <label>Taille de police des paragraphes</label>
<input <input
type="number" type="number"
step="0.1" step="0.1"
v-model.number="localFontSize" :value="fontSizeData?.value ?? 1"
@input="updateFontSize" @input="updateFontSize(parseFloat($event.target.value))"
/> />
<span>{{ unit }}</span> <span>{{ fontSizeData?.unit ?? 'rem' }}</span>
</div> </div>
</aside> </aside>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue'; import { computed } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
const props = defineProps({ const stylesheetStore = useStylesheetStore();
fontSize: Number,
unit: String, const fontSizeData = computed(() => {
return stylesheetStore.extractValue('p', 'font-size');
}); });
const emit = defineEmits(['update:fontSize']); const updateFontSize = (value) => {
if (!fontSizeData.value) return;
const localFontSize = ref(props.fontSize); stylesheetStore.updateProperty(
'p',
watch( 'font-size',
() => props.fontSize, value,
(newVal) => { fontSizeData.value.unit
localFontSize.value = newVal; );
}
);
const updateFontSize = () => {
emit('update:fontSize', localFontSize.value);
}; };
</script> </script>

View file

@ -6,19 +6,19 @@
> >
<div class="popup-header"> <div class="popup-header">
<span>{{ selector }}</span> <span>{{ selector }}</span>
<button class="close-btn" @click="$emit('close')">×</button> <button class="close-btn" @click="close">×</button>
</div> </div>
<div class="popup-body"> <div class="popup-body">
<div class="popup-controls"> <div class="popup-controls">
<div class="control" v-if="currentFontSize !== null"> <div class="control" v-if="fontSizeData">
<label>font-size</label> <label>font-size</label>
<input <input
type="number" type="number"
step="0.1" step="0.1"
:value="currentFontSize" :value="fontSizeData.value"
@input="$emit('update:fontSize', parseFloat($event.target.value))" @input="updateFontSize(parseFloat($event.target.value))"
/> />
<span>{{ fontSizeUnit }}</span> <span>{{ fontSizeData.unit }}</span>
</div> </div>
<p v-else class="no-styles">Aucun style éditable</p> <p v-else class="no-styles">Aucun style éditable</p>
</div> </div>
@ -30,28 +30,86 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { ref, computed } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
import hljs from 'highlight.js/lib/core'; import hljs from 'highlight.js/lib/core';
import css from 'highlight.js/lib/languages/css'; import css from 'highlight.js/lib/languages/css';
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
hljs.registerLanguage('css', css); hljs.registerLanguage('css', css);
const stylesheetStore = useStylesheetStore();
const props = defineProps({ const props = defineProps({
visible: Boolean, iframeRef: Object
position: Object,
selector: String,
elementCss: String,
currentFontSize: Number,
fontSizeUnit: String
}); });
defineEmits(['close', 'update:fontSize']); const visible = ref(false);
const position = ref({ x: 0, y: 0 });
const selector = ref('');
const getSelectorFromElement = (element) => {
return element.id
? `#${element.id}`
: `.${element.className.split(' ')[0]}`;
};
const calculatePosition = (element) => {
const rect = element.getBoundingClientRect();
const iframeRect = props.iframeRef.getBoundingClientRect();
return {
x: iframeRect.left + rect.left,
y: iframeRect.top + rect.bottom + 5,
};
};
const open = (element) => {
selector.value = getSelectorFromElement(element);
position.value = calculatePosition(element);
visible.value = true;
};
const close = () => {
visible.value = false;
};
const handleIframeClick = (event) => {
const element = event.target;
if (element.tagName === 'BODY' || element.tagName === 'HTML') {
close();
return;
}
open(element);
};
const elementCss = computed(() => {
if (!selector.value) return '';
return stylesheetStore.extractBlock(selector.value);
});
const fontSizeData = computed(() => {
if (!selector.value) return null;
return stylesheetStore.extractValue(selector.value, 'font-size');
});
const highlightedCss = computed(() => { const highlightedCss = computed(() => {
if (!props.elementCss) return '<span class="no-css">Aucun style défini</span>'; if (!elementCss.value) return '<span class="no-css">Aucun style défini</span>';
return hljs.highlight(props.elementCss, { language: 'css' }).value; return hljs.highlight(elementCss.value, { language: 'css' }).value;
}); });
const updateFontSize = (value) => {
if (!fontSizeData.value) return;
stylesheetStore.updateProperty(
selector.value,
'font-size',
value,
fontSizeData.value.unit
);
};
defineExpose({ handleIframeClick });
</script> </script>
<style scoped> <style scoped>

View file

@ -1,6 +1,6 @@
<template> <template>
<section class="chapter"> <section class="chapter">
<p class="about"> <p>
Accumsan arcu tristique purus eros pellentesque rutrum hendrerit phasellus Accumsan arcu tristique purus eros pellentesque rutrum hendrerit phasellus
euismod maximus rutrum vivamus dolor erat sollicitudin ut quam metus euismod maximus rutrum vivamus dolor erat sollicitudin ut quam metus
gravida proin nisl lacus sed lacus. Morbi fusce eros euismod varius ex gravida proin nisl lacus sed lacus. Morbi fusce eros euismod varius ex
@ -14,7 +14,7 @@
condimentum morbi eros amet portaest sit quam a hendrerit fusce quam condimentum morbi eros amet portaest sit quam a hendrerit fusce quam
tristique arcu id maximus nunc fusce suspendisse. tristique arcu id maximus nunc fusce suspendisse.
</p> </p>
<p class="about"> <p>
Accumsan arcu tristique purus eros pellentesque rutrum hendrerit phasellus Accumsan arcu tristique purus eros pellentesque rutrum hendrerit phasellus
euismod maximus rutrum vivamus dolor erat sollicitudin ut quam metus euismod maximus rutrum vivamus dolor erat sollicitudin ut quam metus
gravida proin nisl lacus sed lacus. Morbi fusce eros euismod varius ex gravida proin nisl lacus sed lacus. Morbi fusce eros euismod varius ex

View file

@ -1,5 +1,8 @@
import { createApp } from 'vue' import { createApp } from 'vue';
import './style.css' import { createPinia } from 'pinia';
import App from './App.vue' import './style.css';
import App from './App.vue';
createApp(App).mount('#app') const app = createApp(App);
app.use(createPinia());
app.mount('#app');

38
src/stores/stylesheet.js Normal file
View file

@ -0,0 +1,38 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import cssParsingUtils from '../utils/css-parsing';
export const useStylesheetStore = defineStore('stylesheet', () => {
const content = ref('');
const loadStylesheet = async () => {
const response = await fetch('/assets/css/stylesheet.css');
content.value = await response.text();
};
const updateProperty = (selector, property, value, unit) => {
content.value = cssParsingUtils.updateCssValue({
css: content.value,
selector,
property,
value,
unit
});
};
const extractValue = (selector, property) => {
return cssParsingUtils.extractCssValue(content.value, selector, property);
};
const extractBlock = (selector) => {
return cssParsingUtils.extractCssBlock(content.value, selector);
};
return {
content,
loadStylesheet,
updateProperty,
extractValue,
extractBlock
};
});

77
src/utils/css-parsing.js Normal file
View file

@ -0,0 +1,77 @@
/**
* CSS parsing utilities for extracting and manipulating CSS rules
* @module css-parsing
*/
/**
* Extracts a complete CSS block for a given selector
* @param {string} css - The CSS stylesheet content
* @param {string} selector - The CSS selector to find (e.g., '@page', '.my-class')
* @returns {string} The matched CSS block including selector and braces, or empty string if not found
* @example
* const css = '@page { margin: 20mm; }';
* extractCssBlock(css, '@page'); // '@page { margin: 20mm; }'
*/
const extractCssBlock = (css, selector) => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(`${escaped}\\s*{[^}]*}`, 'gi'));
return match ? match[0] : '';
};
/**
* Extracts a CSS property value and its unit from a stylesheet
* @param {string} css - The CSS stylesheet content
* @param {string} selector - The CSS selector to search within
* @param {string} property - The CSS property name to extract
* @returns {{value: number, unit: string}|null} Object with numeric value and unit, or null if not found
* @example
* const css = '@page { margin: 20mm; }';
* extractCssValue(css, '@page', 'margin'); // { value: 20, unit: 'mm' }
*/
const extractCssValue = (css, selector, property) => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(
`${escaped}\\s*{[^}]*${property}:\\s*([\\d.]+)(px|rem|em|mm|cm|in)`,
'i'
);
const match = css.match(regex);
return match ? { value: parseFloat(match[1]), unit: match[2] } : null;
};
/**
* Updates a CSS property value in a stylesheet
* @param {Object} options - Configuration object
* @param {string} options.css - The CSS stylesheet content to modify
* @param {string} options.selector - The CSS selector to target
* @param {string} options.property - The CSS property to update
* @param {number} options.value - The new numeric value
* @param {string} options.unit - The CSS unit (px, rem, em, mm, cm, in, etc.)
* @returns {string} The modified CSS stylesheet content
* @example
* updateCssValue({
* css: '@page { margin: 20mm; }',
* selector: '@page',
* property: 'margin',
* value: 30,
* unit: 'mm'
* }); // '@page { margin: 30mm; }'
*/
const updateCssValue = ({ css, selector, property, value, unit }) => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(
`(${escaped}\\s*{[^}]*${property}:\\s*)[\\d.]+(px|rem|em|mm|cm|in)`,
'gi'
);
return css.replace(regex, `$1${value}${unit}`);
};
/**
* Collection of CSS parsing utilities
* @type {Object}
* @property {Function} extractCssBlock - Extract a CSS block by selector
* @property {Function} extractCssValue - Extract a property value with unit
* @property {Function} updateCssValue - Update a property value in CSS
*/
const cssParsingUtils = { extractCssBlock, extractCssValue, updateCssValue };
export default cssParsingUtils;