Compare commits

...

4 commits

Author SHA1 Message Date
isUnknown
b584a539fe feat: add rem unit option to margins in PageSettings and TextSettings
Adds 'rem' as an available unit for margin inputs in both components. Updates useCssSync composable to parse rem values from CSS.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 16:38:29 +01:00
isUnknown
d3cd296fd7 fix: initialize margin fields from stylesheet detailed values
Fixes margin/padding field initialization when CSS contains 4-value shorthand (e.g., margin: 0mm 0mm 24mm 0mm). Now properly populates both simple and detailed fields, and auto-opens detailed editor when values differ.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 16:35:53 +01:00
isUnknown
c4d2015a69 refactor: extract reusable UI components and composables from TextSettings
Major refactoring to improve code quality and reduce duplication:

TextSettings.vue: 1127 → 269 lines (-76%)

New composables:
- useCssUpdater.js: generic CSS update/remove functions
- useCssSync.js: CSS parsing to form fields

New UI components:
- UnitToggle.vue: reusable unit selector buttons
- InputWithUnit.vue: number input with unit toggle
- MarginEditor.vue: simple/detailed margin editor with sync

Benefits:
- Reusable components for other settings panels
- Centralized CSS manipulation logic
- Better separation of concerns
- Easier to test and maintain

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 16:30:44 +01:00
isUnknown
94112ab1a8 feat: add automatic CSS formatting with Prettier
Integrates Prettier into the stylesheet store for automatic CSS formatting:
- Installs prettier v3.7.4 with postcss plugin
- Implements formatContent() function using Prettier API
- Auto-formats CSS after 500ms of inactivity (debounced)
- Prevents infinite loops with isFormatting flag
- Ensures consistent indentation and line breaks
- Cleans up extra blank lines and formatting issues

This ensures the CSS in the Code tab stays clean and properly formatted
after reactive edits from TextSettings and PageSettings panels.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 16:23:42 +01:00
10 changed files with 793 additions and 1008 deletions

16
package-lock.json generated
View file

