geoproject-app/src/components/PagePopup.vue
isUnknown 681517db21 refactor: extract debounce logic into shared composable
- Create useDebounce composable to avoid code duplication
- Apply debounce to TextSettings margin/padding inputs
- Harmonize debounce delay to 500ms across all components
- Fix input lag when typing values like "30mm"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 11:51:53 +01:00

677 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
v-if="visible"
id="page-popup"
class="settings-popup"
:style="{ top: position.y + 'px', left: position.x + 'px' }"
>
<div class="popup-header">
<div class="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>
</div>
<button class="close-btn" @click="close">×</button>
</div>
<div class="popup-body">
<!-- Left: Controls -->
<div class="popup-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>
<button
type="button"
:class="{ active: margins.top.unit === 'rem' }"
:disabled="inheritanceLocked"
@click="margins.top.unit = 'rem'"
>
rem
</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>
<button
type="button"
:class="{ active: margins.bottom.unit === 'rem' }"
:disabled="inheritanceLocked"
@click="margins.bottom.unit = 'rem'"
>
rem
</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>
<button
type="button"
:class="{ active: margins.left.unit === 'rem' }"
:disabled="inheritanceLocked"
@click="margins.left.unit = 'rem'"
>
rem
</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>
<button
type="button"
:class="{ active: margins.right.unit === 'rem' }"
:disabled="inheritanceLocked"
@click="margins.right.unit = 'rem'"
>
rem
</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>
<!-- Lock/Unlock Inheritance Button -->
<div class="settings-subsection">
<button class="inheritance-btn" @click="toggleInheritance">
<svg
v-if="inheritanceLocked"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M19 10H20C20.5523 10 21 10.4477 21 11V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V11C3 10.4477 3.44772 10 4 10H5V9C5 5.13401 8.13401 2 12 2C15.866 2 19 5.13401 19 9V10ZM5 12V20H19V12H5ZM11 14H13V18H11V14ZM17 10V9C17 6.23858 14.7614 4 12 4C9.23858 4 7 6.23858 7 9V10H17Z"
></path>
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M7 10H20C20.5523 10 21 10.4477 21 11V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V11C3 10.4477 3.44772 10 4 10H5V9C5 5.13401 8.13401 2 12 2C14.7405 2 17.1131 3.5748 18.2624 5.86882L16.4731 6.76344C15.6522 5.12486 13.9575 4 12 4C9.23858 4 7 6.23858 7 9V10ZM5 12V20H19V12H5ZM10 15H14V17H10V15Z"
></path>
</svg>
<span>{{
inheritanceLocked
? "Déverrouiller l'héritage"
: "Verrouiller l'héritage"
}}</span>
</button>
</div>
</div>
<!-- Right: CSS Editor -->
<div class="popup-css">
<div class="css-header">
<span>CSS</span>
<label
class="toggle"
:class="{ 'field--view-only': inheritanceLocked }"
>
<span class="toggle-label">Mode édition</span>
<input
type="checkbox"
v-model="isEditable"
:disabled="inheritanceLocked"
/>
<span class="toggle-switch"></span>
</label>
</div>
<pre
v-if="!isEditable"
class="readonly"
><code class="hljs language-css" v-html="highlightedCss"></code></pre>
<textarea
v-else
:value="pageCss"
@input="handleCssInput"
:disabled="inheritanceLocked"
spellcheck="false"
></textarea>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
import { usePopupPosition } from '../composables/usePopupPosition';
import { useDebounce } from '../composables/useDebounce';
import NumberInput from './ui/NumberInput.vue';
import Coloris from '@melloware/coloris';
import '@melloware/coloris/dist/coloris.css';
import hljs from 'highlight.js/lib/core';
import css from 'highlight.js/lib/languages/css';
import 'highlight.js/styles/atom-one-dark.css';
hljs.registerLanguage('css', css);
const stylesheetStore = useStylesheetStore();
const props = defineProps({
iframeRef: Object,
});
const emit = defineEmits(['close']);
const visible = ref(false);
const position = ref({ x: 0, y: 0 });
const selectedPageElement = ref(null);
const pageCount = ref(0);
const templateName = ref('');
const isEditable = ref(false);
const inheritanceLocked = ref(true);
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();
};
const POPUP_WIDTH = 550;
const POPUP_HEIGHT = 600;
const { calculatePosition } = usePopupPosition(POPUP_WIDTH, POPUP_HEIGHT);
// 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) {
// Create new block with current values from @page
const baseBlock = stylesheetStore.extractBlock('@page');
if (baseBlock) {
// Insert the new template block after @page
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.content = stylesheetStore.content.replace(
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) {
// Remove the block and any surrounding whitespace
stylesheetStore.content = stylesheetStore.content.replace(
new RegExp(`\\n?${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}\\n?`),
'\n'
);
}
};
const updateMargins = () => {
// Only update if inheritance is unlocked
if (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;
const selector = getTemplateSelector();
if (currentBlock.includes('margin:')) {
const updatedBlock = currentBlock.replace(
/(margin:\s*)[^;]+/,
`$1${marginValue}`
);
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
} else {
const updatedBlock = currentBlock.replace(
/(\s*})$/,
` margin: ${marginValue};\n$1`
);
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
}
};
const updateBackground = () => {
// Only update if inheritance is unlocked
if (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.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
} else {
const updatedBlock = currentBlock.replace(
/(\s*})$/,
` background: ${background.value.value};\n$1`
);
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
}
};
// 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;
// Extract values from @page block (same logic as PageSettings)
const pageBlock = stylesheetStore.extractBlock('@page');
if (!pageBlock) return;
// Parse margins with regex (top right bottom left)
const marginMatch = pageBlock.match(
/margin:\s*([0-9.]+)([a-z]+)\s+([0-9.]+)([a-z]+)\s+([0-9.]+)([a-z]+)\s+([0-9.]+)([a-z]+)/i
);
if (marginMatch) {
margins.value.top = {
value: parseFloat(marginMatch[1]),
unit: marginMatch[2],
};
margins.value.right = {
value: parseFloat(marginMatch[3]),
unit: marginMatch[4],
};
margins.value.bottom = {
value: parseFloat(marginMatch[5]),
unit: marginMatch[6],
};
margins.value.left = {
value: parseFloat(marginMatch[7]),
unit: marginMatch[8],
};
}
// Extract background
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;
position.value = calculatePosition(event);
// Extract template name from data-page-type attribute
templateName.value = pageElement.getAttribute('data-page-type') || '';
// Reset inheritance state when opening
inheritanceLocked.value = true;
// Load values from stylesheet (@page block)
loadValuesFromStylesheet();
visible.value = true;
// Initialize Coloris after opening
setTimeout(() => {
Coloris.init();
Coloris({
el: '[data-coloris]',
theme: 'pill',
themeMode: 'dark',
formatToggle: true,
alpha: true,
closeButton: true,
closeLabel: 'Fermer',
clearButton: true,
clearLabel: 'Effacer',
swatchesOnly: false,
inline: false,
wrap: true,
swatches: [
'#264653',
'#2a9d8f',
'#e9c46a',
'#f4a261',
'#e76f51',
'#d62828',
'#023e8a',
'#0077b6',
'#ffffff',
'#000000',
],
});
}, 0);
};
const close = () => {
selectedPageElement.value = null;
visible.value = false;
isEditable.value = false;
emit('close');
};
const toggleInheritance = () => {
const wasLocked = inheritanceLocked.value;
inheritanceLocked.value = !inheritanceLocked.value;
if (inheritanceLocked.value && !wasLocked) {
// Re-locking: remove the template-specific block
// Fields keep their values, but preview returns to @page defaults
removeTemplateBlock();
}
// When unlocking: fields already have values, block will be created on first edit
};
const pageCss = computed(() => {
// Show template-specific block if unlocked and exists, otherwise show @page
if (!inheritanceLocked.value && templateName.value) {
const templateBlock = stylesheetStore.extractBlock(`@page ${templateName.value}`);
if (templateBlock) return templateBlock;
}
return stylesheetStore.extractBlock('@page') || '';
});
const highlightedCss = computed(() => {
if (!pageCss.value) return '';
return hljs.highlight(pageCss.value, { language: 'css' }).value;
});
let cssDebounceTimer = null;
const handleCssInput = (event) => {
const newCss = event.target.value;
if (cssDebounceTimer) {
clearTimeout(cssDebounceTimer);
}
cssDebounceTimer = setTimeout(() => {
const oldBlock = pageCss.value;
if (oldBlock) {
stylesheetStore.content = stylesheetStore.content.replace(
oldBlock,
newCss
);
}
}, 500);
};
// Watch isEditable to format when exiting edit mode
watch(isEditable, async (newValue, oldValue) => {
stylesheetStore.isEditing = newValue;
// Format when exiting editing mode
if (oldValue && !newValue) {
await stylesheetStore.formatContent();
}
});
// Watch stylesheet changes to sync values
watch(
() => stylesheetStore.content,
() => {
if (visible.value && !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>