geoproject-app/src/components/ElementPopup.vue

1112 lines
40 KiB
Vue
Raw Normal View History

<template>
<BasePopup
ref="basePopup"
id="element-popup"
:display-css="displayedCss"
:editable-css="elementCss"
:popup-width="800"
:popup-height="600"
2026-03-05 11:42:18 +01:00
:show-inheritance="false"
@close="close"
@css-input="handleCssInput"
>
<template #header-left>
<span class="element-label">{{ selector || '' }}</span>
<span class="instance-count">{{ elementInstanceCount }} instances</span>
</template>
<template #controls>
<!-- Font Family -->
2026-03-05 14:49:58 +01:00
<div class="settings-subsection" :class="{ 'setting-disabled': !settingEnabled.font }">
<input type="checkbox" class="toggle-setting" :checked="settingEnabled.font" @change="onToggleSetting('font', $event.target.checked)" />
<div class="field field-font">
<label class="label-with-tooltip" data-css="font-family">Police</label>
2026-03-05 11:08:44 +01:00
<div class="field-font__options">
2026-03-05 14:49:58 +01:00
<select v-model="fontFamily">
2026-03-05 16:29:42 +01:00
<option value="sans-serif">Police système (sans-serif)</option>
<option v-for="f in projectFonts" :key="f.name" :value="f.name" :style="{ fontFamily: `'${f.name}', ${f.category}` }">{{ f.name }}</option>
</select>
<div class="field-checkbox">
2026-03-05 14:49:58 +01:00
<input type="checkbox" v-model="italic" />
<label class="label-with-tooltip" data-css="font-style">Italique</label>
2025-12-11 13:39:23 +01:00
</div>
2026-03-05 11:08:44 +01:00
<div class="field-checkbox">
2026-03-05 14:49:58 +01:00
<input type="checkbox" v-model="bold" />
2026-03-05 11:08:44 +01:00
<label class="label-with-tooltip" data-css="font-weight">Gras</label>
</div>
</div>
</div>
</div>
<!-- Font Size -->
2026-03-05 14:49:58 +01:00
<div class="settings-subsection" :class="{ 'setting-disabled': !settingEnabled.fontSize }">
<input type="checkbox" class="toggle-setting" :checked="settingEnabled.fontSize" @change="onToggleSetting('fontSize', $event.target.checked)" />
<div class="field field-text-size">
<label class="label-with-tooltip" data-css="font-size">Taille du texte</label>
2026-03-05 11:42:18 +01:00
<InputWithUnit
v-model="fontSizeModel"
:units="['px']"
:min="6"
:max="72"
showRange
/>
</div>
</div>
2026-03-02 17:29:49 +01:00
<!-- LineHeight -->
2026-03-05 14:49:58 +01:00
<div class="settings-subsection" :class="{ 'setting-disabled': !settingEnabled.lineHeight }">
<input type="checkbox" class="toggle-setting" :checked="settingEnabled.lineHeight" @change="onToggleSetting('lineHeight', $event.target.checked)" />
<div class="field field-text-size">
2026-03-02 17:29:49 +01:00
<label class="label-with-tooltip" data-css="line-height">Interlignage</label>
2026-03-05 11:42:18 +01:00
<InputWithUnit
v-model="lineHeightModel"
:units="['px']"
:min="0"
:max="72"
showRange
/>
2026-03-02 17:29:49 +01:00
</div>
</div>
<!-- Text Alignment -->
2026-03-05 14:49:58 +01:00
<div class="settings-subsection" :class="{ 'setting-disabled': !settingEnabled.textAlign }">
<input type="checkbox" class="toggle-setting" :checked="settingEnabled.textAlign" @change="onToggleSetting('textAlign', $event.target.checked)" />
<div class="field">
<label class="label-with-tooltip" data-css="text-align">Alignement</label>
2026-03-05 14:49:58 +01:00
<select v-model="textAlign">
<option value="left">Gauche</option>
<option value="center">Centre</option>
<option value="right">Droite</option>
<option value="justify">Justifié</option>
</select>
</div>
</div>
2026-03-05 14:49:58 +01:00
<!-- Color -->
<div class="settings-subsection" :class="{ 'setting-disabled': !settingEnabled.color }">
<input type="checkbox" class="toggle-setting" :checked="settingEnabled.color" @change="onToggleSetting('color', $event.target.checked)" />
<div class="field field-simple">
<label class="label-with-tooltip" data-css="color">Couleur du texte</label>
<div class="input-with-color">
<input
ref="colorInput"
type="text"
v-model="color"
data-coloris
/>
</div>
</div>
</div>
<!-- Background -->
<div class="settings-subsection" :class="{ 'setting-disabled': !settingEnabled.background }">
<input type="checkbox" class="toggle-setting" :checked="settingEnabled.background" @change="onToggleSetting('background', $event.target.checked)" />
<div class="field field-simple">
<label class="label-with-tooltip" data-css="background">Arrière-plan</label>
<div class="input-with-color">
<input
ref="backgroundInput"
type="text"
v-model="background"
data-coloris
/>
</div>
</div>
</div>
2026-03-05 11:08:44 +01:00
<!-- Bordure -->
2026-03-05 14:49:58 +01:00
<div class="settings-subsection" :class="{ 'setting-disabled': !settingEnabled.border }">
<input type="checkbox" class="toggle-setting" :checked="settingEnabled.border" @change="onToggleSetting('border', $event.target.checked)" />
<div class="field field-border">
2026-03-05 11:08:44 +01:00
<div class="settings-subsection-header">
2026-03-05 14:49:58 +01:00
<label class="label-with-tooltip" data-css="border">Bordure</label>
2026-03-05 11:08:44 +01:00
</div>
<div class="field-border__options">
<div class="field-border__option">
<label class="label-with-tooltip" data-css="border-width">Épaisseur</label>
<div class="input-with-unit">
<NumberInput
v-model="borderWidth.value"
:min="0"
:step="1"
2026-03-05 14:49:58 +01:00
/>
2026-03-05 11:08:44 +01:00
<div class="unit-toggle">
2026-03-05 14:49:58 +01:00
<button type="button" class="active">px</button>
2026-03-05 11:08:44 +01:00
</div>
</div>
</div>
<div class="field-border__option">
<label class="label-with-tooltip" data-css="border-style">Style</label>
2026-03-05 14:49:58 +01:00
<select v-model="borderStyle">
2026-03-05 11:08:44 +01:00
<option value="solid">Plein</option>
<option value="dotted">Pointillés</option>
<option value="dashed">Tirets</option>
<option value="double">Double</option>
</select>
</div>
<div class="field-border__option">
<label class="label-with-tooltip" data-css="border-color">Couleur</label>
<div class="input-with-color">
<input
ref="borderColorInput"
type="text"
v-model="borderColor"
2026-03-05 14:49:58 +01:00
data-coloris
2026-03-05 11:08:44 +01:00
/>
</div>
</div>
</div>
</div>
</div>
<!-- Outer Margins -->
2026-03-05 14:49:58 +01:00
<div class="settings-subsection" :class="{ 'setting-disabled': !settingEnabled.margin }">
<input type="checkbox" class="toggle-setting" :checked="settingEnabled.margin" @change="onToggleSetting('margin', $event.target.checked)" />
2026-03-05 10:45:55 +01:00
<div class="settings-subsection-header">
<span class="label-with-tooltip" data-css="margin">Marges extérieures</span>
<button
type="button"
class="lock-toggle"
:class="{ locked: marginLocked }"
2026-03-05 14:49:58 +01:00
@click="marginLocked = !marginLocked"
2026-03-05 10:45:55 +01:00
:title="marginLocked ? 'Déverrouiller (modifier indépendamment)' : 'Verrouiller (modifier ensemble)'"
>
<svg v-if="marginLocked" width="11" height="13" viewBox="0 0 11 13" fill="none">
<rect x="1" y="5.5" width="9" height="7" rx="1" fill="currentColor"/>
<path d="M2.5 5.5V3.5a3 3 0 0 1 6 0v2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<svg v-else width="11" height="13" viewBox="0 0 11 13" fill="none">
<rect x="1" y="5.5" width="9" height="7" rx="1" fill="currentColor"/>
<path d="M2.5 5.5V3.5a3 3 0 0 1 6 0" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<div
v-for="side in sides"
:key="side.key"
class="field field-margin"
2026-03-05 14:49:58 +01:00
>
2026-03-05 10:45:55 +01:00
<label class="label-with-tooltip" :data-css="`margin-${side.key}`">{{ side.label }}</label>
<div class="input-with-unit">
<NumberInput
2026-03-05 10:45:55 +01:00
:modelValue="margin[side.key].value"
:min="0"
:step="1"
2026-03-05 14:49:58 +01:00
@update:modelValue="(v) => updateMarginValue(side.key, v)"
/>
<div class="unit-toggle">
<button
type="button"
2026-03-05 10:45:55 +01:00
:class="{ active: margin[side.key].unit === 'mm' }"
2026-03-05 14:49:58 +01:00
@click="updateMarginUnit(side.key, 'mm')"
2026-03-05 10:45:55 +01:00
>mm</button>
<button
type="button"
2026-03-05 10:45:55 +01:00
:class="{ active: margin[side.key].unit === 'px' }"
2026-03-05 14:49:58 +01:00
@click="updateMarginUnit(side.key, 'px')"
2026-03-05 10:45:55 +01:00
>px</button>
</div>
</div>
</div>
</div>
<!-- Inner Margins (Padding) -->
2026-03-05 14:49:58 +01:00
<div class="settings-subsection" :class="{ 'setting-disabled': !settingEnabled.padding }">
<input type="checkbox" class="toggle-setting" :checked="settingEnabled.padding" @change="onToggleSetting('padding', $event.target.checked)" />
2026-03-05 10:45:55 +01:00
<div class="settings-subsection-header">
<span class="label-with-tooltip" data-css="padding">Marges intérieures</span>
<button
type="button"
class="lock-toggle"
:class="{ locked: paddingLocked }"
2026-03-05 14:49:58 +01:00
@click="paddingLocked = !paddingLocked"
2026-03-05 10:45:55 +01:00
:title="paddingLocked ? 'Déverrouiller (modifier indépendamment)' : 'Verrouiller (modifier ensemble)'"
>
<svg v-if="paddingLocked" width="11" height="13" viewBox="0 0 11 13" fill="none">
<rect x="1" y="5.5" width="9" height="7" rx="1" fill="currentColor"/>
<path d="M2.5 5.5V3.5a3 3 0 0 1 6 0v2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<svg v-else width="11" height="13" viewBox="0 0 11 13" fill="none">
<rect x="1" y="5.5" width="9" height="7" rx="1" fill="currentColor"/>
<path d="M2.5 5.5V3.5a3 3 0 0 1 6 0" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<div
v-for="side in sides"
:key="side.key"
class="field field-margin"
2026-03-05 14:49:58 +01:00
>
2026-03-05 10:45:55 +01:00
<label class="label-with-tooltip" :data-css="`padding-${side.key}`">{{ side.label }}</label>
<div class="input-with-unit">
<NumberInput
2026-03-05 10:45:55 +01:00
:modelValue="padding[side.key].value"
:min="0"
:step="1"
2026-03-05 14:49:58 +01:00
@update:modelValue="(v) => updatePaddingValue(side.key, v)"
/>
<div class="unit-toggle">
<button
type="button"
2026-03-05 10:45:55 +01:00
:class="{ active: padding[side.key].unit === 'mm' }"
2026-03-05 14:49:58 +01:00
@click="updatePaddingUnit(side.key, 'mm')"
2026-03-05 10:45:55 +01:00
>mm</button>
<button
type="button"
2026-03-05 10:45:55 +01:00
:class="{ active: padding[side.key].unit === 'px' }"
2026-03-05 14:49:58 +01:00
@click="updatePaddingUnit(side.key, 'px')"
2026-03-05 10:45:55 +01:00
>px</button>
</div>
</div>
</div>
</div>
</template>
</BasePopup>
</template>
<script setup>
2026-03-05 16:29:42 +01:00
import { ref, reactive, computed, watch, nextTick, onMounted } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
import { useDebounce } from '../composables/useDebounce';
2026-03-05 14:49:58 +01:00
import { useCssSync } from '../composables/useCssSync';
import { useTextDefaults } from '../composables/useTextDefaults';
2026-03-05 16:29:42 +01:00
import { useProjectFonts } from '../composables/useProjectFonts';
2025-12-09 17:08:40 +01:00
import NumberInput from './ui/NumberInput.vue';
2026-03-05 11:42:18 +01:00
import InputWithUnit from './ui/InputWithUnit.vue';
import BasePopup from './ui/BasePopup.vue';
import { convertUnit } from '../utils/unit-conversion';
const stylesheetStore = useStylesheetStore();
2026-03-05 14:49:58 +01:00
const { extractValue: cssExtractValue, extractNumericValue } = useCssSync();
const textDefaults = useTextDefaults();
2026-03-05 16:29:42 +01:00
const { fonts: projectFonts, loadFont, loadAllFontPreviews } = useProjectFonts();
const props = defineProps({
iframeRef: Object,
});
const emit = defineEmits(['close']);
const basePopup = ref(null);
const visible = computed(() => basePopup.value?.visible ?? false);
const selector = ref('');
const selectedElement = ref(null);
const elementInstanceCount = ref(0);
const colorInput = ref(null);
const backgroundInput = ref(null);
2026-03-05 11:08:44 +01:00
const borderColorInput = ref(null);
let isUpdatingFromStore = false;
const { debouncedUpdate } = useDebounce(500);
// Style properties — flat refs for simple values, reactive for value+unit
2026-03-05 16:29:42 +01:00
const fontFamily = ref('sans-serif');
const italic = ref(false);
2026-03-05 11:08:44 +01:00
const bold = ref(false);
const textAlign = ref('left');
const color = ref('rgb(0, 0, 0)');
const background = ref('transparent');
const fontSize = reactive({ value: 23, unit: 'px' });
2026-03-05 11:42:18 +01:00
const fontSizeModel = computed({
get: () => ({ value: fontSize.value, unit: fontSize.unit }),
2026-03-05 11:42:18 +01:00
set: (v) => { fontSize.value = v.value; fontSize.unit = v.unit; },
});
2026-03-02 17:29:49 +01:00
const lineHeight = reactive({ value: 28, unit: 'px' });
2026-03-05 11:42:18 +01:00
const lineHeightModel = computed({
get: () => ({ value: lineHeight.value, unit: lineHeight.unit }),
set: (v) => { lineHeight.value = v.value; lineHeight.unit = v.unit; },
});
2026-03-05 14:49:58 +01:00
const borderWidth = reactive({ value: 1, unit: 'px' });
2026-03-05 11:08:44 +01:00
const borderStyle = ref('solid');
const borderColor = ref('#000000');
2026-03-05 10:45:55 +01:00
const marginLocked = ref(true);
const margin = reactive({
top: { value: 0, unit: 'mm' },
right: { value: 0, unit: 'mm' },
bottom: { value: 0, unit: 'mm' },
left: { value: 0, unit: 'mm' },
});
const paddingLocked = ref(true);
const padding = reactive({
top: { value: 0, unit: 'mm' },
right: { value: 0, unit: 'mm' },
bottom: { value: 0, unit: 'mm' },
left: { value: 0, unit: 'mm' },
});
2026-03-05 14:49:58 +01:00
// Cache for special groups values when unchecked (to restore on re-check)
const settingCache = reactive({
font: null, // { fontFamily, italic, bold }
fontSize: null, // { value, unit }
lineHeight: null, // { value, unit }
color: null, // string
2026-03-05 14:49:58 +01:00
});
2026-03-05 19:00:29 +01:00
// Persistent state per element selector (survives open/close)
const elementStates = new Map();
const saveElementState = () => {
if (!selector.value) return;
elementStates.set(selector.value, {
toggles: { ...settingEnabled },
cache: {
font: settingCache.font ? { ...settingCache.font } : null,
fontSize: settingCache.fontSize ? { ...settingCache.fontSize } : null,
lineHeight: settingCache.lineHeight ? { ...settingCache.lineHeight } : null,
color: settingCache.color ?? null,
},
// Store current values for enabled groups (survives external CSS changes on shared selectors)
values: {
fontFamily: fontFamily.value,
italic: italic.value,
bold: bold.value,
fontSize: { value: fontSize.value, unit: fontSize.unit },
lineHeight: { value: lineHeight.value, unit: lineHeight.unit },
color: color.value,
},
});
};
// Per-subsection toggle state — all unchecked by default
// Special groups (font, fontSize, lineHeight, color) appear in popup-css with "valeur par défaut" comment
2026-03-05 14:49:58 +01:00
const settingEnabled = reactive({
font: false,
fontSize: false,
2026-03-05 14:49:58 +01:00
lineHeight: false,
textAlign: false,
color: false,
2026-03-05 14:49:58 +01:00
background: false,
border: false,
margin: false,
padding: false,
});
// Style property descriptors (with group field)
const styleProps = [
2026-03-05 19:00:29 +01:00
{ css: 'font-family', group: 'font', get: () => fontFamily.value === 'sans-serif' ? 'sans-serif' : `"${fontFamily.value}"`, set: v => fontFamily.value = v.replace(/['"]/g, ''), debounce: false, skipWatch: true },
2026-03-05 14:49:58 +01:00
{ css: 'font-style', group: 'font', get: () => italic.value ? 'italic' : 'normal', set: v => italic.value = v === 'italic', debounce: false, skipWhenDefault: v => v !== 'italic' },
{ css: 'font-weight', group: 'font', get: () => bold.value ? 'bold' : 'normal', set: v => bold.value = v === 'bold' || parseInt(v) >= 700, debounce: false, skipWhenDefault: v => v === 'normal' },
{ css: 'text-align', group: 'textAlign', get: () => textAlign.value, set: v => textAlign.value = v, debounce: false },
{ css: 'color', group: 'color', get: () => color.value, set: v => color.value = v, debounce: true },
{ css: 'background', group: 'background', get: () => background.value, set: v => background.value = v, debounce: true },
{ css: 'border-style', group: 'border', get: () => borderStyle.value, set: v => borderStyle.value = v || 'solid', debounce: false },
{ css: 'border-color', group: 'border', get: () => borderColor.value, set: v => borderColor.value = v, debounce: true },
];
const unitProps = [
2026-03-05 14:49:58 +01:00
{ css: 'font-size', group: 'fontSize', ref: fontSize, debounce: true },
{ css: 'line-height', group: 'lineHeight', ref: lineHeight, debounce: true },
{ css: 'border-width', group: 'border', ref: borderWidth, debounce: true },
];
2026-03-05 10:45:55 +01:00
const sides = [
{ key: 'top', label: 'Haut' },
{ key: 'bottom', label: 'Bas' },
{ key: 'left', label: 'Gauche' },
{ key: 'right', label: 'Droite' },
];
const updateMarginValue = (side, value) => {
if (marginLocked.value) {
for (const s of ['top', 'right', 'bottom', 'left']) margin[s].value = value;
} else {
margin[side].value = value;
}
};
const updateMarginUnit = (side, unit) => {
if (marginLocked.value) {
for (const s of ['top', 'right', 'bottom', 'left']) {
margin[s].value = convertUnit(margin[s].value, margin[s].unit, unit);
margin[s].unit = unit;
}
} else {
margin[side].value = convertUnit(margin[side].value, margin[side].unit, unit);
margin[side].unit = unit;
}
};
const updatePaddingValue = (side, value) => {
if (paddingLocked.value) {
for (const s of ['top', 'right', 'bottom', 'left']) padding[s].value = value;
} else {
padding[side].value = value;
}
};
const updatePaddingUnit = (side, unit) => {
if (paddingLocked.value) {
for (const s of ['top', 'right', 'bottom', 'left']) {
padding[s].value = convertUnit(padding[s].value, padding[s].unit, unit);
padding[s].unit = unit;
}
} else {
padding[side].value = convertUnit(padding[side].value, padding[side].unit, unit);
padding[side].unit = unit;
}
};
2026-03-05 14:49:58 +01:00
// CSS properties covered by each group
const settingGroups = {
font: ['font-family', 'font-style', 'font-weight'],
fontSize: ['font-size'],
lineHeight:['line-height'],
textAlign: ['text-align'],
color: ['color'],
background:['background'],
border: ['border-width', 'border-style', 'border-color'],
margin: ['margin-top', 'margin-right', 'margin-bottom', 'margin-left'],
padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
};
// Remove CSS properties from the element's custom CSS block
const removeProps = (cssProps) => {
if (!selector.value) return;
const block = stylesheetStore.extractBlock(selector.value);
if (!block || !stylesheetStore.customCss.includes(block)) return;
let newBlock = block;
for (const prop of cssProps) {
const escaped = prop.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
newBlock = newBlock.replace(
new RegExp('[ \\t]*' + escaped + '\\s*:[^;]*;[ \\t]*\\n?', 'g'),
''
);
}
// If the block is now empty, remove it entirely
const inner = newBlock.replace(/^[^{]*\{/, '').replace(/\}[^}]*$/, '');
if (!inner.trim()) {
stylesheetStore.replaceInCustomCss(block, '');
} else {
stylesheetStore.replaceBlock(block, newBlock);
}
};
// Generic update: push a single property to the stylesheet store
const updateProp = (cssProp, value, unit) => {
if (!selector.value) return;
stylesheetStore.updateProperty(selector.value, cssProp, value, unit);
};
const getSelectorFromElement = (element) => {
if (element.id) {
return `#${element.id}`;
}
const tagName = element.tagName.toLowerCase();
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;
};
const getInstanceCount = (sel) => {
if (!props.iframeRef || !props.iframeRef.contentDocument) return 0;
try {
return props.iframeRef.contentDocument.querySelectorAll(sel).length;
} catch (e) {
return 0;
}
};
const elementCss = computed(() => {
if (!selector.value) return '';
return stylesheetStore.extractBlock(selector.value) || '';
});
2026-03-05 19:00:29 +01:00
// Canonical property order matching the template settings-subsections
const displayedCssOrder = [
// font group
{ css: 'font-family', group: 'font', special: true,
getValue: () => { const val = settingEnabled.font ? fontFamily.value : textDefaults.fontFamily; return val === 'sans-serif' ? 'sans-serif' : `"${val}"`; } },
{ css: 'font-style', group: 'font',
getValue: () => italic.value ? 'italic' : null,
skip: () => !settingEnabled.font || !italic.value },
{ css: 'font-weight', group: 'font',
getValue: () => bold.value ? 'bold' : null,
skip: () => !settingEnabled.font || !bold.value },
// fontSize group
{ css: 'font-size', group: 'fontSize', special: true,
getValue: () => `${(settingEnabled.fontSize ? fontSize : textDefaults.fontSize).value}${(settingEnabled.fontSize ? fontSize : textDefaults.fontSize).unit}` },
// lineHeight group
{ css: 'line-height', group: 'lineHeight', special: true,
getValue: () => `${(settingEnabled.lineHeight ? lineHeight : textDefaults.lineHeight).value}${(settingEnabled.lineHeight ? lineHeight : textDefaults.lineHeight).unit}` },
// textAlign group
{ css: 'text-align', group: 'textAlign',
getValue: () => textAlign.value,
skip: () => !settingEnabled.textAlign },
// color group
{ css: 'color', group: 'color', special: true,
getValue: () => settingEnabled.color ? color.value : textDefaults.color },
// background group
{ css: 'background', group: 'background',
getValue: () => background.value,
skip: () => !settingEnabled.background },
// border group
{ css: 'border-width', group: 'border',
getValue: () => `${borderWidth.value}${borderWidth.unit}`,
skip: () => !settingEnabled.border },
{ css: 'border-style', group: 'border',
getValue: () => borderStyle.value,
skip: () => !settingEnabled.border || borderWidth.value === 0 },
{ css: 'border-color', group: 'border',
getValue: () => borderColor.value,
skip: () => !settingEnabled.border || borderWidth.value === 0 },
];
const displayedCss = computed(() => {
if (!selector.value) return '';
2026-03-05 19:00:29 +01:00
const lines = [];
2026-03-05 19:00:29 +01:00
for (const entry of displayedCssOrder) {
if (entry.skip && entry.skip()) continue;
const val = entry.getValue();
if (val === null || val === undefined) continue;
const comment = (entry.special && !settingEnabled[entry.group]) ? ' /* valeur par défaut */' : '';
lines.push(` ${entry.css}: ${val};${comment}`);
}
2026-03-05 19:00:29 +01:00
// margin
2026-03-05 14:49:58 +01:00
if (settingEnabled.margin) {
for (const side of ['top', 'right', 'bottom', 'left']) {
2026-03-05 19:00:29 +01:00
lines.push(` margin-${side}: ${margin[side].value}${margin[side].unit};`);
2026-03-05 10:45:55 +01:00
}
}
2026-03-05 14:49:58 +01:00
2026-03-05 19:00:29 +01:00
// padding
2026-03-05 14:49:58 +01:00
if (settingEnabled.padding) {
for (const side of ['top', 'right', 'bottom', 'left']) {
2026-03-05 19:00:29 +01:00
lines.push(` padding-${side}: ${padding[side].value}${padding[side].unit};`);
2026-03-05 10:45:55 +01:00
}
}
2026-03-05 19:00:29 +01:00
return `${selector.value} {\n${lines.join('\n')}\n}`;
});
2026-03-05 14:49:58 +01:00
// Apply all properties for a given group to the stylesheet
const applyGroup = (group) => {
if (!selector.value) return;
2026-03-05 14:49:58 +01:00
for (const prop of styleProps) {
2026-03-05 14:49:58 +01:00
if (prop.group !== group) continue;
if (prop.skipWhenDefault && prop.skipWhenDefault(prop.get())) continue;
updateProp(prop.css, prop.get());
}
for (const prop of unitProps) {
2026-03-05 14:49:58 +01:00
if (prop.group === group) updateProp(prop.css, prop.ref.value, prop.ref.unit);
}
if (group === 'margin') {
for (const side of ['top', 'right', 'bottom', 'left']) {
updateProp(`margin-${side}`, margin[side].value, margin[side].unit);
}
}
2026-03-05 14:49:58 +01:00
if (group === 'padding') {
for (const side of ['top', 'right', 'bottom', 'left']) {
updateProp(`padding-${side}`, padding[side].value, padding[side].unit);
}
}
};
// Apply all currently-enabled groups to keep the CSS block consistent
const applyAllEnabledGroups = () => {
for (const group of Object.keys(settingEnabled)) {
if (settingEnabled[group]) applyGroup(group);
}
};
// When unchecking a special group: save element value, load TextSettings fallback for display
const saveToCache = (group) => {
2026-03-05 14:49:58 +01:00
if (group === 'fontSize') {
settingCache.fontSize = { value: fontSize.value, unit: fontSize.unit };
} else if (group === 'lineHeight') {
settingCache.lineHeight = { value: lineHeight.value, unit: lineHeight.unit };
2026-03-05 14:49:58 +01:00
} else if (group === 'font') {
settingCache.font = { fontFamily: fontFamily.value, italic: italic.value, bold: bold.value };
} else if (group === 'color') {
settingCache.color = color.value;
}
};
// When re-checking a special group: restore cached element value
const restoreFromCache = (group) => {
if (group === 'fontSize' && settingCache.fontSize) {
fontSize.value = settingCache.fontSize.value;
fontSize.unit = settingCache.fontSize.unit;
settingCache.fontSize = null;
} else if (group === 'lineHeight' && settingCache.lineHeight) {
lineHeight.value = settingCache.lineHeight.value;
lineHeight.unit = settingCache.lineHeight.unit;
settingCache.lineHeight = null;
2026-03-05 14:49:58 +01:00
} else if (group === 'font' && settingCache.font) {
fontFamily.value = settingCache.font.fontFamily;
italic.value = settingCache.font.italic;
bold.value = settingCache.font.bold;
settingCache.font = null;
} else if (group === 'color' && settingCache.color !== null) {
color.value = settingCache.color;
settingCache.color = null;
}
};
2026-03-05 19:00:29 +01:00
// Remove a special group's CSS properties so the element inherits from TextSettings defaults
// Then re-write TextSettings values on their target selectors (p/body) to prevent
// shared selector conflicts (e.g. popup on `p` removing TextSettings' font-size)
const removeSpecialGroupProps = (group) => {
if (!selector.value) return;
if (group === 'fontSize') {
2026-03-05 19:00:29 +01:00
removeProps(['font-size']);
stylesheetStore.updateProperty('p', 'font-size', textDefaults.fontSize.value, textDefaults.fontSize.unit);
} else if (group === 'lineHeight') {
2026-03-05 19:00:29 +01:00
removeProps(['line-height']);
stylesheetStore.updateProperty('p', 'line-height', textDefaults.lineHeight.value, textDefaults.lineHeight.unit);
} else if (group === 'color') {
2026-03-05 19:00:29 +01:00
removeProps(['color']);
stylesheetStore.updateProperty('body', 'color', textDefaults.color);
} else if (group === 'font') {
2026-03-05 19:00:29 +01:00
removeProps(['font-family', 'font-style', 'font-weight']);
2026-03-05 16:29:42 +01:00
const fontVal = textDefaults.fontFamily === 'sans-serif' ? 'sans-serif' : `"${textDefaults.fontFamily}"`;
2026-03-05 19:00:29 +01:00
stylesheetStore.updateProperty('body', 'font-family', fontVal);
}
};
2026-03-05 14:49:58 +01:00
// Toggle a setting group on/off
const onToggleSetting = (group, enabled) => {
settingEnabled[group] = enabled;
isUpdatingFromStore = true;
2026-03-05 14:49:58 +01:00
if (enabled) {
restoreFromCache(group);
applyAllEnabledGroups();
} else {
saveToCache(group);
const specialGroups = ['font', 'fontSize', 'lineHeight', 'color'];
if (specialGroups.includes(group)) {
2026-03-05 19:00:29 +01:00
removeSpecialGroupProps(group);
} else {
removeProps(settingGroups[group]);
}
2026-03-05 10:45:55 +01:00
}
2026-03-05 19:00:29 +01:00
saveElementState();
nextTick(() => { isUpdatingFromStore = false; });
};
2026-03-05 19:00:29 +01:00
// Load font when fontFamily changes, then update CSS (after font-face is ready)
2026-03-05 16:29:42 +01:00
watch(fontFamily, async (val) => {
if (val && val !== 'sans-serif') await loadFont(val);
2026-03-05 19:00:29 +01:00
if (isUpdatingFromStore) return;
if (!settingEnabled.font) return;
const cssValue = val === 'sans-serif' ? 'sans-serif' : `"${val}"`;
updateProp('font-family', cssValue);
2026-03-05 16:29:42 +01:00
});
2026-03-05 19:00:29 +01:00
// Sync special groups fields with TextSettings — only when toggle is OFF and no cached element value
2026-03-05 16:51:35 +01:00
watch(() => textDefaults.fontFamily, (val) => {
2026-03-05 19:00:29 +01:00
if (!settingEnabled.font && settingCache.font === null) fontFamily.value = val;
2026-03-05 16:51:35 +01:00
});
watch(() => textDefaults.fontSize, (val) => {
2026-03-05 19:00:29 +01:00
if (!settingEnabled.fontSize && settingCache.fontSize === null) {
2026-03-05 16:51:35 +01:00
fontSize.value = val.value;
fontSize.unit = val.unit;
}
}, { deep: true });
watch(() => textDefaults.lineHeight, (val) => {
2026-03-05 19:00:29 +01:00
if (!settingEnabled.lineHeight && settingCache.lineHeight === null) {
2026-03-05 16:51:35 +01:00
lineHeight.value = val.value;
lineHeight.unit = val.unit;
}
}, { deep: true });
watch(() => textDefaults.color, (val) => {
2026-03-05 19:00:29 +01:00
if (!settingEnabled.color && settingCache.color === null) color.value = val;
2026-03-05 16:51:35 +01:00
});
2026-03-05 14:49:58 +01:00
// Watchers — simple props (with group guard)
for (const prop of styleProps) {
2026-03-05 19:00:29 +01:00
if (prop.skipWatch) continue;
watch(prop.get, () => {
if (isUpdatingFromStore) return;
2026-03-05 14:49:58 +01:00
if (!settingEnabled[prop.group]) return;
const fn = () => updateProp(prop.css, prop.get());
prop.debounce ? debouncedUpdate(fn) : fn();
});
}
2026-03-05 14:49:58 +01:00
// Watchers — unit props (with group guard)
for (const prop of unitProps) {
watch(() => prop.ref.value, () => {
if (isUpdatingFromStore) return;
2026-03-05 14:49:58 +01:00
if (!settingEnabled[prop.group]) return;
const fn = () => updateProp(prop.css, prop.ref.value, prop.ref.unit);
prop.debounce ? debouncedUpdate(fn) : fn();
});
watch(() => prop.ref.unit, () => {
if (isUpdatingFromStore) return;
2026-03-05 14:49:58 +01:00
if (!settingEnabled[prop.group]) return;
updateProp(prop.css, prop.ref.value, prop.ref.unit);
});
}
2026-03-05 10:45:55 +01:00
// Watchers — margin sides
watch(
() => [margin.top.value, margin.right.value, margin.bottom.value, margin.left.value],
() => {
if (isUpdatingFromStore) return;
2026-03-05 14:49:58 +01:00
if (!settingEnabled.margin) return;
2026-03-05 10:45:55 +01:00
debouncedUpdate(() => {
for (const side of ['top', 'right', 'bottom', 'left']) {
updateProp(`margin-${side}`, margin[side].value, margin[side].unit);
}
});
}
);
watch(
() => [margin.top.unit, margin.right.unit, margin.bottom.unit, margin.left.unit],
() => {
if (isUpdatingFromStore) return;
2026-03-05 14:49:58 +01:00
if (!settingEnabled.margin) return;
2026-03-05 10:45:55 +01:00
for (const side of ['top', 'right', 'bottom', 'left']) {
updateProp(`margin-${side}`, margin[side].value, margin[side].unit);
}
}
);
// Watchers — padding sides
watch(
() => [padding.top.value, padding.right.value, padding.bottom.value, padding.left.value],
() => {
if (isUpdatingFromStore) return;
2026-03-05 14:49:58 +01:00
if (!settingEnabled.padding) return;
2026-03-05 10:45:55 +01:00
debouncedUpdate(() => {
for (const side of ['top', 'right', 'bottom', 'left']) {
updateProp(`padding-${side}`, padding[side].value, padding[side].unit);
}
});
}
);
watch(
() => [padding.top.unit, padding.right.unit, padding.bottom.unit, padding.left.unit],
() => {
if (isUpdatingFromStore) return;
2026-03-05 14:49:58 +01:00
if (!settingEnabled.padding) return;
2026-03-05 10:45:55 +01:00
for (const side of ['top', 'right', 'bottom', 'left']) {
updateProp(`padding-${side}`, padding[side].value, padding[side].unit);
}
}
);
const handleCssInput = (newCss) => {
const oldBlock = elementCss.value;
if (oldBlock) {
stylesheetStore.replaceInCustomCss(oldBlock, newCss);
}
};
2026-03-05 19:00:29 +01:00
// Re-apply stored enabled properties for all elements with active toggles
// Protects popup values when TextSettings writes to shared selectors (e.g. `p`)
const reapplyStoredEnabledGroups = () => {
const currentSel = basePopup.value?.visible ? selector.value : null;
for (const [sel, state] of elementStates) {
if (!state.toggles || !state.values) continue;
if (sel === currentSel) continue; // handled by applyAllEnabledGroups below
if (state.toggles.font) {
const val = state.values.fontFamily === 'sans-serif' ? 'sans-serif' : `"${state.values.fontFamily}"`;
stylesheetStore.updateProperty(sel, 'font-family', val);
}
if (state.toggles.fontSize) {
stylesheetStore.updateProperty(sel, 'font-size', state.values.fontSize.value, state.values.fontSize.unit);
}
if (state.toggles.lineHeight) {
stylesheetStore.updateProperty(sel, 'line-height', state.values.lineHeight.value, state.values.lineHeight.unit);
}
if (state.toggles.color) {
stylesheetStore.updateProperty(sel, 'color', state.values.color);
}
}
// For visible popup, use live refs
if (currentSel) {
applyAllEnabledGroups();
}
};
// Watch stylesheet changes: re-apply all stored enabled properties
// flush: 'sync' ensures this runs BEFORE the preview renderer watcher,
// so the corrected CSS is what gets rendered (avoids race condition)
watch(
() => stylesheetStore.customCss,
() => {
2026-03-05 19:00:29 +01:00
if (isUpdatingFromStore) return;
isUpdatingFromStore = true;
reapplyStoredEnabledGroups();
nextTick(() => { isUpdatingFromStore = false; });
},
{ flush: 'sync' }
);
// Also watch when exiting edit mode
watch(
() => stylesheetStore.isEditing,
(isEditing, wasEditing) => {
if (basePopup.value?.visible && wasEditing && !isEditing && !isUpdatingFromStore) {
isUpdatingFromStore = true;
loadValuesFromStylesheet();
nextTick(() => { isUpdatingFromStore = false; });
}
}
);
2026-03-05 14:49:58 +01:00
const loadValuesFromStylesheet = (isInitialLoad = false) => {
if (!selector.value) return;
2026-03-05 19:00:29 +01:00
// During live sync, don't update any refs — the popup's state is the source of truth
if (!isInitialLoad) return;
2026-03-05 14:49:58 +01:00
try {
2026-03-05 19:00:29 +01:00
// Simple props — read from element's CSS block
for (const prop of styleProps) {
const data = stylesheetStore.extractValue(selector.value, prop.css);
if (data) {
2026-03-05 19:00:29 +01:00
const value = typeof data === 'string' ? data : data.value;
prop.set(value);
}
}
// Unit props
for (const prop of unitProps) {
const data = stylesheetStore.extractValue(selector.value, prop.css);
if (data && data.value !== undefined) {
2026-03-05 19:00:29 +01:00
prop.ref.value = data.value;
prop.ref.unit = data.unit;
}
}
2026-03-05 10:45:55 +01:00
// Margin sides — try individual first, fallback to shorthand
const spacingSides = ['top', 'right', 'bottom', 'left'];
for (const side of spacingSides) {
const data = stylesheetStore.extractValue(selector.value, `margin-${side}`);
if (data && data.value !== undefined) {
margin[side].value = data.value;
margin[side].unit = data.unit;
}
}
// Padding sides — try individual first, fallback to shorthand
for (const side of spacingSides) {
const data = stylesheetStore.extractValue(selector.value, `padding-${side}`);
if (data && data.value !== undefined) {
padding[side].value = data.value;
padding[side].unit = data.unit;
}
}
2026-03-05 19:00:29 +01:00
// Special groups: for disabled groups without cache, load fallback from TextSettings selectors
if (!settingEnabled.font && !settingCache.font) {
const ff = stylesheetStore.extractValue('body', 'font-family');
if (ff) fontFamily.value = (typeof ff === 'string' ? ff : ff.value).replace(/['"]/g, '');
const fs = stylesheetStore.extractValue('p', 'font-style');
if (fs) italic.value = (typeof fs === 'string' ? fs : fs.value) === 'italic';
const fw = stylesheetStore.extractValue('p', 'font-weight');
if (fw) { const v = typeof fw === 'string' ? fw : fw.value; bold.value = v === 'bold' || parseInt(v) >= 700; }
2026-03-05 14:49:58 +01:00
}
2026-03-05 19:00:29 +01:00
if (!settingEnabled.fontSize && !settingCache.fontSize) {
const data = stylesheetStore.extractValue('p', 'font-size');
if (data && data.value !== undefined) {
fontSize.value = data.value;
fontSize.unit = data.unit;
2026-03-05 14:49:58 +01:00
}
2026-03-05 19:00:29 +01:00
}
2026-03-05 14:49:58 +01:00
2026-03-05 19:00:29 +01:00
if (!settingEnabled.lineHeight && !settingCache.lineHeight) {
const data = stylesheetStore.extractValue('p', 'line-height');
if (data && data.value !== undefined) {
lineHeight.value = data.value;
lineHeight.unit = data.unit;
}
2026-03-05 19:00:29 +01:00
}
2026-03-05 19:00:29 +01:00
if (!settingEnabled.color && settingCache.color === null) {
const c = stylesheetStore.extractValue('body', 'color');
if (c) color.value = typeof c === 'string' ? c : c.value;
2026-03-05 14:49:58 +01:00
}
} catch (error) {
console.error('Error loading values from stylesheet:', error);
}
};
const open = (element, event, count = null) => {
isUpdatingFromStore = true;
selectedElement.value = element;
selector.value = getSelectorFromElement(element);
elementInstanceCount.value = count !== null ? count : getInstanceCount(selector.value);
2026-03-05 19:00:29 +01:00
// Restore persistent state for this element, or initialize all OFF
const stored = elementStates.get(selector.value);
if (stored) {
Object.assign(settingEnabled, stored.toggles);
settingCache.font = stored.cache.font ? { ...stored.cache.font } : null;
settingCache.fontSize = stored.cache.fontSize ? { ...stored.cache.fontSize } : null;
settingCache.lineHeight = stored.cache.lineHeight ? { ...stored.cache.lineHeight } : null;
settingCache.color = stored.cache.color ?? null;
} else {
for (const group of Object.keys(settingEnabled)) {
settingEnabled[group] = false;
}
settingCache.font = null;
settingCache.fontSize = null;
settingCache.lineHeight = null;
settingCache.color = null;
}
// Load CSS values for initial population
2026-03-05 14:49:58 +01:00
loadValuesFromStylesheet(true);
2026-03-05 19:00:29 +01:00
// Restore stored values: for enabled groups (CSS may have been overwritten by TextSettings)
// and for disabled groups with cache (field shows cached user value, grayed out)
if (stored && stored.values) {
if (settingEnabled.font) {
fontFamily.value = stored.values.fontFamily;
italic.value = stored.values.italic;
bold.value = stored.values.bold;
} else if (settingCache.font) {
fontFamily.value = settingCache.font.fontFamily;
italic.value = settingCache.font.italic;
bold.value = settingCache.font.bold;
}
if (settingEnabled.fontSize) {
fontSize.value = stored.values.fontSize.value;
fontSize.unit = stored.values.fontSize.unit;
} else if (settingCache.fontSize) {
fontSize.value = settingCache.fontSize.value;
fontSize.unit = settingCache.fontSize.unit;
}
if (settingEnabled.lineHeight) {
lineHeight.value = stored.values.lineHeight.value;
lineHeight.unit = stored.values.lineHeight.unit;
} else if (settingCache.lineHeight) {
lineHeight.value = settingCache.lineHeight.value;
lineHeight.unit = settingCache.lineHeight.unit;
}
if (settingEnabled.color) {
color.value = stored.values.color;
} else if (settingCache.color !== null) {
color.value = settingCache.color;
}
}
basePopup.value.open(event);
nextTick(() => { isUpdatingFromStore = false; });
};
const close = () => {
2026-03-05 19:00:29 +01:00
saveElementState();
basePopup.value?.close();
selector.value = '';
selectedElement.value = null;
emit('close');
};
const handleIframeClick = (event, targetElement = null, elementCount = null) => {
const element = targetElement || event.target;
if (element.tagName === 'BODY' || element.tagName === 'HTML') {
close();
return;
}
if (basePopup.value?.visible) {
close();
return;
}
open(element, event, elementCount);
};
2026-03-05 16:29:42 +01:00
onMounted(() => {
loadAllFontPreviews();
});
defineExpose({ handleIframeClick, close, visible });
</script>
<style scoped>
/* ElementPopup-specific styles (purple theme) */
.element-label {
background: var(--color-purple);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
}
.instance-count {
color: var(--color-purple);
font-size: 0.875rem;
}
2026-03-05 10:45:55 +01:00
.settings-subsection-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
2026-03-05 14:49:58 +01:00
/* Toggle setting checkbox */
.toggle-setting {
display: block;
margin-bottom: 0.25rem;
cursor: pointer;
accent-color: var(--color-purple, #7c3aed);
}
/* Disabled state: grey out fields but keep checkbox interactive */
.setting-disabled .field,
.setting-disabled .settings-subsection-header {
opacity: 0.4;
pointer-events: none;
}
2026-03-05 10:45:55 +01:00
.lock-toggle {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
padding: 2px 4px;
color: var(--color-text-muted, #999);
transition: color 0.15s, border-color 0.15s;
}
.lock-toggle:hover:not(:disabled) {
color: var(--color-text, #333);
border-color: var(--color-border, #ccc);
}
.lock-toggle.locked {
color: var(--color-purple, #7c3aed);
}
.lock-toggle:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>