Compare commits

...

12 commits

Author SHA1 Message Date
isUnknown
f3c7132044 refactor: simplify ElementPopup with flat refs and style descriptors
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 22s
Flatten ref({ value }) to simple ref(), replace 9 updateX functions with
a generic updateProp driven by descriptor arrays, and loop over descriptors
in generatePreviewCss/loadValuesFromStylesheet/applyAllStyles. Remove
trivial passthrough computed properties. (-123 lines)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:47:58 +01:00
isUnknown
69d5ebe7ed refactor: extract shared patterns from popup/settings components
- 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>
2026-02-26 15:35:45 +01:00
isUnknown
0c682c78c0 rename parseCarte → parseMap, fix marker icon size, remove testing checklist
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:10:14 +01:00
isUnknown
41fbe71a1f feat: render full map content (image, intro, markers) in PagedJS
- Fix template check 'carte' → 'map' so map subpages are served by API
- Add parseMarker() and enrich parseCarte() with static image, intro, markers
- Include map children in parseGeoformat() alongside chapters
- Resolve map block references in chapters to full carte data
- Update narrative store flattening with new carte fields
- Replace MapBlock placeholder with full carte rendering (title, image, tags, intro, markers with icons and blocks)
- Add default marker-pin.svg for markers without custom icon

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:00:28 +01:00
isUnknown
9d80845335 improve map blueprint layout 2026-02-26 11:37:54 +01:00
isUnknown
d07522ae7f map blueprint : layout and field text -> intro 2026-02-26 11:35:30 +01:00
isUnknown
45a41e5d89 coverLabel : add " 2026-02-26 11:30:32 +01:00
isUnknown
033023f6ef refactor: move coverFiles to plugin as page/file methods
Replace GeoformatPage model with a plugin registering coverFiles as a
pageMethod (available on all pages) and coverLabel as a fileMethod to
display "Carte [title]" for map children files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:30:32 +01:00
isUnknown
c8c544e427 merge 2026-02-26 11:30:25 +01:00
isUnknown
c5cc94bf5d feat: add geoformat page model with coverFiles method
Expose combined files (page files + map children files) for the cover
field query in the geoformat blueprint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:29:07 +01:00
isUnknown
9482dfa08c refactor: restructure blueprints with tabs and shared fields
Reorganize narrative, geoformat, chapter, project and map blueprints
to use tabs layout. Add shared blueprint fields and files tab partials.
Update map block query for new page hierarchy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:29:07 +01:00
isUnknown
ffcb1a9f2e rename: chapitre/projet templates to chapter/project
Standardize French template names to English across blueprints,
content files, PHP templates, Vue components and Pinia stores.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:29:07 +01:00
41 changed files with 1350 additions and 1848 deletions

View file

@ -1,276 +0,0 @@
# Map Editor Plugin - Testing Checklist
## Pre-Testing Setup
1. [ ] Build the frontend: `npm run build`
2. [ ] Ensure Kirby is running (PHP server)
3. [ ] Log into Kirby Panel
4. [ ] Navigate to the map page (e.g., `/panel/pages/map+carte`)
## Phase 1: Multi-Mode Testing (Map Page)
### Basic Marker Operations
1. [ ] **View existing markers**
- Open map page in Panel
- Verify markers are loaded and displayed on map
- Verify markers appear in sidebar list
2. [ ] **Create marker via button**
- Click "Add Marker" button in sidebar
- Verify marker appears at map center
- Verify marker appears in sidebar
- Check browser console for errors
3. [ ] **Create marker via map click**
- Click anywhere on the map
- Verify marker appears at clicked location
- Verify marker appears in sidebar
4. [ ] **Select marker**
- Click marker in sidebar
- Verify map centers on marker
- Verify marker is highlighted
5. [ ] **Drag marker**
- Drag a marker to new position
- Verify position updates in real-time
- Reload page and verify position persisted
6. [ ] **Edit marker**
- Click "Edit" button for a marker in sidebar
- Verify redirect to marker page in Panel
- Verify marker page loads correctly
7. [ ] **Delete marker**
- Click "Delete" button for a marker
- Verify confirmation dialog appears
- Confirm deletion
- Verify marker removed from map and sidebar
- Reload page and verify deletion persisted
### Map Data Persistence
8. [ ] **Map view changes**
- Pan and zoom the map
- Reload the page
- Verify map returns to same view
9. [ ] **Check YAML data**
- View the mapdata field source
- Verify it contains: background, center, zoom
- Verify it does NOT contain markers array
### Edge Cases
10. [ ] **Max markers limit**
- Create markers up to the limit (default 50)
- Verify "Add Marker" button becomes disabled
- Verify map clicks don't create new markers
11. [ ] **Geocode search**
- Use the address search in sidebar
- Search for an address
- Verify map centers on result
12. [ ] **Error handling**
- Open browser DevTools Network tab
- Try operations with network offline (simulate)
- Verify errors are logged to console
## Phase 2: Single-Mode Testing (Marker Page)
### Marker Page Structure
1. [ ] **Create test marker**
- In map page sidebar, click "Add Marker"
- Click "Edit" to open the marker page
2. [ ] **Content tab**
- Verify "Contenu" tab exists
- Edit title field
- Add blocks (heading, text, image, etc.)
- Save and verify content persists
3. [ ] **Position tab**
- Switch to "Position" tab
- Verify latitude/longitude fields on left
- Verify map preview on right
### Single-Mode Map Functionality
4. [ ] **View marker position**
- Verify single marker appears on map
- Verify marker is at coordinates shown in fields
5. [ ] **Drag marker in single mode**
- Drag the marker to a new position
- Check browser console for API call
- Reload page
- Verify new position persisted
6. [ ] **Update coordinates via fields**
- Edit latitude field (e.g., 43.8)
- Edit longitude field (e.g., 4.3)
- Save the page
- Verify marker moved on map preview
7. [ ] **Geocode search in single mode**
- Use address search (if available)
- Verify map centers on result
- Drag marker to searched location
### Single-Mode Restrictions
8. [ ] **No CRUD buttons**
- Verify no "Add Marker" button
- Verify no "Delete" button
- Verify no marker list sidebar
9. [ ] **Map size**
- Verify map height is smaller (400px) than multi-mode
## Phase 3: Integration Testing
### Subpage Management
1. [ ] **View markers as subpages**
- In map page, check sidebar "Marqueurs" section
- Verify all markers listed as subpages
- Verify ordering by num
2. [ ] **Reorder markers**
- Drag markers in Panel pages section
- Verify order updates
- View map editor
- Verify sidebar reflects new order
3. [ ] **Delete marker via Panel**
- Delete a marker subpage via Panel (not map editor)
- View map page
- Verify marker removed from map
4. [ ] **Create marker manually**
- Create a new marker subpage via Panel
- Set template to "marker"
- Add title, latitude, longitude
- View map page
- Verify marker appears on map
### Multi-Marker Performance
5. [ ] **Create 10 markers**
- Create 10 markers via map
- Verify performance is acceptable
- Check load time
6. [ ] **Create 50 markers** (optional stress test)
- Create markers up to limit
- Verify UI remains responsive
- Check browser memory usage
## Phase 4: Security & API Testing
### Authentication
1. [ ] **Logged out access**
- Log out of Panel
- Try accessing API directly (e.g., via curl)
- Verify 401 Unauthorized response
### CSRF Protection
2. [ ] **Invalid CSRF token**
- Use browser DevTools to modify X-CSRF header
- Try creating/updating/deleting marker
- Verify 403 Forbidden response
### Permissions
3. [ ] **Create restricted user** (optional)
- Create user with limited permissions
- Log in as that user
- Try marker operations
- Verify permission checks work
### API Responses
4. [ ] **Check API responses**
- Open DevTools Network tab
- Perform marker operations
- Verify responses match expected format:
```json
{
"status": "success",
"data": { ... }
}
```
5. [ ] **Test error responses**
- Try invalid coordinates (e.g., lat: 200)
- Verify 400 Bad Request response
- Verify error message is descriptive
## Phase 5: Build & Deployment
### Build Verification
1. [ ] **Clean build**
- Run `npm run build`
- Verify no errors or warnings (except font warnings)
- Check dist folder created
2. [ ] **Production test**
- Test on production server (if available)
- Verify all functionality works
### Browser Compatibility
3. [ ] **Test in Chrome**
4. [ ] **Test in Firefox**
5. [ ] **Test in Safari**
6. [ ] **Test in Edge** (optional)
## Common Issues & Solutions
### Issue: Markers not loading
- **Check**: Browser console for API errors
- **Check**: Network tab for 401/403 errors
- **Solution**: Verify user is logged in, CSRF token is valid
### Issue: Drag doesn't update position
- **Check**: Console for API errors
- **Check**: Network tab for PATCH request
- **Solution**: Verify page permissions, CSRF token
### Issue: Redirect to Panel doesn't work
- **Check**: Console for errors
- **Check**: panelUrl in API response
- **Solution**: Verify page ID format is correct
### Issue: Single mode shows multiple markers
- **Check**: MapEditor component mode prop
- **Check**: Blueprint field configuration
- **Solution**: Verify `mode: single` in blueprint
## Test Results Summary
Date: __________
Tester: __________
| Phase | Tests Passed | Tests Failed | Notes |
|-------|-------------|--------------|-------|
| Phase 1: Multi-Mode | __ / 12 | __ | |
| Phase 2: Single-Mode | __ / 9 | __ | |
| Phase 3: Integration | __ / 6 | __ | |
| Phase 4: Security | __ / 5 | __ | |
| Phase 5: Build | __ / 6 | __ | |
| **Total** | __ / 38 | __ | |
## Next Steps After Testing
- [ ] Document any bugs found
- [ ] Create GitHub issues for bugs
- [ ] Update README with usage instructions
- [ ] Add user documentation
- [ ] Consider implementing suggested improvements

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5z"/></svg>

