diff --git a/public/assets/css/style.css b/public/assets/css/style.css index 6435e6e..a042ac7 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -481,6 +481,7 @@ input[type=number] { .settings-subsection { padding: var(--space-xs) 0; + padding-left: 30px; } .settings-subsection h3 { margin-top: calc(var(--space-xs) * 1.5); diff --git a/src/components/ElementPopup.vue b/src/components/ElementPopup.vue index b69a429..8211678 100644 --- a/src/components/ElementPopup.vue +++ b/src/components/ElementPopup.vue @@ -346,6 +346,31 @@ const settingCache = reactive({ color: null, // string }); +// 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 const settingEnabled = reactive({ @@ -362,7 +387,7 @@ const settingEnabled = reactive({ // Style property descriptors (with group field) const styleProps = [ - { css: 'font-family', group: 'font', get: () => fontFamily.value === 'sans-serif' ? 'sans-serif' : `"${fontFamily.value}"`, set: v => fontFamily.value = v.replace(/['"]/g, ''), debounce: false }, + { 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 }, { 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 }, @@ -501,90 +526,74 @@ const elementCss = computed(() => { return stylesheetStore.extractBlock(selector.value) || ''; }); -const generatePreviewCss = () => { - if (!selector.value) return ''; - - const properties = []; - - for (const prop of styleProps) { - if (!settingEnabled[prop.group]) continue; - const val = prop.get(); - if (!val) continue; - if (prop.css === 'font-style' && val !== 'italic') continue; - if (prop.css === 'font-weight' && val === 'normal') continue; - if ((prop.css === 'border-style' || prop.css === 'border-color') && borderWidth.value === 0) continue; - properties.push(` ${prop.css}: ${val};`); - } - - for (const prop of unitProps) { - if (!settingEnabled[prop.group]) continue; - if (prop.ref.value !== undefined && prop.ref.value !== null) { - properties.push(` ${prop.css}: ${prop.ref.value}${prop.ref.unit};`); - } - } - - if (settingEnabled.margin) { - for (const side of ['top', 'right', 'bottom', 'left']) { - if (margin[side].value !== undefined && margin[side].value !== null) { - properties.push(` margin-${side}: ${margin[side].value}${margin[side].unit};`); - } - } - } - - if (settingEnabled.padding) { - for (const side of ['top', 'right', 'bottom', 'left']) { - if (padding[side].value !== undefined && padding[side].value !== null) { - properties.push(` padding-${side}: ${padding[side].value}${padding[side].unit};`); - } - } - } - - if (properties.length === 0) return ''; - - return `${selector.value} {\n${properties.join('\n')}\n}`; -}; +// 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 ''; - let base = elementCss.value || generatePreviewCss(); - if (!base) base = `${selector.value} {\n}`; + const lines = []; - // Special groups config for displayedCss annotation - const specialDisplayProps = [ - { group: 'fontSize', css: 'font-size', getValue: () => `${(settingEnabled.fontSize ? fontSize : textDefaults.fontSize).value}${(settingEnabled.fontSize ? fontSize : textDefaults.fontSize).unit}` }, - { group: 'lineHeight', css: 'line-height', getValue: () => `${(settingEnabled.lineHeight ? lineHeight : textDefaults.lineHeight).value}${(settingEnabled.lineHeight ? lineHeight : textDefaults.lineHeight).unit}` }, - { group: 'color', css: 'color', getValue: () => settingEnabled.color ? color.value : textDefaults.color }, - { group: 'font', css: 'font-family', getValue: () => { const val = settingEnabled.font ? fontFamily.value : textDefaults.fontFamily; return val === 'sans-serif' ? 'sans-serif' : `"${val}"`; } }, - ]; + 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}`); + } - // For disabled special groups: annotate existing lines with comment - // For enabled special groups: remove stale comments - for (const sp of specialDisplayProps) { - if (!settingEnabled[sp.group] && base.includes(sp.css + ':')) { - const escaped = sp.css.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - base = base.replace(new RegExp(`(${escaped}:\\s*[^;]+;)(?!\\s*\\/\\*)`), '$1 /* valeur par défaut */'); - } - if (settingEnabled[sp.group]) { - const escaped = sp.css.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - base = base.replace(new RegExp(`(${escaped}:\\s*[^;]+;)\\s*\\/\\* valeur par défaut \\*\\/`), '$1'); + // margin + if (settingEnabled.margin) { + for (const side of ['top', 'right', 'bottom', 'left']) { + lines.push(` margin-${side}: ${margin[side].value}${margin[side].unit};`); } } - // Inject missing special properties (if not yet in block at all) - const toInject = []; - for (const sp of specialDisplayProps) { - if (!base.includes(sp.css + ':')) { - const comment = !settingEnabled[sp.group] ? ' /* valeur par défaut */' : ''; - toInject.push(` ${sp.css}: ${sp.getValue()};${comment}`); + // padding + if (settingEnabled.padding) { + for (const side of ['top', 'right', 'bottom', 'left']) { + lines.push(` padding-${side}: ${padding[side].value}${padding[side].unit};`); } } - if (toInject.length > 0) { - base = base.replace(/\}\s*$/, toInject.join('\n') + '\n}'); - } - - return base; + return `${selector.value} {\n${lines.join('\n')}\n}`; }); // Apply all properties for a given group to the stylesheet @@ -652,19 +661,24 @@ const restoreFromCache = (group) => { } }; -// Replace a special group's CSS values with TextSettings defaults -const applyDefaultsForGroup = (group) => { +// 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') { - updateProp('font-size', textDefaults.fontSize.value, textDefaults.fontSize.unit); + removeProps(['font-size']); + stylesheetStore.updateProperty('p', 'font-size', textDefaults.fontSize.value, textDefaults.fontSize.unit); } else if (group === 'lineHeight') { - updateProp('line-height', textDefaults.lineHeight.value, textDefaults.lineHeight.unit); + removeProps(['line-height']); + stylesheetStore.updateProperty('p', 'line-height', textDefaults.lineHeight.value, textDefaults.lineHeight.unit); } else if (group === 'color') { - updateProp('color', textDefaults.color); + removeProps(['color']); + stylesheetStore.updateProperty('body', 'color', textDefaults.color); } else if (group === 'font') { + removeProps(['font-family', 'font-style', 'font-weight']); const fontVal = textDefaults.fontFamily === 'sans-serif' ? 'sans-serif' : `"${textDefaults.fontFamily}"`; - updateProp('font-family', fontVal); - removeProps(['font-style', 'font-weight']); + stylesheetStore.updateProperty('body', 'font-family', fontVal); } }; @@ -679,41 +693,47 @@ const onToggleSetting = (group, enabled) => { saveToCache(group); const specialGroups = ['font', 'fontSize', 'lineHeight', 'color']; if (specialGroups.includes(group)) { - applyDefaultsForGroup(group); + removeSpecialGroupProps(group); } else { removeProps(settingGroups[group]); } } + saveElementState(); nextTick(() => { isUpdatingFromStore = false; }); }; -// Load font when fontFamily changes +// Load font when fontFamily changes, then update CSS (after font-face is ready) watch(fontFamily, async (val) => { if (val && val !== 'sans-serif') await loadFont(val); + if (isUpdatingFromStore) return; + if (!settingEnabled.font) return; + const cssValue = val === 'sans-serif' ? 'sans-serif' : `"${val}"`; + updateProp('font-family', cssValue); }); -// Sync disabled special groups with TextSettings defaults +// Sync special groups fields with TextSettings — only when toggle is OFF and no cached element value watch(() => textDefaults.fontFamily, (val) => { - if (!settingEnabled.font) fontFamily.value = val; + if (!settingEnabled.font && settingCache.font === null) fontFamily.value = val; }); watch(() => textDefaults.fontSize, (val) => { - if (!settingEnabled.fontSize) { + if (!settingEnabled.fontSize && settingCache.fontSize === null) { fontSize.value = val.value; fontSize.unit = val.unit; } }, { deep: true }); watch(() => textDefaults.lineHeight, (val) => { - if (!settingEnabled.lineHeight) { + if (!settingEnabled.lineHeight && settingCache.lineHeight === null) { lineHeight.value = val.value; lineHeight.unit = val.unit; } }, { deep: true }); watch(() => textDefaults.color, (val) => { - if (!settingEnabled.color) color.value = val; + if (!settingEnabled.color && settingCache.color === null) color.value = val; }); // Watchers — simple props (with group guard) for (const prop of styleProps) { + if (prop.skipWatch) continue; watch(prop.get, () => { if (isUpdatingFromStore) return; if (!settingEnabled[prop.group]) return; @@ -792,16 +812,48 @@ const handleCssInput = (newCss) => { } }; -// Watch stylesheet changes to sync values +// 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, () => { - if (basePopup.value?.visible && !isUpdatingFromStore) { - isUpdatingFromStore = true; - loadValuesFromStylesheet(); - nextTick(() => { isUpdatingFromStore = false; }); - } - } + if (isUpdatingFromStore) return; + isUpdatingFromStore = true; + reapplyStoredEnabledGroups(); + nextTick(() => { isUpdatingFromStore = false; }); + }, + { flush: 'sync' } ); // Also watch when exiting edit mode @@ -819,30 +871,16 @@ watch( const loadValuesFromStylesheet = (isInitialLoad = false) => { if (!selector.value) return; - const groupsFound = new Set(); - - // Only detect settingEnabled from the custom CSS block (not baseCss fallback) - const customCssBlock = (() => { - const css = stylesheetStore.customCss; - if (!css) return ''; - const escaped = selector.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const match = new RegExp(escaped + '\\s*\\{([^}]*)\\}').exec(css); - return match ? match[1] : ''; - })(); - const isInCustomCss = (cssProp) => - customCssBlock.includes(cssProp + ':') || customCssBlock.includes(cssProp + ' :'); + // During live sync, don't update any refs — the popup's state is the source of truth + if (!isInitialLoad) return; try { - // Simple props + // Simple props — read from element's CSS block for (const prop of styleProps) { const data = stylesheetStore.extractValue(selector.value, prop.css); if (data) { - if (isInCustomCss(prop.css)) groupsFound.add(prop.group); - // During live sync, don't overwrite refs for disabled groups (they show cached/fallback values) - if (isInitialLoad || settingEnabled[prop.group]) { - const value = typeof data === 'string' ? data : data.value; - prop.set(value); - } + const value = typeof data === 'string' ? data : data.value; + prop.set(value); } } @@ -850,94 +888,59 @@ const loadValuesFromStylesheet = (isInitialLoad = false) => { for (const prop of unitProps) { const data = stylesheetStore.extractValue(selector.value, prop.css); if (data && data.value !== undefined) { - if (isInCustomCss(prop.css)) groupsFound.add(prop.group); - if (isInitialLoad || settingEnabled[prop.group]) { - prop.ref.value = data.value; - prop.ref.unit = data.unit; - } + prop.ref.value = data.value; + prop.ref.unit = data.unit; } } // Margin sides — try individual first, fallback to shorthand const spacingSides = ['top', 'right', 'bottom', 'left']; - let anyMarginFound = false; 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; - if (isInCustomCss(`margin-${side}`)) anyMarginFound = true; } } - if (!anyMarginFound && isInCustomCss('margin')) { - const data = stylesheetStore.extractValue(selector.value, 'margin'); - if (data && data.value !== undefined) { - for (const side of spacingSides) { - margin[side].value = data.value; - margin[side].unit = data.unit; - } - anyMarginFound = true; - } - } - if (anyMarginFound) groupsFound.add('margin'); // Padding sides — try individual first, fallback to shorthand - let anyPaddingFound = false; 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; - if (isInCustomCss(`padding-${side}`)) anyPaddingFound = true; } } - if (!anyPaddingFound && isInCustomCss('padding')) { - const data = stylesheetStore.extractValue(selector.value, 'padding'); + + // 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; } + } + + if (!settingEnabled.fontSize && !settingCache.fontSize) { + const data = stylesheetStore.extractValue('p', 'font-size'); if (data && data.value !== undefined) { - for (const side of spacingSides) { - padding[side].value = data.value; - padding[side].unit = data.unit; - } - anyPaddingFound = true; + fontSize.value = data.value; + fontSize.unit = data.unit; } } - if (anyPaddingFound) groupsFound.add('padding'); - // settingEnabled is NEVER modified automatically — only by user toggle clicks - - // Special groups: font, fontSize, color - // If not found in element CSS, load fallback values from TextSettings selectors - // Only on initial open — during live sync, ElementPopup is independent from TextSettings - if (isInitialLoad) { - if (!groupsFound.has('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; } + 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; } + } - if (!groupsFound.has('fontSize')) { - const data = stylesheetStore.extractValue('p', 'font-size'); - if (data && data.value !== undefined) { - fontSize.value = data.value; - fontSize.unit = data.unit; - } - } - - if (!groupsFound.has('lineHeight')) { - const data = stylesheetStore.extractValue('p', 'line-height'); - if (data && data.value !== undefined) { - lineHeight.value = data.value; - lineHeight.unit = data.unit; - } - } - - if (!groupsFound.has('color')) { - const c = stylesheetStore.extractValue('body', 'color'); - if (c) color.value = typeof c === 'string' ? c : c.value; - } + if (!settingEnabled.color && settingCache.color === null) { + const c = stylesheetStore.extractValue('body', 'color'); + if (c) color.value = typeof c === 'string' ? c : c.value; } } catch (error) { @@ -946,31 +949,72 @@ const loadValuesFromStylesheet = (isInitialLoad = false) => { }; const open = (element, event, count = null) => { - // Reset all toggles to unchecked for each new element - for (const group of Object.keys(settingEnabled)) { - settingEnabled[group] = false; - } - - // Clear cache from any previous element - settingCache.font = null; - settingCache.fontSize = null; - settingCache.lineHeight = null; - settingCache.color = null; - isUpdatingFromStore = true; selectedElement.value = element; selector.value = getSelectorFromElement(element); - elementInstanceCount.value = count !== null ? count : getInstanceCount(selector.value); - loadValuesFromStylesheet(true); - basePopup.value.open(event); + // 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 + loadValuesFromStylesheet(true); + + // 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 = () => { + saveElementState(); basePopup.value?.close(); selector.value = ''; selectedElement.value = null; diff --git a/src/components/editor/TextSettings.vue b/src/components/editor/TextSettings.vue index 6e989f2..06c26b2 100644 --- a/src/components/editor/TextSettings.vue +++ b/src/components/editor/TextSettings.vue @@ -72,7 +72,7 @@