feat: implement reactive EditorPanel with bidirectional sync

- Reorganize editor components into dedicated folder
- Create PageSettings component with page format, margins, background controls
- Create TextSettings component (structure only, to be populated)
- Implement debounced updates (1s delay) to stylesheet store
- Add bidirectional sync between EditorPanel and StylesheetViewer
- Preserve scroll position as percentage when reloading preview
- Move @page rules from App.vue to stylesheet.css for unified management
- Extend css-parsing utils to handle text values (e.g., 'A4', 'portrait')
- Remove unnecessary comments, use explicit naming instead

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
isUnknown 2025-12-03 15:20:49 +01:00
parent b8cb77c0e5
commit 9f10971041
7 changed files with 1104 additions and 166 deletions

View file

@ -1,59 +1,33 @@
<script setup>
import PagedJsWrapper from './components/PagedJsWrapper.vue';
import EditorPanel from './components/EditorPanel.vue';
import EditorPanel from './components/editor/EditorPanel.vue';
import StylesheetViewer from './components/StylesheetViewer.vue';
import ElementPopup from './components/ElementPopup.vue';
import { onMounted, ref, watch } from 'vue';
import { useStylesheetStore } from './stores/stylesheet';
// ============================================================================
// Store
// ============================================================================
const stylesheetStore = useStylesheetStore();
// ============================================================================
// PagedJS configuration
// ============================================================================
const printStyles = `
h2 { break-before: page; }
@page {
size: A4;
margin: 20mm 15mm 26mm 15mm;
}
@page {
@bottom-center { content: string(title); }
}
.chapter > h2 { string-set: title content(text); }
`;
// ============================================================================
// Iframe preview
// ============================================================================
const previewFrame = ref(null);
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 = stylesheetStore.content;
};
const elementPopup = ref(null);
const renderPreview = async () => {
let savedScrollPercentage = 0;
const renderPreview = async (shouldReloadFromFile = false) => {
const iframe = previewFrame.value;
if (!iframe) return;
await stylesheetStore.loadStylesheet();
if (iframe.contentWindow && iframe.contentDocument) {
const scrollTop = iframe.contentWindow.scrollY || 0;
const scrollHeight = iframe.contentDocument.documentElement.scrollHeight;
const clientHeight = iframe.contentWindow.innerHeight;
const maxScroll = scrollHeight - clientHeight;
savedScrollPercentage = maxScroll > 0 ? scrollTop / maxScroll : 0;
}
if (shouldReloadFromFile || !stylesheetStore.content) {
await stylesheetStore.loadStylesheet();
}
iframe.srcdoc = `
<!DOCTYPE html>
@ -61,7 +35,6 @@ const renderPreview = async () => {
<head>
<link rel="stylesheet" href="/assets/css/pagedjs-interface.css">
<style id="dynamic-styles">${stylesheetStore.content}</style>
<style>${printStyles}</style>
<script src="https://unpkg.com/pagedjs/dist/paged.polyfill.js"><\/script>
</head>
<body>${document.getElementById('content-source').innerHTML}</body>
@ -70,14 +43,23 @@ const renderPreview = async () => {
iframe.onload = () => {
iframe.contentDocument.addEventListener('click', elementPopup.value.handleIframeClick);
setTimeout(() => {
const scrollHeight = iframe.contentDocument.documentElement.scrollHeight;
const clientHeight = iframe.contentWindow.innerHeight;
const maxScroll = scrollHeight - clientHeight;
const targetScroll = savedScrollPercentage * maxScroll;
iframe.contentWindow.scrollTo(0, targetScroll);
}, 500);
};
};
// ============================================================================
// Lifecycle
// ============================================================================
watch(() => stylesheetStore.content, injectStylesToIframe);
onMounted(renderPreview);
watch(() => stylesheetStore.content, () => {
renderPreview();
});
onMounted(() => renderPreview(true));
</script>
<template>

View file

@ -1,69 +0,0 @@
<template>
<aside id="editor-panel">
<h3>Éditeur</h3>
<div class="control">
<label>Taille de police des paragraphes</label>
<input
type="number"
step="0.1"
:value="fontSizeData?.value ?? 1"
@input="updateFontSize(parseFloat($event.target.value))"
/>
<span>{{ fontSizeData?.unit ?? 'rem' }}</span>
</div>
</aside>
</template>
<script setup>
import { computed } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
const stylesheetStore = useStylesheetStore();
const fontSizeData = computed(() => {
return stylesheetStore.extractValue('p', 'font-size');
});
const updateFontSize = (value) => {
if (!fontSizeData.value) return;
stylesheetStore.updateProperty(
'p',
'font-size',
value,
fontSizeData.value.unit
);
};
</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,84 @@
<template>
<aside id="editor-panel">
<nav class="tabs">
<button
type="button"
:class="{ active: activeTab === 'document' }"
@click="activeTab = 'document'"
>
Document
</button>
<button
type="button"
:class="{ active: activeTab === 'code' }"
@click="activeTab = 'code'"
>
Code
</button>
<button
type="button"
:class="{ active: activeTab === 'contenu' }"
@click="activeTab = 'contenu'"
>
Contenu
</button>
</nav>
<div class="tab-content">
<div v-if="activeTab === 'document'" class="tab-panel">
<PageSettings />
<TextSettings />
</div>
<div v-else-if="activeTab === 'code'" class="tab-panel">
<!-- Code tab content -->
</div>
<div v-else-if="activeTab === 'contenu'" class="tab-panel">
<!-- Contenu tab content -->
</div>
</div>
</aside>
</template>
<script setup>
import { ref } from 'vue';
import PageSettings from './PageSettings.vue';
import TextSettings from './TextSettings.vue';
// Tab management
const activeTab = ref('document');
</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,360 @@
<template>
<section class="settings-section">
<h2>Réglage des pages</h2>
<div class="field">
<label for="page-format">Format d'impression</label>
<select id="page-format" v-model="pageFormat">
<option value="A4">A4</option>
<option value="A5">A5</option>
<option value="A3">A3</option>
<option value="letter">Letter</option>
<option value="legal">Legal</option>
</select>
</div>
<div class="field">
<label for="page-width">Largeur</label>
<input
id="page-width"
type="text"
:value="pageWidth"
disabled
/>
</div>
<div class="field">
<label for="page-height">Hauteur</label>
<input
id="page-height"
type="text"
:value="pageHeight"
disabled
/>
</div>
<div class="subsection">
<h3>Marges</h3>
<div class="field">
<label for="margin-top">Haut</label>
<div class="field-with-unit">
<input
id="margin-top"
type="number"
v-model.number="margins.top.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: margins.top.unit === 'mm' }"
@click="margins.top.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: margins.top.unit === 'px' }"
@click="margins.top.unit = 'px'"
>
px
</button>
</div>
</div>
</div>
<div class="field">
<label for="margin-bottom">Bas</label>
<div class="field-with-unit">
<input
id="margin-bottom"
type="number"
v-model.number="margins.bottom.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: margins.bottom.unit === 'mm' }"
@click="margins.bottom.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: margins.bottom.unit === 'px' }"
@click="margins.bottom.unit = 'px'"
>
px
</button>
</div>
</div>
</div>
<div class="field">
<label for="margin-left">Gauche</label>
<div class="field-with-unit">
<input
id="margin-left"
type="number"
v-model.number="margins.left.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: margins.left.unit === 'mm' }"
@click="margins.left.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: margins.left.unit === 'px' }"
@click="margins.left.unit = 'px'"
>
px
</button>
</div>
</div>
</div>
<div class="field">
<label for="margin-right">Droite</label>
<div class="field-with-unit">
<input
id="margin-right"
type="number"
v-model.number="margins.right.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: margins.right.unit === 'mm' }"
@click="margins.right.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: margins.right.unit === 'px' }"
@click="margins.right.unit = 'px'"
>
px
</button>
</div>
</div>
</div>
</div>
<div class="field">
<label for="background">Arrière-plan</label>
<div class="field-with-unit">
<input
id="background"
type="text"
v-model="background.value"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: background.format === 'rgb' }"
@click="background.format = 'rgb'"
>
rgb
</button>
<button
type="button"
:class="{ active: background.format === 'hex' }"
@click="background.format = 'hex'"
>
hex
</button>
</div>
</div>
</div>
<div class="field">
<label for="pattern">Motif</label>
<select id="pattern" v-model="pattern">
<option value="">Choisissez</option>
<option value="dots">Points</option>
<option value="lines">Lignes</option>
<option value="grid">Grille</option>
</select>
</div>
<div class="field checkbox-field">
<input
id="page-numbers"
type="checkbox"
v-model="pageNumbers"
/>
<label for="page-numbers">Numéro de page</label>
</div>
<div class="field checkbox-field">
<input
id="running-title"
type="checkbox"
v-model="runningTitle"
/>
<label for="running-title">Titre courant</label>
</div>
</section>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useStylesheetStore } from '../../stores/stylesheet';
const stylesheetStore = useStylesheetStore();
let isUpdatingFromStore = false;
let updateTimer = null;
const pageFormat = ref('A4');
const pageFormats = {
A4: { width: '210mm', height: '297mm' },
A5: { width: '148mm', height: '210mm' },
A3: { width: '297mm', height: '420mm' },
letter: { width: '8.5in', height: '11in' },
legal: { width: '8.5in', height: '14in' }
};
const pageWidth = computed(() => pageFormats[pageFormat.value].width);
const pageHeight = computed(() => pageFormats[pageFormat.value].height);
const margins = ref({
top: { value: 20, unit: 'mm' },
bottom: { value: 20, unit: 'mm' },
left: { value: 20, unit: 'mm' },
right: { value: 20, unit: 'mm' }
});
const background = ref({
value: '',
format: 'hex'
});
const pattern = ref('');
const pageNumbers = ref(false);
const runningTitle = ref(false);
const debouncedUpdate = (callback) => {
clearTimeout(updateTimer);
updateTimer = setTimeout(callback, 1000);
};
watch(pageFormat, (newFormat) => {
if (isUpdatingFromStore) return;
debouncedUpdate(() => {
stylesheetStore.updateProperty('@page', 'size', newFormat, '');
});
});
watch(margins, (newMargins) => {
if (isUpdatingFromStore) return;
debouncedUpdate(() => {
const marginValue = `${newMargins.top.value}${newMargins.top.unit} ${newMargins.right.value}${newMargins.right.unit} ${newMargins.bottom.value}${newMargins.bottom.unit} ${newMargins.left.value}${newMargins.left.unit}`;
const currentBlock = stylesheetStore.extractBlock('@page');
const updatedBlock = currentBlock.replace(
/(margin:\s*)[^;]+/,
`$1${marginValue}`
);
stylesheetStore.content = stylesheetStore.content.replace(currentBlock, updatedBlock);
});
}, { deep: true });
watch(background, (newBg) => {
if (!newBg.value) return;
if (isUpdatingFromStore) return;
debouncedUpdate(() => {
const currentBlock = stylesheetStore.extractBlock('@page');
if (currentBlock.includes('background:')) {
const updatedBlock = currentBlock.replace(
/(background:\s*)[^;]+/,
`$1${newBg.value}`
);
stylesheetStore.content = stylesheetStore.content.replace(currentBlock, updatedBlock);
} else {
const updatedBlock = currentBlock.replace(
/(\s*})$/,
` background: ${newBg.value};\n$1`
);
stylesheetStore.content = stylesheetStore.content.replace(currentBlock, updatedBlock);
}
});
}, { deep: true });
watch(pattern, (newPattern) => {
if (!newPattern || isUpdatingFromStore) return;
debouncedUpdate(() => {
// TODO: implement pattern application
});
});
watch(pageNumbers, (enabled) => {
if (isUpdatingFromStore) return;
debouncedUpdate(() => {
// TODO: implement page numbers toggle
});
});
watch(runningTitle, (enabled) => {
if (isUpdatingFromStore) return;
debouncedUpdate(() => {
// TODO: implement running title toggle
});
});
const syncFromStore = () => {
isUpdatingFromStore = true;
try {
const pageBlock = stylesheetStore.extractBlock('@page');
const sizeMatch = pageBlock.match(/size:\s*([A-Za-z0-9]+)/);
if (sizeMatch) {
pageFormat.value = sizeMatch[1];
}
const marginMatch = pageBlock.match(/margin:\s*([0-9.]+)([a-z]+)\s+([0-9.]+)([a-z]+)\s+([0-9.]+)([a-z]+)\s+([0-9.]+)([a-z]+)/i);
if (marginMatch) {
margins.value.top = { value: parseFloat(marginMatch[1]), unit: marginMatch[2] };
margins.value.right = { value: parseFloat(marginMatch[3]), unit: marginMatch[4] };
margins.value.bottom = { value: parseFloat(marginMatch[5]), unit: marginMatch[6] };
margins.value.left = { value: parseFloat(marginMatch[7]), unit: marginMatch[8] };
}
const bgMatch = pageBlock.match(/background:\s*([^;]+)/);
if (bgMatch) {
background.value.value = bgMatch[1].trim();
}
} finally {
isUpdatingFromStore = false;
}
};
watch(() => stylesheetStore.content, () => {
if (!isUpdatingFromStore) {
syncFromStore();
}
});
onMounted(() => {
syncFromStore();
});
</script>

View file

@ -0,0 +1,604 @@
<template>
<section class="settings-section">
<h2>Réglage du texte</h2>
<p class="infos">
Ces réglages s'appliquent à l'ensemble des éléments du document.
Vous pouvez modifier ensuite les éléments indépendamment.
</p>
<div class="field">
<label for="text-font">Police</label>
<div class="field-with-option">
<select id="text-font" v-model="font">
<option value="Alegreya Sans">Alegreya Sans</option>
<option value="Arial">Arial</option>
<option value="Georgia">Georgia</option>
<option value="Helvetica">Helvetica</option>
<option value="Times New Roman">Times New Roman</option>
</select>
<div class="field-checkbox">
<input
id="text-italic"
type="checkbox"
v-model="italic"
/>
<label for="text-italic">Italique</label>
</div>
</div>
</div>
<div class="field">
<label>Graisse</label>
<div class="weight-toggle">
<button
type="button"
:class="{ active: weight === '200' }"
@click="weight = '200'"
>
200
</button>
<button
type="button"
:class="{ active: weight === '300' }"
@click="weight = '300'"
>
300
</button>
<button
type="button"
:class="{ active: weight === '400' }"
@click="weight = '400'"
>
400
</button>
<button
type="button"
:class="{ active: weight === '600' }"
@click="weight = '600'"
>
600
</button>
<button
type="button"
:class="{ active: weight === '800' }"
@click="weight = '800'"
>
800
</button>
<button
type="button"
:class="{ active: weight === 'normal' }"
@click="weight = 'normal'"
>
normal
</button>
<button
type="button"
:class="{ active: weight === 'bold' }"
@click="weight = 'bold'"
>
bold
</button>
</div>
</div>
<div class="field">
<label for="text-size-range">Taille du texte</label>
<div class="field-with-unit">
<input
id="text-size-range"
type="range"
v-model.number="fontSize.value"
min="8"
max="72"
step="1"
/>
<input
type="number"
v-model.number="fontSize.value"
min="8"
max="72"
step="1"
class="size-input"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: fontSize.unit === 'px' }"
@click="fontSize.unit = 'px'"
>
px
</button>
<button
type="button"
:class="{ active: fontSize.unit === 'em' }"
@click="fontSize.unit = 'em'"
>
em
</button>
<button
type="button"
:class="{ active: fontSize.unit === 'rem' }"
@click="fontSize.unit = 'rem'"
>
rem
</button>
</div>
</div>
</div>
<div class="field">
<label for="text-alignment">Alignement</label>
<select id="text-alignment" v-model="alignment">
<option value="left">Gauche</option>
<option value="center">Centre</option>
<option value="right">Droite</option>
<option value="justify">Justifié</option>
</select>
</div>
<div class="field">
<label for="text-color">Couleur</label>
<div class="field-with-color">
<input
type="color"
v-model="color.picker"
class="color-picker"
/>
<input
id="text-color"
type="text"
v-model="color.value"
class="color-input"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: color.format === 'rgb' }"
@click="color.format = 'rgb'"
>
rgb
</button>
<button
type="button"
:class="{ active: color.format === 'hex' }"
@click="color.format = 'hex'"
>
hex
</button>
<button
type="button"
class="clear-btn"
@click="clearColor"
title="Réinitialiser"
>
×
</button>
</div>
</div>
</div>
<div class="field">
<label for="text-background">Arrière-plan</label>
<div class="field-with-color">
<button
type="button"
class="toggle-btn"
:class="{ active: background.enabled }"
@click="background.enabled = !background.enabled"
>
×
</button>
<input
id="text-background"
type="text"
v-model="background.value"
:disabled="!background.enabled"
class="color-input"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: background.format === 'rgb' }"
:disabled="!background.enabled"
@click="background.format = 'rgb'"
>
rgb
</button>
<button
type="button"
:class="{ active: background.format === 'hex' }"
:disabled="!background.enabled"
@click="background.format = 'hex'"
>
hex
</button>
<button
type="button"
class="clear-btn"
:disabled="!background.enabled"
@click="clearBackground"
title="Effacer"
>
×
</button>
</div>
</div>
</div>
<div class="field">
<label for="margin-outer">Marges extérieures</label>
<div class="field-with-unit">
<input
id="margin-outer"
type="number"
v-model.number="marginOuter.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginOuter.unit === 'mm' }"
@click="marginOuter.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: marginOuter.unit === 'px' }"
@click="marginOuter.unit = 'px'"
>
px
</button>
</div>
<button
type="button"
class="collapse-toggle"
:class="{ expanded: marginOuterExpanded }"
@click="marginOuterExpanded = !marginOuterExpanded"
title="Réglages détaillés"
>
</button>
</div>
</div>
<div v-if="marginOuterExpanded" class="subsection collapsed-section">
<div class="field">
<label for="margin-outer-top">Haut</label>
<div class="field-with-unit">
<input
id="margin-outer-top"
type="number"
v-model.number="marginOuterDetailed.top.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginOuterDetailed.top.unit === 'mm' }"
@click="marginOuterDetailed.top.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: marginOuterDetailed.top.unit === 'px' }"
@click="marginOuterDetailed.top.unit = 'px'"
>
px
</button>
</div>
</div>
</div>
<div class="field">
<label for="margin-outer-bottom">Bas</label>
<div class="field-with-unit">
<input
id="margin-outer-bottom"
type="number"
v-model.number="marginOuterDetailed.bottom.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginOuterDetailed.bottom.unit === 'mm' }"
@click="marginOuterDetailed.bottom.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: marginOuterDetailed.bottom.unit === 'px' }"
@click="marginOuterDetailed.bottom.unit = 'px'"
>
px
</button>
</div>
</div>
</div>
<div class="field">
<label for="margin-outer-left">Gauche</label>
<div class="field-with-unit">
<input
id="margin-outer-left"
type="number"
v-model.number="marginOuterDetailed.left.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginOuterDetailed.left.unit === 'mm' }"
@click="marginOuterDetailed.left.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: marginOuterDetailed.left.unit === 'px' }"
@click="marginOuterDetailed.left.unit = 'px'"
>
px
</button>
</div>
</div>
</div>
<div class="field">
<label for="margin-outer-right">Droite</label>
<div class="field-with-unit">
<input
id="margin-outer-right"
type="number"
v-model.number="marginOuterDetailed.right.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginOuterDetailed.right.unit === 'mm' }"
@click="marginOuterDetailed.right.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: marginOuterDetailed.right.unit === 'px' }"
@click="marginOuterDetailed.right.unit = 'px'"
>
px
</button>
</div>
</div>
</div>
</div>
<div class="field">
<label for="margin-inner">Marges intérieures</label>
<div class="field-with-unit">
<input
id="margin-inner"
type="number"
v-model.number="marginInner.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginInner.unit === 'mm' }"
@click="marginInner.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: marginInner.unit === 'px' }"
@click="marginInner.unit = 'px'"
>
px
</button>
</div>
<button
type="button"
class="collapse-toggle"
:class="{ expanded: marginInnerExpanded }"
@click="marginInnerExpanded = !marginInnerExpanded"
title="Réglages détaillés"
>
</button>
</div>
</div>
<div v-if="marginInnerExpanded" class="subsection collapsed-section">
<div class="field">
<label for="margin-inner-top">Haut</label>
<div class="field-with-unit">
<input
id="margin-inner-top"
type="number"
v-model.number="marginInnerDetailed.top.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginInnerDetailed.top.unit === 'mm' }"
@click="marginInnerDetailed.top.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: marginInnerDetailed.top.unit === 'px' }"
@click="marginInnerDetailed.top.unit = 'px'"
>
px
</button>
</div>
</div>
</div>
<div class="field">
<label for="margin-inner-bottom">Bas</label>
<div class="field-with-unit">
<input
id="margin-inner-bottom"
type="number"
v-model.number="marginInnerDetailed.bottom.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginInnerDetailed.bottom.unit === 'mm' }"
@click="marginInnerDetailed.bottom.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: marginInnerDetailed.bottom.unit === 'px' }"
@click="marginInnerDetailed.bottom.unit = 'px'"
>
px
</button>
</div>
</div>
</div>
<div class="field">
<label for="margin-inner-left">Gauche</label>
<div class="field-with-unit">
<input
id="margin-inner-left"
type="number"
v-model.number="marginInnerDetailed.left.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginInnerDetailed.left.unit === 'mm' }"
@click="marginInnerDetailed.left.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: marginInnerDetailed.left.unit === 'px' }"
@click="marginInnerDetailed.left.unit = 'px'"
>
px
</button>
</div>
</div>
</div>
<div class="field">
<label for="margin-inner-right">Droite</label>
<div class="field-with-unit">
<input
id="margin-inner-right"
type="number"
v-model.number="marginInnerDetailed.right.value"
min="0"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginInnerDetailed.right.unit === 'mm' }"
@click="marginInnerDetailed.right.unit = 'mm'"
>
mm
</button>
<button
type="button"
:class="{ active: marginInnerDetailed.right.unit === 'px' }"
@click="marginInnerDetailed.right.unit = 'px'"
>
px
</button>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref } from 'vue';
// Font
const font = ref('Alegreya Sans');
const italic = ref(false);
// Weight
const weight = ref('400');
// Font size
const fontSize = ref({
value: 23,
unit: 'px'
});
// Alignment
const alignment = ref('left');
// Color
const color = ref({
picker: '#000000',
value: 'rgb(250, 250, 250)',
format: 'rgb'
});
const clearColor = () => {
color.value.picker = '#000000';
color.value.value = '';
};
// Background
const background = ref({
enabled: false,
value: 'transparent',
format: 'hex'
});
const clearBackground = () => {
background.value.value = 'transparent';
};
// Margin outer
const marginOuter = ref({
value: 23,
unit: 'mm'
});
const marginOuterExpanded = ref(false);
const marginOuterDetailed = ref({
top: { value: 23, unit: 'mm' },
bottom: { value: 23, unit: 'mm' },
left: { value: 23, unit: 'mm' },
right: { value: 23, unit: 'mm' }
});
// Margin inner
const marginInner = ref({
value: 23,
unit: 'mm'
});
const marginInnerExpanded = ref(false);
const marginInnerDetailed = ref({
top: { value: 23, unit: 'mm' },
bottom: { value: 23, unit: 'mm' },
left: { value: 23, unit: 'mm' },
right: { value: 23, unit: 'mm' }
});
</script>

View file

@ -1,33 +1,9 @@
/**
* 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(
@ -38,26 +14,17 @@ const extractCssValue = (css, selector, property) => {
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, '\\$&');
if (unit === '') {
const regex = new RegExp(
`(${escaped}\\s*{[^}]*${property}:\\s*)[^;]+`,
'gi'
);
return css.replace(regex, `$1${value}`);
}
const regex = new RegExp(
`(${escaped}\\s*{[^}]*${property}:\\s*)[\\d.]+(px|rem|em|mm|cm|in)`,
'gi'
@ -65,13 +32,6 @@ const updateCssValue = ({ css, selector, property, value, unit }) => {
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;