After

Width:  |  Height:  |  Size: 236 B

View file

@ -0,0 +1,24 @@
Title: Carte test
----
Tags:
----
Text:
----
Mapdata:
background:
type: osm
center:
lat: 48.868167700000015
lon: 2.3924468999999817
zoom: 13
----
Uuid: qd3aqmiburbztsxq

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View file

@ -0,0 +1,24 @@
Title: test
----
Tags:
----
Text:
----
Mapdata:
background:
type: osm
center:
lat: 43.836699
lon: 4.360054
zoom: 13
----
Uuid: o06wmgdr075lthky

View file

@ -10,7 +10,7 @@ Tags:
----
Cover: - file://15innylddvc2qann
Cover:
----

View file

@ -2,7 +2,11 @@ Title: Nîmes
----
Content:
Cover:
----
Text: [{"content":{"text":"<p>Nisi nec morbi diam tortor quis interdum fusce quisque sit aliquam scelerisque a vivamus gravida id eros nisl tortor commodo amet mi quis tincidunt metus.</p><p>Congue proin urna eget quisque sem a fusce felis eros purus hendrerit facilisis hendrerit metus accumsan metus nec eu cursus elementum maecenas ut scelerisque sit.</p>"},"id":"1adbada8-6dee-4ab4-b33b-d914d4806b70","isHidden":false,"type":"text"}]
----
@ -22,4 +26,8 @@ Markericonsize: 40
----
Content:
----
Uuid: lajqvh51bnvty5xr

View file