@ -12,6 +12,7 @@
"highlight.js": "^11.11.1",
"pagedjs": "^0.4.3",
"pinia": "^3.0.4",
"prettier": "^3.7.4",
"vue": "^3.5.24"
},
"devDependencies": {
@ -1888,6 +1889,21 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prettier": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",

View file

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

View file

@ -66,6 +66,13 @@
>
px
</button>
<button
type="button"
:class="{ active: margins.top.unit === 'rem' }"
@click="margins.top.unit = 'rem'"
>
rem
</button>
</div>
</div>
</div>
@ -94,6 +101,13 @@
>
px
</button>
<button
type="button"
:class="{ active: margins.bottom.unit === 'rem' }"
@click="margins.bottom.unit = 'rem'"
>
rem
</button>
</div>
</div>
</div>
@ -122,6 +136,13 @@
>
px
</button>
<button
type="button"
:class="{ active: margins.left.unit === 'rem' }"
@click="margins.left.unit = 'rem'"
>
rem
</button>
</div>
</div>
</div>
@ -150,6 +171,13 @@
>
px
</button>
<button
type="button"
:class="{ active: margins.right.unit === 'rem' }"
@click="margins.right.unit = 'rem'"
>
rem
</button>
</div>
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
<template>
<div class="input-with-unit">
<input
v-if="showRange"
type="range"
:value="modelValue.value"
:min="min"
:max="max"
:step="step"
@input="updateValue(Number($event.target.value))"
/>
<input
type="number"
:value="modelValue.value"
:min="min"
:max="max"
:step="step"
class="size-input"
@input="updateValue(Number($event.target.value))"
/>
<UnitToggle
:modelValue="modelValue.unit"
:units="units"
@update:modelValue="updateUnit"
/>
<slot name="after" />
</div>
</template>
<script setup>
import UnitToggle from './UnitToggle.vue';
const props = defineProps({
modelValue: {
type: Object,
required: true,
validator: (v) => 'value' in v && 'unit' in v
},
units: {
type: Array,
default: () => ['mm', 'px']
},
min: {
type: Number,
default: 0
},
max: {
type: Number,
default: 100
},
step: {
type: Number,
default: 1
},
showRange: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue']);
const updateValue = (value) => {
emit('update:modelValue', { ...props.modelValue, value });
};
const updateUnit = (unit) => {
emit('update:modelValue', { ...props.modelValue, unit });
};
</script>

View file

@ -0,0 +1,162 @@
<template>
<div class="margin-editor">
<div class="field">
<label :for="id">{{ label }}</label>
<div class="input-with-unit">
<input
:id="id"
type="number"
:value="simple.value"
min="0"
@input="updateSimpleValue(Number($event.target.value))"
/>
<UnitToggle
:modelValue="simple.unit"
:units="units"
@update:modelValue="updateSimpleUnit"
/>
<button
type="button"
class="collapse-toggle"
:class="{ expanded }"
@click="toggleExpanded"
title="Réglages détaillés"
>
</button>
</div>
</div>
<div v-if="expanded" class="subsection collapsed-section">
<div v-for="side in sides" :key="side.key" class="field">
<label :for="`${id}-${side.key}`">{{ side.label }}</label>
<div class="input-with-unit">
<input
:id="`${id}-${side.key}`"
type="number"
:value="detailed[side.key].value"
min="0"
@input="updateDetailedValue(side.key, Number($event.target.value))"
/>
<UnitToggle
:modelValue="detailed[side.key].unit"
:units="units"
@update:modelValue="(unit) => updateDetailedUnit(side.key, unit)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import UnitToggle from './UnitToggle.vue';
const props = defineProps({
id: {
type: String,
required: true
},
label: {
type: String,
required: true
},
simple: {
type: Object,
required: true,
validator: (v) => 'value' in v && 'unit' in v
},
detailed: {
type: Object,
required: true,
validator: (v) => ['top', 'right', 'bottom', 'left'].every(k => k in v)
},
units: {
type: Array,
default: () => ['mm', 'px']
}
});
const emit = defineEmits(['update:simple', 'update:detailed', 'change']);
const expanded = ref(false);
let isSyncing = false;
const sides = [
{ key: 'top', label: 'Haut' },
{ key: 'bottom', label: 'Bas' },
{ key: 'left', label: 'Gauche' },
{ key: 'right', label: 'Droite' }
];
const toggleExpanded = () => {
expanded.value = !expanded.value;
if (expanded.value) {
// Sync detailed values from simple when expanding
syncDetailedFromSimple();
}
};
const syncDetailedFromSimple = () => {
isSyncing = true;
const newDetailed = {};
for (const side of sides) {
newDetailed[side.key] = { value: props.simple.value, unit: props.simple.unit };
}
emit('update:detailed', newDetailed);
isSyncing = false;
};
const updateSimpleValue = (value) => {
const newSimple = { ...props.simple, value };
emit('update:simple', newSimple);
// Sync all detailed values
isSyncing = true;
const newDetailed = {};
for (const side of sides) {
newDetailed[side.key] = { value, unit: props.simple.unit };
}
emit('update:detailed', newDetailed);
isSyncing = false;
emit('change', { type: 'simple', simple: newSimple });
};
const updateSimpleUnit = (unit) => {
const newSimple = { ...props.simple, unit };
emit('update:simple', newSimple);
// Sync all detailed units
isSyncing = true;
const newDetailed = {};
for (const side of sides) {
newDetailed[side.key] = { ...props.detailed[side.key], unit };
}
emit('update:detailed', newDetailed);
isSyncing = false;
emit('change', { type: 'simple', simple: newSimple });
};
const updateDetailedValue = (key, value) => {
if (isSyncing) return;
const newDetailed = { ...props.detailed, [key]: { ...props.detailed[key], value } };
emit('update:detailed', newDetailed);
if (expanded.value) {
emit('change', { type: 'detailed', detailed: newDetailed });
}
};
const updateDetailedUnit = (key, unit) => {
if (isSyncing) return;
const newDetailed = { ...props.detailed, [key]: { ...props.detailed[key], unit } };
emit('update:detailed', newDetailed);
if (expanded.value) {
emit('change', { type: 'detailed', detailed: newDetailed });
}
};
defineExpose({ expanded });
</script>

View file

@ -0,0 +1,28 @@
<template>
<div class="unit-toggle">
<button
v-for="unit in units"
:key="unit"
type="button"
:class="{ active: modelValue === unit }"
@click="$emit('update:modelValue', unit)"
>
{{ unit }}
</button>
</div>
</template>
<script setup>
defineProps({
modelValue: {
type: String,
required: true
},
units: {
type: Array,
default: () => ['mm', 'px']
}
});
defineEmits(['update:modelValue']);
</script>

View file

@ -0,0 +1,119 @@
import { useStylesheetStore } from '../stores/stylesheet';
export function useCssSync() {
const store = useStylesheetStore();
/**
* Extract a simple CSS value (string)
*/
const extractValue = (selector, property) => {
const block = store.extractBlock(selector);
if (!block) return null;
const match = block.match(new RegExp(`${property}:\\s*([^;]+)`, 'i'));
return match ? match[1].trim() : null;
};
/**
* Extract a numeric CSS value with unit
* Returns { value: number, unit: string } or null
*/
const extractNumericValue = (selector, property, allowedUnits = ['px', 'em', 'rem', 'mm']) => {
const block = store.extractBlock(selector);
if (!block) return null;
const unitsPattern = allowedUnits.join('|');
const match = block.match(new RegExp(`${property}:\\s*([0-9.]+)(${unitsPattern})`, 'i'));
if (match) {
return {
value: parseFloat(match[1]),
unit: match[2].toLowerCase()
};
}
return null;
};
/**
* Extract margin/padding shorthand (handles 1 or 4 values)
* Returns { simple: { value, unit } } or { detailed: { top, right, bottom, left } }
*/
const extractSpacing = (selector, property, allowedUnits = ['mm', 'px', 'rem']) => {
const block = store.extractBlock(selector);
if (!block) return null;
const unitsPattern = allowedUnits.join('|');
// Check for detailed properties first (property-top, property-right, etc.)
const topMatch = block.match(new RegExp(`${property}-top:\\s*([0-9.]+)(${unitsPattern})`, 'i'));
if (topMatch) {
const rightMatch = block.match(new RegExp(`${property}-right:\\s*([0-9.]+)(${unitsPattern})`, 'i'));
const bottomMatch = block.match(new RegExp(`${property}-bottom:\\s*([0-9.]+)(${unitsPattern})`, 'i'));
const leftMatch = block.match(new RegExp(`${property}-left:\\s*([0-9.]+)(${unitsPattern})`, 'i'));
return {
detailed: {
top: topMatch ? { value: parseFloat(topMatch[1]), unit: topMatch[2] } : { value: 0, unit: 'mm' },
right: rightMatch ? { value: parseFloat(rightMatch[1]), unit: rightMatch[2] } : { value: 0, unit: 'mm' },
bottom: bottomMatch ? { value: parseFloat(bottomMatch[1]), unit: bottomMatch[2] } : { value: 0, unit: 'mm' },
left: leftMatch ? { value: parseFloat(leftMatch[1]), unit: leftMatch[2] } : { value: 0, unit: 'mm' },
}
};
}
// Check for shorthand property
const shorthandMatch = block.match(new RegExp(`${property}:\\s*([^;]+)`, 'i'));
if (!shorthandMatch) return null;
const shorthandValue = shorthandMatch[1].trim();
// Check for 4-value format: "0mm 0mm 24mm 0mm" (top right bottom left)
const fourValuePattern = new RegExp(
`^([0-9.]+)(${unitsPattern})\\s+([0-9.]+)(${unitsPattern})\\s+([0-9.]+)(${unitsPattern})\\s+([0-9.]+)(${unitsPattern})$`,
'i'
);
const fourValueMatch = shorthandValue.match(fourValuePattern);
if (fourValueMatch) {
return {
detailed: {
top: { value: parseFloat(fourValueMatch[1]), unit: fourValueMatch[2] },
right: { value: parseFloat(fourValueMatch[3]), unit: fourValueMatch[4] },
bottom: { value: parseFloat(fourValueMatch[5]), unit: fourValueMatch[6] },
left: { value: parseFloat(fourValueMatch[7]), unit: fourValueMatch[8] },
}
};
}
// Single value format: "10mm"
const singleValuePattern = new RegExp(`^([0-9.]+)(${unitsPattern})$`, 'i');
const singleValueMatch = shorthandValue.match(singleValuePattern);
if (singleValueMatch) {
return {
simple: {
value: parseFloat(singleValueMatch[1]),
unit: singleValueMatch[2]
}
};
}
return null;
};
/**
* Check if a property value equals a specific string
*/
const hasValue = (selector, property, expectedValue) => {
const value = extractValue(selector, property);
return value === expectedValue;
};
return {
extractValue,
extractNumericValue,
extractSpacing,
hasValue,
};
}

View file

@ -0,0 +1,120 @@
import { useStylesheetStore } from '../stores/stylesheet';
export function useCssUpdater() {
const store = useStylesheetStore();
/**
* Update or add a CSS property for a given selector
*/
const updateStyle = (selector, property, value) => {
const currentBlock = store.extractBlock(selector) || createRule(selector);
if (currentBlock.includes(`${property}:`)) {
const updatedBlock = currentBlock.replace(
new RegExp(`(${property}:\\s*)[^;]+`, 'i'),
`$1${value}`
);
store.content = store.content.replace(currentBlock, updatedBlock);
} else {
const updatedBlock = currentBlock.replace(
/(\s*})$/,
` ${property}: ${value};\n$1`
);
store.content = store.content.replace(currentBlock, updatedBlock);
}
};
/**
* Remove a CSS property from a selector
*/
const removeProperty = (selector, property) => {
const currentBlock = store.extractBlock(selector);
if (!currentBlock) return;
const updatedBlock = currentBlock.replace(
new RegExp(`\\s*${property}:\\s*[^;]+;\\n?`, 'gi'),
''
);
if (updatedBlock !== currentBlock) {
store.content = store.content.replace(currentBlock, updatedBlock);
}
};
/**
* Remove multiple CSS properties from a selector
*/
const removeProperties = (selector, properties) => {
let currentBlock = store.extractBlock(selector);
if (!currentBlock) return;
let updatedBlock = currentBlock;
for (const property of properties) {
updatedBlock = updatedBlock.replace(
new RegExp(`\\s*${property}:\\s*[^;]+;\\n?`, 'gi'),
''
);
}
if (updatedBlock !== currentBlock) {
store.content = store.content.replace(currentBlock, updatedBlock);
}
};
/**
* Create a new CSS rule for a selector
*/
const createRule = (selector) => {
store.content += `\n\n${selector} {\n}\n`;
return `${selector} {\n}`;
};
/**
* Remove detailed margin properties and set shorthand
*/
const setMargin = (selector, value, unit) => {
removeProperties(selector, ['margin-top', 'margin-right', 'margin-bottom', 'margin-left']);
updateStyle(selector, 'margin', `${value}${unit}`);
};
/**
* Remove shorthand margin and set detailed margins
*/
const setDetailedMargins = (selector, top, right, bottom, left) => {
removeProperty(selector, 'margin');
updateStyle(selector, 'margin-top', `${top.value}${top.unit}`);
updateStyle(selector, 'margin-right', `${right.value}${right.unit}`);
updateStyle(selector, 'margin-bottom', `${bottom.value}${bottom.unit}`);
updateStyle(selector, 'margin-left', `${left.value}${left.unit}`);
};
/**
* Remove detailed padding properties and set shorthand
*/
const setPadding = (selector, value, unit) => {
removeProperties(selector, ['padding-top', 'padding-right', 'padding-bottom', 'padding-left']);
updateStyle(selector, 'padding', `${value}${unit}`);
};
/**
* Remove shorthand padding and set detailed padding
*/
const setDetailedPadding = (selector, top, right, bottom, left) => {
removeProperty(selector, 'padding');
updateStyle(selector, 'padding-top', `${top.value}${top.unit}`);
updateStyle(selector, 'padding-right', `${right.value}${right.unit}`);
updateStyle(selector, 'padding-bottom', `${bottom.value}${bottom.unit}`);
updateStyle(selector, 'padding-left', `${left.value}${left.unit}`);
};
return {
updateStyle,
removeProperty,
removeProperties,
createRule,
setMargin,
setDetailedMargins,
setPadding,
setDetailedPadding,
};
}

View file

@ -1,9 +1,44 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { ref, watch } from 'vue';
import cssParsingUtils from '../utils/css-parsing';
import prettier from 'prettier/standalone';
import parserPostcss from 'prettier/plugins/postcss';
export const useStylesheetStore = defineStore('stylesheet', () => {
const content = ref('');
let formatTimer = null;
let isFormatting = false;
// Format CSS with Prettier
const formatContent = async () => {
if (isFormatting || !content.value) return;
try {
isFormatting = true;
const formatted = await prettier.format(content.value, {
parser: 'css',
plugins: [parserPostcss],
printWidth: 80,
tabWidth: 2,
useTabs: false,
});
content.value = formatted;
} catch (error) {
console.error('CSS formatting error:', error);
} finally {
isFormatting = false;
}
};
// Watch content and format after 500ms of inactivity
watch(content, () => {
if (isFormatting) return;
clearTimeout(formatTimer);
formatTimer = setTimeout(() => {
formatContent();
}, 500);
});
const loadStylesheet = async () => {
const response = await fetch('/assets/css/stylesheet.css');
@ -33,6 +68,7 @@ export const useStylesheetStore = defineStore('stylesheet', () => {
loadStylesheet,
updateProperty,
extractValue,
extractBlock
extractBlock,
formatContent
};
});