- Create useColoris composable (shared Coloris init across 4 files) - Create useLinkedSpacing composable (linked margin/padding logic from TextSettings) - Create BasePopup component (shared popup shell, CSS editor, inheritance button) - Add watchProp helper in ElementPopup (12 watchers → 12 compact lines) - Use extractSpacing for @page margin parsing in PagePopup and PageSettings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
543 lines
16 KiB
Vue
543 lines
16 KiB
Vue
<template>
|
|
<BasePopup
|
|
ref="basePopup"
|
|
id="page-popup"
|
|
:display-css="displayedCss"
|
|
:editable-css="pageCss"
|
|
:popup-width="550"
|
|
:popup-height="600"
|
|
@close="close"
|
|
@css-input="handleCssInput"
|
|
@toggle-inheritance="toggleInheritance"
|
|
>
|
|
<template #header-left>
|
|
<span class="page-label">@page</span>
|
|
<span class="page-name">{{ templateName || 'default' }}</span>
|
|
<span class="page-count">{{ pageCount }} page{{ pageCount > 1 ? 's' : '' }}</span>
|
|
</template>
|
|
|
|
<template #controls>
|
|
<!-- Margins -->
|
|
<div class="settings-subsection">
|
|
<h4>Marges</h4>
|
|
<div class="margin-grid">
|
|
<div
|
|
class="field"
|
|
:class="{ 'field--view-only': inheritanceLocked }"
|
|
>
|
|
<label class="label-with-tooltip" data-css="margin-top">Haut</label>
|
|
<div class="input-with-unit">
|
|
<NumberInput
|
|
v-model="margins.top.value"
|
|
:min="0"
|
|
:step="1"
|
|
:disabled="inheritanceLocked"
|
|
/>
|
|
<div class="unit-toggle">
|
|
<button
|
|
type="button"
|
|
:class="{ active: margins.top.unit === 'mm' }"
|
|
:disabled="inheritanceLocked"
|
|
@click="margins.top.unit = 'mm'"
|
|
>
|
|
mm
|
|
</button>
|
|
<button
|
|
type="button"
|
|
:class="{ active: margins.top.unit === 'px' }"
|
|
:disabled="inheritanceLocked"
|
|
@click="margins.top.unit = 'px'"
|
|
>
|
|
px
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="field"
|
|
:class="{ 'field--view-only': inheritanceLocked }"
|
|
>
|
|
<label class="label-with-tooltip" data-css="margin-bottom">Bas</label>
|
|
<div class="input-with-unit">
|
|
<NumberInput
|
|
v-model="margins.bottom.value"
|
|
:min="0"
|
|
:step="1"
|
|
:disabled="inheritanceLocked"
|
|
/>
|
|
<div class="unit-toggle">
|
|
<button
|
|
type="button"
|
|
:class="{ active: margins.bottom.unit === 'mm' }"
|
|
:disabled="inheritanceLocked"
|
|
@click="margins.bottom.unit = 'mm'"
|
|
>
|
|
mm
|
|
</button>
|
|
<button
|
|
type="button"
|
|
:class="{ active: margins.bottom.unit === 'px' }"
|
|
:disabled="inheritanceLocked"
|
|
@click="margins.bottom.unit = 'px'"
|
|
>
|
|
px
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="field"
|
|
:class="{ 'field--view-only': inheritanceLocked }"
|
|
>
|
|
<label class="label-with-tooltip" data-css="margin-left">Gauche</label>
|
|
<div class="input-with-unit">
|
|
<NumberInput
|
|
v-model="margins.left.value"
|
|
:min="0"
|
|
:step="1"
|
|
:disabled="inheritanceLocked"
|
|
/>
|
|
<div class="unit-toggle">
|
|
<button
|
|
type="button"
|
|
:class="{ active: margins.left.unit === 'mm' }"
|
|
:disabled="inheritanceLocked"
|
|
@click="margins.left.unit = 'mm'"
|
|
>
|
|
mm
|
|
</button>
|
|
<button
|
|
type="button"
|
|
:class="{ active: margins.left.unit === 'px' }"
|
|
:disabled="inheritanceLocked"
|
|
@click="margins.left.unit = 'px'"
|
|
>
|
|
px
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="field"
|
|
:class="{ 'field--view-only': inheritanceLocked }"
|
|
>
|
|
<label class="label-with-tooltip" data-css="margin-right">Droite</label>
|
|
<div class="input-with-unit">
|
|
<NumberInput
|
|
v-model="margins.right.value"
|
|
:min="0"
|
|
:step="1"
|
|
:disabled="inheritanceLocked"
|
|
/>
|
|
<div class="unit-toggle">
|
|
<button
|
|
type="button"
|
|
:class="{ active: margins.right.unit === 'mm' }"
|
|
:disabled="inheritanceLocked"
|
|
@click="margins.right.unit = 'mm'"
|
|
>
|
|
mm
|
|
</button>
|
|
<button
|
|
type="button"
|
|
:class="{ active: margins.right.unit === 'px' }"
|
|
:disabled="inheritanceLocked"
|
|
@click="margins.right.unit = 'px'"
|
|
>
|
|
px
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Background -->
|
|
<div class="settings-subsection">
|
|
<div class="field" :class="{ 'field--view-only': inheritanceLocked }">
|
|
<label class="label-with-tooltip" data-css="background">Arrière-plan</label>
|
|
<div class="input-with-color">
|
|
<input
|
|
ref="backgroundColorInput"
|
|
type="text"
|
|
v-model="background.value"
|
|
:disabled="inheritanceLocked"
|
|
data-coloris
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Patterns -->
|
|
<div class="settings-subsection">
|
|
<div class="field" :class="{ 'field--view-only': inheritanceLocked }">
|
|
<label class="label-with-tooltip" data-css="background-image">Motifs</label>
|
|
<select v-model="pattern" :disabled="inheritanceLocked">
|
|
<option value="">Choisissez</option>
|
|
<option value="dots">Points</option>
|
|
<option value="lines">Lignes</option>
|
|
<option value="grid">Grille</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</BasePopup>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch } from 'vue';
|
|
import { useStylesheetStore } from '../stores/stylesheet';
|
|
import { useDebounce } from '../composables/useDebounce';
|
|
import { useCssSync } from '../composables/useCssSync';
|
|
import NumberInput from './ui/NumberInput.vue';
|
|
import BasePopup from './ui/BasePopup.vue';
|
|
|
|
const stylesheetStore = useStylesheetStore();
|
|
const { extractSpacing } = useCssSync();
|
|
|
|
const props = defineProps({
|
|
iframeRef: Object,
|
|
});
|
|
|
|
const emit = defineEmits(['close']);
|
|
|
|
const basePopup = ref(null);
|
|
|
|
const visible = computed(() => basePopup.value?.visible ?? false);
|
|
const inheritanceLocked = computed(() => basePopup.value?.inheritanceLocked ?? true);
|
|
|
|
const selectedPageElement = ref(null);
|
|
const pageCount = ref(0);
|
|
const templateName = ref('');
|
|
const backgroundColorInput = ref(null);
|
|
|
|
let isUpdatingFromStore = false;
|
|
const { debouncedUpdate } = useDebounce(500);
|
|
|
|
const margins = ref({
|
|
top: { value: 0, unit: 'mm' },
|
|
bottom: { value: 0, unit: 'mm' },
|
|
left: { value: 0, unit: 'mm' },
|
|
right: { value: 0, unit: 'mm' },
|
|
});
|
|
|
|
const background = ref({
|
|
value: '',
|
|
format: 'hex',
|
|
});
|
|
|
|
const pattern = ref('');
|
|
|
|
const immediateUpdate = (callback) => {
|
|
callback();
|
|
};
|
|
|
|
// Get the selector for the current template's @page rule
|
|
const getTemplateSelector = () => {
|
|
return templateName.value ? `@page ${templateName.value}` : '@page';
|
|
};
|
|
|
|
// Get or create the template-specific @page block
|
|
const getOrCreateTemplateBlock = () => {
|
|
const selector = getTemplateSelector();
|
|
let block = stylesheetStore.extractBlock(selector);
|
|
|
|
if (!block && templateName.value) {
|
|
const baseBlock = stylesheetStore.extractBlock('@page');
|
|
if (baseBlock) {
|
|
const marginValue = `${margins.value.top.value}${margins.value.top.unit} ${margins.value.right.value}${margins.value.right.unit} ${margins.value.bottom.value}${margins.value.bottom.unit} ${margins.value.left.value}${margins.value.left.unit}`;
|
|
const newBlock = `\n@page ${templateName.value} {\n margin: ${marginValue};${background.value.value ? `\n background: ${background.value.value};` : ''}\n}\n`;
|
|
|
|
stylesheetStore.replaceInCustomCss(
|
|
baseBlock,
|
|
baseBlock + newBlock
|
|
);
|
|
block = stylesheetStore.extractBlock(selector);
|
|
}
|
|
}
|
|
|
|
return block;
|
|
};
|
|
|
|
// Remove the template-specific @page block
|
|
const removeTemplateBlock = () => {
|
|
if (!templateName.value) return;
|
|
|
|
const selector = `@page ${templateName.value}`;
|
|
const block = stylesheetStore.extractBlock(selector);
|
|
|
|
if (block) {
|
|
stylesheetStore.replaceInCustomCss(
|
|
new RegExp(`\\n?${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}\\n?`),
|
|
'\n'
|
|
);
|
|
}
|
|
};
|
|
|
|
const updateMargins = (force = false) => {
|
|
if (!force && inheritanceLocked.value) return;
|
|
|
|
const marginValue = `${margins.value.top.value}${margins.value.top.unit} ${margins.value.right.value}${margins.value.right.unit} ${margins.value.bottom.value}${margins.value.bottom.unit} ${margins.value.left.value}${margins.value.left.unit}`;
|
|
|
|
const currentBlock = getOrCreateTemplateBlock();
|
|
if (!currentBlock) return;
|
|
|
|
if (currentBlock.includes('margin:')) {
|
|
const updatedBlock = currentBlock.replace(
|
|
/(margin:\s*)[^;]+/,
|
|
`$1${marginValue}`
|
|
);
|
|
stylesheetStore.replaceInCustomCss(
|
|
currentBlock,
|
|
updatedBlock
|
|
);
|
|
} else {
|
|
const updatedBlock = currentBlock.replace(
|
|
/(\s*})$/,
|
|
` margin: ${marginValue};\n$1`
|
|
);
|
|
stylesheetStore.replaceInCustomCss(
|
|
currentBlock,
|
|
updatedBlock
|
|
);
|
|
}
|
|
};
|
|
|
|
const updateBackground = (force = false) => {
|
|
if (!force && inheritanceLocked.value) return;
|
|
if (!background.value.value) return;
|
|
|
|
const currentBlock = getOrCreateTemplateBlock();
|
|
if (!currentBlock) return;
|
|
|
|
if (currentBlock.includes('background:')) {
|
|
const updatedBlock = currentBlock.replace(
|
|
/(background:\s*)[^;]+/,
|
|
`$1${background.value.value}`
|
|
);
|
|
stylesheetStore.replaceInCustomCss(
|
|
currentBlock,
|
|
updatedBlock
|
|
);
|
|
} else {
|
|
const updatedBlock = currentBlock.replace(
|
|
/(\s*})$/,
|
|
` background: ${background.value.value};\n$1`
|
|
);
|
|
stylesheetStore.replaceInCustomCss(
|
|
currentBlock,
|
|
updatedBlock
|
|
);
|
|
}
|
|
};
|
|
|
|
// Apply all current field values to create/update the CSS block
|
|
const applyAllStyles = () => {
|
|
updateMargins(true);
|
|
updateBackground(true);
|
|
};
|
|
|
|
// Watch margin values (number inputs) with debounce
|
|
watch(
|
|
() => [
|
|
margins.value.top.value,
|
|
margins.value.bottom.value,
|
|
margins.value.left.value,
|
|
margins.value.right.value,
|
|
],
|
|
() => {
|
|
if (isUpdatingFromStore) return;
|
|
debouncedUpdate(updateMargins);
|
|
}
|
|
);
|
|
|
|
// Watch margin units (button clicks) without debounce
|
|
watch(
|
|
() => [
|
|
margins.value.top.unit,
|
|
margins.value.bottom.unit,
|
|
margins.value.left.unit,
|
|
margins.value.right.unit,
|
|
],
|
|
() => {
|
|
if (isUpdatingFromStore) return;
|
|
immediateUpdate(updateMargins);
|
|
}
|
|
);
|
|
|
|
// Watch background value with debounce
|
|
watch(
|
|
() => background.value.value,
|
|
() => {
|
|
if (isUpdatingFromStore) return;
|
|
debouncedUpdate(updateBackground);
|
|
}
|
|
);
|
|
|
|
const loadValuesFromStylesheet = () => {
|
|
try {
|
|
isUpdatingFromStore = true;
|
|
|
|
// Use extractSpacing from useCssSync
|
|
const spacing = extractSpacing('@page', 'margin');
|
|
if (spacing?.detailed) {
|
|
margins.value.top = spacing.detailed.top;
|
|
margins.value.right = spacing.detailed.right;
|
|
margins.value.bottom = spacing.detailed.bottom;
|
|
margins.value.left = spacing.detailed.left;
|
|
} else if (spacing?.simple) {
|
|
margins.value.top = { ...spacing.simple };
|
|
margins.value.right = { ...spacing.simple };
|
|
margins.value.bottom = { ...spacing.simple };
|
|
margins.value.left = { ...spacing.simple };
|
|
}
|
|
|
|
// Extract background
|
|
const pageBlock = stylesheetStore.extractBlock('@page');
|
|
if (pageBlock) {
|
|
const bgMatch = pageBlock.match(/background:\s*([^;]+)/);
|
|
if (bgMatch) {
|
|
background.value.value = bgMatch[1].trim();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading values from stylesheet:', error);
|
|
} finally {
|
|
isUpdatingFromStore = false;
|
|
}
|
|
};
|
|
|
|
const open = (pageElement, event, count = 1) => {
|
|
selectedPageElement.value = pageElement;
|
|
pageCount.value = count;
|
|
|
|
// Extract template name from data-page-type attribute
|
|
templateName.value = pageElement.getAttribute('data-page-type') || '';
|
|
|
|
// Read inheritance state from page element's data attribute
|
|
basePopup.value.inheritanceLocked = pageElement.dataset.inheritanceUnlocked !== 'true';
|
|
|
|
// Load values from stylesheet (@page block)
|
|
loadValuesFromStylesheet();
|
|
|
|
// Open popup (sets visible, position, inits Coloris)
|
|
basePopup.value.open(event);
|
|
};
|
|
|
|
const close = () => {
|
|
selectedPageElement.value = null;
|
|
basePopup.value?.close();
|
|
emit('close');
|
|
};
|
|
|
|
const toggleInheritance = () => {
|
|
const wasLocked = basePopup.value.inheritanceLocked;
|
|
basePopup.value.inheritanceLocked = !wasLocked;
|
|
|
|
if (selectedPageElement.value) {
|
|
if (basePopup.value.inheritanceLocked) {
|
|
delete selectedPageElement.value.dataset.inheritanceUnlocked;
|
|
} else {
|
|
selectedPageElement.value.dataset.inheritanceUnlocked = 'true';
|
|
}
|
|
}
|
|
|
|
if (basePopup.value.inheritanceLocked && !wasLocked) {
|
|
removeTemplateBlock();
|
|
} else if (!basePopup.value.inheritanceLocked && wasLocked) {
|
|
applyAllStyles();
|
|
}
|
|
};
|
|
|
|
const pageCss = computed(() => {
|
|
if (!basePopup.value?.inheritanceLocked && templateName.value) {
|
|
const templateBlock = stylesheetStore.extractBlock(`@page ${templateName.value}`);
|
|
if (templateBlock) return templateBlock;
|
|
}
|
|
return stylesheetStore.extractBlock('@page') || '';
|
|
});
|
|
|
|
const generatePreviewCss = () => {
|
|
if (!templateName.value) return '';
|
|
|
|
const properties = [];
|
|
|
|
const marginValue = `${margins.value.top.value}${margins.value.top.unit} ${margins.value.right.value}${margins.value.right.unit} ${margins.value.bottom.value}${margins.value.bottom.unit} ${margins.value.left.value}${margins.value.left.unit}`;
|
|
properties.push(` margin: ${marginValue};`);
|
|
|
|
if (background.value.value) {
|
|
properties.push(` background: ${background.value.value};`);
|
|
}
|
|
|
|
if (properties.length === 0) return '';
|
|
|
|
return `@page ${templateName.value} {\n${properties.join('\n')}\n}`;
|
|
};
|
|
|
|
const displayedCss = computed(() => {
|
|
if (!basePopup.value?.inheritanceLocked) {
|
|
return pageCss.value;
|
|
}
|
|
|
|
if (!templateName.value) {
|
|
return pageCss.value;
|
|
}
|
|
|
|
const preview = generatePreviewCss();
|
|
if (!preview) return pageCss.value;
|
|
|
|
return '/* Héritage verrouillé - déverrouiller pour appliquer */\n/* ' +
|
|
preview.split('\n').join('\n ') +
|
|
' */';
|
|
});
|
|
|
|
const handleCssInput = (newCss) => {
|
|
const oldBlock = pageCss.value;
|
|
if (oldBlock) {
|
|
stylesheetStore.replaceInCustomCss(oldBlock, newCss);
|
|
}
|
|
};
|
|
|
|
// Watch stylesheet changes to sync values
|
|
watch(
|
|
() => stylesheetStore.content,
|
|
() => {
|
|
if (basePopup.value?.visible && !stylesheetStore.isEditing) {
|
|
loadValuesFromStylesheet();
|
|
}
|
|
}
|
|
);
|
|
|
|
defineExpose({ open, close, visible });
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* PagePopup-specific styles (orange theme) */
|
|
.page-label {
|
|
background: var(--color-page-highlight);
|
|
color: white;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 4px;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.page-name {
|
|
font-weight: 600;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.page-count {
|
|
color: var(--color-page-highlight);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.margin-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 0.75rem;
|
|
}
|
|
</style>
|