@ -25,13 +25,13 @@ Customcss:
body {
font-family: "DM Sans", sans-serif;
text-align: left;
color: black;
background: transparent;
color: rgb(0, 0, 0);
}
/*
p {
text-align: right;
font-size: 14px;
font-size: 49px;
margin: 0mm;
font-weight: 300;
font-family: Arial;
@ -41,6 +41,7 @@ p {
padding-bottom: 0mm;
padding-left: 0mm;
}
*/
h1 {
font-family: DM Sans;

BIN
public/kirby/panel/dist.zip Normal file

Binary file not shown.

View file

@ -1,8 +1,8 @@
name: Carte
icon: map
fields:
map:
map:
label: Choisir la carte
type: pages
query: page.parent.parent.children.filterBy('intendedTemplate', 'map')
multiple: false
query: page.children.filterBy('intendedTemplate', 'map')
multiple: false

View file

@ -0,0 +1,2 @@
type: headline
label: Page "{{ page.blueprint.title }}"

View file

@ -1,38 +0,0 @@
title: Chapitre
columns:
main:
width: 2/3
sections:
fields:
type: fields
fields:
text:
label: Contenu
type: blocks
fieldsets:
text:
label: Texte
type: group
fieldsets:
- heading
- text
- list
- quote
media:
label: Medias
type: group
fieldsets:
- map
- image
- video
sidebar:
width: 1/3
sections:
files:
label: Fichiers
type: files

View file

@ -0,0 +1,41 @@
title: Chapitre
tabs:
contentTab:
label: Contenu
columns:
main:
width: 2/3
sections:
fields:
type: fields
fields:
blueprint: fields/blueprint
text:
label: Contenu
type: blocks
fieldsets:
text:
label: Texte
type: group
fieldsets:
- heading
- text
- list
- quote
media:
label: Medias
type: group
fieldsets:
- map
- image
- video
sidebar:
width: 1/3
sections:
maps:
label: Cartes
type: pages
template: map
files: tabs/files

View file

@ -1,38 +1,44 @@
title: Géoformat
columns:
main:
width: 2/3
sections:
fields:
type: fields
fields:
subtitle:
label: Sous-titre
type: text
width: 1/2
tags:
label: Mots-clés
type: tags
width: 1/2
cover:
label: Media de couverture
type: files
multiple: false
width: 1/2
text:
label: Chapeau
type: writer
pages:
label: Chapitres
type: pages
template: chapitre
sidebar:
width: 1/3
sections:
files:
label: Fichiers
type: files
tabs:
contentTab:
columns:
main:
width: 2/3
sections:
fields:
type: fields
fields:
blueprint: fields/blueprint
subtitle:
label: Sous-titre
type: text
width: 1/2
tags:
label: Mots-clés
type: tags
width: 1/2
cover:
label: Media de couverture
type: files
query: page.coverFiles
text: "{{ file.coverLabel }}"
multiple: false
width: 1/2
text:
label: Chapeau
type: writer
chapters:
label: Chapitres
type: pages
template: chapter
sidebar:
width: 1/3
sections:
maps:
label: Cartes
type: pages
template: map
help: Créez des cartes ici avant de pouvoir les ajouter dans un bloc ou en couverture.
filesTab: tabs/files

View file

@ -7,21 +7,26 @@ columns:
fields:
type: fields
fields:
tags:
label: Mots-clés
type: tags
text:
label: Présentation de la carte
type: writer
blueprint: fields/blueprint
mapdata:
label: Carte
type: map-editor
defaultCenter: [43.836699, 4.360054]
defaultZoom: 13
maxMarkers: 50
intro:
label: Présentation de la carte
type: writer
sidebar:
width: 1/3
sections:
files:
label: Fichiers
type: files
tagsSection:
type: fields
fields:
emptyField:
type: gap
secondEmptyField:
type: gap
tags:
label: Mots-clés
type: tags

View file

@ -1,44 +1,51 @@
title: Narrative
title: Récit
columns:
main:
width: 2/3
sections:
fields:
type: fields
fields:
blueprint:
type: headline
label: Page "{{ page.intendedTemplate }}"
author:
label: Auteur·ice(s)
type: text
width: 1/2
cover:
label: Image de couverture
tabs:
contentTab:
label: Contenu
columns:
main:
width: 2/3
sections:
fields:
type: fields
fields:
blueprint: fields/blueprint
author:
label: Auteur·ice(s)
type: text
width: 1/2
cover:
label: Image de couverture
type: files
query: page.coverFiles
text: "{{ file.coverLabel }}"
multiple: false
width: 1/2
introduction:
label: Introduction
type: writer
parts:
label: Parties
type: pages
template:
- map
- geoformat
info: "{{ page.blueprint.title }}"
sidebar:
width: 1/3
sections:
files:
label: Fichiers
type: files
multiple: false
width: 1/2
introduction:
label: Introduction
type: writer
customCss:
label: Custom CSS
type: code-editor
language: css
help: Custom CSS styling for this narrative's print view
theme: monokai
size: large
pages:
label: Pages
type: pages
template:
- map
- geoformat
info: "{{ page.intendedTemplate }}"
sidebar:
width: 1/3
sections:
files:
label: Fichiers
type: files
CSSTab:
label: CSS
icon: brush
fields:
customCss:
label: Custom CSS
type: code-editor
language: css
help: Custom CSS styling for this narrative's print view
theme: monokai
size: large

View file

@ -7,6 +7,7 @@ columns:
fields:
type: fields
fields:
blueprint: fields/blueprint
subtitle:
label: Sous-titre
type: text

View file

@ -13,7 +13,7 @@ tabs:
label: Projets
type: pages
templates:
- projet
- project
secondary:
width: 1/2
sections:

View file

@ -0,0 +1,15 @@
label: Fichiers
icon: attachment
columns:
- width: 1/4
fields:
manageFilesInfo:
label: false
type: info
text: À droite, tous les fichiers que stocke la page. Pensez à supprimer les fichiers inutilisés éviter de surcharger inutilement le serveur.
- width: 3/4
sections:
filesSection:
label: Fichiers
type: files

View file

@ -0,0 +1,19 @@
<?php
Kirby::plugin('geoproject/cover-files', [
'pageMethods' => [
'coverFiles' => function () {
return $this->files()->add(
$this->children()->filterBy('intendedTemplate', 'map')->files()
);
}
],
'fileMethods' => [
'coverLabel' => function () {
if ($this->parent()->intendedTemplate()->name() === 'map') {
return 'Carte "' . $this->parent()->title() . '"';
}
return $this->filename();
}
]
]);

View file

@ -3,7 +3,7 @@
* Virtual Print Page Plugin
*
* Creates a virtual /print page for each narrative
* Allows access to print editor via /projet/narrative/print
* Allows access to print editor via /project/narrative/print
*/
use Kirby\Cms\Page;

View file

@ -1,7 +1,7 @@
<?php
/**
* JSON template to expose narrative data
* Accessible via /projet/narrative.json or /projet/narrative?format=json
* Accessible via /project/narrative.json or /project/narrative?format=json
*/
header('Content-Type: application/json; charset=utf-8');
@ -106,9 +106,14 @@ function parseBlocks($blocksField, $page) {
break;
case 'map':
$blockData['content'] = [
'map' => $block->map()->value()
];
$mapPage = $block->map()->toPages()->first();
if ($mapPage) {
$blockData['content'] = parseMap($mapPage);
} else {
$blockData['content'] = [
'map' => $block->map()->value()
];
}
break;
default:
@ -122,31 +127,53 @@ function parseBlocks($blocksField, $page) {
}
/**
* Parse un chapitre
* Parse a chapter
*/
function parseChapitre($chapitre) {
function parseChapter($chapter) {
return [
'id' => $chapitre->id(),
'uuid' => $chapitre->uuid()->toString(),
'template' => 'chapitre',
'title' => $chapitre->title()->value(),
'slug' => $chapitre->slug(),
'blocks' => parseBlocks($chapitre->text(), $chapitre)
'id' => $chapter->id(),
'uuid' => $chapter->uuid()->toString(),
'template' => 'chapter',
'title' => $chapter->title()->value(),
'slug' => $chapter->slug(),
'blocks' => parseBlocks($chapter->text(), $chapter)
];
}
/**
* Parse une carte
* Parse a marker
*/
function parseCarte($carte) {
function parseMarker($marker) {
return [
'id' => $carte->id(),
'uuid' => $carte->uuid()->toString(),
'title' => $marker->title()->value(),
'cover' => resolveFileUrl($marker->cover(), $marker),
'icon' => $marker->markerIcon()->toFile() ? $marker->markerIcon()->toFile()->url() : null,
'iconSize' => $marker->markerIconSize()->value() ?? 40,
'blocks' => parseBlocks($marker->text(), $marker)
];
}
/**
* Parse a map
*/
function parseMap($map) {
$markers = [];
foreach ($map->children()->listed() as $child) {
if ($child->intendedTemplate()->name() === 'marker') {
$markers[] = parseMarker($child);
}
}
$staticImage = $map->file('map-static.png');
return [
'id' => $map->id(),
'uuid' => $map->uuid()->toString(),
'template' => 'carte',
'title' => $carte->title()->value(),
'slug' => $carte->slug(),
'tags' => $carte->tags()->isNotEmpty() ? $carte->tags()->split() : [],
'text' => resolveImagesInHtml($carte->text()->value(), $carte)
'title' => $map->title()->value(),
'slug' => $map->slug(),
'tags' => $map->tags()->isNotEmpty() ? $map->tags()->split() : [],
'image' => $staticImage ? $staticImage->url() : null,
'intro' => resolveImagesInHtml($map->text()->value(), $map),
'markers' => $markers
];
}
@ -154,10 +181,13 @@ function parseCarte($carte) {
* Parse un geoformat
*/
function parseGeoformat($geoformat) {
$chapitres = [];
$children = [];
foreach ($geoformat->children()->listed() as $child) {
if ($child->intendedTemplate()->name() === 'chapitre') {
$chapitres[] = parseChapitre($child);
$template = $child->intendedTemplate()->name();
if ($template === 'chapter') {
$children[] = parseChapter($child);
} elseif ($template === 'map') {
$children[] = parseMap($child);
}
}
@ -171,7 +201,7 @@ function parseGeoformat($geoformat) {
'tags' => $geoformat->tags()->isNotEmpty() ? $geoformat->tags()->split() : [],
'cover' => resolveFileUrl($geoformat->cover(), $geoformat),
'text' => resolveImagesInHtml($geoformat->text()->value(), $geoformat),
'children' => $chapitres
'children' => $children
];
}
@ -197,8 +227,8 @@ foreach ($page->children()->listed() as $child) {
if ($template === 'geoformat') {
$data['children'][] = parseGeoformat($child);
} elseif ($template === 'carte') {
$data['children'][] = parseCarte($child);
} elseif ($template === 'map') {
$data['children'][] = parseMap($child);
}
}

View file

@ -1,7 +1,7 @@
<?php
/**
* Template for Vue.js print editor
* Route: /projet/narrative/print
* Route: /project/narrative/print
*
* This template loads the Vue app and passes the parent narrative JSON URL
*/

File diff suppressed because it is too large Load diff

View file

@ -1,301 +1,202 @@
<template>
<div
v-if="visible"
<BasePopup
ref="basePopup"
id="page-popup"
class="settings-popup"
:style="{ top: position.y + 'px', left: position.x + 'px' }"
:display-css="displayedCss"
:editable-css="pageCss"
:popup-width="550"
:popup-height="600"
@close="close"
@css-input="handleCssInput"
@toggle-inheritance="toggleInheritance"
>
<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>
<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>
<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"
<template #controls>
<!-- Margins -->
<div class="settings-subsection">
<h4>Marges</h4>
<div class="margin-grid">
<div
class="field"
: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>
<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>
<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>
<!-- 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, onMounted } from 'vue';
import { ref, computed, watch } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
import { usePopupPosition } from '../composables/usePopupPosition';
import { useDebounce } from '../composables/useDebounce';
import { useCssSync } from '../composables/useCssSync';
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);
import BasePopup from './ui/BasePopup.vue';
const stylesheetStore = useStylesheetStore();
const { extractSpacing } = useCssSync();
const props = defineProps({
iframeRef: Object,
@ -303,13 +204,14 @@ const props = defineProps({
const emit = defineEmits(['close']);
const visible = ref(false);
const position = ref({ x: 0, y: 0 });
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 isEditable = ref(false);
const inheritanceLocked = ref(true);
const backgroundColorInput = ref(null);
let isUpdatingFromStore = false;
@ -333,11 +235,6 @@ 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';
@ -349,10 +246,8 @@ const getOrCreateTemplateBlock = () => {
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`;
@ -375,7 +270,6 @@ const removeTemplateBlock = () => {
const block = stylesheetStore.extractBlock(selector);
if (block) {
// Remove the block and any surrounding whitespace
stylesheetStore.replaceInCustomCss(
new RegExp(`\\n?${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}\\n?`),
'\n'
@ -384,7 +278,6 @@ const removeTemplateBlock = () => {
};
const updateMargins = (force = false) => {
// Only update if inheritance is unlocked (unless forced)
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}`;
@ -392,8 +285,6 @@ const updateMargins = (force = false) => {
const currentBlock = getOrCreateTemplateBlock();
if (!currentBlock) return;
const selector = getTemplateSelector();
if (currentBlock.includes('margin:')) {
const updatedBlock = currentBlock.replace(
/(margin:\s*)[^;]+/,
@ -416,7 +307,6 @@ const updateMargins = (force = false) => {
};
const updateBackground = (force = false) => {
// Only update if inheritance is unlocked (unless forced)
if (!force && inheritanceLocked.value) return;
if (!background.value.value) return;
@ -491,37 +381,27 @@ 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],
};
// 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 bgMatch = pageBlock.match(/background:\s*([^;]+)/);
if (bgMatch) {
background.value.value = bgMatch[1].trim();
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);
@ -533,100 +413,61 @@ const loadValuesFromStylesheet = () => {
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') || '';
// Read inheritance state from page element's data attribute
inheritanceLocked.value = pageElement.dataset.inheritanceUnlocked !== 'true';
basePopup.value.inheritanceLocked = pageElement.dataset.inheritanceUnlocked !== '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);
// Open popup (sets visible, position, inits Coloris)
basePopup.value.open(event);
};
const close = () => {
selectedPageElement.value = null;
visible.value = false;
isEditable.value = false;
basePopup.value?.close();
emit('close');
};
const toggleInheritance = () => {
const wasLocked = inheritanceLocked.value;
inheritanceLocked.value = !inheritanceLocked.value;
const wasLocked = basePopup.value.inheritanceLocked;
basePopup.value.inheritanceLocked = !wasLocked;
// Store the inheritance state in the page element's data attribute
if (selectedPageElement.value) {
if (inheritanceLocked.value) {
if (basePopup.value.inheritanceLocked) {
delete selectedPageElement.value.dataset.inheritanceUnlocked;
} else {
selectedPageElement.value.dataset.inheritanceUnlocked = 'true';
}
}
if (inheritanceLocked.value && !wasLocked) {
// Re-locking: remove the template-specific block
if (basePopup.value.inheritanceLocked && !wasLocked) {
removeTemplateBlock();
} else if (!inheritanceLocked.value && wasLocked) {
// Unlocking: apply all current field values to create the CSS block
} else if (!basePopup.value.inheritanceLocked && wasLocked) {
applyAllStyles();
}
};
const pageCss = computed(() => {
// Show template-specific block if unlocked and exists, otherwise show @page
if (!inheritanceLocked.value && templateName.value) {
if (!basePopup.value?.inheritanceLocked && templateName.value) {
const templateBlock = stylesheetStore.extractBlock(`@page ${templateName.value}`);
if (templateBlock) return templateBlock;
}
return stylesheetStore.extractBlock('@page') || '';
});
// Generate a preview CSS block from current field values
const generatePreviewCss = () => {
if (!templateName.value) return '';
const properties = [];
// Always include margin with current values
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};`);
// Include background if it has a value
if (background.value.value) {
properties.push(` background: ${background.value.value};`);
}
@ -637,14 +478,11 @@ const generatePreviewCss = () => {
};
const displayedCss = computed(() => {
// If unlocked, show the actual CSS block from stylesheet
if (!inheritanceLocked.value) {
if (!basePopup.value?.inheritanceLocked) {
return pageCss.value;
}
// If locked, show commented preview of what would be applied
if (!templateName.value) {
// For base @page, just show it normally
return pageCss.value;
}
@ -656,47 +494,18 @@ const displayedCss = computed(() => {
' */';
});
const highlightedCss = computed(() => {
if (!displayedCss.value) return '';
return hljs.highlight(displayedCss.value, { language: 'css' }).value;
});
let cssDebounceTimer = null;
const handleCssInput = (event) => {
const newCss = event.target.value;
if (cssDebounceTimer) {
clearTimeout(cssDebounceTimer);
const handleCssInput = (newCss) => {
const oldBlock = pageCss.value;
if (oldBlock) {
stylesheetStore.replaceInCustomCss(oldBlock, newCss);
}
cssDebounceTimer = setTimeout(() => {
// Get the actual CSS block (not the commented preview)
const oldBlock = pageCss.value;
if (oldBlock) {
stylesheetStore.replaceInCustomCss(
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.formatCustomCss();
}
});
// Watch stylesheet changes to sync values
watch(
() => stylesheetStore.content,
() => {
if (visible.value && !stylesheetStore.isEditing) {
if (basePopup.value?.visible && !stylesheetStore.isEditing) {
loadValuesFromStylesheet();
}
}

View file

@ -40,10 +40,10 @@
<div v-if="item.text" class="chapeau" v-html="item.text"></div>
</section>
<!-- Chapitre -->
<!-- Chapter -->
<section
v-else-if="item.template === 'chapitre'"
class="chapitre"
v-else-if="item.template === 'chapter'"
class="chapter"
:data-page-type="item.template"
>
<h3>{{ item.title }}</h3>
@ -63,11 +63,40 @@
class="carte"
:data-page-type="item.template"
>
<h2>{{ item.title }}</h2>
<h4>{{ item.title }}</h4>
<img v-if="item.image" :src="item.image" class="carte-image" alt="" />
<div v-if="item.tags && item.tags.length" class="tags">
<span v-for="tag in item.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div v-if="item.text" class="carte-content" v-html="item.text"></div>
<div v-if="item.intro" class="intro" v-html="item.intro"></div>
<div v-if="item.markers && item.markers.length" class="markers">
<div v-for="(marker, idx) in item.markers" :key="idx" class="marker">
<h5 class="marker-title">
<img
v-if="marker.icon"
:src="marker.icon"
class="marker-icon"
alt=""
/>
<img
v-else
src="/assets/svg/marker-pin.svg"
class="marker-icon"
alt=""
/>
{{ marker.title }}
</h5>
<img v-if="marker.cover" :src="marker.cover" class="marker-cover" alt="" />
<template v-if="marker.blocks">
<component
v-for="block in visibleBlocks(marker.blocks)"
:key="block.id"
:is="getBlockComponent(block.type)"
:content="block.content"
/>
</template>
</div>
</div>
</section>
</template>
</template>
@ -116,7 +145,7 @@ const getBlockComponent = (type) => {
/* Base print styles for content sections */
.narrative-cover,
.geoformat,
.chapitre,
.chapter,
.carte {
break-before: page;
}
@ -186,9 +215,34 @@ const getBlockComponent = (type) => {
}
.block-map {
background: #f5f5f5;
padding: 2rem;
text-align: center;
border: 1px dashed #ccc;
break-before: page;
}
.carte-image {
max-width: 100%;
height: auto;
}
.marker {
margin-top: 1.5rem;
}
.marker-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.marker-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
object-fit: contain;
}
.marker-cover {
max-width: 100%;
height: auto;
margin: 0.5rem 0;
}
</style>

View file

@ -1,14 +1,80 @@
<template>
<div class="block-map">
<div
v-if="content.template === 'carte'"
class="block-map"
:data-slug="content.slug"
>
<h4>{{ content.title }}</h4>
<img v-if="content.image" :src="content.image" class="carte-image" alt="" />
<div v-if="content.tags && content.tags.length" class="tags">
<span v-for="tag in content.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div v-if="content.intro" class="intro" v-html="content.intro"></div>
<div v-if="content.markers && content.markers.length" class="markers">
<div v-for="(marker, idx) in content.markers" :key="idx" class="marker">
<h5 class="marker-title">
<img
v-if="marker.icon"
:src="marker.icon"
class="marker-icon"
alt=""
/>
<img
v-else
src="/assets/svg/marker-pin.svg"
class="marker-icon"
alt=""
/>
{{ marker.title }}
</h5>
<img
v-if="marker.cover"
:src="marker.cover"
class="marker-cover"
alt=""
/>
<template v-if="marker.blocks">
<component
v-for="block in visibleBlocks(marker.blocks)"
:key="block.id"
:is="getBlockComponent(block.type)"
:content="block.content"
/>
</template>
</div>
</div>
</div>
<div v-else class="block-map">
<p class="map-placeholder">[Carte: {{ content.map }}]</p>
</div>
</template>
<script setup>
import TextBlock from './TextBlock.vue';
import HeadingBlock from './HeadingBlock.vue';
import ImageBlock from './ImageBlock.vue';
import ListBlock from './ListBlock.vue';
import QuoteBlock from './QuoteBlock.vue';
defineProps({
content: {
type: Object,
required: true
}
required: true,
},
});
const visibleBlocks = (blocks) => {
return blocks.filter((block) => !block.isHidden);
};
const getBlockComponent = (type) => {
const components = {
text: TextBlock,
heading: HeadingBlock,
image: ImageBlock,
list: ListBlock,
quote: QuoteBlock,
};
return components[type] || TextBlock;
};
</script>

View file

@ -319,13 +319,14 @@
import { ref, computed, watch, onMounted, inject } from 'vue';
import { useStylesheetStore } from '../../stores/stylesheet';
import { useDebounce } from '../../composables/useDebounce';
import Coloris from '@melloware/coloris';
import { useCssSync } from '../../composables/useCssSync';
import { initColoris } from '../../composables/useColoris';
import NumberInput from '../ui/NumberInput.vue';
import { convertUnit } from '../../utils/unit-conversion';
import '@melloware/coloris/dist/coloris.css';
const stylesheetStore = useStylesheetStore();
const { debouncedUpdate } = useDebounce(500);
const { extractSpacing } = useCssSync();
const backgroundColorInput = ref(null);
const activeTab = inject('activeTab', ref('document'));
@ -621,26 +622,17 @@ const syncFromStore = () => {
}
}
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],
};
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 };
}
const bgMatch = pageBlock.match(/background:\s*([^;]+)/);
@ -705,34 +697,7 @@ watch(activeTab, (newTab, oldTab) => {
onMounted(() => {
syncFromStore();
// Initialize Coloris
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',
],
});
initColoris();
// Initialize button color if value exists
if (background.value.value) {

View file

@ -372,14 +372,14 @@
<script setup>
import { ref, watch, onMounted } from 'vue';
import Coloris from '@melloware/coloris';
import { initColoris } from '../../composables/useColoris';
import UnitToggle from '../ui/UnitToggle.vue';
import InputWithUnit from '../ui/InputWithUnit.vue';
import NumberInput from '../ui/NumberInput.vue';
import { useCssUpdater } from '../../composables/useCssUpdater';
import { useCssSync } from '../../composables/useCssSync';
import { useDebounce } from '../../composables/useDebounce';
import { convertUnit } from '../../utils/unit-conversion';
import { useLinkedSpacing } from '../../composables/useLinkedSpacing';
const { updateStyle, setMargin, setDetailedMargins, setPadding, setDetailedPadding } = useCssUpdater();
const { extractValue, extractNumericValue, extractSpacing } = useCssSync();
@ -406,59 +406,39 @@ const background = ref('transparent');
const colorInput = ref(null);
const backgroundInput = ref(null);
const marginOuterDetailed = ref({
top: { value: 0, unit: 'mm' },
right: { value: 0, unit: 'mm' },
bottom: { value: 24, unit: 'mm' },
left: { value: 0, unit: 'mm' }
});
const marginInnerDetailed = ref({
top: { value: 0, unit: 'mm' },
right: { value: 0, unit: 'mm' },
bottom: { value: 0, unit: 'mm' },
left: { value: 0, unit: 'mm' }
});
const marginOuterLinked = ref(false);
const marginInnerLinked = ref(false);
// Track previous values to detect which one changed
const prevMarginOuter = ref({
top: 0,
right: 0,
bottom: 24,
left: 0
});
const prevMarginInner = ref({
top: 0,
right: 0,
bottom: 0,
left: 0
});
let isUpdatingFromStore = false;
// Update margin outer unit for all sides with conversion
const updateMarginOuterUnit = (newUnit) => {
const sides = ['top', 'right', 'bottom', 'left'];
sides.forEach((side) => {
const s = marginOuterDetailed.value[side];
s.value = convertUnit(s.value, s.unit, newUnit);
s.unit = newUnit;
});
};
const {
sides: marginOuterDetailed,
linked: marginOuterLinked,
updateUnit: updateMarginOuterUnit,
setFromSpacing: setMarginOuterFromSpacing,
} = useLinkedSpacing({
initialValues: {
top: { value: 0, unit: 'mm' },
right: { value: 0, unit: 'mm' },
bottom: { value: 24, unit: 'mm' },
left: { value: 0, unit: 'mm' },
},
isUpdatingFromStore: () => isUpdatingFromStore,
debouncedUpdate,
onUpdate: (s) => {
setDetailedMargins('p', s.top, s.right, s.bottom, s.left);
},
});
// Update margin inner unit for all sides with conversion
const updateMarginInnerUnit = (newUnit) => {
const sides = ['top', 'right', 'bottom', 'left'];
sides.forEach((side) => {
const s = marginInnerDetailed.value[side];
s.value = convertUnit(s.value, s.unit, newUnit);
s.unit = newUnit;
});
};
const {
sides: marginInnerDetailed,
linked: marginInnerLinked,
updateUnit: updateMarginInnerUnit,
setFromSpacing: setMarginInnerFromSpacing,
} = useLinkedSpacing({
isUpdatingFromStore: () => isUpdatingFromStore,
debouncedUpdate,
onUpdate: (s) => {
setDetailedPadding('p', s.top, s.right, s.bottom, s.left);
},
});
// Watchers for body styles
watch(font, (val) => {
@ -499,160 +479,6 @@ watch(fontSize, (val) => {
});
}, { deep: true });
// Watch when link is toggled
watch(marginOuterLinked, (isLinked) => {
if (isLinked) {
// When linking, sync all to the first non-zero value or top value
const current = marginOuterDetailed.value;
const syncValue = current.top.value || current.bottom.value || current.left.value || current.right.value;
isUpdatingFromStore = true;
marginOuterDetailed.value.top.value = syncValue;
marginOuterDetailed.value.bottom.value = syncValue;
marginOuterDetailed.value.left.value = syncValue;
marginOuterDetailed.value.right.value = syncValue;
prevMarginOuter.value.top = syncValue;
prevMarginOuter.value.bottom = syncValue;
prevMarginOuter.value.left = syncValue;
prevMarginOuter.value.right = syncValue;
isUpdatingFromStore = false;
}
});
watch(marginInnerLinked, (isLinked) => {
if (isLinked) {
// When linking, sync all to the first non-zero value or top value
const current = marginInnerDetailed.value;
const syncValue = current.top.value || current.bottom.value || current.left.value || current.right.value;
isUpdatingFromStore = true;
marginInnerDetailed.value.top.value = syncValue;
marginInnerDetailed.value.bottom.value = syncValue;
marginInnerDetailed.value.left.value = syncValue;
marginInnerDetailed.value.right.value = syncValue;
prevMarginInner.value.top = syncValue;
prevMarginInner.value.bottom = syncValue;
prevMarginInner.value.left = syncValue;
prevMarginInner.value.right = syncValue;
isUpdatingFromStore = false;
}
});
// Watch margin outer values
watch(() => [
marginOuterDetailed.value.top.value,
marginOuterDetailed.value.bottom.value,
marginOuterDetailed.value.left.value,
marginOuterDetailed.value.right.value,
], () => {
if (isUpdatingFromStore) return;
// If linked, sync all values to the one that changed
if (marginOuterLinked.value) {
const current = {
top: marginOuterDetailed.value.top.value,
bottom: marginOuterDetailed.value.bottom.value,
left: marginOuterDetailed.value.left.value,
right: marginOuterDetailed.value.right.value,
};
// Find which value actually changed by comparing with previous
let changedValue = null;
if (current.top !== prevMarginOuter.value.top) changedValue = current.top;
else if (current.bottom !== prevMarginOuter.value.bottom) changedValue = current.bottom;
else if (current.left !== prevMarginOuter.value.left) changedValue = current.left;
else if (current.right !== prevMarginOuter.value.right) changedValue = current.right;
if (changedValue !== null) {
isUpdatingFromStore = true;
marginOuterDetailed.value.top.value = changedValue;
marginOuterDetailed.value.bottom.value = changedValue;
marginOuterDetailed.value.left.value = changedValue;
marginOuterDetailed.value.right.value = changedValue;
// Update previous values
prevMarginOuter.value.top = changedValue;
prevMarginOuter.value.bottom = changedValue;
prevMarginOuter.value.left = changedValue;
prevMarginOuter.value.right = changedValue;
isUpdatingFromStore = false;
}
} else {
// Update previous values even when not linked
prevMarginOuter.value.top = marginOuterDetailed.value.top.value;
prevMarginOuter.value.bottom = marginOuterDetailed.value.bottom.value;
prevMarginOuter.value.left = marginOuterDetailed.value.left.value;
prevMarginOuter.value.right = marginOuterDetailed.value.right.value;
}
debouncedUpdate(() => {
setDetailedMargins('p',
marginOuterDetailed.value.top,
marginOuterDetailed.value.right,
marginOuterDetailed.value.bottom,
marginOuterDetailed.value.left
);
});
});
// Watch margin inner values
watch(() => [
marginInnerDetailed.value.top.value,
marginInnerDetailed.value.bottom.value,
marginInnerDetailed.value.left.value,
marginInnerDetailed.value.right.value,
], () => {
if (isUpdatingFromStore) return;
// If linked, sync all values to the one that changed
if (marginInnerLinked.value) {
const current = {
top: marginInnerDetailed.value.top.value,
bottom: marginInnerDetailed.value.bottom.value,
left: marginInnerDetailed.value.left.value,
right: marginInnerDetailed.value.right.value,
};
// Find which value actually changed by comparing with previous
let changedValue = null;
if (current.top !== prevMarginInner.value.top) changedValue = current.top;
else if (current.bottom !== prevMarginInner.value.bottom) changedValue = current.bottom;
else if (current.left !== prevMarginInner.value.left) changedValue = current.left;
else if (current.right !== prevMarginInner.value.right) changedValue = current.right;
if (changedValue !== null) {
isUpdatingFromStore = true;
marginInnerDetailed.value.top.value = changedValue;
marginInnerDetailed.value.bottom.value = changedValue;
marginInnerDetailed.value.left.value = changedValue;
marginInnerDetailed.value.right.value = changedValue;
// Update previous values
prevMarginInner.value.top = changedValue;
prevMarginInner.value.bottom = changedValue;
prevMarginInner.value.left = changedValue;
prevMarginInner.value.right = changedValue;
isUpdatingFromStore = false;
}
} else {
// Update previous values even when not linked
prevMarginInner.value.top = marginInnerDetailed.value.top.value;
prevMarginInner.value.bottom = marginInnerDetailed.value.bottom.value;
prevMarginInner.value.left = marginInnerDetailed.value.left.value;
prevMarginInner.value.right = marginInnerDetailed.value.right.value;
}
debouncedUpdate(() => {
setDetailedPadding('p',
marginInnerDetailed.value.top,
marginInnerDetailed.value.right,
marginInnerDetailed.value.bottom,
marginInnerDetailed.value.left
);
});
});
// Sync from store
const syncFromStore = () => {
@ -680,70 +506,11 @@ const syncFromStore = () => {
// Margins
const margins = extractSpacing('p', 'margin');
if (margins) {
if (margins.simple) {
// All margins are the same
marginOuterDetailed.value = {
top: { ...margins.simple },
right: { ...margins.simple },
bottom: { ...margins.simple },
left: { ...margins.simple }
};
marginOuterLinked.value = true;
} else if (margins.detailed) {
marginOuterDetailed.value = margins.detailed;
// Check if all values are the same
const allSame =
margins.detailed.top.value === margins.detailed.right.value &&
margins.detailed.top.value === margins.detailed.bottom.value &&
margins.detailed.top.value === margins.detailed.left.value &&
margins.detailed.top.unit === margins.detailed.right.unit &&
margins.detailed.top.unit === margins.detailed.bottom.unit &&
margins.detailed.top.unit === margins.detailed.left.unit;
marginOuterLinked.value = allSame;
}
}
if (margins) setMarginOuterFromSpacing(margins);
// Padding
const padding = extractSpacing('p', 'padding');
if (padding) {
if (padding.simple) {
// All paddings are the same
marginInnerDetailed.value = {
top: { ...padding.simple },
right: { ...padding.simple },
bottom: { ...padding.simple },
left: { ...padding.simple }
};
marginInnerLinked.value = true;
} else if (padding.detailed) {
marginInnerDetailed.value = padding.detailed;
// Check if all values are the same
const allSame =
padding.detailed.top.value === padding.detailed.right.value &&
padding.detailed.top.value === padding.detailed.bottom.value &&
padding.detailed.top.value === padding.detailed.left.value &&
padding.detailed.top.unit === padding.detailed.right.unit &&
padding.detailed.top.unit === padding.detailed.bottom.unit &&
padding.detailed.top.unit === padding.detailed.left.unit;
marginInnerLinked.value = allSame;
}
}
// Update previous values to match current state
prevMarginOuter.value = {
top: marginOuterDetailed.value.top.value,
right: marginOuterDetailed.value.right.value,
bottom: marginOuterDetailed.value.bottom.value,
left: marginOuterDetailed.value.left.value
};
prevMarginInner.value = {
top: marginInnerDetailed.value.top.value,
right: marginInnerDetailed.value.right.value,
bottom: marginInnerDetailed.value.bottom.value,
left: marginInnerDetailed.value.left.value
};
if (padding) setMarginInnerFromSpacing(padding);
isUpdatingFromStore = false;
};
@ -757,13 +524,9 @@ const updateColorisButtons = () => {
};
onMounted(() => {
Coloris.init();
Coloris({
themeMode: 'dark',
alpha: true,
initColoris({
format: 'auto',
formatToggle: true,
swatches: ['#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', 'transparent']
swatches: ['#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', 'transparent'],
});
syncFromStore();
setTimeout(updateColorisButtons, 100);

View file

@ -0,0 +1,156 @@
<template>
<div
v-if="visible"
class="settings-popup"
:style="{ top: position.y + 'px', left: position.x + 'px' }"
>
<div class="popup-header">
<div class="header-left">
<slot name="header-left" />
</div>
<button class="close-btn" @click="$emit('close')">×</button>
</div>
<div class="popup-body">
<!-- Left: Controls -->
<div class="popup-controls">
<slot name="controls" />
<!-- Lock/Unlock Inheritance Button -->
<div class="settings-subsection">
<button class="inheritance-btn" @click="$emit('toggle-inheritance')">
<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="editableCss"
@input="handleCssInput"
:disabled="inheritanceLocked"
spellcheck="false"
></textarea>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { useStylesheetStore } from '../../stores/stylesheet';
import { usePopupPosition } from '../../composables/usePopupPosition';
import { initColoris } from '../../composables/useColoris';
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({
displayCss: { type: String, default: '' },
editableCss: { type: String, default: '' },
popupWidth: { type: Number, default: 800 },
popupHeight: { type: Number, default: 600 },
});
const emit = defineEmits(['close', 'css-input', 'toggle-inheritance']);
const visible = ref(false);
const position = ref({ x: 0, y: 0 });
const isEditable = ref(false);
const inheritanceLocked = ref(true);
const { calculatePosition } = usePopupPosition(props.popupWidth, props.popupHeight);
const highlightedCss = computed(() => {
if (!props.displayCss) return '';
return hljs.highlight(props.displayCss, { language: 'css' }).value;
});
let cssDebounceTimer = null;
const handleCssInput = (event) => {
const newCss = event.target.value;
if (cssDebounceTimer) {
clearTimeout(cssDebounceTimer);
}
cssDebounceTimer = setTimeout(() => {
emit('css-input', newCss);
}, 500);
};
// Watch isEditable to format when exiting edit mode
watch(isEditable, async (newValue, oldValue) => {
stylesheetStore.isEditing = newValue;
if (oldValue && !newValue) {
await stylesheetStore.formatCustomCss();
}
});
const open = (event) => {
position.value = calculatePosition(event);
visible.value = true;
// Initialize Coloris after opening
setTimeout(() => initColoris(), 0);
};
const close = () => {
visible.value = false;
isEditable.value = false;
emit('close');
};
defineExpose({ visible, position, isEditable, inheritanceLocked, open, close, calculatePosition });
</script>

View file

@ -0,0 +1,34 @@
import Coloris from '@melloware/coloris';
import '@melloware/coloris/dist/coloris.css';
const defaultConfig = {
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',
],
};
export function initColoris(overrides = {}) {
Coloris.init();
Coloris({ ...defaultConfig, ...overrides });
}

View file

@ -0,0 +1,146 @@
import { ref, watch } from 'vue';
import { convertUnit } from '../utils/unit-conversion';
/**
* Composable for managing linked/unlinked spacing (margin/padding) with 4 sides.
* @param {Object} options
* @param {string} options.defaultUnit - Default unit for all sides (default: 'mm')
* @param {Object} options.initialValues - Initial values for sides { top, right, bottom, left } each { value, unit }
* @param {Function} options.onUpdate - Callback called with the detailed sides when values change
* @param {Function} options.isUpdatingFromStore - Function that returns whether we're syncing from store
* @param {Function} options.debouncedUpdate - Debounced update function
*/
export function useLinkedSpacing({ defaultUnit = 'mm', initialValues, onUpdate, isUpdatingFromStore, debouncedUpdate }) {
const sides = ref(initialValues || {
top: { value: 0, unit: defaultUnit },
right: { value: 0, unit: defaultUnit },
bottom: { value: 0, unit: defaultUnit },
left: { value: 0, unit: defaultUnit },
});
const linked = ref(false);
const prevValues = ref({
top: sides.value.top.value,
right: sides.value.right.value,
bottom: sides.value.bottom.value,
left: sides.value.left.value,
});
const updateUnit = (newUnit) => {
const sideNames = ['top', 'right', 'bottom', 'left'];
sideNames.forEach((side) => {
const s = sides.value[side];
s.value = convertUnit(s.value, s.unit, newUnit);
s.unit = newUnit;
});
};
// When link is toggled on, sync all sides to first non-zero value
watch(linked, (isLinked) => {
if (!isLinked) return;
const current = sides.value;
const syncValue = current.top.value || current.bottom.value || current.left.value || current.right.value;
sides.value.top.value = syncValue;
sides.value.bottom.value = syncValue;
sides.value.left.value = syncValue;
sides.value.right.value = syncValue;
prevValues.value.top = syncValue;
prevValues.value.bottom = syncValue;
prevValues.value.left = syncValue;
prevValues.value.right = syncValue;
});
// Watch side values for linked sync + update callback
watch(() => [
sides.value.top.value,
sides.value.bottom.value,
sides.value.left.value,
sides.value.right.value,
], () => {
if (isUpdatingFromStore()) return;
if (linked.value) {
const current = {
top: sides.value.top.value,
bottom: sides.value.bottom.value,
left: sides.value.left.value,
right: sides.value.right.value,
};
let changedValue = null;
if (current.top !== prevValues.value.top) changedValue = current.top;
else if (current.bottom !== prevValues.value.bottom) changedValue = current.bottom;
else if (current.left !== prevValues.value.left) changedValue = current.left;
else if (current.right !== prevValues.value.right) changedValue = current.right;
if (changedValue !== null) {
sides.value.top.value = changedValue;
sides.value.bottom.value = changedValue;
sides.value.left.value = changedValue;
sides.value.right.value = changedValue;
prevValues.value.top = changedValue;
prevValues.value.bottom = changedValue;
prevValues.value.left = changedValue;
prevValues.value.right = changedValue;
}
} else {
prevValues.value.top = sides.value.top.value;
prevValues.value.bottom = sides.value.bottom.value;
prevValues.value.left = sides.value.left.value;
prevValues.value.right = sides.value.right.value;
}
if (onUpdate) {
debouncedUpdate(() => {
onUpdate(sides.value);
});
}
});
/**
* Set all sides from extracted spacing data (from syncFromStore).
* Also updates prevValues and optionally sets linked state.
*/
const setFromSpacing = (spacing) => {
if (spacing.simple) {
sides.value = {
top: { ...spacing.simple },
right: { ...spacing.simple },
bottom: { ...spacing.simple },
left: { ...spacing.simple },
};
linked.value = true;
} else if (spacing.detailed) {
sides.value = spacing.detailed;
const d = spacing.detailed;
const allSame =
d.top.value === d.right.value &&
d.top.value === d.bottom.value &&
d.top.value === d.left.value &&
d.top.unit === d.right.unit &&
d.top.unit === d.bottom.unit &&
d.top.unit === d.left.unit;
linked.value = allSame;
}
prevValues.value = {
top: sides.value.top.value,
right: sides.value.right.value,
bottom: sides.value.bottom.value,
left: sides.value.left.value,
};
};
return {
sides,
linked,
updateUnit,
prevValues,
setFromSpacing,
};
}

View file

@ -48,10 +48,10 @@ export const useNarrativeStore = defineStore('narrative', () => {
if (child.children && child.children.length > 0) {
flattenChildren(child.children, depth + 1);
}
} else if (child.template === 'chapitre') {
} else if (child.template === 'chapter') {
items.push({
id: child.id,
template: 'chapitre',
template: 'chapter',
title: child.title,
blocks: child.blocks,
});
@ -61,7 +61,9 @@ export const useNarrativeStore = defineStore('narrative', () => {
template: 'carte',
title: child.title,
tags: child.tags,
text: child.text,
image: child.image,
intro: child.intro,
markers: child.markers,
});
}
}