Compare commits

..

7 commits
main ... styles

Author SHA1 Message Date
Julie Blanc
b99ff90cab add position num/running 2025-12-10 17:48:10 +01:00
Julie Blanc
e279941168 styles infos 2025-12-10 17:26:12 +01:00
Julie Blanc
cb7a43d564 checkbox 2025-12-10 17:02:44 +01:00
Julie Blanc
46eaf01111 styles infos 2025-12-10 16:45:13 +01:00
Julie Blanc
202b7aeab4 sliders personnalisés 2025-12-10 16:24:58 +01:00
Julie Blanc
68343a52f5 add line height value + gestion passage px/em 2025-12-10 16:01:36 +01:00
Julie Blanc
7f3aff948b change colors 2025-12-10 15:18:12 +01:00
98 changed files with 1566 additions and 11699 deletions

View file

@ -1,43 +0,0 @@
# CI/CD avec Forgejo
## Vue d'ensemble
Le workflow `deploy.yml` automatise le build de l'application Vue et le déploiement sur le serveur de production.
## Workflow
À chaque push sur la branche `main` :
1. **Checkout** : Clone le dépôt
2. **Setup Node.js** : Installe Node.js 20
3. **Install dependencies** : Installe les dépendances npm
4. **Build Vue app** : Compile l'application Vue vers `public/assets/dist/`
5. **Deploy via FTP** : Synchronise les fichiers vers le serveur de production
## Configuration des secrets
Dans Forgejo, configurez les secrets suivants (Settings > Secrets and Variables > Actions) :
- `USERNAME` : Nom d'utilisateur FTP
- `PASSWORD` : Mot de passe FTP
- `PRODUCTION_HOST` : Hôte FTP (format : `ftp://host.example.com`)
## Fichiers déployés
Le workflow déploie depuis le dossier `public/` :
- `public/assets/``assets/` (incluant le build Vue dans `assets/dist/`)
- `public/site/``site/` (excluant accounts/, cache/, sessions/)
- `public/kirby/``kirby/`
- `public/vendor/``vendor/`
- `public/index.php``index.php`
## Build local
Pour tester le build localement :
```bash
npm run build
```
Les fichiers seront générés dans `public/assets/dist/` (ignorés par git).

View file

@ -1,57 +0,0 @@
name: Deploy
on:
push:
branches:
- main
jobs:
deploy:
name: Build and Deploy to Production
runs-on: docker
container:
image: forgejo-ci-node:latest
steps:
- name: Checkout code
run: |
git clone --depth 1 --branch main https://forge.studio-variable.com/${{ github.repository }}.git .
ls -la
- name: Install dependencies
run: |
npm ci
- name: Build Vue app
run: |
npm run build
ls -la dist/
ls -la dist/assets/dist/
- name: Deploy via FTP
env:
USERNAME: ${{ secrets.USERNAME }}
PASSWORD: ${{ secrets.PASSWORD }}
PRODUCTION_HOST: ${{ secrets.PRODUCTION_HOST }}
run: |
cd dist
lftp -c "
set ftp:ssl-allow no;
open -u $USERNAME,$PASSWORD $PRODUCTION_HOST;
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
-x 'local/' \
-x 'css/src/' \
-x 'css/style.css' \
-x 'css/style.css.map' \
-x 'css/style.scss' \
assets assets;
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
-x 'accounts/' \
-x 'cache/' \
-x 'sessions/' \
site site;
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
kirby kirby;
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
vendor vendor;
put index.php -o index.php;
quit"

8
.gitignore vendored
View file

@ -23,10 +23,4 @@ dist-ssr
*.sln
*.sw?
.claude
# Variables d'environnement Brevo
api/.env
# Claude settings
.claude
/.claude/*
.claude

View file

@ -1,12 +0,0 @@
# Image Docker pour CI/CD Forgejo - Projets Node.js
# À placer dans ~/docker-images/Dockerfile.ci-node sur le VPS
FROM node:20-slim
# Install required tools for CI/CD
RUN apt-get update && apt-get install -y \
lftp \
git \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /workspace

View file

@ -1,243 +0,0 @@
# Map Editor Plugin Transformation - Implementation Summary
## Overview
Successfully transformed the map-editor plugin to use Kirby subpages for markers instead of YAML storage. Markers are now fully-featured Kirby pages with extensible block content.
## Changes Implemented
### 1. Backend Infrastructure
#### New Files Created
**`/public/site/plugins/map-editor/api/routes.php`**
- Implements 5 API endpoints:
- `GET /api/map-editor/pages/:pageId/markers` - List all markers for a map page
- `POST /api/map-editor/pages/:pageId/markers` - Create new marker
- `PATCH /api/map-editor/pages/:pageId/markers/:markerId` - Update marker position (multi mode)
- `DELETE /api/map-editor/pages/:pageId/markers/:markerId` - Delete marker
- `PATCH /api/map-editor/pages/:pageId/position` - Update position (single mode)
- All endpoints include:
- Authentication checks
- CSRF token verification
- Permission checks (read/create/update/delete)
- Proper HTTP status codes
- Error handling
**`/public/site/blueprints/pages/marker.yml`**
- Two-tab structure:
- **Content tab**: Title + extensible blocks field (heading, text, image, list, quote)
- **Position tab**:
- Left column: latitude/longitude number fields
- Right column: map-editor in single mode for visual positioning
**`/public/site/plugins/map-editor/src/composables/useMarkersApi.js`**
- Replaces old YAML-based useMarkers.js
- Provides reactive API interface:
- `fetchMarkers()` - Load markers from API
- `createMarker(position)` - Create new marker
- `updateMarkerPosition(markerId, position)` - Update position
- `deleteMarker(markerId)` - Delete marker
- Includes loading states, error handling, CSRF management
#### Modified Files
**`/public/site/plugins/map-editor/index.php`**
- Registered API routes
- Added new field props: `mode`, `latitude`, `longitude`
**`/public/site/blueprints/pages/map.yml`**
- Added markers section to sidebar:
- Type: pages
- Template: marker
- Sorted by num (Kirby's built-in ordering)
**`/public/site/plugins/map-editor/src/composables/useMapData.js`**
- Removed all marker-related logic
- `saveMapData()` now only saves: background, center, zoom
- Removed markers parameter from function signatures
### 2. Frontend Refactoring
**`/public/site/plugins/map-editor/src/components/field/MapEditor.vue`**
Major refactor with two distinct modes:
#### Multi Mode (default)
- Displays MarkerList sidebar
- Loads markers via API on mount
- Create marker: `handleAddMarker()` → API call
- Delete marker: `deleteMarker()` → API call with confirmation
- Edit marker: `editMarker()` → Redirects to Kirby Panel
- Drag marker: `handleMarkerMoved()` → API call to update position
- Automatically fetches markers from subpages
#### Single Mode (for marker blueprint)
- Hides MarkerList sidebar
- Creates single marker from `latitude`/`longitude` props
- Displays marker at current page coordinates
- Drag marker: Updates page via `/api/map-editor/pages/:pageId/position` endpoint
- Watches latitude/longitude props to update map when fields change
- Smaller height (400px vs 600px)
### 3. Removed Files
- `/public/site/plugins/map-editor/src/components/map/MarkerEditor.vue` - Modal editor no longer needed (Panel handles editing)
- `/public/site/plugins/map-editor/src/composables/useMarkers.js` - Replaced by useMarkersApi.js
## Data Flow
### Multi Mode (Map Page)
1. User opens map page in Panel
2. MapEditor fetches markers via `GET /api/map-editor/pages/:pageId/markers`
3. Markers displayed on map + in sidebar
4. User actions:
- Click "Add" or click map → `POST /api/.../markers` → New subpage created
- Drag marker → `PATCH /api/.../markers/:markerId` → Position updated
- Click "Edit" → Redirect to Panel marker page
- Click "Delete" → `DELETE /api/.../markers/:markerId` → Subpage deleted
5. Changes to center/zoom → Saved to mapdata YAML
### Single Mode (Marker Page)
1. User opens marker page in Panel
2. MapEditor receives latitude/longitude from blueprint query (`{{ page.latitude }}`)
3. Creates visual marker at those coordinates
4. User drags marker → `PATCH /api/map-editor/pages/:pageId/position` → Updates latitude/longitude fields
5. No markers section or CRUD buttons shown
## API Response Format
### GET /api/map-editor/pages/:pageId/markers
```json
{
"status": "success",
"data": {
"markers": [
{
"id": "map/carte/marker-1234567890",
"slug": "marker-1234567890",
"title": "Marqueur 1",
"position": {"lat": 43.8, "lon": 4.3},
"num": 1,
"panelUrl": "/panel/pages/map+carte+marker-1234567890"
}
]
}
}
```
### POST /api/map-editor/pages/:pageId/markers
Request:
```json
{"position": {"lat": 43.8, "lon": 4.3}}
```
Response: Same format as GET, but with single marker
### Error Response
```json
{
"status": "error",
"message": "Error description",
"code": 400
}
```
## Security
- All API endpoints require authentication
- CSRF protection via `X-CSRF` header
- Permission checks (isReadable, can('create'), can('update'), can('delete'))
- Input validation (coordinate ranges, required fields)
- Proper error handling with try/catch
## Marker Ordering
- Uses Kirby's native `num` field for ordering
- Listed subpages sorted by `num asc`
- Panel drag-and-drop automatically manages num
- No custom ordering logic needed
## Migration Notes
- Only one map page exists with fake content → No migration needed
- Old YAML markers structure no longer used
- mapdata field now only stores: background, center, zoom
## Testing Checklist
- [x] API routes created and registered
- [x] Blueprint structure correct
- [x] MapEditor.vue refactored for API
- [x] Single mode implemented
- [x] Old files removed (MarkerEditor.vue, useMarkers.js)
- [ ] Test marker creation from map
- [ ] Test marker deletion with confirmation
- [ ] Test marker drag updates position
- [ ] Test edit button redirects to Panel
- [ ] Test single mode in marker page
- [ ] Test single mode drag updates coordinates
- [ ] Test GeocodeSearch in both modes
- [ ] Test with 50 markers (performance)
- [ ] Verify CSRF protection works
- [ ] Verify permissions are enforced
## Known Considerations
1. **Panel Refresh**: In single mode, when dragging a marker, the API updates the latitude/longitude fields, but the Panel doesn't automatically refresh. Users may need to reload to see updated number values.
2. **Page ID Extraction**: The code extracts page ID from `props.name` with a regex. This works for standard Kirby field names but may need adjustment if field naming changes.
3. **Error Handling**: API errors are logged to console. Consider adding user-visible error messages in the UI.
4. **Loading States**: Loading states are available in the component but not visually displayed. Consider adding a loading spinner.
## Next Steps (Future Improvements)
1. Add visual loading indicators during API calls
2. Add user-visible error messages (toasts/alerts)
3. Implement real-time Panel field sync in single mode
4. Add marker icon customization
5. Add marker search/filter in MarkerList
6. Consider pagination for maps with many markers
7. Add bulk operations (delete multiple, reorder)
8. Add marker clustering on map for better performance
## File Structure After Implementation
```
/public/site/plugins/map-editor/
├── api/
│ └── routes.php (NEW)
├── src/
│ ├── components/
│ │ ├── field/
│ │ │ └── MapEditor.vue (MODIFIED - major refactor)
│ │ └── map/
│ │ ├── MapPreview.vue
│ │ ├── MarkerList.vue
│ │ └── MarkerEditor.vue (DELETED)
│ └── composables/
│ ├── useMarkersApi.js (NEW)
│ ├── useMapData.js (MODIFIED)
│ └── useMarkers.js (DELETED)
└── index.php (MODIFIED)
/public/site/blueprints/pages/
├── marker.yml (NEW)
└── map.yml (MODIFIED)
```
## Summary
The transformation successfully achieves the goal of making markers first-class Kirby content with extensible fields. The implementation:
- ✅ Maintains backward compatibility for map data (center, zoom, background)
- ✅ Provides clean API-based architecture
- ✅ Supports both multi-marker (map page) and single-marker (marker page) modes
- ✅ Leverages Kirby's built-in Panel for content editing
- ✅ Includes proper security and error handling
- ✅ Uses Kirby's native ordering system
- ✅ Removes obsolete YAML-based marker storage
The plugin is now ready for testing and refinement based on real-world usage.

206
README.md
View file

@ -1,205 +1,5 @@
# GeoProject - Web-to-Print Interface
# Vue 3 + Vite
A web-to-print application for creating printable narratives with real-time layout editing.
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Overview
GeoProject is a sophisticated web-to-print platform that combines:
- **Kirby CMS** for content management
- **Vue 3** for interactive editing interface
- **PagedJS** for print-ready rendering
The application allows users to create and edit multi-page narratives with dynamic layouts, supporting various content blocks (text, images, videos, maps) and custom page templates.
## Tech Stack
- **Frontend**: Vue 3 (Composition API) + Vite
- **Print Engine**: PagedJS (CSS Paged Media)
- **CMS**: Kirby 5 (headless, flat-file)
- **Backend**: PHP 8.1+
- **State Management**: Pinia
- **Styling**: CSS with CSS Variables
## Project Structure
```
/src # Vue 3 SPA
├── main.js # Vue bootstrap
├── App.vue # Root component + PagedJS init
├── components/
│ ├── blocks/ # Content block components (HeadingBlock, TextBlock, etc.)
│ ├── editor/ # Editor panels (PageSettings, TextSettings, etc.)
│ ├── ui/ # Reusable UI components (InputWithUnit, MarginEditor, etc.)
│ └── *.vue # Core components (PagedJsWrapper, ElementPopup, etc.)
├── composables/ # Vue composables (useCssSync, useCssUpdater, etc.)
├── stores/ # Pinia stores (narrative.js, stylesheet.js)
└── utils/ # JavaScript utilities
/public # Kirby CMS + static assets
├── site/
│ ├── blueprints/ # Content schemas
│ ├── templates/ # PHP templates
│ ├── snippets/ # PHP snippets
│ └── plugins/ # Kirby plugins
├── content/ # Markdown content files
└── assets/ # Static assets (CSS, fonts, SVG)
/.forgejo/workflows # CI/CD pipeline
```
## Key Features
### Content Types
- **Narratives**: Main story containers with cover, author, introduction
- **Geoformats**: Structured content sections with chapters
- **Chapters**: Individual chapters with rich content blocks
- **Maps**: Special map-based content pages
### Content Blocks
- Text blocks with rich formatting
- Headings with customizable levels
- Images with captions and positioning
- Lists (ordered and unordered)
- Blockquotes with citations
- Video embeds
- Interactive maps
### Print Features
- Real-time preview with PagedJS rendering
- Custom @page rules for different templates
- Interactive element and page editing
- CSS variable-based theming
- Print-optimized output
## Getting Started
### Development
```bash
# Install dependencies
npm install
# Start Vite dev server
npm run dev
# Start PHP server for Kirby (separate terminal)
php -S localhost:8000 -t public
```
The Vue app will be served at `http://localhost:5173` and Kirby at `http://localhost:8000`.
### Production Build
```bash
# Build for production
npm run build
```
Builds are output to `/public/assets/dist/`.
## Data Flow
1. **Kirby CMS** stores and manages content as flat files
2. **PHP Templates** render the HTML structure and inject Vue
3. **Vue App** provides the interactive editing interface
4. **PagedJS** transforms content into print-ready pages
## API
### Narrative JSON Endpoint
```
GET /projet/{narrative-slug}.json
```
Returns the complete narrative data structure including all child pages, blocks, and metadata.
## Naming Conventions
- **Vue Components**: PascalCase (e.g., `PagedJsWrapper.vue`)
- **Composables**: Prefixed with `use` (e.g., `useCssSync`)
- **Stores**: camelCase files, PascalCase store names (e.g., `useNarrativeStore`)
- **Code Language**: English preferred for all code, comments, and identifiers
## English-French Dictionary
The codebase uses English naming conventions, but some French terms remain in content and templates for compatibility. Here's a reference guide:
### Core Concepts
| English | French | Context |
|---------|--------|---------|
| narrative | récit | Main content container type |
| chapter | chapitre | Chapter/section within a geoformat |
| map | carte | Map-based content page |
| cover | couverture | Cover page/image |
| author | auteur | Narrative author(s) |
| introduction | introduction | Introductory text |
| print | impression | Print/output functionality |
### Template Types
| English | French | File/Template Name |
|---------|--------|--------------------|
| narrative | recit | `narrative.php`, `narrative.json.php` |
| chapter | chapitre | `chapitre.php` |
| map | carte | `carte.php` |
| geoformat | geoformat | `geoformat.php` |
### UI Elements
| English | French | Notes |
|---------|--------|-------|
| settings | paramètres | Editor panel settings |
| page | page | Page template/type |
| block | bloc | Content block |
| edit | éditer | Edit action |
| preview | aperçu | Preview mode |
### Technical Terms
| English | French | Notes |
|---------|--------|-------|
| store | magasin | Pinia store (use English 'store') |
| template | template/modèle | Page template |
| blueprint | schéma | Kirby content schema |
| field | champ | Form/content field |
### Code Examples
**Store naming:**
```javascript
// Correct
import { useNarrativeStore } from './stores/narrative';
// Old (deprecated)
import { useRecitStore } from './stores/recit';
```
**Template references:**
```javascript
// Check for narrative template
if (item.template === 'narrative') { /* ... */ }
// Check for chapter template
if (item.template === 'chapitre') { /* ... */ }
```
**CSS classes:**
```css
/* Narrative cover page */
.narrative-cover { /* ... */ }
/* Chapter content */
.chapitre { /* ... */ }
```
## CI/CD
The project uses Forgejo Actions for continuous deployment:
1. Code is pushed to Forgejo repository
2. Workflow builds the Vue app
3. Files are deployed via FTP to production server
See `.forgejo/workflows/deploy.yml` for details.
## Contributing
For detailed development guidelines, see `CLAUDE.md`.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

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

123
claude.md
View file

@ -2,7 +2,7 @@
## Vue d'ensemble
Application web-to-print permettant la mise en page de récits (narratives) imprimables. L'édition de contenu se fait via Kirby CMS (headless), la mise en page est rendue par PagedJS, et l'interface d'édition réactive utilise Vue 3.
Application web-to-print permettant la mise en page de récits imprimables. L'édition de contenu se fait via Kirby CMS (headless), la mise en page est rendue par PagedJS, et l'interface d'édition réactive utilise Vue 3.
## Stack technique
@ -14,47 +14,19 @@ Application web-to-print permettant la mise en page de récits (narratives) impr
## Architecture
```
/src # Vue 3 SPA
├── main.js # Bootstrap Vue
├── App.vue # Root + init PagedJS
├── style.css # Styles globaux
├── components/
│ ├── blocks/ # COMPOSANTS DE BLOCS (HeadingBlock, TextBlock, etc.)
│ │ └── index.js # Export central des blocs
│ ├── editor/ # PANNEAUX D'ÉDITION (PageSettings, TextSettings, etc.)
│ ├── ui/ # COMPOSANTS UI RÉUTILISABLES (InputWithUnit, MarginEditor, etc.)
│ ├── PagedJsWrapper.vue # Wrapper principal PagedJS
│ ├── ElementPopup.vue # Popup d'édition d'élément
│ ├── PagePopup.vue # Popup d'édition de page
│ ├── SidePanel.vue # Panneau latéral
│ ├── PreviewLoader.vue # Loader de preview
│ └── StylesheetViewer.vue # Viewer de feuilles de style
├── composables/ # COMPOSABLES VUE (useCssSync, useCssUpdater, etc.)
├── stores/ # STORES PINIA (narrative.js, stylesheet.js)
└── utils/ # UTILITAIRES JS (css-parsing.js, etc.)
/src # Vue 3 SPA
├── main.js # Bootstrap Vue
├── App.vue # Root + init PagedJS
└── components/
└── PagedJsWrapper.vue # Contenu print
/api/cache # Cache des données API (donorbox_data.json, etc.)
/public # Kirby CMS + assets statiques
├── index.php # Entry Kirby
├── composer.json # Dépendances PHP
├── assets/
│ ├── css/ # CSS sources (exclus du déploiement)
│ ├── fonts/ # Webfonts
│ └── svg/ # Icônes SVG
/public # Kirby CMS
├── index.php # Entry Kirby
├── site/
│ ├── blueprints/ # Schémas de contenu Kirby
│ ├── templates/ # Templates PHP Kirby
│ ├── snippets/ # Snippets PHP (header.php injecte Vue)
│ ├── plugins/ # Plugins Kirby
│ ├── accounts/ # Comptes utilisateurs (exclu déploiement)
│ ├── cache/ # Cache Kirby (exclu déploiement)
│ └── sessions/ # Sessions (exclu déploiement)
├── content/ # Contenus markdown Kirby
└── media/ # Médias uploadés
/.forgejo/workflows # CI/CD Forgejo
/Dockerfile.ci # Image Docker pour CI (à copier sur VPS)
│ ├── blueprints/ # Schémas de contenu
│ ├── templates/ # Templates PHP
│ └── snippets/ # header.php injecte Vue
└── content/ # Contenus markdown
```
## Flux de données
@ -73,82 +45,25 @@ Application web-to-print permettant la mise en page de récits (narratives) impr
### PagedJS
- CSS `@page` rules dans les composants Vue
- Interface preview dans `/src/assets/pagedjs-interface.css`
- Initialisé via `Previewer` dans `App.vue`
- Preview navigable avec interface de navigation
### State Management
- **Pinia** utilisé pour la gestion d'état
- `stores/narrative.js` : État du récit/narrative (contenu, navigation)
- `stores/stylesheet.js` : État des feuilles de style CSS
### Conventions de placement (IMPORTANT : respecter cette organisation)
**Nouveau composant de bloc (heading, text, image, etc.)**
`/src/components/blocks/NomBlock.vue` + export dans `blocks/index.js`
**Nouveau panneau d'édition/settings**
`/src/components/editor/NomSettings.vue`
**Nouveau composant UI réutilisable (input, bouton, etc.)**
`/src/components/ui/NomComposant.vue`
**Nouveau composable Vue (logique réutilisable)**
`/src/composables/useNomComposable.js`
**Nouveau store Pinia**
`/src/stores/nom.js`
**Nouvelle fonction utilitaire**
`/src/utils/nom-util.js`
**Cache API externe**
`/api/cache/nom_data.json`
**Nouveau template/snippet Kirby**
`/public/site/templates/` ou `/public/site/snippets/`
**Nouveau blueprint Kirby**
`/public/site/blueprints/`
### À implémenter
- API REST Kirby pour exposer le contenu en JSON
- Fetch dynamique dans Vue
- Panneaux/popups d'édition réactive
- State management si nécessaire
## Commandes
```bash
npm run dev # Vite dev server (5173)
npm run build # Build prod dans /public/assets/dist/
npm run build # Build prod
# Kirby sur serveur PHP séparé
```
## Déploiement
### CI/CD Forgejo
- Workflow : `.forgejo/workflows/deploy.yml`
- Image Docker : `forgejo-ci-node:latest` (construite depuis `Dockerfile.ci`)
- Déploiement via FTP avec `lftp`
### Fichiers/dossiers exclus du déploiement FTP
```
assets/local/ # Assets locaux
assets/css/src/ # Sources SCSS
assets/css/*.css # CSS compilés localement
assets/css/*.map # Source maps
assets/css/*.scss # Sources SCSS
site/accounts/ # Comptes utilisateurs
site/cache/ # Cache Kirby
site/sessions/ # Sessions actives
```
### Build process
1. Clone du repo
2. `npm ci` : Install des dépendances
3. Copie de `/public` vers `/dist`
4. `npm run build` : Vite build dans `/dist/assets/dist/`
5. Déploiement FTP de `/dist` vers production
## Conventions
- Composants Vue : PascalCase
- CSS : Variables pour theming, scoped styles
- Print CSS : W3C Paged Media spec
- Stores Pinia : camelCase pour les fichiers, PascalCase pour les noms (ex: `useNarrativeStore`)
- Composables : Préfixe `use` (ex: `useCssSync`)
- Code naming : English preferred (ex: `narrative` instead of `recit`)

5
public/.gitignore vendored
View file

@ -48,8 +48,3 @@ Icon
# ---------------
/site/config/.license
# Content
# ---------------
content
/content/*

View file

@ -1,5 +1,5 @@
.unit-toggle{
.field{
button:not(.spinner-btn) {
cursor: pointer;

View file

@ -1,34 +1,57 @@
.settings-section {
margin: var(--space-m) 0;
h2 {
margin-bottom: var(--space);
font-weight: 600;
font-size: 1.4rem;
border-bottom: 1px solid var(--color-200);
color: var(--color-800);
}
.infos {
font-size: 0.8rem;
color: var(--color-interface-400);
.infos{
position: relative;
padding-left: 2.5ch;
.info-icon{
position: absolute;
left: 0px;
top: -5px;
svg{
width: 16px;
fill: var(--color-interface-400);
}
}
p{
font-size: 0.8rem;
color: var(--color-interface-400);
}
}
.container{
margin-top: var(--space-xs);
}
}
.settings-subsection:not(:last-child) {
border-bottom: 1px solid var(--color-interface-100);
}
.settings-subsection {
padding: var(--space-xs) 0;
h3 {
margin-top: calc(var(--space-xs) * 1.5);
margin-bottom: calc(var(--space-xs) * 2);
// color: var(--color-600);
font-size: 1rem;
font-weight: 600;
border-bottom: 1px solid var(--color-interface-100);
}
}
.settings-subsection {
padding: var(--space-xs) 0;
h3 {
margin-top: calc(var(--space-xs)*1.5);
margin-bottom: calc(var(--space-xs)*2);
font-size: 1rem;
font-weight: 600;
color: var(--color-800);
}
}

View file

@ -8,20 +8,129 @@ input[type="number"] {
color: var(--color-txt);
font-size: 1rem;
padding-left: 0.5ch;
// min-width: var(--input-w);
// width: 100%;
// padding: 0 1ch;
&:focus{
border-color: var(--color-interface-400);
}
}
/// RANGE---------------------------------------------------------------------------------
input[type="range"]{
-webkit-appearance: none;
background: transparent;
}
@mixin thumb(){
border: 2px solid var(--color-panel-bg);
height: 16px;
width: 16px;
border-radius: 50%;
background: var(--color-interface-500);
position: relative;
top: -6px;
cursor: pointer;
}
@mixin track(){
width: 100%;
height: 4px;
cursor: pointer;
background: var(--color-interface-200);
border-radius: 2px;
}
/* WebKit/Blink */
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
@include thumb();
}
input[type=range]::-webkit-slider-runnable-track {
@include track();
}
/* Firefox */
input[type=range]::-moz-range-thumb {
@include thumb();
}
input[type=range]::-moz-range-track{
@include track();
}
/* IE */
input[type=range]::-ms-thumb {
@include thumb();
}
input[type=range]::-ms-track{
background: transparent;
border-color: transparent;
color: transparent;
@include track();
}
// Checkbox ===============================================
input[type="checkbox"]{
display: none;
}
input[type="checkbox"] + label{
position: relative;
padding-left: 24px;
}
input[type="checkbox"] + label::before{
content: "";
font-size: 11px;
padding-bottom: 3px;
padding-left: 3px;
--size: 11px;
width: var(--size);
height: var(--size);
border-radius: var(--border-radius);
display: block;
position: absolute;
top: 0px;
border: 1px solid var(--color-interface-400);
background-color: var(--color-interface-100);
color: transparent;
}
input[type="checkbox"]:checked + label::before{
background-color: var(--color-interface-600);
border-color: var(--color-interface-600);
color: var(--color-panel-bg);
}
// GENERALFIELD ===========================================
.field--view-only {
opacity: 0.3;
}
.field {
display: flex;
display: grid;
grid-template-columns: var(--label-w) 1fr;
label {
font-weight: 600;
color: var(--color-800);
align-self: center;
}
.input-with-unit {
.input-with-range {
display: flex;
gap: 0.3rem;
}
@ -38,91 +147,104 @@ input[type="number"] {
.clr-field {
width: 100%;
display: grid;
grid-template-columns: var(--input-h) 1fr;
grid-template-columns: var(--input-h);
grid-gap: 1ch;
button {
button{
grid-column: 1;
position: relative;
border-radius: var(--border-radius);
cursor: pointer;
pointer-events: auto;
}
input {
input{
grid-column: 2;
}
}
}
}
.field {
display: grid;
grid-template-columns: var(--label-w) 1fr;
label {
align-self: center;
}
}
.field-font {
.field-font{
display: grid;
grid-template-columns: var(--label-w) 1fr;
grid-template-rows: var(--input-h) var(--input-h);
select {
select{
width: 100%;
}
.field-checkbox {
.checkbox-field{
grid-column: 2;
padding-top: var(--space-xs);
label {
margin: 0;
label{
font-weight: 400;
margin-left: 0.75ch;
color: var(--color-txt);
text-decoration-color: var(--color-interface-300)!important;
}
}
}
.field-text-size {
input[type="number"] {
.field-text-size{
input[type="number"]{
width: var(--input-w-small);
padding-left: 0.75ch;
}
input[type="range"] {
input[type="range"]{
flex-grow: 2;
flex-shrink: 2;
}
}
.field-margin,
.field-size {
.field-margin, .field-size{
display: inline-grid;
width: calc(50% - 1ch);
grid-template-columns: 6.5ch var(--input-w-small) 1fr;
margin-bottom: var(--space-xs);
input {
input{
width: var(--input-w-small);
padding-left: 0.75ch;
}
&:nth-of-type(odd) {
&:nth-of-type(odd){
margin-right: 2ch;
}
}
.checkbox-field {
margin: calc(var(--space-xs) * 2) 0;
grid-template-columns: 3ch 1fr;
input {
justify-self: left;
margin: 0;
.margins label{
color: var(--color-txt);
font-weight: normal;
text-decoration-color: var(--color-interface-300)!important;
}
.field-generated-content{
.positions{
display: flex;
gap: 2em;
}
.group-position{
display: flex;
gap: 0.3em;
}
}
.field--view-only {
opacity: 0.3;
}
/* Label with CSS tooltip */
// TOOLTIP ===============================================
.label-with-tooltip {
text-decoration: underline dotted 1px var(--color-200);
text-decoration: underline dotted 1px var(--color-400);
text-underline-offset: 2px;
cursor: help;
position: relative;
@ -154,9 +276,14 @@ input[type="number"] {
}
}
// INPUTNUMBER ===============================================
// Masquer les spinners natifs partout
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
@ -168,107 +295,35 @@ input[type="number"] {
-moz-appearance: textfield;
}
.number-input {
.number-input{
position: relative;
// padding: 0 1ch!important;
input {
input{
padding-top: 0;
padding-bottom: 0;
}
.spinner-buttons {
.spinner-buttons{
height: var(--input-h);
width: var(--input-h);
width: var(--input-h);
display: flex;
flex-direction: column;
position: absolute;
right: 0;
top: 0;
button {
height: calc(var(--input-h) * 0.5);
button{
height: calc(var(--input-h)*0.5);
cursor: pointer;
padding: 0;
svg {
svg{
width: 10px;
height: auto;
}
svg path {
fill: var(--color-interface-600);
}
&:hover {
svg path {
fill: var(--color-interface-900);
}
}
}
.spinner-down {
svg {
// position: relative;
// top: -2px;
svg path{ fill: var(--color-interface-600); }
&:hover{
svg path{ fill: var(--color-interface-900); }
}
}
}
}
// Composant NumberInput avec boutons personnalisés
// .number-input {
// position: relative;
// display: flex;
// align-items: center;
// width: 100%;
// position: relative;
// input[type="number"] {
// width: 100%;
// box-sizing: border-box;
// }
// .spinner-buttons {
// background-color: red;
// position: absolute;
// right: 1px;
// top: 1px;
// bottom: 1px;
// display: flex;
// flex-direction: column;
// justify-content: center;
// gap: 1px;
// z-index: 10;
// .spinner-btn {
// display: flex;
// align-items: center;
// justify-content: center;
// width: 20px;
// height: 10px;
// padding: 0;
// margin: 0;
// background-color: var(--color-interface-200);
// border: 1px solid var(--color-interface-300);
// border-radius: 2px;
// cursor: pointer;
// transition: background-color 0.15s ease;
// color: var(--color-interface-700);
// line-height: 0;
// svg {
// width: 8px;
// height: 6px;
// display: block;
// }
// &:hover:not(:disabled) {
// background-color: var(--color-interface-300);
// color: var(--color-interface-900);
// }
// &:active:not(:disabled) {
// background-color: var(--color-interface-400);
// }
// &:disabled {
// opacity: 0.3;
// cursor: not-allowed;
// }
// }
// }
// }

View file

@ -5,7 +5,7 @@
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
z-index: 10;
z-index: 10000;
width: 860px;
max-height: 600px;
display: flex;

View file

@ -1,3 +1,4 @@
@charset "UTF-8";
@font-face {
font-family: "DM Sans";
src: url("/assets/fonts/DMSans/DMSans[opsz,wght].woff2") format("woff2-variations");
@ -280,15 +281,127 @@ input[type=number] {
font-size: 1rem;
padding-left: 0.5ch;
}
select:focus,
input[type=text]:focus,
input[type=number]:focus {
border-color: var(--color-interface-400);
}
input[type=range] {
-webkit-appearance: none;
background: transparent;
}
/* WebKit/Blink */
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
border: 2px solid var(--color-panel-bg);
height: 16px;
width: 16px;
border-radius: 50%;
background: var(--color-interface-500);
position: relative;
top: -6px;
cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
background: var(--color-interface-200);
border-radius: 2px;
}
/* Firefox */
input[type=range]::-moz-range-thumb {
border: 2px solid var(--color-panel-bg);
height: 16px;
width: 16px;
border-radius: 50%;
background: var(--color-interface-500);
position: relative;
top: -6px;
cursor: pointer;
}
input[type=range]::-moz-range-track {
width: 100%;
height: 4px;
cursor: pointer;
background: var(--color-interface-200);
border-radius: 2px;
}
/* IE */
input[type=range]::-ms-thumb {
border: 2px solid var(--color-panel-bg);
height: 16px;
width: 16px;
border-radius: 50%;
background: var(--color-interface-500);
position: relative;
top: -6px;
cursor: pointer;
}
input[type=range]::-ms-track {
background: transparent;
border-color: transparent;
color: transparent;
width: 100%;
height: 4px;
cursor: pointer;
background: var(--color-interface-200);
border-radius: 2px;
}
input[type=checkbox] {
display: none;
}
input[type=checkbox] + label {
position: relative;
padding-left: 24px;
}
input[type=checkbox] + label::before {
content: "✔";
font-size: 11px;
padding-bottom: 3px;
padding-left: 3px;
--size: 11px;
width: var(--size);
height: var(--size);
border-radius: var(--border-radius);
display: block;
position: absolute;
top: 0px;
border: 1px solid var(--color-interface-400);
background-color: var(--color-interface-100);
color: transparent;
}
input[type=checkbox]:checked + label::before {
background-color: var(--color-interface-600);
border-color: var(--color-interface-600);
color: var(--color-panel-bg);
}
.field--view-only {
opacity: 0.3;
}
.field {
display: flex;
display: grid;
grid-template-columns: var(--label-w) 1fr;
}
.field label {
font-weight: 600;
color: var(--color-800);
align-self: center;
}
.field .input-with-unit {
.field .input-with-range {
display: flex;
gap: 0.3rem;
}
@ -304,28 +417,18 @@ input[type=number] {
.field .input-with-color .clr-field {
width: 100%;
display: grid;
grid-template-columns: var(--input-h) 1fr;
grid-template-columns: var(--input-h);
grid-gap: 1ch;
}
.field .input-with-color .clr-field button {
grid-column: 1;
position: relative;
border-radius: var(--border-radius);
cursor: pointer;
pointer-events: auto;
}
.field .input-with-color .clr-field input {
grid-column: 2;
}
.field {
display: grid;
grid-template-columns: var(--label-w) 1fr;
}
.field label {
align-self: center;
}
.field-font {
display: grid;
grid-template-columns: var(--label-w) 1fr;
@ -334,14 +437,15 @@ input[type=number] {
.field-font select {
width: 100%;
}
.field-font .field-checkbox {
.field-font .checkbox-field {
grid-column: 2;
padding-top: var(--space-xs);
margin: 0;
}
.field-font .field-checkbox label {
.field-font .checkbox-field label {
font-weight: 400;
margin-left: 0.75ch;
color: var(--color-txt);
text-decoration-color: var(--color-interface-300) !important;
}
.field-text-size input[type=number] {
@ -353,40 +457,38 @@ input[type=number] {
flex-shrink: 2;
}
.field-margin,
.field-size {
.field-margin, .field-size {
display: inline-grid;
width: calc(50% - 1ch);
grid-template-columns: 6.5ch var(--input-w-small) 1fr;
margin-bottom: var(--space-xs);
}
.field-margin input,
.field-size input {
.field-margin input, .field-size input {
width: var(--input-w-small);
padding-left: 0.75ch;
}
.field-margin:nth-of-type(odd),
.field-size:nth-of-type(odd) {
.field-margin:nth-of-type(odd), .field-size:nth-of-type(odd) {
margin-right: 2ch;
}
.checkbox-field {
margin: calc(var(--space-xs) * 2) 0;
grid-template-columns: 3ch 1fr;
}
.checkbox-field input {
justify-self: left;
margin: 0;
.margins label {
color: var(--color-txt);
font-weight: normal;
text-decoration-color: var(--color-interface-300) !important;
}
.field--view-only {
opacity: 0.3;
.field-generated-content .positions {
display: flex;
gap: 2em;
}
.field-generated-content .group-position {
display: flex;
gap: 0.3em;
}
/* Label with CSS tooltip */
.label-with-tooltip {
-webkit-text-decoration: underline dotted 1px var(--color-200);
text-decoration: underline dotted 1px var(--color-200);
-webkit-text-decoration: underline dotted 1px var(--color-400);
text-decoration: underline dotted 1px var(--color-400);
text-underline-offset: 2px;
cursor: help;
position: relative;
@ -456,22 +558,38 @@ input[type=number] {
.number-input .spinner-buttons button:hover svg path {
fill: var(--color-interface-900);
}
.settings-section {
margin: var(--space-m) 0;
}
.settings-section h2 {
margin-bottom: var(--space);
font-weight: 600;
font-size: 1.4rem;
border-bottom: 1px solid var(--color-200);
color: var(--color-800);
}
.settings-section .infos {
position: relative;
padding-left: 2.5ch;
}
.settings-section .infos .info-icon {
position: absolute;
left: 0px;
top: -5px;
}
.settings-section .infos .info-icon svg {
width: 16px;
fill: var(--color-interface-400);
}
.settings-section .infos p {
font-size: 0.8rem;
color: var(--color-interface-400);
}
.settings-section .container {
margin-top: var(--space-xs);
}
.settings-subsection:not(:last-child) {
.settings-subsection {
border-bottom: 1px solid var(--color-interface-100);
}
@ -483,9 +601,10 @@ input[type=number] {
margin-bottom: calc(var(--space-xs) * 2);
font-size: 1rem;
font-weight: 600;
color: var(--color-800);
}
.unit-toggle button:not(.spinner-btn) {
.field button:not(.spinner-btn) {
cursor: pointer;
border: 1px solid var(--color-interface-400);
color: var(--color-interface-400);
@ -494,10 +613,10 @@ input[type=number] {
padding: 0.1rem 0.3rem;
height: calc(var(--input-h) * 0.75);
}
.unit-toggle button:not(.spinner-btn):not(.active):hover {
.field button:not(.spinner-btn):not(.active):hover {
background-color: var(--color-interface-100);
}
.unit-toggle button:not(.spinner-btn).active {
.field button:not(.spinner-btn).active {
color: var(--color-interface-050);
background-color: var(--color-interface-500);
cursor: auto;
@ -529,7 +648,7 @@ input[type=number] {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
z-index: 10;
z-index: 10000;
width: 860px;
max-height: 600px;
display: flex;

File diff suppressed because one or more lines are too long

View file

@ -31,7 +31,7 @@ h2 {
}
p {
font-size: 1rem;
font-size: 16px;
margin: 0mm 0mm 5mm 0mm;
}

View file

@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m12.002 2.005c5.518 0 9.998 4.48 9.998 9.997 0 5.518-4.48 9.998-9.998 9.998-5.517 0-9.997-4.48-9.997-9.998 0-5.517 4.48-9.997 9.997-9.997zm0 1.5c-4.69 0-8.497 3.807-8.497 8.497s3.807 8.498 8.497 8.498 8.498-3.808 8.498-8.498-3.808-8.497-8.498-8.497zm0 6.5c-.414 0-.75.336-.75.75v5.5c0 .414.336.75.75.75s.75-.336.75-.75v-5.5c0-.414-.336-.75-.75-.75zm-.002-3c.552 0 1 .448 1 1s-.448 1-1 1-1-.448-1-1 .448-1 1-1z" fill-rule="nonzero"/></svg>

After

Width:  |  Height:  |  Size: 593 B

View file

@ -22,8 +22,7 @@
},
"require": {
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
"getkirby/cms": "^5.0",
"sylvainjule/code-editor": "^1.1"
"getkirby/cms": "^5.0"
},
"config": {
"allow-plugins": {

40
public/composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "82adb49b472cb54cd88e72b31f49ada3",
"content-hash": "0b7fb803e22a45eb87e24172337208aa",
"packages": [
{
"name": "christian-riesen/base32",
@ -725,44 +725,6 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "sylvainjule/code-editor",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/sylvainjule/kirby-code-editor.git",
"reference": "adbc2c8a728994cc57ea72a7f8628f27d202b8df"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sylvainjule/kirby-code-editor/zipball/adbc2c8a728994cc57ea72a7f8628f27d202b8df",
"reference": "adbc2c8a728994cc57ea72a7f8628f27d202b8df",
"shasum": ""
},
"require": {
"getkirby/composer-installer": "^1.2"
},
"type": "kirby-plugin",
"extra": {
"installer-name": "code-editor"
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sylvain Julé",
"email": "contact@sylvain-jule.fr"
}
],
"description": "Code editor field for Kirby 3, 4 and 5",
"support": {
"issues": "https://github.com/sylvainjule/kirby-code-editor/issues",
"source": "https://github.com/sylvainjule/kirby-code-editor/tree/1.1.0"
},
"time": "2025-08-04T17:32:08+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",

View file

@ -0,0 +1,17 @@
Title: Nîmes ([nim] ou prononcé localement [ˈnimə] est une commune du sud de la France.
----
Author:
----
Tags: Nîmes
----
Text: <p>Cette carte géographique présente mon lieu de vie depuis un an et demi : Nîmes, se trouvant dans la préfecture du département du Gard en région Occitanie, codes 30000 et 30900, située entre les Cévennes et la Camargue. Nîmes est une commune urbaine qui comptait 150 444 habitants en 2022 : les Nîmois et Nîmoises.</p><p>Les marqueurs sur cette carte esquissent mon trajet quotidien, routinier. Ce trajet, que je pourrais parcourir machinalement, est celui d' une déambulation attentive ; une marche sensible où chaque détail rencontré (urbanisé, imprévisible, spontané) devient le point de départ dun récit.</p>
----
Uuid: dcesbtdkfuilhqsw

View file

@ -0,0 +1,17 @@
Title: Es la Patagonia
----
Author:
----
Tags: voyage
----
Text: <p>Jai parcouru le monde de mes 2 à mes 13 ans. Dabord dans un sac sur le dos de mes parents, puis sur mes propres jambes. De novembre 2010 à février 2011, nous avons arpenté dans tous les sens les paysages patagons.</p><p>Voici des extraits des carnets de voyage de mes parents (eux, ils sen souviennent très bien), confrontés aux souvenirs d'une enfant de 4 ans.</p><p>Photos et textes cités par Emmanuel et Frédérique Icardo.</p>
----
Uuid: mz1p8yuonufyppdt

View file

@ -0,0 +1,17 @@
Title: zone E- 62
----
Author:
----
Tags: intergalactique, mystérieux
----
Text: <p>Au delà de vos frontières se trouve ce fragment de survie. Certains ont tenté de délimiter rationnellement cet espace peu défini. Personne ne sait ce qu'il se trouve en dépassant les limites imposées de E- 62. Il vaut peut-être mieux de ne pas savoir. C'est ici que, caché, Julian fait son blog intergalactique.</p>
----
Uuid: vvgu2agajw9agdlo

View file

@ -0,0 +1,17 @@
Title: Le trajet des courses
----
Author:
----
Tags: chemin, paris, Rue de Sèvres
----
Text: <p>Dans le 7ème arrondissement, à l'arrêt de métro Saint-Placide, se trouve la rue de Sèvres. Mes pieds ont foulé chaque recoin de cette rue. La plupart du temps elle est calme. Un lourd silence étrange, au milieu de cette capitale bruyante. Aucune manifestation, aucun évènement ne se produit dans cette dernière. Et pourtant, chaque personne la traversant semblent pressée de la quitter.</p><p>Cette rue est mon havre de paix. Je m'y sens bien et je la connais par cœur.</p>
----
Uuid: qdofat9jfhj50hqx

View file

@ -0,0 +1,13 @@
Title: Roadtrip à vos risques et périls !
----
Tags: Créatures japonaises
----
Text: <p>♫ Yôkais, attrapez-les touuuus ♫</p><p>Parcourons le Japon à la recherche de nos petits farceurs !</p><p>On peut trouver des créatures dans les provinces, les villes, au bord d'un lac, perdues au fin fond d'un bois hanté ou peut-être sous votre lit...</p><p>Les Yôkais* sont célébrés lors de festivals comme le Tokushima Yôkai Festival où les Japonais portent des masques à l'effigie des démons qui peuvent représenter les vices des humains.</p><p>Regardons ces marqueurs de plus près !</p><p>*Yôkai signifie phénomène/manifestation étrange</p>
----
Uuid: gltzchzl0qipgrti

View file

@ -14,46 +14,4 @@ Introduction: <p>Ah le Japon... Quel beau pays où nous trouvons des créatures
----
Customcss:
@page {
size: A4;
margin: 50mm 15mm 26mm 15mm;
background: rgba(255, 255, 255, 1);
}
body {
font-family: "DM Sans", sans-serif;
text-align: left;
color: rgb(190, 9, 9);
background: blue;
}
p {
text-align: right;
font-size: 14px;
margin: 0mm;
font-weight: 300;
font-family: Arial;
font-style: italic;
padding-top: 20mm;
padding-right: 20mm;
padding-bottom: 20mm;
padding-left: 20mm;
}
h1 {
font-family: DM Sans;
font-style: normal;
font-weight: 700;
font-size: 48px;
text-align: start;
color: rgb(0, 0, 0);
background: rgba(113, 54, 255, 0.1);
margin: 32.16px;
padding: 0px;
}
----
Uuid: xi60pjkz5bp1nlwp

View file

@ -4,5 +4,5 @@ fields:
map:
label: Choisir la carte
type: pages
query: page.parent.parent.children.filterBy('intendedTemplate', 'map')
query: page.parent.parent.children.filterBy('intendedTemplate', 'carte')
multiple: false

View file

@ -7,21 +7,24 @@ columns:
fields:
type: fields
fields:
tags:
tags:
label: Mots-clés
type: tags
text:
text:
label: Présentation de la carte
type: writer
mapdata:
map:
label: Carte
type: map-editor
defaultCenter: [43.836699, 4.360054]
defaultZoom: 13
maxMarkers: 50
type: info
text: |
Ici le plugin pour la carte et les marqueurs
Avoir la possibilité de changer le fond de carte en image
sidebar:
width: 1/3
sections:
files:
label: Fichiers
type: files

View file

@ -1,78 +0,0 @@
title: Marqueur
icon: location
tabs:
content:
label: Contenu
columns:
main:
width: 1/1
sections:
fields:
type: fields
fields:
content:
label: Contenu
type: blocks
fieldsets:
- heading
- text
- image
- list
- quote
position:
label: Position
columns:
left:
width: 1/3
sections:
coordinates:
type: fields
fields:
latitude:
label: Latitude
type: number
step: 0.000001
min: -90
max: 90
required: true
longitude:
label: Longitude
type: number
step: 0.000001
min: -180
max: 180
required: true
right:
width: 2/3
sections:
map:
type: fields
fields:
mapPreview:
label: Position sur la carte
type: map-editor
mode: single
help: Déplacez le marqueur
markerIcon:
label: Icône personnalisée
type: files
multiple: false
accept:
- image/jpeg
- image/png
- image/svg+xml
width: 1/2
help: Image à utiliser comme marqueur (JPG, PNG ou SVG). Laissez vide pour utiliser le marqueur par défaut.
markerIconSize:
label: Taille de l'icône
type: range
min: 20
max: 500
step: 5
default: 40
after: px
width: 1/2
help: Taille de l'icône en pixels

View file

@ -17,9 +17,9 @@ columns:
multiple: false
width: 1/2
pages:
label: Narratives
label: Récits
type: pages
template: narrative
template: recit
sidebar:
width: 1/3
sections:

View file

@ -1,4 +1,4 @@
title: Narrative
title: Récit
columns:
main:
@ -22,18 +22,11 @@ columns:
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
- carte
- geoformat
sidebar:
width: 1/3

View file

@ -1,12 +0,0 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
[*.php]
indent_size = 4

View file

@ -1,6 +0,0 @@
.DS_Store
.cache
node_modules
package-lock.json
yarn.lock
composer.lock

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 Sylvain Julé
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,111 +0,0 @@
# Kirby Code editor
Code editor field for Kirby 3, 4 and 5.
![screenshot-code-editor](https://user-images.githubusercontent.com/14079751/109679014-7b043800-7b7b-11eb-8c4e-2ae25da8288d.png)
<br/>
## Overview
> This plugin is completely free and published under the MIT license. However, if you are using it in a commercial project and want to help me keep up with maintenance, you can consider [making a donation of your choice](https://paypal.me/sylvainjl).
- [1. Installation](#1-installation)
- [2. Setup](#2-setup)
- [3. Options](#3-options)
- [4. Available languages](#4-available-languages)
- [5. License](#5-license)
- [6. Credits](#6-credits)
<br/>
## 1. Installation
Download and copy this repository to ```/site/plugins/code-editor```
Alternatively, you can install it with composer: ```composer require sylvainjule/code-editor```
<br/>
## 2. Setup
This field adds a code editor in the panel:
```yaml
editor:
label: My code editor
type: code-editor
```
<br/>
## 3. Options
| Name | Type | Default | Options | Description |
| -------------------- | ------------------ | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| language | `String` | `'css'` | - | Syntax mode of the editor. See below for available languages |
| size | `String` | `'small'` | - | Min height of the editor. `small / medium / large / huge` |
| lineNumbers | `Boolean` | `true` | - | Whether to show line numbers. |
| tabSize | `number` | `4` | - | The number of characters to insert when pressing tab key. |
| insertSpaces | `boolean` | `true` | - | Whether to use spaces for indentation. If you set it to `false`, you might also want to set `tabSize` to `1` |
| ignoreTabKey | `boolean` | `false` | - | Whether the editor should ignore tab key presses so that keyboard users can tab past the editor. Users can toggle this behaviour using `Ctrl+Shift+M` (Mac) / `Ctrl+M` manually when this is `false`. |
Note that you can make the default height any height you want with some [custom panel CSS](https://getkirby.com/docs/reference/system/options/panel#custom-panel-css). First, set the `size` option to any string you'd like:
```yaml
size: custom-size
```
Then in your `panel.css`:
```css
.k-code-editor-input[data-size="custom-size"] {
min-height: 15rem;
}
```
### 3.1. Default options
You can globally override the default options, instead of setting them on a per-field basis. In your `site/config/config.php`:
```php
return [
'sylvainjule.code-editor.language' => 'css',
'sylvainjule.code-editor.size' => 'small',
'sylvainjule.code-editor.lineNumbers' => true,
'sylvainjule.code-editor.tabSize' => 4,
'sylvainjule.code-editor.insertSpaces' => true,
'sylvainjule.code-editor.ignoreTabKey' => false,
];
```
<br/>
## 4. Available languages
Currently supported languages are:
* `css`
* `javascript`
* `json`
* `less`
* `php`
* `python`
* `ruby`
* `scss`
* `yaml`
<br/>
## 5. License
MIT
<br/>
## 6. Credits
**Code editor:**
- [Vue Prism Editor](https://github.com/koca/vue-prism-editor)

View file

@ -1,20 +0,0 @@
{
"name": "sylvainjule/code-editor",
"description": "Code editor field for Kirby 3, 4 and 5",
"type": "kirby-plugin",
"license": "MIT",
"version": "1.1.0",
"authors": [
{
"name": "Sylvain Julé",
"email": "contact@sylvain-jule.fr"
}
],
"require": {
"getkirby/composer-installer": "^1.2"
},
"extra": {
"installer-name": "code-editor"
},
"minimum-stability": "beta"
}

View file

@ -1,28 +0,0 @@
import js from "@eslint/js";
import prettier from "eslint-config-prettier";
import vue from "eslint-plugin-vue";
export default [
js.configs.recommended,
...vue.configs["flat/vue2-recommended"],
prettier,
{
rules: {
"vue/attributes-order": "error",
"vue/component-definition-name-casing": "off",
"vue/html-closing-bracket-newline": [
"error",
{
singleline: "never",
multiline: "always"
}
],
"vue/multi-word-component-names": "off",
"vue/require-default-prop": "off",
"vue/require-prop-types": "error"
},
languageOptions: {
ecmaVersion: 2022
}
}
];

View file

@ -1 +0,0 @@
.prism-editor-wrapper{width:100%;height:100%;display:flex;align-items:flex-start;overflow:auto;-o-tab-size:1.5em;tab-size:1.5em;-moz-tab-size:1.5em}@media (-ms-high-contrast:active),(-ms-high-contrast:none){.prism-editor-wrapper .prism-editor__textarea{color:transparent!important}.prism-editor-wrapper .prism-editor__textarea::-moz-selection{background-color:#accef7!important;color:transparent!important}.prism-editor-wrapper .prism-editor__textarea::selection{background-color:#accef7!important;color:transparent!important}}.prism-editor-wrapper .prism-editor__container{position:relative;text-align:left;box-sizing:border-box;padding:0;overflow:hidden;width:100%}.prism-editor-wrapper .prism-editor__line-numbers{height:100%;overflow:hidden;flex-shrink:0;padding-top:4px;margin-top:0;margin-right:10px}.prism-editor-wrapper .prism-editor__line-number{text-align:right;white-space:nowrap}.prism-editor-wrapper .prism-editor__textarea{position:absolute;top:0;left:0;height:100%;width:100%;resize:none;color:inherit;overflow:hidden;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;-webkit-text-fill-color:transparent}.prism-editor-wrapper .prism-editor__editor,.prism-editor-wrapper .prism-editor__textarea{margin:0;border:0;background:none;box-sizing:inherit;display:inherit;font-family:inherit;font-size:inherit;font-style:inherit;font-variant-ligatures:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;-moz-tab-size:inherit;-o-tab-size:inherit;tab-size:inherit;text-indent:inherit;text-rendering:inherit;text-transform:inherit;white-space:pre-wrap;word-wrap:keep-all;overflow-wrap:break-word;padding:0}.prism-editor-wrapper .prism-editor__textarea--empty{-webkit-text-fill-color:inherit!important}.prism-editor-wrapper .prism-editor__editor{position:relative;pointer-events:none}code[class*=language-],pre[class*=language-]{color:#ccc;background:none;font-family:Consolas,Monaco,Andale Mono,Ubuntu Mono,monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.comment,.token.block-comment,.token.prolog,.token.doctype,.token.cdata{color:#999}.token.punctuation{color:#ccc}.token.tag,.token.attr-name,.token.namespace,.token.deleted{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.number,.token.function{color:#f08d49}.token.property,.token.class-name,.token.constant,.token.symbol{color:#f8c555}.token.selector,.token.important,.token.atrule,.token.keyword,.token.builtin{color:#cc99cd}.token.string,.token.char,.token.attr-value,.token.regex,.token.variable{color:#7ec699}.token.operator,.token.entity,.token.url{color:#67cdcc}.token.important,.token.bold{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}.k-code-editor-input{background:light-dark(var(--color-gray-950),var(--input-color-back));color:var(--color-gray-200);font-family:var(--font-mono);font-size:var(--text-sm);line-height:1.5;padding:var(--spacing-2);border-radius:var(--rounded)}.k-code-editor-input[data-size=small]{min-height:7.5rem}.k-code-editor-input[data-size=medium]{min-height:15rem}.k-code-editor-input[data-size=large]{min-height:30rem}.k-code-editor-input[data-size=huge]{min-height:45rem}.prism-editor__textarea:focus{outline:none}

File diff suppressed because one or more lines are too long

View file

@ -1,15 +0,0 @@
<?php
Kirby::plugin('sylvainjule/code-editor', [
'options' => array(
'language' => 'css',
'size' => 'small',
'lineNumbers' => true,
'tabSize' => 4,
'insertSpaces' => true,
'ignoreTabKey' => false,
),
'fields' => array(
'code-editor' => require_once __DIR__ . '/lib/fields/code-editor.php',
),
]);

View file

@ -1,32 +0,0 @@
<?php
$options = require kirby()->root('kirby') . '/config/fields/textarea.php';
/* Merge new properties
--------------------------------*/
$options = A::merge($options, [
'props' => [
'size' => function($size = null) {
return $size ?? option('sylvainjule.code-editor.size');
},
'language' => function($language = null) {
return $language ?? option('sylvainjule.code-editor.language');
},
'lineNumbers' => function($lineNumbers = null) {
return $lineNumbers ?? option('sylvainjule.code-editor.lineNumbers');
},
'tabSize' => function($tabSize = null) {
return $tabSize ?? option('sylvainjule.code-editor.tabSize');
},
'insertSpaces' => function($insertSpaces = null) {
return $tabSize ?? option('sylvainjule.code-editor.insertSpaces');
},
'ignoreTabKey' => function($ignoreTabKey = null) {
return $tabSize ?? option('sylvainjule.code-editor.ignoreTabKey');
},
]
]);
// return the updated options
return $options;

View file

@ -1,30 +0,0 @@
{
"name": "kirby-code-editor",
"version": "1.0.3",
"description": "Code editor field for Kirby 3 and 4",
"main": "index.js",
"author": "Kirby Community",
"license": "MIT",
"repository": {
"type": "git",
"url": "git@github.com:sylvainjule/kirby-code-editor.git"
},
"scripts": {
"dev": "kirbyup src/index.js --watch",
"build": "kirbyup src/index.js",
"lint": "eslint \"src/**/*.{js,vue}\"",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --write \"src/**/*.{css,js,vue}\"",
"prepare": "node src/node/patchVuePrismEditor.mjs"
},
"devDependencies": {
"consola": "^3.4.2",
"eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-vue": "^9.33.0",
"kirbyup": "^3.3.0",
"prettier": "^3.5.3",
"prismjs": "^1.30.0",
"vue-prism-editor": "^1.3.0"
}
}

View file

@ -1,97 +0,0 @@
<template>
<k-field :input="uid" v-bind="$props" class="k-code-editor-field">
<prism-editor
v-model="code"
class="k-code-editor-input"
:highlight="highlighter"
:line-numbers="lineNumbers"
:tab-size="tabSize"
:insert-spaces="insertSpaces"
:ignore-tab-key="ignoreTabKey"
:data-size="size"
@input="onCodeInput"
/>
</k-field>
</template>
<script>
import { PrismEditor } from "vue-prism-editor";
import "vue-prism-editor/dist/prismeditor.min.css";
import { highlight, languages } from "prismjs/components/prism-core";
import "prismjs/components/prism-markup-templating";
import "prismjs/components/prism-clike";
import "prismjs/components/prism-css";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-json";
import "prismjs/components/prism-less";
import "prismjs/components/prism-php";
import "prismjs/components/prism-python";
import "prismjs/components/prism-ruby";
import "prismjs/components/prism-scss";
import "prismjs/components/prism-yaml";
import "prismjs/themes/prism-tomorrow.css";
export default {
components: { PrismEditor },
extends: "k-textarea-field",
props: {
size: String,
language: String,
lineNumbers: Boolean,
tabSize: Number,
insertSpaces: Boolean,
ignoreTabKey: Boolean,
},
data() {
return {
code: "",
};
},
mounted() {
this.code = this.value;
},
methods: {
highlighter() {
return highlight(this.code, languages[this.language]);
},
onCodeInput() {
this.$emit("input", this.code);
},
},
};
</script>
<style>
.k-code-editor-input {
background: light-dark(var(--color-gray-950), var(--input-color-back));
color: var(--color-gray-200);
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.5;
padding: var(--spacing-2);
border-radius: var(--rounded);
}
.k-code-editor-input[data-size="small"] {
min-height: 7.5rem;
}
.k-code-editor-input[data-size="medium"] {
min-height: 15rem;
}
.k-code-editor-input[data-size="large"] {
min-height: 30rem;
}
.k-code-editor-input[data-size="huge"] {
min-height: 45rem;
}
.prism-editor__textarea:focus {
outline: none;
}
</style>

View file

@ -1,7 +0,0 @@
import CodeEditor from "./components/field/CodeEditor.vue";
window.panel.plugin("sylvainjule/code-editor", {
fields: {
"code-editor": CodeEditor,
},
});

View file

@ -1,38 +0,0 @@
import { existsSync, readFileSync, writeFileSync } from "fs";
import chalk from "chalk";
import consola from "consola";
const srcPath = "node_modules/vue-prism-editor/dist/prismeditor.esm.js";
async function main() {
consola.start("Vue Prism Editor patcher");
if (!existsSync(srcPath)) {
consola.error(
`couldn't find ${chalk.cyan(srcPath)}, did you run ${chalk.green(
"npm install"
)}?`
);
return;
}
const source = readFileSync(srcPath, "utf8");
if (!source.includes("Vue.extend")) {
consola.success("already patched");
return;
}
consola.info("patching the source component...");
let output = source
.replace(/^import Vue from 'vue';/, "")
.replace("/*#__PURE__*/Vue.extend(", "")
.replace(/\}\)(;\s+export)/, "}$1");
writeFileSync(srcPath, output, "utf8");
consola.success("successfully patched");
}
main().catch((err) => consola.error(err));

View file

@ -1,91 +0,0 @@
# Kirby Map Editor Plugin
Plugin d'édition de cartes interactives pour Kirby CMS avec marqueurs enrichis et géocodage.
## Structure du code
```
map-editor/
├── index.php # Enregistrement du plugin Kirby
├── lib/fields/
│ └── map-editor.php # Définition du champ custom
├── src/
│ ├── index.js # Entry point
│ ├── composables/ # Logique métier réutilisable
│ │ ├── useMarkers.js # Gestion des marqueurs (CRUD)
│ │ └── useMapData.js # Gestion des données YAML
│ ├── components/
│ │ ├── field/
│ │ │ └── MapEditor.vue # Composant principal (orchestrateur)
│ │ └── map/
│ │ ├── MapPreview.vue # Carte MapLibre interactive
│ │ ├── MarkerList.vue # Liste des marqueurs (sidebar)
│ │ ├── MarkerEditor.vue # Modal d'édition de marqueur
│ │ └── GeocodeSearch.vue # Recherche d'adresse
│ └── utils/
│ ├── constants.js # Constantes globales
│ ├── api/
│ │ └── nominatim.js # Client API Nominatim
│ └── helpers/
│ └── debounce.js # Fonction de debounce
├── package.json
└── README.md
```
## Composables
### useMarkers
Gère l'état et les opérations CRUD sur les marqueurs.
**Utilisation** :
\`\`\`javascript
import { useMarkers } from '../../composables/useMarkers.js';
const {
markers,
selectedMarkerId,
canAddMarker,
addMarker,
deleteMarker,
} = useMarkers({ maxMarkers: 50 });
\`\`\`
### useMapData
Gère le chargement/sauvegarde des données de carte en YAML.
**Utilisation** :
\`\`\`javascript
import { useMapData } from '../../composables/useMapData.js';
const { center, zoom, loadMapData, debouncedSave } = useMapData({
defaultCenter: { lat: 43.836699, lon: 4.360054 },
defaultZoom: 13,
onSave: (yamlString) => emit('input', yamlString),
});
\`\`\`
## Build
\`\`\`bash
npm install
npm run build
\`\`\`
Build avec Kirbyup → \`/index.js\` et \`/index.css\`
## Avantages du refactoring
- **Réutilisabilité** : Composables utilisables dans d'autres composants
- **Testabilité** : Fonctions pures testables indépendamment
- **Maintenabilité** : Code organisé par responsabilité
- **Lisibilité** : MapEditor réduit de 370 → 230 lignes
- **Performance** : Auto-save optimisé
## Technologies
- Vue 3 Composition API
- MapLibre GL JS 3.6+
- Nominatim API (OpenStreetMap)
- js-yaml pour parsing YAML

View file

@ -1,441 +0,0 @@
<?php
/**
* API Routes for Map Editor Plugin
*
* Provides CRUD operations for marker subpages
*/
return [
[
'pattern' => 'map-editor/pages/(:all)/markers',
'method' => 'GET',
'auth' => false, // Allow Panel session auth
'action' => function (string $pageId) {
try {
// Get user from session (Panel context)
$user = kirby()->user();
// For Panel requests, we trust the session is valid
// The Panel itself already requires authentication
if (!$user && !kirby()->option('debug', false)) {
return [
'status' => 'error',
'message' => 'Unauthorized',
'code' => 401
];
}
// Get the map page
$mapPage = kirby()->page($pageId);
if (!$mapPage) {
return [
'status' => 'error',
'message' => 'Map page not found',
'code' => 404
];
}
// Check if user can read the page
if (!$mapPage->isReadable()) {
return [
'status' => 'error',
'message' => 'Forbidden',
'code' => 403
];
}
// Get all marker subpages, listed only, sorted by num
$markerPages = $mapPage
->children()
->listed()
->filterBy('intendedTemplate', 'marker')
->sortBy('num', 'asc');
// Format markers for response
$markers = [];
foreach ($markerPages as $marker) {
$markers[] = [
'id' => $marker->id(),
'slug' => $marker->slug(),
'title' => $marker->title()->value(),
'position' => [
'lat' => (float) $marker->latitude()->value(),
'lon' => (float) $marker->longitude()->value()
],
'num' => $marker->num(),
'panelUrl' => (string) $marker->panel()->url()
];
}
return [
'status' => 'success',
'data' => [
'markers' => $markers
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
],
[
'pattern' => 'map-editor/pages/(:all)/markers',
'method' => 'POST',
'auth' => false, // Allow Panel session auth
'action' => function (string $pageId) {
try {
// Get user from session (Panel context)
$user = kirby()->user();
// For Panel requests, we trust the session is valid
if (!$user && !kirby()->option('debug', false)) {
return [
'status' => 'error',
'message' => 'Unauthorized',
'code' => 401
];
}
// Note: CSRF verification skipped for Panel session requests
// The Panel session itself is already authenticated and secure
// Get the map page
$mapPage = kirby()->page($pageId);
if (!$mapPage) {
return [
'status' => 'error',
'message' => 'Map page not found',
'code' => 404
];
}
// Check if user can create children
if (!$mapPage->permissions()->can('create')) {
return [
'status' => 'error',
'message' => 'Forbidden',
'code' => 403
];
}
// Get position from request body
// Use data() instead of body() - Kirby automatically parses JSON
$data = kirby()->request()->data();
if (!isset($data['position']['lat']) || !isset($data['position']['lon'])) {
return [
'status' => 'error',
'message' => 'Position (lat, lon) is required',
'code' => 400
];
}
$lat = (float) $data['position']['lat'];
$lon = (float) $data['position']['lon'];
// Validate coordinates
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
return [
'status' => 'error',
'message' => 'Invalid coordinates',
'code' => 400
];
}
// Get existing markers to determine next num
$existingMarkers = $mapPage
->children()
->filterBy('intendedTemplate', 'marker');
$nextNum = $existingMarkers->count() + 1;
// Generate unique slug
$slug = 'marker-' . time();
// Create the new marker page
$newMarker = $mapPage->createChild([
'slug' => $slug,
'template' => 'marker',
'content' => [
'title' => 'Marqueur ' . $nextNum,
'latitude' => $lat,
'longitude' => $lon
]
]);
// Publish the page as listed with the correct num
$newMarker->changeStatus('listed', $nextNum);
return [
'status' => 'success',
'data' => [
'marker' => [
'id' => $newMarker->id(),
'slug' => $newMarker->slug(),
'title' => $newMarker->title()->value(),
'position' => [
'lat' => $lat,
'lon' => $lon
],
'num' => $newMarker->num(),
'panelUrl' => '/panel/pages/' . $newMarker->id()
]
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
],
[
'pattern' => 'map-editor/pages/(:all)/markers/(:all)',
'method' => 'PATCH',
'auth' => false, // Allow Panel session auth
'action' => function (string $pageId, string $markerId) {
try {
// Get user from session (Panel context)
$user = kirby()->user();
if (!$user && !kirby()->option('debug', false)) {
return [
'status' => 'error',
'message' => 'Unauthorized',
'code' => 401
];
}
// Note: CSRF verification skipped for Panel session requests
// The Panel session itself is already authenticated and secure
// Get the marker page
$marker = kirby()->page($markerId);
if (!$marker) {
return [
'status' => 'error',
'message' => 'Marker not found',
'code' => 404
];
}
// Check if user can update the page
if (!$marker->permissions()->can('update')) {
return [
'status' => 'error',
'message' => 'Forbidden',
'code' => 403
];
}
// Get position from request body
$data = kirby()->request()->data();
if (!isset($data['position']['lat']) || !isset($data['position']['lon'])) {
return [
'status' => 'error',
'message' => 'Position (lat, lon) is required',
'code' => 400
];
}
$lat = (float) $data['position']['lat'];
$lon = (float) $data['position']['lon'];
// Validate coordinates
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
return [
'status' => 'error',
'message' => 'Invalid coordinates',
'code' => 400
];
}
// Update the marker position
$marker->update([
'latitude' => $lat,
'longitude' => $lon
]);
return [
'status' => 'success',
'data' => [
'marker' => [
'id' => $marker->id(),
'slug' => $marker->slug(),
'title' => $marker->title()->value(),
'position' => [
'lat' => $lat,
'lon' => $lon
],
'num' => $marker->num(),
'panelUrl' => '/panel/pages/' . $marker->id()
]
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
],
[
'pattern' => 'map-editor/pages/(:all)/markers/(:all)',
'method' => 'DELETE',
'auth' => false, // Allow Panel session auth
'action' => function (string $pageId, string $markerId) {
try {
// Get user from session (Panel context)
$user = kirby()->user();
if (!$user && !kirby()->option('debug', false)) {
return [
'status' => 'error',
'message' => 'Unauthorized',
'code' => 401
];
}
// Note: CSRF verification skipped for Panel session requests
// The Panel session itself is already authenticated and secure
// Get the marker page
$marker = kirby()->page($markerId);
if (!$marker) {
return [
'status' => 'error',
'message' => 'Marker not found',
'code' => 404
];
}
// Check if user can delete the page
if (!$marker->permissions()->can('delete')) {
return [
'status' => 'error',
'message' => 'Forbidden',
'code' => 403
];
}
// Delete the marker page
$marker->delete(true); // true = force delete
return [
'status' => 'success',
'data' => [
'message' => 'Marker deleted successfully'
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
],
[
'pattern' => 'map-editor/pages/(:all)/position',
'method' => 'PATCH',
'auth' => false, // Allow Panel session auth
'action' => function (string $pageId) {
try {
// Get user from session (Panel context)
$user = kirby()->user();
if (!$user && !kirby()->option('debug', false)) {
return [
'status' => 'error',
'message' => 'Unauthorized',
'code' => 401
];
}
// Note: CSRF verification skipped for Panel session requests
// The Panel session itself is already authenticated and secure
// Get the page (marker page in single mode)
$page = kirby()->page($pageId);
if (!$page) {
return [
'status' => 'error',
'message' => 'Page not found',
'code' => 404
];
}
// Check if user can update the page
if (!$page->permissions()->can('update')) {
return [
'status' => 'error',
'message' => 'Forbidden',
'code' => 403
];
}
// Get coordinates from request body
$data = kirby()->request()->data();
if (!isset($data['latitude']) || !isset($data['longitude'])) {
return [
'status' => 'error',
'message' => 'Latitude and longitude are required',
'code' => 400
];
}
$lat = (float) $data['latitude'];
$lon = (float) $data['longitude'];
// Validate coordinates
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
return [
'status' => 'error',
'message' => 'Invalid coordinates',
'code' => 400
];
}
// Update the page position
$page->update([
'latitude' => $lat,
'longitude' => $lon
]);
return [
'status' => 'success',
'data' => [
'latitude' => $lat,
'longitude' => $lon
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
]
];

View file

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<circle cx="20" cy="16" r="14" fill="#e74c3c" stroke="white" stroke-width="3"/>
<circle cx="20" cy="16" r="6" fill="white"/>
<path d="M 20 30 L 15 20 L 25 20 Z" fill="#e74c3c" stroke="white" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 307 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,55 +0,0 @@
<?php
/**
* Map Editor Plugin for Kirby CMS
*
* Interactive map editor with MapLibre GL JS for creating
* print-ready maps with markers and rich content.
*/
Kirby::plugin('geoproject/map-editor', [
'fields' => [
'map-editor' => [
'props' => [
'defaultCenter' => function ($defaultCenter = [43.836699, 4.360054]) {
return $defaultCenter;
},
'defaultZoom' => function ($defaultZoom = 13) {
return $defaultZoom;
},
'maxMarkers' => function ($maxMarkers = 50) {
return $maxMarkers;
},
'mode' => function ($mode = 'multi') {
return $mode;
},
'latitude' => function ($latitude = null) {
return $latitude;
},
'longitude' => function ($longitude = null) {
return $longitude;
},
'markerIconUrl' => function ($markerIconUrl = null) {
// Auto-detect marker icon from page files
if ($markerIconUrl === null && $this->model()) {
$iconFile = $this->model()->markerIcon()->toFile();
if ($iconFile) {
return $iconFile->url();
}
}
return $markerIconUrl;
},
'markerIconSize' => function ($markerIconSize = 40) {
// Auto-detect marker icon size from page
if ($this->model() && $this->model()->markerIconSize()->isNotEmpty()) {
return (int) $this->model()->markerIconSize()->value();
}
return $markerIconSize;
}
]
]
],
'api' => [
'routes' => require __DIR__ . '/api/routes.php'
]
]);

File diff suppressed because it is too large Load diff

View file

@ -1,17 +0,0 @@
{
"name": "map-editor",
"version": "1.0.0",
"description": "Interactive map editor plugin for Kirby CMS",
"type": "module",
"scripts": {
"dev": "npx -y kirbyup src/index.js --watch",
"build": "npx -y kirbyup src/index.js"
},
"dependencies": {
"maplibre-gl": "^3.6.0",
"js-yaml": "^4.1.0"
},
"devDependencies": {
"kirbyup": "^3.3.0"
}
}

View file

@ -1,489 +0,0 @@
<template>
<k-field v-bind="$props" class="k-map-editor-field">
<div class="map-editor-container">
<div class="map-content" :class="{ 'single-mode': mode === 'single' }">
<!-- Marker list sidebar (only in multi mode) -->
<MarkerList
v-if="mode === 'multi'"
:markers="markers"
:selected-marker-id="selectedMarkerId"
:max-markers="maxMarkers"
@add-marker="handleAddMarker"
@select-marker="handleSelectMarker"
@edit-marker="editMarker"
@delete-marker="deleteMarker"
@select-location="handleLocationSelect"
/>
<!-- Map preview -->
<div class="map-preview-container">
<MapPreview
v-if="mapReady"
ref="mapPreview"
:center="center"
:zoom="zoom"
:markers="markers"
:selected-marker-id="selectedMarkerId"
@marker-moved="handleMarkerMoved"
@map-click="handleMapClick"
@marker-click="handleSelectMarker"
@marker-dblclick="editMarker"
/>
</div>
</div>
</div>
</k-field>
</template>
<script>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import MapPreview from '../map/MapPreview.vue';
import MarkerList from '../map/MarkerList.vue';
import { useMarkersApi } from '../../composables/useMarkersApi.js';
import { useMapData } from '../../composables/useMapData.js';
export default {
components: {
MapPreview,
MarkerList,
},
props: {
value: String,
name: String,
label: String,
help: String,
disabled: Boolean,
defaultCenter: {
type: Array,
default: () => [43.836699, 4.360054],
},
defaultZoom: {
type: Number,
default: 13,
},
maxMarkers: {
type: Number,
default: 50,
},
mode: {
type: String,
default: 'multi',
validator: (value) => ['multi', 'single'].includes(value),
},
latitude: {
type: [Number, String],
default: null,
},
longitude: {
type: [Number, String],
default: null,
},
markerIconUrl: {
type: String,
default: null,
},
markerIconSize: {
type: Number,
default: 40,
},
},
setup(props, { emit }) {
const mapReady = ref(false);
const mapPreview = ref(null);
const selectedMarkerId = ref(null);
// Extract page ID from field name
// For single mode, we don't need the API
const pageId = computed(() => {
if (props.mode === 'single') return null;
// In Kirby Panel, the field name contains the page context
// We need to get the current page ID from the Panel context
// Try to extract from the current URL
const urlMatch = window.location.pathname.match(/\/panel\/pages\/(.+)/);
if (urlMatch) {
// Convert URL format (map+carte) to page ID format (map/carte)
return urlMatch[1].replace(/\+/g, '/');
}
// Fallback: try to extract from props.name if available
// Format might be "pages/map+carte/fields/mapdata"
const nameMatch = props.name?.match(/pages\/([^/]+)/);
if (nameMatch) {
return nameMatch[1].replace(/\+/g, '/');
}
console.warn('Could not extract page ID, using default');
return 'map/carte';
});
// Initialize API composable (only for multi mode)
const markersApi = props.mode === 'multi'
? useMarkersApi(pageId.value)
: { markers: ref([]), loading: ref(false), error: ref(null) };
const { center, zoom, loadMapData, debouncedSave } = useMapData({
defaultCenter: {
lat: props.defaultCenter[0],
lon: props.defaultCenter[1],
},
defaultZoom: props.defaultZoom,
onSave: (yamlString) => emit('input', yamlString),
});
// Computed: markers based on mode
const markers = computed(() => {
if (props.mode === 'single') {
// Single mode: create one marker from form values
const lat = singleLat.value;
const lon = singleLon.value;
// Only create marker if we have valid coordinates
if (!isNaN(lat) && !isNaN(lon) && lat !== null && lon !== null && lat !== 0 && lon !== 0) {
return [{
id: 'single-marker',
position: { lat, lon },
title: 'Current position',
iconUrl: props.markerIconUrl,
iconSize: props.markerIconSize,
}];
}
return [];
}
return markersApi.markers.value;
});
const canAddMarker = computed(() => {
if (props.mode === 'single') return false;
return markers.value.length < props.maxMarkers;
});
// Single mode: reactive references for coordinates from form fields
const singleLat = ref(null);
const singleLon = ref(null);
// Function to get coordinates from Panel form fields (single mode)
function getCoordinatesFromForm() {
// Find the latitude and longitude input fields in the same form
const form = document.querySelector('.k-form');
if (!form) return { lat: null, lon: null };
const latInput = form.querySelector('input[name*="latitude"]');
const lonInput = form.querySelector('input[name*="longitude"]');
return {
lat: latInput ? parseFloat(latInput.value) : null,
lon: lonInput ? parseFloat(lonInput.value) : null
};
}
// Load data on mount
onMounted(async () => {
if (props.mode === 'multi') {
// Multi mode: load from API
try {
await markersApi.fetchMarkers();
} catch (error) {
console.error('Failed to load markers:', error);
}
} else if (props.mode === 'single') {
// Single mode: get coordinates from form
const coords = getCoordinatesFromForm();
singleLat.value = coords.lat;
singleLon.value = coords.lon;
if (!isNaN(coords.lat) && !isNaN(coords.lon) && coords.lat !== 0 && coords.lon !== 0) {
center.value = { lat: coords.lat, lon: coords.lon };
}
// Watch for changes in the form fields
const form = document.querySelector('.k-form');
if (form) {
// Listen to input events
form.addEventListener('input', (e) => {
if (e.target.name && (e.target.name.includes('latitude') || e.target.name.includes('longitude'))) {
const newCoords = getCoordinatesFromForm();
singleLat.value = newCoords.lat;
singleLon.value = newCoords.lon;
}
});
// Also use MutationObserver to detect value changes (e.g., from "Supprimer" button)
const latInput = form.querySelector('input[name*="latitude"]');
const lonInput = form.querySelector('input[name*="longitude"]');
if (latInput && lonInput) {
const observer = new MutationObserver(() => {
const newCoords = getCoordinatesFromForm();
if (newCoords.lat !== singleLat.value || newCoords.lon !== singleLon.value) {
singleLat.value = newCoords.lat;
singleLon.value = newCoords.lon;
}
});
// Observe attribute changes (value attribute)
observer.observe(latInput, { attributes: true, attributeFilter: ['value'] });
observer.observe(lonInput, { attributes: true, attributeFilter: ['value'] });
// Also poll periodically as a fallback
const pollInterval = setInterval(() => {
const newCoords = getCoordinatesFromForm();
if (newCoords.lat !== singleLat.value || newCoords.lon !== singleLon.value) {
singleLat.value = newCoords.lat;
singleLon.value = newCoords.lon;
}
}, 500);
// Cleanup on unmount
onBeforeUnmount(() => {
observer.disconnect();
clearInterval(pollInterval);
});
}
}
}
// Load map data (center, zoom, background)
if (props.value && props.mode === 'multi') {
loadMapData(props.value);
}
await nextTick();
mapReady.value = true;
});
// Watch center and zoom for automatic save (multi mode only)
watch(
[center, zoom],
() => {
if (props.mode === 'multi') {
debouncedSave();
}
},
{ deep: true }
);
// Watch form coordinates in single mode
watch(
() => [singleLat.value, singleLon.value],
([lat, lon]) => {
if (props.mode === 'single') {
// Center map on new position if valid
if (!isNaN(lat) && !isNaN(lon) && lat !== null && lon !== null && lat !== 0 && lon !== 0) {
// Force immediate reactivity
nextTick(() => {
if (mapPreview.value && mapPreview.value.centerOnPosition) {
mapPreview.value.centerOnPosition(lat, lon);
}
});
} else {
// Coordinates are invalid/cleared - reset to default center
center.value = {
lat: props.defaultCenter[0],
lon: props.defaultCenter[1]
};
nextTick(() => {
if (mapPreview.value && mapPreview.value.centerOnPosition) {
mapPreview.value.centerOnPosition(center.value.lat, center.value.lon);
}
});
}
}
}
);
/**
* Get current map center or fallback to state center
*/
function getCurrentCenter() {
if (mapPreview.value && mapPreview.value.getCurrentCenter) {
return mapPreview.value.getCurrentCenter();
}
return { lat: center.value.lat, lon: center.value.lon };
}
/**
* Handle add marker button click (multi mode only)
*/
async function handleAddMarker() {
if (!canAddMarker.value || props.mode === 'single') {
return;
}
const currentCenter = getCurrentCenter();
// Normalize position format (ensure lon, not lng)
const position = {
lat: currentCenter.lat,
lon: currentCenter.lon || currentCenter.lng
};
try {
await markersApi.createMarker(position);
} catch (error) {
console.error('Failed to create marker:', error);
}
}
/**
* Handle map click to add marker (multi mode only)
*/
async function handleMapClick(position) {
if (!canAddMarker.value || props.mode === 'single') {
return;
}
try {
await markersApi.createMarker({ lat: position.lat, lon: position.lng });
} catch (error) {
console.error('Failed to create marker:', error);
}
}
/**
* Handle marker selection
*/
function handleSelectMarker(markerId) {
selectedMarkerId.value = markerId;
// Center map on marker
const marker = markers.value.find((m) => m.id === markerId);
if (marker && mapPreview.value && mapPreview.value.centerOnPosition) {
mapPreview.value.centerOnPosition(marker.position.lat, marker.position.lon);
}
}
/**
* Handle marker drag end
*/
async function handleMarkerMoved({ markerId, position }) {
if (props.mode === 'single') {
// Single mode: update form fields directly
const form = document.querySelector('.k-form');
if (form) {
const latInput = form.querySelector('input[name*="latitude"]');
const lonInput = form.querySelector('input[name*="longitude"]');
if (latInput && lonInput) {
latInput.value = position.lat;
lonInput.value = position.lng;
// Trigger input event to update Vue/Kirby state
latInput.dispatchEvent(new Event('input', { bubbles: true }));
lonInput.dispatchEvent(new Event('input', { bubbles: true }));
// Update local refs
singleLat.value = position.lat;
singleLon.value = position.lng;
}
}
} else {
// Multi mode: update via API
try {
await markersApi.updateMarkerPosition(markerId, {
lat: position.lat,
lon: position.lng,
});
} catch (error) {
console.error('Failed to update marker position:', error);
}
}
}
/**
* Edit marker - redirect to Panel
*/
function editMarker(markerId) {
if (props.mode === 'single') return;
const marker = markers.value.find(m => m.id === markerId);
if (marker && marker.panelUrl) {
window.top.location.href = marker.panelUrl;
}
}
/**
* Delete marker (multi mode only)
*/
async function deleteMarker(markerId) {
if (props.mode === 'single') return;
if (!confirm('Supprimer ce marqueur ?')) {
return;
}
try {
await markersApi.deleteMarker(markerId);
if (selectedMarkerId.value === markerId) {
selectedMarkerId.value = null;
}
} catch (error) {
console.error('Failed to delete marker:', error);
}
}
/**
* Handle location selection from geocoding
*/
function handleLocationSelect(location) {
if (mapPreview.value && mapPreview.value.centerOnPosition) {
mapPreview.value.centerOnPosition(location.lat, location.lon);
}
}
return {
// State
center,
zoom,
markers,
selectedMarkerId,
mapReady,
mapPreview,
canAddMarker,
loading: markersApi.loading,
error: markersApi.error,
// Methods
handleAddMarker,
handleMapClick,
handleSelectMarker,
handleMarkerMoved,
handleLocationSelect,
deleteMarker,
editMarker,
};
},
};
</script>
<style>
.k-map-editor-field {
--marker-list-width: 250px;
}
.map-editor-container {
border: 1px solid var(--color-border);
border-radius: var(--rounded);
overflow: hidden;
background: var(--color-white);
}
.map-content {
display: flex;
height: 600px;
background: var(--color-white);
}
.map-content.single-mode {
height: 400px;
}
.map-preview-container {
flex: 1;
position: relative;
background: #f0f0f0;
min-width: 0;
}
</style>

View file

@ -1,357 +0,0 @@
<template>
<div class="geocode-search">
<div class="search-input-wrapper">
<input
ref="searchInput"
v-model="searchQuery"
type="text"
class="search-input"
placeholder="Rechercher une adresse..."
@input="handleInput"
@keydown.escape="clearSearch"
@keydown.enter.prevent="selectFirstResult"
@keydown.down.prevent="navigateResults(1)"
@keydown.up.prevent="navigateResults(-1)"
/>
<button
v-if="searchQuery"
type="button"
class="clear-button"
@click="clearSearch"
title="Effacer"
>
<k-icon type="cancel" />
</button>
<div v-if="isLoading" class="search-spinner">
<div class="spinner-icon"></div>
</div>
</div>
<!-- Results dropdown -->
<div v-if="showResults" class="results-dropdown">
<div v-if="error" class="error-message">
<k-icon type="alert" />
<span>{{ error }}</span>
</div>
<div v-else-if="results.length === 0 && !isLoading" class="no-results">
Aucun résultat trouvé
</div>
<div v-else class="results-list">
<div
v-for="(result, index) in results"
:key="result.id"
class="result-item"
:class="{ active: index === selectedIndex }"
@click="selectResult(result)"
@mouseenter="selectedIndex = index"
>
<div class="result-icon">
<k-icon type="pin" />
</div>
<div class="result-content">
<div class="result-name">{{ result.displayName }}</div>
<div class="result-coords">
{{ result.lat.toFixed(6) }}, {{ result.lon.toFixed(6) }}
</div>
</div>
</div>
</div>
<div v-if="results.length > 0" class="results-footer">
<small>Powered by OpenStreetMap Nominatim</small>
</div>
</div>
</div>
</template>
<script>
import { ref, watch } from 'vue';
import { geocode } from '../../utils/api/nominatim.js';
import { debounce } from '../../utils/helpers/debounce.js';
import { DEBOUNCE_DELAYS } from '../../utils/constants.js';
export default {
emits: ['select-location', 'center-map'],
setup(props, { emit }) {
const searchInput = ref(null);
const searchQuery = ref('');
const results = ref([]);
const isLoading = ref(false);
const error = ref(null);
const showResults = ref(false);
const selectedIndex = ref(-1);
// Debounced search function
const debouncedSearch = debounce(async (query) => {
if (!query || query.trim().length < 3) {
results.value = [];
showResults.value = false;
isLoading.value = false;
return;
}
isLoading.value = true;
error.value = null;
try {
const data = await geocode(query);
results.value = data;
showResults.value = true;
selectedIndex.value = -1;
} catch (err) {
error.value = 'Erreur lors de la recherche. Veuillez réessayer.';
results.value = [];
} finally {
isLoading.value = false;
}
}, DEBOUNCE_DELAYS.GEOCODING);
function handleInput() {
debouncedSearch(searchQuery.value);
}
function selectResult(result) {
emit('select-location', {
lat: result.lat,
lon: result.lon,
displayName: result.displayName,
});
searchQuery.value = result.displayName;
showResults.value = false;
}
function selectFirstResult() {
if (results.value.length > 0) {
selectResult(results.value[0]);
}
}
function navigateResults(direction) {
if (!showResults.value || results.value.length === 0) {
return;
}
selectedIndex.value += direction;
if (selectedIndex.value < 0) {
selectedIndex.value = results.value.length - 1;
} else if (selectedIndex.value >= results.value.length) {
selectedIndex.value = 0;
}
}
function clearSearch() {
searchQuery.value = '';
results.value = [];
showResults.value = false;
error.value = null;
selectedIndex.value = -1;
}
function focus() {
if (searchInput.value) {
searchInput.value.focus();
}
}
// Close dropdown when clicking outside
function handleClickOutside(event) {
if (!event.target.closest('.geocode-search')) {
showResults.value = false;
}
}
// Add/remove click outside listener
watch(showResults, (newValue) => {
if (newValue) {
setTimeout(() => {
document.addEventListener('click', handleClickOutside);
}, 100);
} else {
document.removeEventListener('click', handleClickOutside);
}
});
return {
searchInput,
searchQuery,
results,
isLoading,
error,
showResults,
selectedIndex,
handleInput,
selectResult,
selectFirstResult,
navigateResults,
clearSearch,
focus,
};
},
};
</script>
<style>
.geocode-search {
position: relative;
width: 100%;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--rounded-sm);
font-size: 0.875rem;
background: var(--color-white);
transition: border-color 0.2s;
color: #fff;
background: var(--input-color-back);
}
.search-input:focus {
outline: none;
border-color: var(--color-focus);
box-shadow: 0 0 0 2px var(--color-focus-outline);
}
.clear-button {
position: absolute;
right: 0.5rem;
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--color-text-light);
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.clear-button:hover {
color: var(--color-text);
}
.search-spinner {
position: absolute;
right: 0.75rem;
pointer-events: none;
}
.spinner-icon {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-focus);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.results-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--rounded-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
z-index: 1000;
}
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
color: var(--color-negative);
font-size: 0.875rem;
}
.no-results {
padding: 1rem;
text-align: center;
color: var(--color-text-light);
font-size: 0.875rem;
}
.results-list {
display: flex;
flex-direction: column;
}
.result-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
cursor: pointer;
transition: background-color 0.2s;
border-bottom: 1px solid var(--color-background);
}
.result-item:last-child {
border-bottom: none;
}
.result-item:hover,
.result-item.active {
background: var(--color-focus-outline);
}
.result-icon {
flex-shrink: 0;
color: var(--color-text-light);
padding-top: 0.125rem;
}
.result-content {
flex: 1;
min-width: 0;
}
.result-name {
font-size: 0.875rem;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #000;
}
.result-coords {
font-size: 0.75rem;
color: var(--color-text-light);
font-family: monospace;
}
.results-footer {
padding: 0.5rem;
border-top: 1px solid var(--color-border);
text-align: center;
}
.results-footer small {
font-size: 0.7rem;
color: var(--color-text-light);
}
</style>

View file

@ -1,479 +0,0 @@
<template>
<div class="map-preview">
<div ref="mapContainer" class="map-container"></div>
<div v-if="loading" class="map-loading">
<div class="spinner"></div>
<span>Loading map...</span>
</div>
</div>
</template>
<script>
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
export default {
props: {
center: {
type: Object,
required: true
},
zoom: {
type: Number,
required: true
},
markers: {
type: Array,
default: () => []
},
selectedMarkerId: {
type: String,
default: null
}
},
setup(props, { emit }) {
// State
const mapContainer = ref(null);
const map = ref(null);
const loading = ref(true);
const markerElements = ref(new Map());
const isDragging = ref(false);
// Lifecycle
onMounted(async () => {
await nextTick();
initMap();
});
onBeforeUnmount(() => {
if (map.value) {
map.value.remove();
map.value = null;
}
markerElements.value.clear();
});
// Watchers - only for updates FROM parent, not TO parent
watch(() => props.center, (newCenter) => {
if (map.value && map.value.loaded() && !isDragging.value) {
const currentCenter = map.value.getCenter();
// Only update if significantly different to avoid render loops
if (Math.abs(currentCenter.lat - newCenter.lat) > 0.00001 ||
Math.abs(currentCenter.lng - newCenter.lon) > 0.00001) {
map.value.setCenter([newCenter.lon, newCenter.lat]);
}
}
}, { deep: true });
watch(() => props.zoom, (newZoom) => {
if (map.value && map.value.loaded()) {
const currentZoom = map.value.getZoom();
// Only update if significantly different
if (Math.abs(currentZoom - newZoom) > 0.01) {
map.value.setZoom(newZoom);
}
}
});
watch(() => props.markers, () => {
updateMarkers();
}, { deep: true });
watch(() => props.selectedMarkerId, (newId) => {
updateMarkerSelection(newId);
});
// Methods
function initMap() {
if (!mapContainer.value) {
console.error("Map container not found");
return;
}
loading.value = true;
try {
map.value = new maplibregl.Map({
container: mapContainer.value,
style: {
version: 8,
sources: {
osm: {
type: "raster",
tiles: [
"https://a.tile.openstreetmap.org/{z}/{x}/{y}.png",
"https://b.tile.openstreetmap.org/{z}/{x}/{y}.png",
"https://c.tile.openstreetmap.org/{z}/{x}/{y}.png"
],
tileSize: 256,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}
},
layers: [
{
id: "osm",
type: "raster",
source: "osm",
minzoom: 0,
maxzoom: 19
}
]
},
center: [props.center.lon, props.center.lat],
zoom: props.zoom
});
map.value.on("load", () => {
loading.value = false;
updateMarkers();
});
// Handle map clicks to add markers
map.value.on("click", (e) => {
// Check if click was on a marker element
const target = e.originalEvent.target;
const isMarkerClick = target.closest('.custom-marker');
if (!isMarkerClick) {
const clickPos = {
lat: e.lngLat.lat,
lng: e.lngLat.lng
};
emit("map-click", clickPos);
}
});
} catch (error) {
console.error("Error initializing map:", error);
loading.value = false;
}
}
function updateMarkers() {
if (!map.value || !map.value.loaded()) {
return;
}
// Remove existing markers
markerElements.value.forEach(({ marker }) => {
if (marker) {
marker.remove();
}
});
markerElements.value.clear();
// Add markers
if (props.markers && Array.isArray(props.markers)) {
props.markers.forEach((markerData, index) => {
addMarkerToMap(markerData, index);
});
}
}
function addMarkerToMap(markerData, index) {
if (!map.value || !markerData || !markerData.position) {
return;
}
// Create marker element (outer wrapper for MapLibre positioning)
const el = document.createElement("div");
el.className = "custom-marker";
// Check if custom icon is provided
if (markerData.iconUrl) {
// Use custom image
el.classList.add("custom-icon");
const img = document.createElement("img");
img.src = markerData.iconUrl;
img.className = "marker-icon-image";
// Set size from marker data or default to 40px
const size = markerData.iconSize || 40;
img.style.width = `${size}px`;
img.style.height = `${size}px`;
if (props.selectedMarkerId === markerData.id) {
img.classList.add("selected");
}
el.appendChild(img);
} else {
// Use default pin marker
// Create inner wrapper for visual transforms (isolates from MapLibre transforms)
const inner = document.createElement("div");
inner.className = "marker-inner";
if (props.selectedMarkerId === markerData.id) {
inner.classList.add("selected");
}
// Add marker number
const numberEl = document.createElement("div");
numberEl.className = "marker-number";
numberEl.textContent = index + 1;
inner.appendChild(numberEl);
el.appendChild(inner);
}
try {
const coords = [markerData.position.lon, markerData.position.lat];
// Create MapLibre marker
// Anchor at bottom-center (where the pin tip is)
const marker = new maplibregl.Marker({
element: el,
draggable: true,
anchor: 'bottom'
})
.setLngLat(coords)
.addTo(map.value);
// Handle marker drag
marker.on("dragstart", () => {
isDragging.value = true;
});
marker.on("dragend", () => {
const lngLat = marker.getLngLat();
emit("marker-moved", {
markerId: markerData.id,
position: {
lat: lngLat.lat,
lng: lngLat.lng
}
});
setTimeout(() => {
isDragging.value = false;
}, 100);
});
// Handle marker click
el.addEventListener("click", (e) => {
e.stopPropagation();
emit("marker-click", markerData.id);
});
// Handle marker double-click
el.addEventListener("dblclick", (e) => {
e.stopPropagation();
emit("marker-dblclick", markerData.id);
});
// Store marker reference
markerElements.value.set(markerData.id, {
marker,
element: el
});
} catch (error) {
console.error("Error adding marker to map:", error);
}
}
function updateMarkerSelection(selectedId) {
markerElements.value.forEach(({ element }, markerId) => {
if (element) {
// Handle default pin marker
const inner = element.querySelector('.marker-inner');
if (inner) {
if (markerId === selectedId) {
inner.classList.add("selected");
} else {
inner.classList.remove("selected");
}
}
// Handle custom icon marker
const img = element.querySelector('.marker-icon-image');
if (img) {
if (markerId === selectedId) {
img.classList.add("selected");
} else {
img.classList.remove("selected");
}
}
}
});
}
function getCurrentCenter() {
if (map.value && map.value.loaded()) {
const center = map.value.getCenter();
return {
lat: center.lat,
lon: center.lng
};
}
return {
lat: props.center.lat,
lon: props.center.lon
};
}
function centerOnPosition(lat, lon) {
if (map.value && map.value.loaded()) {
map.value.flyTo({
center: [lon, lat],
zoom: map.value.getZoom(),
duration: 1000
});
}
}
return {
mapContainer,
loading,
getCurrentCenter,
centerOnPosition
};
}
};
</script>
<style>
.map-preview {
position: relative;
width: 100%;
height: 100%;
}
.map-container {
width: 100%;
height: 100%;
}
.map-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: rgba(255, 255, 255, 0.9);
z-index: 1000;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Custom marker outer wrapper - NO transforms here, MapLibre handles positioning */
.custom-marker {
/* MapLibre will position this element via transform: translate3d() */
cursor: grab;
}
.custom-marker:active {
cursor: grabbing;
}
/* Inner wrapper for visual styling - transforms are isolated here */
.marker-inner {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.marker-inner::before {
content: "";
position: absolute;
width: 40px;
height: 40px;
background: #e74c3c;
border: 3px solid white;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
}
.marker-inner:hover::before {
background: #c0392b;
transform: rotate(-45deg) scale(1.1);
}
.marker-inner.selected::before {
background: #3498db;
border-color: #2980b9;
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.6);
}
.marker-number {
position: relative;
z-index: 1;
color: white;
font-weight: 700;
font-size: 14px;
line-height: 1;
/* transform removed - was causing positioning issues */
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
/* Custom icon marker */
.custom-marker.custom-icon {
cursor: grab;
}
.custom-marker.custom-icon:active {
cursor: grabbing;
}
.marker-icon-image {
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
transition: all 0.2s;
}
.marker-icon-image:hover {
transform: scale(1.1);
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
}
.marker-icon-image.selected {
filter: drop-shadow(0 4px 12px rgba(52, 152, 219, 0.8));
}
/* MapLibre controls styling */
.maplibregl-ctrl-group {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.maplibregl-ctrl-group button {
width: 30px;
height: 30px;
}
.maplibregl-ctrl-attrib {
font-size: 11px;
background: rgba(255, 255, 255, 0.8);
}
.maplibregl-canvas-container {
cursor: crosshair;
}
.maplibregl-canvas-container.maplibregl-interactive {
cursor: crosshair;
}
</style>

View file

@ -1,236 +0,0 @@
<template>
<aside class="k-map-markers-sidebar">
<!-- Header with counter and add button -->
<header class="k-section-header">
<k-headline>
Marqueurs
<k-counter>{{ markers.length }}/{{ maxMarkers }}</k-counter>
</k-headline>
<k-button
icon="add"
size="xs"
variant="filled"
title="Ajouter un marqueur"
@click="$emit('add-marker')"
:disabled="!canAddMarker"
>
Ajouter
</k-button>
</header>
<!-- Geocode search -->
<div class="k-map-markers-search">
<GeocodeSearch @select-location="$emit('select-location', $event)" />
</div>
<!-- Marker list -->
<div class="k-map-markers-list">
<div
v-for="(marker, index) in markers"
:key="marker.id"
class="k-map-marker-item"
:class="{ 'is-selected': selectedMarkerId === marker.id }"
@click="$emit('select-marker', marker.id)"
>
<span class="k-map-marker-icon">
{{ index + 1 }}
</span>
<span class="k-map-marker-text">
{{ marker.title || `Marqueur ${index + 1}` }}
</span>
<span class="k-map-marker-options">
<k-button
icon="open"
size="xs"
title="Modifier le marqueur"
@click.stop="$emit('edit-marker', marker.id)"
/>
<k-button
icon="trash"
size="xs"
title="Supprimer le marqueur"
@click.stop="$emit('delete-marker', marker.id)"
/>
</span>
</div>
<div v-if="markers.length === 0" class="k-map-markers-empty">
<k-icon type="map-pin" />
<p class="k-map-markers-empty-text">Aucun marqueur</p>
<p class="k-map-markers-empty-info">
Cliquez sur la carte ou sur "Ajouter" pour créer un marqueur
</p>
</div>
</div>
</aside>
</template>
<script>
import { computed } from 'vue';
import GeocodeSearch from './GeocodeSearch.vue';
export default {
components: {
GeocodeSearch,
},
props: {
markers: {
type: Array,
required: true,
},
selectedMarkerId: {
type: String,
default: null,
},
maxMarkers: {
type: Number,
default: 50,
},
},
emits: [
'add-marker',
'select-marker',
'edit-marker',
'delete-marker',
'select-location',
],
setup(props) {
const canAddMarker = computed(
() => props.markers.length < props.maxMarkers
);
return {
canAddMarker,
};
},
};
</script>
<style scoped>
/* Sidebar container - uses Kirby's layout system */
.k-map-markers-sidebar {
width: var(--marker-list-width, 280px);
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--color-border);
background: var(--color-white);
}
/* Header - minimal override of k-section-header */
.k-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-3);
border-bottom: 1px solid var(--color-border);
background: var(--panel-color-back);
margin-bottom: 0;
}
.k-section-header .k-headline {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
/* Search container */
.k-map-markers-search {
padding: var(--spacing-3);
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
}
/* List container */
.k-map-markers-list {
flex: 1;
overflow-y: auto;
padding: var(--spacing-2);
background: var(--color-background);
}
/* Marker item - styled like Kirby's k-item */
.k-map-marker-item {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2);
margin-bottom: var(--spacing-1);
background: var(--color-white);
border-radius: var(--rounded);
cursor: pointer;
transition: all 0.2s;
background-color: var(--tag-color-back);
}
.k-map-marker-item.is-selected {
background: var(--color-blue-300);
color: var(--color-white);
}
.k-map-marker-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--input-color-back);
color: var(--color-white);
flex-shrink: 0;
}
.k-map-marker-item.is-selected .k-map-marker-icon {
background: var(--color-blue-600);
}
.k-map-marker-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-sm);
}
.k-map-marker-options {
display: flex;
gap: var(--spacing-1);
flex-shrink: 0;
}
/* Empty state - styled like Kirby's k-empty */
.k-map-markers-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--color-gray-600);
}
.k-map-markers-empty .k-icon {
width: 3rem;
height: 3rem;
margin-bottom: var(--spacing-3);
opacity: 0.25;
}
.k-map-markers-empty-text {
margin: 0 0 var(--spacing-2);
font-size: var(--text-base);
font-weight: 500;
color: var(--color-gray-800);
}
.k-map-markers-empty-info {
margin: 0;
font-size: var(--text-sm);
color: var(--color-gray-600);
}
</style>

View file

@ -1,117 +0,0 @@
/**
* Composable for managing map data persistence (YAML)
* Handles loading and saving map data to/from YAML format
*/
import { ref, onBeforeUnmount } from 'vue';
import yaml from 'js-yaml';
/**
* @param {Object} options
* @param {Object} options.defaultCenter - Default map center {lat, lon}
* @param {number} options.defaultZoom - Default zoom level
* @param {Function} options.onSave - Callback when data is saved
* @returns {Object} MapData composable
*/
export function useMapData(options = {}) {
const {
defaultCenter = { lat: 43.836699, lon: 4.360054 },
defaultZoom = 13,
onSave = () => {},
} = options;
const center = ref({ ...defaultCenter });
const zoom = ref(defaultZoom);
const saveTimeout = ref(null);
// Cleanup timeout on unmount
onBeforeUnmount(() => {
if (saveTimeout.value) {
clearTimeout(saveTimeout.value);
}
});
/**
* Load map data from YAML string
* @param {string} yamlString - YAML content
* @returns {Object|null} Parsed data or null if error
*/
function loadMapData(yamlString) {
if (!yamlString || yamlString.trim() === '') {
return null;
}
try {
const data = yaml.load(yamlString);
if (data) {
if (data.center) {
center.value = {
lat: data.center.lat,
lon: data.center.lon,
};
}
if (data.zoom !== undefined) {
zoom.value = data.zoom;
}
return data;
}
} catch (error) {
console.error('Error loading map data:', error);
return null;
}
return null;
}
/**
* Save map data to YAML format
* Note: Markers are now stored as subpages, not in YAML
* @returns {string} YAML string
*/
function saveMapData() {
const data = {
background: {
type: 'osm',
},
center: {
lat: center.value.lat,
lon: center.value.lon,
},
zoom: zoom.value,
};
const yamlString = yaml.dump(data, {
indent: 2,
lineWidth: -1,
noRefs: true,
});
onSave(yamlString);
return yamlString;
}
/**
* Debounced save function
* @param {number} delay - Delay in milliseconds
*/
function debouncedSave(delay = 300) {
if (saveTimeout.value) {
clearTimeout(saveTimeout.value);
}
saveTimeout.value = setTimeout(() => {
saveMapData();
}, delay);
}
return {
// State
center,
zoom,
// Methods
loadMapData,
saveMapData,
debouncedSave,
};
}

View file

@ -1,194 +0,0 @@
import { ref } from 'vue';
/**
* Composable for managing markers via Kirby API
* Replaces the old YAML-based useMarkers composable
*/
export function useMarkersApi(pageId) {
const markers = ref([]);
const loading = ref(false);
const error = ref(null);
// Get CSRF token from Kirby Panel
const getCsrfToken = () => {
// Try multiple methods to get the CSRF token
// Method 1: From window.panel (Kirby Panel global)
if (window.panel && window.panel.csrf) {
return window.panel.csrf;
}
// Method 2: From meta tag (for non-Panel contexts)
const meta = document.querySelector('meta[name="csrf"]');
if (meta && meta.content) {
return meta.content;
}
// Method 3: From window.csrf (sometimes used in Panel)
if (window.csrf) {
return window.csrf;
}
console.warn('CSRF token not found');
return '';
};
/**
* Fetch all markers for a page
*/
async function fetchMarkers() {
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/map-editor/pages/${pageId}/markers`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (result.status === 'error') {
throw new Error(result.message || 'Failed to fetch markers');
}
markers.value = result.data.markers || [];
return markers.value;
} catch (err) {
error.value = err.message;
console.error('Error fetching markers:', err);
throw err;
} finally {
loading.value = false;
}
}
/**
* Create a new marker at the given position
*/
async function createMarker(position) {
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/map-editor/pages/${pageId}/markers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF': getCsrfToken()
},
body: JSON.stringify({ position })
});
const result = await response.json();
if (result.status === 'error') {
throw new Error(result.message || 'Failed to create marker');
}
const newMarker = result.data.marker;
markers.value.push(newMarker);
return newMarker;
} catch (err) {
error.value = err.message;
console.error('Error creating marker:', err);
throw err;
} finally {
loading.value = false;
}
}
/**
* Update a marker's position
*/
async function updateMarkerPosition(markerId, position) {
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/map-editor/pages/${pageId}/markers/${markerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF': getCsrfToken()
},
body: JSON.stringify({ position })
});
const result = await response.json();
if (result.status === 'error') {
throw new Error(result.message || 'Failed to update marker position');
}
// Update local marker
const index = markers.value.findIndex(m => m.id === markerId);
if (index !== -1) {
markers.value[index] = result.data.marker;
}
return result.data.marker;
} catch (err) {
error.value = err.message;
console.error('Error updating marker position:', err);
throw err;
} finally {
loading.value = false;
}
}
/**
* Delete a marker
*/
async function deleteMarker(markerId) {
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/map-editor/pages/${pageId}/markers/${markerId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF': getCsrfToken()
}
});
const result = await response.json();
if (result.status === 'error') {
throw new Error(result.message || 'Failed to delete marker');
}
// Remove from local markers array
const index = markers.value.findIndex(m => m.id === markerId);
if (index !== -1) {
markers.value.splice(index, 1);
}
return true;
} catch (err) {
error.value = err.message;
console.error('Error deleting marker:', err);
throw err;
} finally {
loading.value = false;
}
}
return {
markers,
loading,
error,
fetchMarkers,
createMarker,
updateMarkerPosition,
deleteMarker
};
}

View file

@ -1,7 +0,0 @@
import MapEditor from "./components/field/MapEditor.vue";
window.panel.plugin("geoproject/map-editor", {
fields: {
"map-editor": MapEditor
}
});

View file

@ -1,76 +0,0 @@
/**
* Nominatim API client for geocoding
* https://nominatim.openstreetmap.org/
*
* Usage policy: https://operations.osmfoundation.org/policies/nominatim/
* Rate limit: 1 request per second
*/
import { NOMINATIM_API } from '../constants.js';
/**
* @typedef {Object} GeocodingResult
* @property {number} id - Place ID
* @property {string} displayName - Full display name of the location
* @property {number} lat - Latitude
* @property {number} lon - Longitude
* @property {string} type - Location type (city, street, etc.)
* @property {number} importance - Importance score
* @property {Array<string>} boundingBox - Bounding box coordinates
*/
/**
* Search for an address using Nominatim API
*
* @param {string} query - Address to search for
* @returns {Promise<Array<GeocodingResult>>} Array of geocoding results
* @throws {Error} When API request fails
*
* @example
* const results = await geocode('Paris, France');
* console.log(results[0].displayName); // "Paris, Île-de-France, France"
*/
export async function geocode(query) {
if (!query || query.trim().length < NOMINATIM_API.MIN_QUERY_LENGTH) {
return [];
}
try {
const params = new URLSearchParams({
q: query.trim(),
format: 'json',
addressdetails: '1',
limit: String(NOMINATIM_API.MAX_RESULTS),
'accept-language': NOMINATIM_API.DEFAULT_LANGUAGE,
});
const response = await fetch(
`${NOMINATIM_API.BASE_URL}?${params.toString()}`,
{
headers: {
'User-Agent': NOMINATIM_API.USER_AGENT,
},
}
);
if (!response.ok) {
throw new Error(`Nominatim API error: ${response.status}`);
}
const data = await response.json();
// Transform results to a consistent format
return data.map((result) => ({
id: result.place_id,
displayName: result.display_name,
lat: parseFloat(result.lat),
lon: parseFloat(result.lon),
type: result.type,
importance: result.importance,
boundingBox: result.boundingbox,
}));
} catch (error) {
console.error('Geocoding error:', error);
throw error;
}
}

View file

@ -1,26 +0,0 @@
/**
* Map editor constants
*/
// Nominatim API configuration
export const NOMINATIM_API = {
BASE_URL: 'https://nominatim.openstreetmap.org/search',
USER_AGENT: 'GeoProject/1.0 (Kirby CMS Map Editor)',
RATE_LIMIT_MS: 1000, // 1 request per second
MIN_QUERY_LENGTH: 3,
MAX_RESULTS: 5,
DEFAULT_LANGUAGE: 'fr',
};
// Map defaults
export const MAP_DEFAULTS = {
CENTER: { lat: 43.836699, lon: 4.360054 },
ZOOM: 13,
MAX_MARKERS: 50,
};
// Debounce delays (ms)
export const DEBOUNCE_DELAYS = {
GEOCODING: 500,
AUTO_SAVE: 300,
};

View file

@ -1,24 +0,0 @@
/**
* Debounce function to limit API calls and improve performance
*
* @param {Function} func - Function to debounce
* @param {number} wait - Milliseconds to wait before executing
* @returns {Function} Debounced function
*
* @example
* const debouncedSearch = debounce(searchFunction, 500);
* input.addEventListener('input', () => debouncedSearch(input.value));
*/
export function debounce(func, wait = 500) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}

View file

@ -2,8 +2,8 @@
/**
* Virtual Print Page Plugin
*
* Creates a virtual /print page for each narrative
* Allows access to print editor via /projet/narrative/print
* Crée une page virtuelle /print pour chaque récit
* Permet d'accéder à l'éditeur d'impression via /projet/recit/print
*/
use Kirby\Cms\Page;
@ -14,20 +14,20 @@ Kirby::plugin('geoproject/virtual-print-page', [
[
'pattern' => '(:all)/print',
'action' => function ($parentPath) {
// Find parent page (the narrative)
// Trouver la page parente (le récit)
$parent = page($parentPath);
if (!$parent || $parent->intendedTemplate()->name() !== 'narrative') {
if (!$parent || $parent->intendedTemplate()->name() !== 'recit') {
return $this->next();
}
// Create virtual page with Page::factory()
// Créer la page virtuelle avec Page::factory()
return Page::factory([
'slug' => 'print',
'template' => 'print',
'parent' => $parent,
'content' => [
'title' => 'Print - ' . $parent->title()->value(),
'title' => 'Impression - ' . $parent->title()->value(),
'uuid' => Uuid::generate()
]
]);

View file

@ -1,116 +0,0 @@
<?php
/**
* Web2Print Plugin
*
* Routes for web-to-print functionality including custom CSS management
*/
use Kirby\Cms\Response;
Kirby::plugin('geoproject/web2print', [
'routes' => [
// POST: Save custom CSS
[
'pattern' => 'narratives/(:all)/css',
'method' => 'POST',
'action' => function ($pagePath) {
// Check authentication
if (!kirby()->user()) {
return Response::json([
'status' => 'error',
'message' => 'Authentication required'
], 401);
}
// Verify CSRF token from header
$csrfToken = kirby()->request()->header('X-CSRF');
if (!csrf($csrfToken)) {
return Response::json([
'status' => 'error',
'message' => 'Invalid CSRF token'
], 403);
}
// Get page
$page = page($pagePath);
if (!$page || $page->intendedTemplate()->name() !== 'narrative') {
return Response::json([
'status' => 'error',
'message' => 'Narrative not found'
], 404);
}
// Get POST data
$data = kirby()->request()->data();
$customCss = $data['customCss'] ?? null;
if ($customCss === null) {
return Response::json([
'status' => 'error',
'message' => 'No CSS content provided'
], 400);
}
try {
// Update page with new custom CSS
$page->update([
'customCss' => $customCss
]);
// Reload page to get updated modification time
$page = page($pagePath);
// Return success with updated modified timestamp
return Response::json([
'status' => 'success',
'data' => [
'modified' => $page->modified(),
'modifiedFormatted' => $page->modified('d/m/Y H:i')
]
]);
} catch (Exception $e) {
return Response::json([
'status' => 'error',
'message' => 'Failed to save CSS: ' . $e->getMessage()
], 500);
}
}
],
// GET: Load custom CSS and last modified time
[
'pattern' => 'narratives/(:all)/css',
'method' => 'GET',
'action' => function ($pagePath) {
// Check authentication
if (!kirby()->user()) {
return Response::json([
'status' => 'error',
'message' => 'Authentication required'
], 401);
}
// Get page
$page = page($pagePath);
if (!$page || $page->intendedTemplate()->name() !== 'narrative') {
return Response::json([
'status' => 'error',
'message' => 'Narrative not found'
], 404);
}
// Return custom CSS content and modified timestamp
return Response::json([
'status' => 'success',
'data' => [
'customCss' => $page->customCss()->value() ?? '',
'modified' => $page->modified(),
'modifiedFormatted' => $page->modified('d/m/Y H:i')
]
]);
}
]
]
]);

View file

@ -14,22 +14,17 @@
<!-- À SUPPRIMER EN PRODUCTION -->
<meta name="robots" content="noindex, nofollow, noarchive">
<!-- CSRF Token for API calls -->
<?php if ($kirby->user()): ?>
<meta name="csrf" content="<?= csrf() ?>">
<?php endif ?>
<!-- APP -->
<?php if (Dir::exists('assets/dist')): ?>
<script type="module"
src="<?= url('assets/dist/index.js') ?>" defer></script>
<link rel="stylesheet"
href="<?= url('assets/dist/index.css') ?>">
<script type="module"
src="<?= url('assets/dist/index.js') ?>" defer></script>
<link rel="stylesheet"
href="<?= url('assets/dist/index.css') ?>">
<?php else: ?>
<script type="module" src="http://localhost:5173/@vite/client" defer></script>
<script type="module" src="http://localhost:5173/src/main.js" defer></script>
<script type="module" src="http://localhost:5173/@vite/client" defer></script>
<script type="module" src="http://localhost:5173/src/main.js" defer></script>
<?php endif ?>
</head>
<body data-template="<?= $page->template() ?>"<?php if (isset($narrativeJsonUrl)): ?> data-narrative-url="<?= $narrativeJsonUrl ?>"<?php endif ?>>
<body data-template="<?= $page->template() ?>"<?php if (isset($recitJsonUrl)): ?> data-recit-url="<?= $recitJsonUrl ?>"<?php endif ?>>
<div id="app">

View file

@ -1,17 +1,17 @@
<?php
/**
* Template for Vue.js print editor
* Route: /projet/narrative/print
* Template pour l'éditeur d'impression Vue.js
* Route: /projet/recit/print
*
* This template loads the Vue app and passes the parent narrative JSON URL
* Ce template charge l'app Vue et lui passe l'URL JSON du récit parent
*/
// Get parent narrative
$narrative = $page->parent();
// Récupérer le récit parent
$recit = $page->parent();
// Build narrative JSON URL
$narrativeJsonUrl = $narrative->url() . '.json';
// Construire l'URL JSON du récit
$recitJsonUrl = $recit->url() . '.json';
?>
<?php snippet('header', ['narrativeJsonUrl' => $narrativeJsonUrl]) ?>
<?php snippet('header', ['recitJsonUrl' => $recitJsonUrl]) ?>
<?php snippet('footer') ?>

View file

@ -1,7 +1,7 @@
<?php
/**
* JSON template to expose narrative data
* Accessible via /projet/narrative.json or /projet/narrative?format=json
* Template JSON pour exposer les données d'un récit
* Accessible via /projet/recit.json ou /projet/recit?format=json
*/
header('Content-Type: application/json; charset=utf-8');
@ -175,23 +175,20 @@ function parseGeoformat($geoformat) {
];
}
// Build JSON response
// Construction de la réponse JSON
$data = [
'id' => $page->id(),
'uuid' => $page->uuid()->toString(),
'template' => 'narrative',
'template' => 'recit',
'title' => $page->title()->value(),
'slug' => $page->slug(),
'author' => $page->author()->value() ?? '',
'cover' => resolveFileUrl($page->cover(), $page),
'introduction' => resolveImagesInHtml($page->introduction()->value(), $page),
'customCss' => $page->customCss()->value() ?? '',
'modified' => $page->modified(),
'modifiedFormatted' => $page->modified('d/m/Y H:i'),
'children' => []
];
// Parse children (geoformats and maps)
// Parser les enfants (geoformats et cartes)
foreach ($page->children()->listed() as $child) {
$template = $child->intendedTemplate()->name();

View file

@ -1,12 +1,12 @@
<?php
/**
* Template to display a narrative
* This template is required for narrative.json.php to work
* Template pour afficher un récit
* Ce template est requis pour que recit.json.php fonctionne
*/
?>
<?php snippet('header') ?>
<article class="narrative">
<article class="recit">
<h1><?= $page->title() ?></h1>
<?php if ($page->author()->isNotEmpty()): ?>
@ -27,7 +27,7 @@
</div>
<?php endif ?>
<p><a href="<?= $page->url() ?>/print">Open print editor</a></p>
<p><a href="<?= $page->url() ?>/print">Ouvrir l'éditeur d'impression</a></p>
</article>
<?php snippet('footer') ?>

View file

@ -752,47 +752,6 @@
},
"install-path": "../psr/log"
},
{
"name": "sylvainjule/code-editor",
"version": "1.1.0",
"version_normalized": "1.1.0.0",
"source": {
"type": "git",
"url": "https://github.com/sylvainjule/kirby-code-editor.git",
"reference": "adbc2c8a728994cc57ea72a7f8628f27d202b8df"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sylvainjule/kirby-code-editor/zipball/adbc2c8a728994cc57ea72a7f8628f27d202b8df",
"reference": "adbc2c8a728994cc57ea72a7f8628f27d202b8df",
"shasum": ""
},
"require": {
"getkirby/composer-installer": "^1.2"
},
"time": "2025-08-04T17:32:08+00:00",
"type": "kirby-plugin",
"extra": {
"installer-name": "code-editor"
},
"installation-source": "dist",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sylvain Julé",
"email": "contact@sylvain-jule.fr"
}
],
"description": "Code editor field for Kirby 3, 4 and 5",
"support": {
"issues": "https://github.com/sylvainjule/kirby-code-editor/issues",
"source": "https://github.com/sylvainjule/kirby-code-editor/tree/1.1.0"
},
"install-path": "../../site/plugins/code-editor"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",

View file

@ -3,7 +3,7 @@
'name' => 'getkirby/plainkit',
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '4d1183d1afd90517610e742ea0bc6553e8312bc6',
'reference' => '76274fff04c54514230ad2bb0aca362139618411',
'type' => 'project',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@ -67,7 +67,7 @@
'getkirby/plainkit' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '4d1183d1afd90517610e742ea0bc6553e8312bc6',
'reference' => '76274fff04c54514230ad2bb0aca362139618411',
'type' => 'project',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@ -124,15 +124,6 @@
'aliases' => array(),
'dev_requirement' => false,
),
'sylvainjule/code-editor' => array(
'pretty_version' => '1.1.0',
'version' => '1.1.0.0',
'reference' => 'adbc2c8a728994cc57ea72a7f8628f27d202b8df',
'type' => 'kirby-plugin',
'install_path' => __DIR__ . '/../../site/plugins/code-editor',
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/deprecation-contracts' => array(
'pretty_version' => 'v3.6.0',
'version' => '3.6.0.0',

View file

@ -4,18 +4,16 @@ import EditorPanel from './components/editor/EditorPanel.vue';
import ElementPopup from './components/ElementPopup.vue';
import PagePopup from './components/PagePopup.vue';
import PreviewLoader from './components/PreviewLoader.vue';
import SaveButton from './components/SaveButton.vue';
import { onMounted, ref, computed, provide } from 'vue';
import { onMounted, ref, watch, computed, provide } from 'vue';
import { useStylesheetStore } from './stores/stylesheet';
import { useNarrativeStore } from './stores/narrative';
import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
import { useIframeInteractions } from './composables/useIframeInteractions';
import { usePreviewRenderer } from './composables/usePreviewRenderer';
import { usePrintPreview } from './composables/usePrintPreview';
import { useRecitStore } from './stores/recit';
import Coloris from '@melloware/coloris';
const stylesheetStore = useStylesheetStore();
const narrativeStore = useNarrativeStore();
const recitStore = useRecitStore();
// Get recit URL from body data attribute (set by print.php template)
const recitUrl = document.body.dataset.recitUrl || null;
const previewFrame1 = ref(null);
const previewFrame2 = ref(null);
const elementPopup = ref(null);
@ -24,32 +22,18 @@ const activeTab = ref('');
provide('activeTab', activeTab);
// Setup iframe interactions (hover, click, labels)
const {
hoveredPage,
selectedPages,
hoveredElement,
selectedElement,
handleIframeMouseMove,
handleIframeClick,
handlePagePopupClose,
handleElementPopupClose,
} = useIframeInteractions({ elementPopup, pagePopup });
// Page interaction state
const hoveredPage = ref(null);
const selectedPages = ref([]); // Pages with active border (when popup is open)
const hoveredElement = ref(null); // Currently hovered content element
const selectedElement = ref(null); // Selected element (when popup is open)
const EDGE_THRESHOLD = 30; // px from edge to trigger hover
const PAGE_HIGHLIGHT_COLOR = '#ff8a50';
const ELEMENT_HIGHLIGHT_COLOR = '#7136ff';
// Setup preview renderer with double buffering
const {
renderPreview,
currentFrameIndex,
isTransitioning,
setKeyboardShortcutHandler,
} = usePreviewRenderer({
previewFrame1,
previewFrame2,
stylesheetStore,
narrativeStore,
handleIframeMouseMove,
handleIframeClick,
});
let savedScrollPercentage = 0;
const currentFrameIndex = ref(1); // 1 or 2, which iframe is currently visible
const isTransitioning = ref(false);
const activeFrame = computed(() => {
return currentFrameIndex.value === 1
@ -57,32 +41,526 @@ const activeFrame = computed(() => {
: previewFrame2.value;
});
// Setup print preview
const { printPreview } = usePrintPreview(activeFrame);
// Check if mouse position is near the edges of a page element
const isNearPageEdge = (pageElement, mouseX, mouseY) => {
const rect = pageElement.getBoundingClientRect();
// Setup keyboard shortcuts (depends on printPreview)
const {
handleKeyboardShortcut,
isMac
} = useKeyboardShortcuts({
stylesheetStore,
elementPopup,
pagePopup,
activeTab,
printPreview,
});
const nearLeft = mouseX >= rect.left && mouseX <= rect.left + EDGE_THRESHOLD;
const nearRight =
mouseX >= rect.right - EDGE_THRESHOLD && mouseX <= rect.right;
const nearTop = mouseY >= rect.top && mouseY <= rect.top + EDGE_THRESHOLD;
const nearBottom =
mouseY >= rect.bottom - EDGE_THRESHOLD && mouseY <= rect.bottom;
// Attach keyboard shortcut handler to renderer
setKeyboardShortcutHandler(handleKeyboardShortcut);
const inHorizontalRange = mouseY >= rect.top && mouseY <= rect.bottom;
const inVerticalRange = mouseX >= rect.left && mouseX <= rect.right;
return (
(nearLeft && inHorizontalRange) ||
(nearRight && inHorizontalRange) ||
(nearTop && inVerticalRange) ||
(nearBottom && inVerticalRange)
);
};
// Get all pages using the same template as the given page
const getPagesWithSameTemplate = (page, doc) => {
const pageType = page.getAttribute('data-page-type') || 'default';
const allPages = doc.querySelectorAll('.pagedjs_page');
return Array.from(allPages).filter(
(p) => (p.getAttribute('data-page-type') || 'default') === pageType
);
};
// Get selector for element (same logic as ElementPopup)
const getSelectorFromElement = (element) => {
if (element.id) {
return `#${element.id}`;
}
const tagName = element.tagName.toLowerCase();
// Filter out state classes (element-hovered, element-selected, page-hovered, page-selected)
const classes = Array.from(element.classList).filter(
(cls) =>
![
'element-hovered',
'element-selected',
'page-hovered',
'page-selected',
].includes(cls)
);
if (classes.length > 0) {
return `${tagName}.${classes[0]}`;
}
return tagName;
};
// Create and position element label on hover
const createElementLabel = (element) => {
const doc = element.ownerDocument;
const existingLabel = doc.querySelector('.element-hover-label');
if (existingLabel) {
existingLabel.remove();
}
const rect = element.getBoundingClientRect();
const scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop;
const scrollLeft = doc.documentElement.scrollLeft || doc.body.scrollLeft;
const label = doc.createElement('div');
label.className = 'element-hover-label';
label.textContent = getSelectorFromElement(element);
label.style.top = `${rect.top + scrollTop - 32}px`;
label.style.left = `${rect.left + scrollLeft}px`;
doc.body.appendChild(label);
return label;
};
// Remove element label
const removeElementLabel = (doc) => {
const label = doc.querySelector('.element-hover-label');
if (label) {
label.remove();
}
};
// Create and position page label on hover
const createPageLabel = (page) => {
const doc = page.ownerDocument;
const existingLabel = doc.querySelector('.page-hover-label');
if (existingLabel) {
existingLabel.remove();
}
const rect = page.getBoundingClientRect();
const scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop;
const scrollLeft = doc.documentElement.scrollLeft || doc.body.scrollLeft;
const templateName = page.getAttribute('data-page-type') || 'default';
const label = doc.createElement('div');
label.className = 'page-hover-label';
label.textContent = `@page ${templateName}`;
label.style.top = `${rect.top + scrollTop - 32}px`;
label.style.left = `${rect.left + scrollLeft}px`;
doc.body.appendChild(label);
return label;
};
// Remove page label
const removePageLabel = (doc) => {
const label = doc.querySelector('.page-hover-label');
if (label) {
label.remove();
}
};
// Handle mouse movement in iframe
const handleIframeMouseMove = (event) => {
const pages = event.target.ownerDocument.querySelectorAll('.pagedjs_page');
let foundPage = null;
// Check if hovering near page edge
for (const page of pages) {
if (isNearPageEdge(page, event.clientX, event.clientY)) {
foundPage = page;
break;
}
}
// Update page hover state
if (foundPage !== hoveredPage.value) {
// Remove highlight from previous page (only if not in selectedPages)
if (hoveredPage.value && !selectedPages.value.includes(hoveredPage.value)) {
hoveredPage.value.classList.remove('page-hovered');
}
// Remove previous page label
removePageLabel(event.target.ownerDocument);
// Add highlight to new page (only if not already selected)
if (foundPage && !selectedPages.value.includes(foundPage)) {
foundPage.classList.add('page-hovered');
createPageLabel(foundPage);
}
hoveredPage.value = foundPage;
}
// If not near page edge, check for content element hover
if (!foundPage) {
const contentElement = getContentElement(event.target);
const doc = event.target.ownerDocument;
if (contentElement !== hoveredElement.value) {
// Remove highlight from previous element (only if not selected)
if (
hoveredElement.value &&
hoveredElement.value !== selectedElement.value
) {
hoveredElement.value.classList.remove('element-hovered');
}
// Remove previous labels
removeElementLabel(doc);
removePageLabel(doc);
// Add highlight to new element (only if not already selected)
if (contentElement && contentElement !== selectedElement.value) {
contentElement.classList.add('element-hovered');
createElementLabel(contentElement);
}
hoveredElement.value = contentElement;
}
} else {
// Clear element hover when hovering page edge
if (
hoveredElement.value &&
hoveredElement.value !== selectedElement.value
) {
hoveredElement.value.classList.remove('element-hovered');
hoveredElement.value = null;
}
// Remove element label when hovering page edge
removeElementLabel(event.target.ownerDocument);
}
};
// Clear selection highlight from all selected pages
const clearSelectedPages = () => {
selectedPages.value.forEach((page) => {
page.classList.remove('page-selected');
});
selectedPages.value = [];
};
// Text elements that can trigger ElementPopup (excluding containers, images, etc.)
const CONTENT_ELEMENTS = [
'P',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'BLOCKQUOTE',
'LI',
'A',
'STRONG',
'EM',
'B',
'I',
'U',
'CODE',
'PRE',
'FIGCAPTION',
];
// Check if element is a content element (or find closest content parent)
const getContentElement = (element) => {
let current = element;
while (current && current.tagName !== 'BODY') {
if (CONTENT_ELEMENTS.includes(current.tagName)) {
return current;
}
current = current.parentElement;
}
return null;
};
// Clear selected element highlight
const clearSelectedElement = () => {
if (selectedElement.value) {
selectedElement.value.classList.remove('element-selected');
const doc = selectedElement.value.ownerDocument;
removeElementLabel(doc);
selectedElement.value = null;
}
};
// Get count of similar elements (same tag)
const getSimilarElementsCount = (element, doc) => {
const tagName = element.tagName;
const allElements = doc.querySelectorAll(tagName);
return allElements.length;
};
// Handle click in iframe
const handleIframeClick = (event) => {
const element = event.target;
// Check if clicking near a page edge
if (hoveredPage.value) {
event.stopPropagation();
// Clear previous selections
clearSelectedPages();
clearSelectedElement();
// Get all pages with same template and highlight them
const doc = event.target.ownerDocument;
const sameTemplatePages = getPagesWithSameTemplate(hoveredPage.value, doc);
sameTemplatePages.forEach((page) => {
page.classList.add('page-selected');
});
selectedPages.value = sameTemplatePages;
// Remove labels when opening popup
removePageLabel(doc);
removeElementLabel(doc);
pagePopup.value.open(hoveredPage.value, event, sameTemplatePages.length);
elementPopup.value.close();
return;
}
// Only show popup for elements inside the page template
const isInsidePage = element.closest('.pagedjs_page');
if (!isInsidePage) {
clearSelectedPages();
clearSelectedElement();
elementPopup.value.close();
pagePopup.value.close();
return;
}
// Only show ElementPopup for content elements, not divs
const contentElement = getContentElement(element);
if (!contentElement) {
clearSelectedPages();
clearSelectedElement();
elementPopup.value.close();
pagePopup.value.close();
return;
}
// Clear page selections
clearSelectedPages();
// If popup is already open and we're clicking another element, close it
if (elementPopup.value.visible) {
clearSelectedElement();
elementPopup.value.close();
pagePopup.value.close();
return;
}
// Clear previous element selection
clearSelectedElement();
// Remove hovered class from the element we're about to select
contentElement.classList.remove('element-hovered');
// Clear the hoveredElement ref if it's the same as what we're selecting
if (hoveredElement.value === contentElement) {
hoveredElement.value = null;
}
// Get document and remove labels when opening popup
const doc = event.target.ownerDocument;
removeElementLabel(doc);
removePageLabel(doc);
// Select the new element
selectedElement.value = contentElement;
contentElement.classList.add('element-selected');
// Get count of similar elements
const count = getSimilarElementsCount(contentElement, doc);
elementPopup.value.handleIframeClick(event, contentElement, count);
pagePopup.value.close();
};
// Expose clearSelectedPages for PagePopup to call when closing
const handlePagePopupClose = () => {
clearSelectedPages();
};
// Handle ElementPopup close
const handleElementPopupClose = () => {
clearSelectedElement();
};
const renderPreview = async (shouldReloadFromFile = false) => {
if (isTransitioning.value) return;
isTransitioning.value = true;
// Determine which iframe is currently visible and which to render to
const visibleFrame =
currentFrameIndex.value === 1 ? previewFrame1.value : previewFrame2.value;
const hiddenFrame =
currentFrameIndex.value === 1 ? previewFrame2.value : previewFrame1.value;
if (!hiddenFrame) {
isTransitioning.value = false;
return;
}
// Save scroll position from visible frame
if (
visibleFrame &&
visibleFrame.contentWindow &&
visibleFrame.contentDocument
) {
const scrollTop = visibleFrame.contentWindow.scrollY || 0;
const scrollHeight =
visibleFrame.contentDocument.documentElement.scrollHeight;
const clientHeight = visibleFrame.contentWindow.innerHeight;
const maxScroll = scrollHeight - clientHeight;
savedScrollPercentage = maxScroll > 0 ? scrollTop / maxScroll : 0;
}
if (shouldReloadFromFile || !stylesheetStore.content) {
await stylesheetStore.loadStylesheet();
}
// Render to the hidden frame
hiddenFrame.srcdoc = `
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/assets/css/pagedjs-interface.css">
<style id="dynamic-styles">${stylesheetStore.content}</style>
<script src="https://unpkg.com/pagedjs/dist/paged.polyfill.js"><\/script>
</head>
<body>${document.getElementById('content-source').innerHTML}</body>
</html>
`;
hiddenFrame.onload = () => {
// Add event listeners for page and element interactions
hiddenFrame.contentDocument.addEventListener(
'mousemove',
handleIframeMouseMove
);
hiddenFrame.contentDocument.addEventListener('click', handleIframeClick);
// Close Coloris when clicking in the iframe
hiddenFrame.contentDocument.addEventListener('click', () => {
Coloris.close();
});
// Wait for PagedJS to finish rendering
setTimeout(() => {
// Restore scroll position
const scrollHeight =
hiddenFrame.contentDocument.documentElement.scrollHeight;
const clientHeight = hiddenFrame.contentWindow.innerHeight;
const maxScroll = scrollHeight - clientHeight;
const targetScroll = savedScrollPercentage * maxScroll;
hiddenFrame.contentWindow.scrollTo(0, targetScroll);
// Start crossfade transition
setTimeout(() => {
// Make hidden frame visible (it's already behind)
hiddenFrame.style.opacity = '1';
hiddenFrame.style.zIndex = '1';
// Fade out visible frame
if (visibleFrame) {
visibleFrame.style.opacity = '0';
}
// After fade completes, swap the frames
setTimeout(() => {
if (visibleFrame) {
visibleFrame.style.zIndex = '0';
}
// Swap current frame
currentFrameIndex.value = currentFrameIndex.value === 1 ? 2 : 1;
isTransitioning.value = false;
}, 200); // Match CSS transition duration
}, 50); // Small delay to ensure scroll is set
}, 200); // Wait for PagedJS
};
};
watch(
() => stylesheetStore.content,
() => {
renderPreview();
}
);
// Re-render when recit data changes
watch(
() => recitStore.data,
() => {
if (recitStore.data) {
renderPreview();
}
}
);
// Print the PagedJS content
const printPreview = async () => {
const frame = activeFrame.value;
if (!frame || !frame.contentDocument) return;
const doc = frame.contentDocument;
// Collect all styles
let allStyles = '';
// Get inline <style> tags content
doc.querySelectorAll('style').forEach((style) => {
allStyles += style.innerHTML + '\n';
});
// Get rules from stylesheets
for (const sheet of doc.styleSheets) {
try {
for (const rule of sheet.cssRules) {
allStyles += rule.cssText + '\n';
}
} catch (e) {
// Cross-origin stylesheet, try to fetch it
if (sheet.href) {
try {
const response = await fetch(sheet.href);
const css = await response.text();
allStyles += css;
} catch (fetchError) {
console.warn('Could not fetch stylesheet:', sheet.href);
}
}
}
}
// Save current page content
const originalContent = document.body.innerHTML;
const originalStyles = document.head.innerHTML;
// Replace page content with iframe content
document.head.innerHTML = `
<meta charset="UTF-8">
<title>Impression</title>
<style>${allStyles}</style>
`;
document.body.innerHTML = doc.body.innerHTML;
// Print
window.print();
// Restore original content after print dialog closes
setTimeout(() => {
document.head.innerHTML = originalStyles;
document.body.innerHTML = originalContent;
// Re-mount Vue app would be needed, so we reload instead
window.location.reload();
}, 100);
};
// Lifecycle: Initialize app on mount
onMounted(async () => {
// Load narrative data (narrativeUrl constructed from location, always present)
await narrativeStore.loadNarrative(location.href + '.json');
// Initialize stylesheet with custom CSS
if (narrativeStore.data) {
await stylesheetStore.initializeFromNarrative(narrativeStore.data);
// Load recit data if URL is provided (print mode)
if (recitUrl) {
await recitStore.loadRecit(recitUrl);
}
// Render preview after data is loaded
@ -110,8 +588,6 @@ onMounted(async () => {
<PreviewLoader :isLoading="isTransitioning" :shifted="activeTab.length > 0" />
<SaveButton />
<ElementPopup
ref="elementPopup"
:iframeRef="activeFrame"
@ -123,7 +599,7 @@ onMounted(async () => {
@close="handlePagePopupClose"
/>
<button class="print-btn" @click="printPreview" :title="`Imprimer (${isMac ? '⌘' : 'Ctrl'}+P)`">
<button class="print-btn" @click="printPreview" title="Imprimer">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
@ -197,14 +673,6 @@ onMounted(async () => {
height: 1.5rem;
}
/* Coloris button - ensure it's clickable and visible */
:deep(.clr-field button) {
pointer-events: auto !important;
cursor: pointer !important;
position: relative;
z-index: 1;
}
/* Hide UI elements when printing */
@media print {
#editor-panel,

View file

@ -24,7 +24,7 @@
<select v-model="fontFamily.value" :disabled="inheritanceLocked">
<option v-for="f in fonts" :key="f" :value="f">{{ f }}</option>
</select>
<div class="field-checkbox">
<div class="checkbox-field">
<input type="checkbox" v-model="fontStyle.italic" :disabled="inheritanceLocked" />
<label class="label-with-tooltip" data-css="font-style">Italique</label>
</div>
@ -44,7 +44,7 @@
<div class="settings-subsection">
<div class="field" :class="{ 'field--view-only': inheritanceLocked }">
<label class="label-with-tooltip" data-css="font-size">Taille du texte</label>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
v-model="fontSize.value"
:min="0"
@ -130,7 +130,7 @@
<div class="settings-subsection">
<div class="field" :class="{ 'field--view-only': inheritanceLocked }">
<label class="label-with-tooltip" data-css="margin">Marges extérieures</label>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
v-model="marginOuter.value"
:min="0"
@ -163,7 +163,7 @@
<div class="settings-subsection">
<div class="field" :class="{ 'field--view-only': inheritanceLocked }">
<label class="label-with-tooltip" data-css="padding">Marges intérieures</label>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
v-model="paddingInner.value"
:min="0"
@ -258,7 +258,7 @@
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { ref, computed, watch } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
import { usePopupPosition } from '../composables/usePopupPosition';
import { useDebounce } from '../composables/useDebounce';
@ -435,7 +435,7 @@ const removeElementBlock = () => {
// Escape special regex characters in selector
const escaped = selector.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Remove the block and any surrounding whitespace
stylesheetStore.replaceInCustomCss(
stylesheetStore.content = stylesheetStore.content.replace(
new RegExp(`\\n?${escaped}\\s*\\{[^}]*\\}\\n?`),
'\n'
);
@ -581,7 +581,7 @@ const handleCssInput = (event) => {
cssDebounceTimer = setTimeout(() => {
const oldBlock = elementCss.value;
if (oldBlock) {
stylesheetStore.replaceInCustomCss(oldBlock, newCss);
stylesheetStore.content = stylesheetStore.content.replace(oldBlock, newCss);
}
}, 500);
};
@ -592,35 +592,16 @@ watch(isEditable, async (newValue, oldValue) => {
// Format when exiting editing mode
if (oldValue && !newValue) {
await stylesheetStore.formatCustomCss();
await stylesheetStore.formatContent();
}
});
// Watch stylesheet changes to sync values
watch(
() => stylesheetStore.customCss,
() => stylesheetStore.content,
() => {
if (visible.value && !isUpdatingFromStore) {
isUpdatingFromStore = true;
if (visible.value && !stylesheetStore.isEditing) {
loadValuesFromStylesheet();
nextTick(() => {
isUpdatingFromStore = false;
});
}
}
);
// Also watch when exiting edit mode
watch(
() => stylesheetStore.isEditing,
(isEditing, wasEditing) => {
// When exiting edit mode, reload values
if (visible.value && wasEditing && !isEditing && !isUpdatingFromStore) {
isUpdatingFromStore = true;
loadValuesFromStylesheet();
nextTick(() => {
isUpdatingFromStore = false;
});
}
}
);
@ -629,6 +610,8 @@ const loadValuesFromStylesheet = () => {
if (!selector.value) return;
try {
isUpdatingFromStore = true;
// Extract font-family
const fontFamilyData = stylesheetStore.extractValue(selector.value, 'font-family');
if (fontFamilyData) {
@ -693,13 +676,12 @@ const loadValuesFromStylesheet = () => {
}
} catch (error) {
console.error('Error loading values from stylesheet:', error);
} finally {
isUpdatingFromStore = false;
}
};
const open = (element, event, count = null) => {
// Block all watchers during initialization
isUpdatingFromStore = true;
selectedElement.value = element;
selector.value = getSelectorFromElement(element);
position.value = calculatePosition(event);
@ -707,30 +689,14 @@ const open = (element, event, count = null) => {
// Store instance count if provided, otherwise calculate it
elementInstanceCount.value = count !== null ? count : getInstanceCount(selector.value);
// Detect inheritance state from CSS block state
const blockState = stylesheetStore.getBlockState(selector.value);
// Read inheritance state from element's data attribute
inheritanceLocked.value = element.dataset.inheritanceUnlocked !== 'true';
if (blockState === 'active') {
// Block exists and is active (not commented) unlocked
inheritanceLocked.value = false;
} else if (blockState === 'commented') {
// Block exists but is commented locked with custom values
inheritanceLocked.value = true;
} else {
// No block locked with inherited values
inheritanceLocked.value = true;
}
// Load values from stylesheet (includes commented blocks)
// Load values from stylesheet
loadValuesFromStylesheet();
visible.value = true;
// Re-enable watchers after initialization (use nextTick to ensure watchers see the flag)
nextTick(() => {
isUpdatingFromStore = false;
});
// Initialize Coloris after opening
setTimeout(() => {
Coloris.init();
@ -789,69 +755,24 @@ const handleIframeClick = (event, targetElement = null, elementCount = null) =>
};
const toggleInheritance = () => {
const blockState = stylesheetStore.getBlockState(selector.value);
const wasLocked = inheritanceLocked.value;
inheritanceLocked.value = !inheritanceLocked.value;
if (inheritanceLocked.value && blockState === 'commented') {
// Case 1: Locked with commented block Uncomment to unlock
stylesheetStore.uncommentCssBlock(selector.value);
inheritanceLocked.value = false;
} else if (inheritanceLocked.value && blockState === 'none') {
// Case 2: Locked with no custom CSS Capture computed values and create block
if (selectedElement.value && props.iframeRef && props.iframeRef.contentWindow) {
const computed = props.iframeRef.contentWindow.getComputedStyle(selectedElement.value);
// Update fields with computed values before creating the block
isUpdatingFromStore = true;
// Font family
fontFamily.value.value = computed.fontFamily.replace(/['"]/g, '').split(',')[0].trim();
// Font style
fontStyle.value.italic = computed.fontStyle === 'italic';
// Font weight
fontWeight.value.value = parseInt(computed.fontWeight);
// Font size
const fontSizeMatch = computed.fontSize.match(/([\d.]+)(px|rem|em|pt)/);
if (fontSizeMatch) {
fontSize.value.value = parseFloat(fontSizeMatch[1]);
fontSize.value.unit = fontSizeMatch[2];
}
// Text align
textAlign.value.value = computed.textAlign;
// Color
color.value.value = computed.color;
// Background
background.value.value = computed.backgroundColor;
// Margin (take the top margin)
const marginMatch = computed.marginTop.match(/([\d.]+)(px|mm|pt)/);
if (marginMatch) {
marginOuter.value.value = parseFloat(marginMatch[1]);
marginOuter.value.unit = marginMatch[2];
}
// Padding (take the top padding)
const paddingMatch = computed.paddingTop.match(/([\d.]+)(px|mm|pt)/);
if (paddingMatch) {
paddingInner.value.value = parseFloat(paddingMatch[1]);
paddingInner.value.unit = paddingMatch[2];
}
isUpdatingFromStore = false;
// Store the inheritance state in the element's data attribute
if (selectedElement.value) {
if (inheritanceLocked.value) {
delete selectedElement.value.dataset.inheritanceUnlocked;
} else {
selectedElement.value.dataset.inheritanceUnlocked = 'true';
}
}
// Now create the block with captured values
if (inheritanceLocked.value && !wasLocked) {
// Re-locking: remove the element-specific CSS block to restore inheritance
removeElementBlock();
} else if (!inheritanceLocked.value && wasLocked) {
// Unlocking: apply all current field values to create the CSS block
applyAllStyles();
inheritanceLocked.value = false;
} else if (!inheritanceLocked.value && blockState === 'active') {
// Case 3: Unlocked with active block Comment to lock
stylesheetStore.commentCssBlock(selector.value);
inheritanceLocked.value = true;
}
};

View file

@ -26,7 +26,7 @@
:class="{ 'field--view-only': inheritanceLocked }"
>
<label class="label-with-tooltip" data-css="margin-top">Haut</label>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
v-model="margins.top.value"
:min="0"
@ -67,7 +67,7 @@
:class="{ 'field--view-only': inheritanceLocked }"
>
<label class="label-with-tooltip" data-css="margin-bottom">Bas</label>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
v-model="margins.bottom.value"
:min="0"
@ -108,7 +108,7 @@
:class="{ 'field--view-only': inheritanceLocked }"
>
<label class="label-with-tooltip" data-css="margin-left">Gauche</label>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
v-model="margins.left.value"
:min="0"
@ -149,7 +149,7 @@
:class="{ 'field--view-only': inheritanceLocked }"
>
<label class="label-with-tooltip" data-css="margin-right">Droite</label>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
v-model="margins.right.value"
:min="0"
@ -356,7 +356,7 @@ const getOrCreateTemplateBlock = () => {
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(
stylesheetStore.content = stylesheetStore.content.replace(
baseBlock,
baseBlock + newBlock
);
@ -376,7 +376,7 @@ const removeTemplateBlock = () => {
if (block) {
// Remove the block and any surrounding whitespace
stylesheetStore.replaceInCustomCss(
stylesheetStore.content = stylesheetStore.content.replace(
new RegExp(`\\n?${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}\\n?`),
'\n'
);
@ -399,7 +399,7 @@ const updateMargins = (force = false) => {
/(margin:\s*)[^;]+/,
`$1${marginValue}`
);
stylesheetStore.replaceInCustomCss(
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
@ -408,7 +408,7 @@ const updateMargins = (force = false) => {
/(\s*})$/,
` margin: ${marginValue};\n$1`
);
stylesheetStore.replaceInCustomCss(
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
@ -428,7 +428,7 @@ const updateBackground = (force = false) => {
/(background:\s*)[^;]+/,
`$1${background.value.value}`
);
stylesheetStore.replaceInCustomCss(
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
@ -437,7 +437,7 @@ const updateBackground = (force = false) => {
/(\s*})$/,
` background: ${background.value.value};\n$1`
);
stylesheetStore.replaceInCustomCss(
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
@ -674,7 +674,7 @@ const handleCssInput = (event) => {
// Get the actual CSS block (not the commented preview)
const oldBlock = pageCss.value;
if (oldBlock) {
stylesheetStore.replaceInCustomCss(
stylesheetStore.content = stylesheetStore.content.replace(
oldBlock,
newCss
);
@ -688,7 +688,7 @@ watch(isEditable, async (newValue, oldValue) => {
// Format when exiting editing mode
if (oldValue && !newValue) {
await stylesheetStore.formatCustomCss();
await stylesheetStore.formatContent();
}
});

View file

@ -1,6 +1,6 @@
<template>
<!-- Fallback static content when no narrative data -->
<template v-if="!hasNarrativeData">
<!-- Fallback static content when no recit data -->
<template v-if="!hasRecitData">
<section class="chapter">
<p>
Accumsan arcu tristique purus eros pellentesque rutrum hendrerit
@ -10,13 +10,13 @@
</section>
</template>
<!-- Dynamic content from narrative -->
<!-- Dynamic content from recit -->
<template v-else>
<template v-for="item in flattenedContent" :key="item.id">
<!-- Narrative (cover page) -->
<!-- Récit (cover page) -->
<section
v-if="item.template === 'narrative'"
class="narrative-cover"
v-if="item.template === 'recit'"
class="recit-cover"
:data-page-type="item.template"
>
<img v-if="item.cover" :src="item.cover" class="cover-image" alt="" />
@ -75,7 +75,7 @@
<script setup>
import { computed } from 'vue';
import { useNarrativeStore } from '../stores/narrative';
import { useRecitStore } from '../stores/recit';
import {
TextBlock,
HeadingBlock,
@ -87,10 +87,10 @@ import {
blockComponents
} from './blocks';
const narrativeStore = useNarrativeStore();
const recitStore = useRecitStore();
const hasNarrativeData = computed(() => narrativeStore.data !== null);
const flattenedContent = computed(() => narrativeStore.flattenedContent);
const hasRecitData = computed(() => recitStore.data !== null);
const flattenedContent = computed(() => recitStore.flattenedContent);
// Filter out hidden blocks
const visibleBlocks = (blocks) => {
@ -114,24 +114,24 @@ const getBlockComponent = (type) => {
<style>
/* Base print styles for content sections */
.narrative-cover,
.recit-cover,
.geoformat,
.chapitre,
.carte {
break-before: page;
}
.narrative-cover .cover-image,
.recit-cover .cover-image,
.geoformat .cover-image {
max-width: 100%;
height: auto;
}
.narrative-cover h1 {
.recit-cover h1 {
margin-top: 1rem;
}
.narrative-cover .author {
.recit-cover .author {
font-style: italic;
color: #666;
}

View file

@ -1,200 +0,0 @@
<template>
<div class="save-button-wrapper">
<button
class="save-btn"
:class="{
'has-changes': isDirty,
'is-saving': isSaving,
'has-error': hasError,
'save-success': showSuccess
}"
:disabled="!isDirty || isSaving"
@click="handleSave"
:title="getTooltip()"
>
<!-- Save icon (default state) -->
<svg v-if="!isSaving && !showSuccess" class="save-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 3H5C3.89 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H19C20.1 21 21 20.1 21 19V7L17 3ZM19 19H5V5H16.17L19 7.83V19ZM12 12C10.34 12 9 13.34 9 15S10.34 18 12 18 15 16.66 15 15 13.66 12 12 12ZM6 6H15V10H6V6Z"/>
</svg>
<!-- Spinner (saving state) -->
<div v-if="isSaving" class="spinner"></div>
<!-- Success checkmark (brief animation) -->
<svg v-if="showSuccess" class="success-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12L3.41 13.41L9 19L21 7L19.59 5.59L9 16.17Z"/>
</svg>
</button>
<!-- Last saved timestamp -->
<div v-if="lastSavedFormatted" class="last-saved">
Saved: {{ lastSavedFormatted }}
</div>
<!-- Error message tooltip -->
<div v-if="hasError && saveError" class="error-tooltip">
{{ saveError }}
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
const stylesheetStore = useStylesheetStore();
const isDirty = computed(() => stylesheetStore.isDirty);
const isSaving = computed(() => stylesheetStore.isSaving);
const saveError = computed(() => stylesheetStore.saveError);
const lastSavedFormatted = computed(() => stylesheetStore.lastSavedFormatted);
const hasError = computed(() => !!saveError.value);
const showSuccess = ref(false);
// Detect platform for keyboard shortcut display
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const shortcutKey = isMac ? '⌘' : 'Ctrl';
const handleSave = async () => {
const result = await stylesheetStore.saveCustomCss();
if (result.status === 'success') {
// Show success animation
showSuccess.value = true;
setTimeout(() => {
showSuccess.value = false;
}, 1500);
}
// Errors are handled in the store and reflected in hasError
};
const handleKeyDown = (event) => {
// Check for Cmd+S (Mac) or Ctrl+S (Windows/Linux)
if ((event.metaKey || event.ctrlKey) && event.key === 's') {
event.preventDefault();
// Only save if there are changes and not currently saving
if (isDirty.value && !isSaving.value) {
handleSave();
}
}
};
const getTooltip = () => {
if (!isDirty.value) return 'Aucune modification à enregistrer';
if (isSaving.value) return 'Enregistrement...';
if (hasError.value) return saveError.value;
return `Enregistrer le CSS personnalisé (${shortcutKey}+S)`;
};
// Add keyboard listener on mount
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
// Remove keyboard listener on unmount
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
});
</script>
<style scoped>
.save-button-wrapper {
position: fixed;
top: 2rem;
right: 5rem;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.save-btn {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
border: none;
background: var(--color-interface-300, #ccc);
color: white;
cursor: not-allowed;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transition: all 0.2s ease;
position: relative;
}
.save-btn.has-changes {
background: var(--color-page-highlight, #ff8a50);
cursor: pointer;
}
.save-btn.has-changes:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
}
.save-btn.is-saving {
cursor: wait;
}
.save-btn.has-error {
background: #e74c3c;
}
.save-btn.save-success {
background: #2ecc71;
}
.save-icon,
.success-icon {
width: 1.5rem;
height: 1.5rem;
}
.spinner {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
border-top: 2px solid white;
border-right: 2px solid transparent;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.last-saved {
font-size: 0.75rem;
color: var(--color-interface-600, #666);
background: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
white-space: nowrap;
}
.error-tooltip {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background: #e74c3c;
color: white;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.875rem;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
max-width: 15rem;
word-wrap: break-word;
white-space: normal;
}
</style>

View file

@ -1,80 +1,31 @@
<template>
<div id="stylesheet-viewer">
<!-- CSS File Import -->
<CssFileImport @import="handleCssImport" />
<!-- Base CSS Section (Collapsable, closed by default) -->
<div class="css-section">
<div
class="section-header"
@click="isBaseCssExpanded = !isBaseCssExpanded"
>
<h3>Base CSS</h3>
<svg
class="expand-icon"
:class="{ expanded: isBaseCssExpanded }"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" />
</svg>
</div>
<div v-show="isBaseCssExpanded" class="section-content">
<pre
class="readonly"
><code class="hljs language-css" v-html="highlightedBaseCss"></code></pre>
</div>
<div class="header">
<h3>Stylesheet</h3>
<label class="toggle">
<span class="toggle-label">Mode édition</span>
<input type="checkbox" v-model="isEditable" />
<span class="toggle-switch"></span>
</label>
</div>
<!-- Custom CSS Section (Editable with toggle) -->
<div class="css-section custom-section">
<div class="section-header">
<h3>CSS personnalisé</h3>
<label class="toggle">
<span class="toggle-label">Mode édition</span>
<input type="checkbox" v-model="isCustomCssEditable" />
<span class="toggle-switch"></span>
</label>
</div>
<pre
v-if="!isEditable"
class="readonly"
><code class="hljs language-css" v-html="highlightedCss"></code></pre>
<div class="section-content">
<pre
v-if="!isCustomCssEditable"
class="readonly"
><code class="hljs language-css" v-html="highlightedCustomCss"></code></pre>
<textarea
v-else
:value="stylesheetStore.customCss"
@input="handleCustomCssInput"
@focus="handleFocus"
spellcheck="false"
placeholder="Ajoutez votre CSS personnalisé ici..."
></textarea>
</div>
</div>
<!-- Export Button -->
<button class="export-button" @click="handleExport" type="button">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M9 16v-6H5l7-7 7 7h-4v6H9zm-4 4h14v-2H5v2z" />
</svg>
<span>Exporter la feuille de style complète</span>
</button>
<textarea
v-else
:value="stylesheetStore.content"
@input="handleInput"
spellcheck="false"
></textarea>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { ref, computed, watch, inject } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
import { useNarrativeStore } from '../stores/narrative';
import CssFileImport from './ui/CssFileImport.vue';
import hljs from 'highlight.js/lib/core';
import css from 'highlight.js/lib/languages/css';
import 'highlight.js/styles/atom-one-dark.css';
@ -82,22 +33,16 @@ import 'highlight.js/styles/atom-one-dark.css';
hljs.registerLanguage('css', css);
const stylesheetStore = useStylesheetStore();
const narrativeStore = useNarrativeStore();
const isBaseCssExpanded = ref(false);
const isCustomCssEditable = ref(false);
const activeTab = inject('activeTab');
const isEditable = ref(false);
let debounceTimer = null;
const highlightedBaseCss = computed(() => {
if (!stylesheetStore.baseCss) return '';
return hljs.highlight(stylesheetStore.baseCss, { language: 'css' }).value;
const highlightedCss = computed(() => {
if (!stylesheetStore.content) return '';
return hljs.highlight(stylesheetStore.content, { language: 'css' }).value;
});
const highlightedCustomCss = computed(() => {
if (!stylesheetStore.customCss) return '';
return hljs.highlight(stylesheetStore.customCss, { language: 'css' }).value;
});
const handleCustomCssInput = (event) => {
const handleInput = (event) => {
const newContent = event.target.value;
if (debounceTimer) {
@ -105,85 +50,24 @@ const handleCustomCssInput = (event) => {
}
debounceTimer = setTimeout(() => {
stylesheetStore.customCss = newContent;
stylesheetStore.content = newContent;
}, 500);
};
const handleFocus = () => {
stylesheetStore.isEditing = true;
};
const handleCssImport = (cssContent) => {
// Replace custom CSS with imported content
stylesheetStore.customCss = cssContent;
};
const handleExport = () => {
const now = new Date();
const dateStr = now.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const narrativeTitle = narrativeStore.data?.title || 'Sans titre';
const narrativeSlug = narrativeStore.data?.slug || 'narrative';
// Build complete CSS with comments
let completeCSS = `/*
* Feuille de style pour l'impression
* Récit : ${narrativeTitle}
* Téléchargé le : ${dateStr}
*
* Ce fichier contient le CSS de base et le CSS personnalisé
* fusionnés pour une utilisation hors ligne.
*/
`;
// Add base CSS
if (stylesheetStore.baseCss) {
completeCSS += `/* ========================================
* CSS DE BASE
* Styles par défaut de l'application
* ======================================== */
${stylesheetStore.baseCss}
`;
}
// Add custom CSS
if (stylesheetStore.customCss) {
completeCSS += `/* ========================================
* CSS PERSONNALISÉ
* Styles spécifiques à ce récit
* ======================================== */
${stylesheetStore.customCss}`;
}
// Create blob and download
const blob = new Blob([completeCSS], { type: 'text/css' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${narrativeSlug}-style.print.css`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
// Watch editing mode and format when exiting
watch(isCustomCssEditable, async (newValue, oldValue) => {
// Sync editing mode with store
watch(isEditable, async (newValue, oldValue) => {
stylesheetStore.isEditing = newValue;
// Format when exiting editing mode
if (oldValue && !newValue) {
await stylesheetStore.formatCustomCss();
await stylesheetStore.formatContent();
}
});
// Disable editing mode when changing tabs
watch(activeTab, (newTab) => {
if (newTab !== 'code' && isEditable.value) {
isEditable.value = false;
}
});
</script>
@ -195,44 +79,18 @@ watch(isCustomCssEditable, async (newValue, oldValue) => {
height: 100%;
background: #282c34;
color: #fff;
gap: 1rem;
overflow-y: auto;
}
.css-section {
display: flex;
flex-direction: column;
background: #21252b;
border-radius: 0.25rem;
overflow: hidden;
}
.custom-section {
flex: 1;
min-height: 300px;
}
.section-header {
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #2c313c;
cursor: pointer;
user-select: none;
}
.css-section.custom-section .section-header {
cursor: default;
margin-bottom: 1rem;
}
h3 {
margin: 0;
color: #fff;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.toggle {
@ -286,28 +144,11 @@ h3 {
transform: translateX(20px);
}
.expand-icon {
width: 1.25rem;
height: 1.25rem;
color: #abb2bf;
transition: transform 0.2s ease;
}
.expand-icon.expanded {
transform: rotate(180deg);
}
.section-content {
display: flex;
flex-direction: column;
flex: 1;
}
.readonly {
margin: 0;
flex: 1;
overflow-y: auto;
padding: 1rem;
padding: 0.5rem;
background: #1e1e1e;
font-family: 'Courier New', Courier, monospace;
font-size: 0.875rem;
@ -321,51 +162,14 @@ h3 {
textarea {
width: 100%;
flex: 1;
min-height: 300px;
max-height: 500px;
overflow-y: auto;
background: #1e1e1e;
color: #abb2bf;
border: none;
padding: 1rem;
padding: 0.5rem;
font-family: 'Courier New', Courier, monospace;
font-size: 0.875rem;
line-height: 1.5;
resize: none;
outline: none;
}
textarea::placeholder {
color: #5c6370;
font-style: italic;
}
.export-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
margin-top: 1rem;
background: #2c313c;
color: #abb2bf;
border: 1px solid #3e4451;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.export-button:hover {
background: #3e4451;
border-color: #61afef;
color: #61afef;
}
.export-button svg {
width: 1.25rem;
height: 1.25rem;
}
</style>

View file

@ -6,7 +6,6 @@
class="tab"
:class="{ active: activeTab === 'document' }"
@click="activeTab = 'document'"
title="Ouvrir l'onglet Document (\)"
>
Document
</button>
@ -15,7 +14,6 @@
class="tab"
:class="{ active: activeTab === 'code' }"
@click="activeTab = 'code'"
title="Ouvrir l'onglet Code"
>
Code
</button>
@ -24,7 +22,6 @@
class="tab"
:class="{ active: activeTab === 'contenu' }"
@click="activeTab = 'contenu'"
title="Ouvrir l'onglet Contenu"
>
Contenu
</button>
@ -35,7 +32,7 @@
type="button"
class="close-button"
@click="activeTab = ''"
title="Fermer le panneau (\)"
title="Fermer le panneau"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -130,12 +127,8 @@ nav {
overflow: hidden;
position: relative;
left: calc(var(--panel-w) * -1);
background-color: var(--color-panel-bg);
box-shadow: -5px 0px 12px;
transition: left 0.3s var(--curve);
pointer-events: all;
}
@ -145,10 +138,11 @@ nav {
}
.tab-panel {
height: calc(100% - var(--panel-nav-h)*2);
height: calc(100% - var(--panel-nav-h));
overflow-y: auto;
overflow-x: hidden;
padding: 0 2em;
padding-bottom: 20vh;
margin-top: var(--panel-nav-h);
}
</style>

View file

@ -2,7 +2,17 @@
<section class="settings-section" id="settings-section_page" data-color-type="page">
<h2>Réglage des pages</h2>
<div class="container">
<div class="infos">
<span class="info-icon">
<svg width="24" height="24" clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m12.002 2.005c5.518 0 9.998 4.48 9.998 9.997 0 5.518-4.48 9.998-9.998 9.998-5.517 0-9.997-4.48-9.997-9.998 0-5.517 4.48-9.997 9.997-9.997zm0 1.5c-4.69 0-8.497 3.807-8.497 8.497s3.807 8.498 8.497 8.498 8.498-3.808 8.498-8.498-3.808-8.497-8.498-8.497zm0 6.5c-.414 0-.75.336-.75.75v5.5c0 .414.336.75.75.75s.75-.336.75-.75v-5.5c0-.414-.336-.75-.75-.75zm-.002-3c.552 0 1 .448 1 1s-.448 1-1 1-1-.448-1-1 .448-1 1-1z" fill-rule="nonzero"/>
</svg>
</span>
<p>
Ces réglages s'appliquent à l'ensemble des pages du document. Vous
pouvez modifier ensuite les pages des gabarits indépendamment.
</p>
</div>
<div class="settings-subsection">
<div class="field field-simple">
@ -54,13 +64,12 @@
<label for="margin-top" class="label-with-tooltip" data-css="margin-top"
>Haut</label
>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
id="margin-top"
:modelValue="margins.top.value"
v-model="margins.top.value"
:min="0"
:step="1"
@update:modelValue="(value) => margins.top.value = value"
/>
<div class="unit-toggle">
<button
@ -95,13 +104,12 @@
data-css="margin-bottom"
>Bas</label
>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
id="margin-bottom"
:modelValue="margins.bottom.value"
v-model="margins.bottom.value"
:min="0"
:step="1"
@update:modelValue="(value) => margins.bottom.value = value"
/>
<div class="unit-toggle">
<button
@ -136,13 +144,12 @@
data-css="margin-left"
>Gauche</label
>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
id="margin-left"
:modelValue="margins.left.value"
v-model="margins.left.value"
:min="0"
:step="1"
@update:modelValue="(value) => margins.left.value = value"
/>
<div class="unit-toggle">
<button
@ -177,13 +184,12 @@
data-css="margin-right"
>Droite</label
>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
id="margin-right"
:modelValue="margins.right.value"
v-model="margins.right.value"
:min="0"
:step="1"
@update:modelValue="(value) => margins.right.value = value"
/>
<div class="unit-toggle">
<button
@ -265,25 +271,65 @@
</div>
<div class="settings-subsection">
<div class="field checkbox-field">
<div class="field field-generated-content">
<div class="checkbox-field">
<input id="page-numbers" type="checkbox" v-model="pageNumbers" />
<label
for="page-numbers"
class="label-with-tooltip"
data-css="@bottom-left/right"
>Numéro de page</label
>
data-css="content: counter(page)"
>
Numéro de page
</label>
</div>
<div class="positions">
<div class="group-position">
<button data-css="@top/bottom-left">Gauche</button>
<button data-css="@top/bottom-center">Centre</button>
<button data-css="@top/bottom-right">Droite</button>
</div>
<div class="group-position">
<button data-css="@top-left/center/right">Haut</button>
<button data-css="@bottom-left/center/right">Bas</button>
</div>
</div>
</div>
</div>
<div class="settings-subsection">
<div class="field field-generated-content">
<div class="checkbox-field">
<input id="running-title" type="checkbox" v-model="runningTitle" />
<label
for="running-title"
class="label-with-tooltip"
data-css="string-set"
>Titre courant</label
>
</div>
<div class="positions">
<div class="group-position">
<button data-css="@top/bottom-left">Gauche</button>
<button data-css="@top/bottom-center">Centre</button>
<button data-css="@top/bottom-right">Droite</button>
</div>
<div class="group-position">
<button data-css="@top-left/center/right">Haut</button>
<button data-css="@bottom-left/center/right">Bas</button>
</div>
</div>
</div>
<div class="field checkbox-field">
<input id="running-title" type="checkbox" v-model="runningTitle" />
<label
for="running-title"
class="label-with-tooltip"
data-css="string-set"
>Titre courant</label
>
</div>
</div>
</div>
</section>
@ -354,7 +400,10 @@ const updateMargins = () => {
`$1${marginValue}`
);
stylesheetStore.replaceBlock(currentBlock, updatedBlock);
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
};
// Watch margin values (number inputs) with debounce
@ -395,13 +444,19 @@ const updateBackground = () => {
/(background:\s*)[^;]+/,
`$1${background.value.value}`
);
stylesheetStore.replaceBlock(currentBlock, updatedBlock);
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
} else {
const updatedBlock = currentBlock.replace(
/(\s*})$/,
` background: ${background.value.value};\n$1`
);
stylesheetStore.replaceBlock(currentBlock, updatedBlock);
stylesheetStore.content = stylesheetStore.content.replace(
currentBlock,
updatedBlock
);
}
};
@ -448,7 +503,7 @@ watch(runningTitle, (enabled) => {
});
const updatePageFooters = () => {
let currentCss = stylesheetStore.customCss;
let currentCss = stylesheetStore.content;
// Remove existing @page:left and @page:right rules
currentCss = currentCss.replace(/@page:left\s*\{[^}]*\}/g, '');
@ -530,7 +585,7 @@ const updatePageFooters = () => {
currentCss.slice(insertPosition);
}
stylesheetStore.setCustomCss(currentCss);
stylesheetStore.content = currentCss;
};
const syncFromStore = () => {

View file

@ -2,11 +2,18 @@
<section class="settings-section" id="settings-section_elem" data-color-type="elem">
<h2>Réglage du texte</h2>
<div class="container">
<p class="infos">
<div class="infos">
<span class="info-icon">
<svg width="24" height="24" clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m12.002 2.005c5.518 0 9.998 4.48 9.998 9.997 0 5.518-4.48 9.998-9.998 9.998-5.517 0-9.997-4.48-9.997-9.998 0-5.517 4.48-9.997 9.997-9.997zm0 1.5c-4.69 0-8.497 3.807-8.497 8.497s3.807 8.498 8.497 8.498 8.498-3.808 8.498-8.498-3.808-8.497-8.498-8.497zm0 6.5c-.414 0-.75.336-.75.75v5.5c0 .414.336.75.75.75s.75-.336.75-.75v-5.5c0-.414-.336-.75-.75-.75zm-.002-3c.552 0 1 .448 1 1s-.448 1-1 1-1-.448-1-1 .448-1 1-1z" fill-rule="nonzero"/>
</svg>
</span>
<p>
Ces réglages s'appliquent à l'ensemble des éléments du document. Vous
pouvez modifier ensuite les éléments indépendamment.
</p>
</p>
</div>
<!-- Police -->
<div class="settings-subsection">
@ -16,7 +23,7 @@
<select id="text-font" v-model="font">
<option v-for="f in fonts" :key="f" :value="f">{{ f }}</option>
</select>
<div class="field-checkbox">
<div class="checkbox-field">
<input id="text-italic" type="checkbox" v-model="italic" />
<label for="text-italic" class="label-with-tooltip" data-css="font-style">Italique</label>
</div>
@ -38,7 +45,7 @@
<label for="text-size-range" class="label-with-tooltip" data-css="font-size">Taille du texte</label>
<InputWithUnit
v-model="fontSize"
:units="['px', 'em', 'rem']"
:units="['px']"
:min="8"
:max="72"
showRange
@ -46,6 +53,20 @@
</div>
</div>
<!-- line height -->
<div class="settings-subsection">
<div class="field field-text-size">
<label for="line-height-range" class="label-with-tooltip" data-css="line-height">Interlignage</label>
<InputWithUnit
v-model="lineHeight"
:units="['px', 'em', 'rem']"
:min="0"
:max="72"
showRange
/>
</div>
</div>
<!-- Alignement -->
<div class="settings-subsection">
<div class="field field-simple">
@ -91,277 +112,31 @@
</div>
<!-- Marges extérieures -->
<div class="settings-subsection margins">
<div class="subsection-header">
<h3>Marges extérieures</h3>
<button
type="button"
class="link-button"
:class="{ active: marginOuterLinked }"
@click="marginOuterLinked = !marginOuterLinked"
:title="marginOuterLinked ? 'Dissocier les marges' : 'Lier les marges'"
>
<svg v-if="marginOuterLinked" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.3638 15.5355L16.9496 14.1213L18.3638 12.7071C20.3164 10.7545 20.3164 7.58866 18.3638 5.63604C16.4112 3.68341 13.2453 3.68341 11.2927 5.63604L9.87849 7.05025L8.46428 5.63604L9.87849 4.22182C12.6122 1.48815 17.0443 1.48815 19.778 4.22182C22.5117 6.95549 22.5117 11.3876 19.778 14.1213L18.3638 15.5355ZM15.5353 18.364L14.1211 19.7782C11.3875 22.5118 6.95531 22.5118 4.22164 19.7782C1.48797 17.0445 1.48797 12.6123 4.22164 9.87868L5.63585 8.46446L7.05007 9.87868L5.63585 11.2929C3.68323 13.2455 3.68323 16.4113 5.63585 18.364C7.58847 20.3166 10.7543 20.3166 12.7069 18.364L14.1211 16.9497L15.5353 18.364ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z"></path></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M17 17H22V19H19V22H17V17ZM7 7H2V5H5V2H7V7ZM18.364 15.5355L16.9497 14.1213L18.364 12.7071C20.3166 10.7545 20.3166 7.58866 18.364 5.63604C16.4113 3.68342 13.2455 3.68342 11.2929 5.63604L9.87868 7.05025L8.46447 5.63604L9.87868 4.22183C12.6123 1.48816 17.0445 1.48816 19.7782 4.22183C22.5118 6.9555 22.5118 11.3877 19.7782 14.1213L18.364 15.5355ZM15.5355 18.364L14.1213 19.7782C11.3877 22.5118 6.9555 22.5118 4.22183 19.7782C1.48816 17.0445 1.48816 12.6123 4.22183 9.87868L5.63604 8.46447L7.05025 9.87868L5.63604 11.2929C3.68342 13.2455 3.68342 16.4113 5.63604 18.364C7.58866 20.3166 10.7545 20.3166 12.7071 18.364L14.1213 16.9497L15.5355 18.364ZM14.8284 7.75736L16.2426 9.17157L9.17157 16.2426L7.75736 14.8284L14.8284 7.75736Z"></path></svg>
</button>
</div>
<div class="field field-margin">
<label for="margin-outer-top" class="label-with-tooltip" data-css="margin-top">Haut</label>
<div class="input-with-unit">
<NumberInput
id="margin-outer-top"
:modelValue="marginOuterDetailed.top.value"
@update:modelValue="(value) => marginOuterDetailed.top.value = value"
:min="0"
:step="1"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginOuterDetailed.top.unit === 'mm' }"
@click="updateMarginOuterUnit('mm')"
>mm</button>
<button
type="button"
:class="{ active: marginOuterDetailed.top.unit === 'px' }"
@click="updateMarginOuterUnit('px')"
>px</button>
<button
type="button"
:class="{ active: marginOuterDetailed.top.unit === 'rem' }"
@click="updateMarginOuterUnit('rem')"
>rem</button>
</div>
</div>
</div>
<div class="field field-margin">
<label for="margin-outer-bottom" class="label-with-tooltip" data-css="margin-bottom">Bas</label>
<div class="input-with-unit">
<NumberInput
id="margin-outer-bottom"
:modelValue="marginOuterDetailed.bottom.value"
@update:modelValue="(value) => marginOuterDetailed.bottom.value = value"
:min="0"
:step="1"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginOuterDetailed.bottom.unit === 'mm' }"
@click="updateMarginOuterUnit('mm')"
>mm</button>
<button
type="button"
:class="{ active: marginOuterDetailed.bottom.unit === 'px' }"
@click="updateMarginOuterUnit('px')"
>px</button>
<button
type="button"
:class="{ active: marginOuterDetailed.bottom.unit === 'rem' }"
@click="updateMarginOuterUnit('rem')"
>rem</button>
</div>
</div>
</div>
<div class="field field-margin">
<label for="margin-outer-left" class="label-with-tooltip" data-css="margin-left">Gauche</label>
<div class="input-with-unit">
<NumberInput
id="margin-outer-left"
:modelValue="marginOuterDetailed.left.value"
@update:modelValue="(value) => marginOuterDetailed.left.value = value"
:min="0"
:step="1"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginOuterDetailed.left.unit === 'mm' }"
@click="updateMarginOuterUnit('mm')"
>mm</button>
<button
type="button"
:class="{ active: marginOuterDetailed.left.unit === 'px' }"
@click="updateMarginOuterUnit('px')"
>px</button>
<button
type="button"
:class="{ active: marginOuterDetailed.left.unit === 'rem' }"
@click="updateMarginOuterUnit('rem')"
>rem</button>
</div>
</div>
</div>
<div class="field field-margin">
<label for="margin-outer-right" class="label-with-tooltip" data-css="margin-right">Droite</label>
<div class="input-with-unit">
<NumberInput
id="margin-outer-right"
:modelValue="marginOuterDetailed.right.value"
@update:modelValue="(value) => marginOuterDetailed.right.value = value"
:min="0"
:step="1"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginOuterDetailed.right.unit === 'mm' }"
@click="updateMarginOuterUnit('mm')"
>mm</button>
<button
type="button"
:class="{ active: marginOuterDetailed.right.unit === 'px' }"
@click="updateMarginOuterUnit('px')"
>px</button>
<button
type="button"
:class="{ active: marginOuterDetailed.right.unit === 'rem' }"
@click="updateMarginOuterUnit('rem')"
>rem</button>
</div>
</div>
</div>
<div class="settings-subsection">
<MarginEditor
ref="marginOuterEditor"
id="margin-outer"
label="Marges extérieures"
cssProperty="margin"
v-model:simple="marginOuter"
v-model:detailed="marginOuterDetailed"
:units="['mm', 'px', 'rem']"
@change="handleMarginOuterChange"
/>
</div>
<!-- Marges intérieures -->
<div class="settings-subsection margins">
<div class="subsection-header">
<h3>Marges intérieures</h3>
<button
type="button"
class="link-button"
:class="{ active: marginInnerLinked }"
@click="marginInnerLinked = !marginInnerLinked"
:title="marginInnerLinked ? 'Dissocier les marges' : 'Lier les marges'"
>
<svg v-if="marginInnerLinked" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.3638 15.5355L16.9496 14.1213L18.3638 12.7071C20.3164 10.7545 20.3164 7.58866 18.3638 5.63604C16.4112 3.68341 13.2453 3.68341 11.2927 5.63604L9.87849 7.05025L8.46428 5.63604L9.87849 4.22182C12.6122 1.48815 17.0443 1.48815 19.778 4.22182C22.5117 6.95549 22.5117 11.3876 19.778 14.1213L18.3638 15.5355ZM15.5353 18.364L14.1211 19.7782C11.3875 22.5118 6.95531 22.5118 4.22164 19.7782C1.48797 17.0445 1.48797 12.6123 4.22164 9.87868L5.63585 8.46446L7.05007 9.87868L5.63585 11.2929C3.68323 13.2455 3.68323 16.4113 5.63585 18.364C7.58847 20.3166 10.7543 20.3166 12.7069 18.364L14.1211 16.9497L15.5353 18.364ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z"></path></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M17 17H22V19H19V22H17V17ZM7 7H2V5H5V2H7V7ZM18.364 15.5355L16.9497 14.1213L18.364 12.7071C20.3166 10.7545 20.3166 7.58866 18.364 5.63604C16.4113 3.68342 13.2455 3.68342 11.2929 5.63604L9.87868 7.05025L8.46447 5.63604L9.87868 4.22183C12.6123 1.48816 17.0445 1.48816 19.7782 4.22183C22.5118 6.9555 22.5118 11.3877 19.7782 14.1213L18.364 15.5355ZM15.5355 18.364L14.1213 19.7782C11.3877 22.5118 6.9555 22.5118 4.22183 19.7782C1.48816 17.0445 1.48816 12.6123 4.22183 9.87868L5.63604 8.46447L7.05025 9.87868L5.63604 11.2929C3.68342 13.2455 3.68342 16.4113 5.63604 18.364C7.58866 20.3166 10.7545 20.3166 12.7071 18.364L14.1213 16.9497L15.5355 18.364ZM14.8284 7.75736L16.2426 9.17157L9.17157 16.2426L7.75736 14.8284L14.8284 7.75736Z"></path></svg>
</button>
</div>
<div class="field field-margin">
<label for="margin-inner-top" class="label-with-tooltip" data-css="padding-top">Haut</label>
<div class="input-with-unit">
<NumberInput
id="margin-inner-top"
:modelValue="marginInnerDetailed.top.value"
@update:modelValue="(value) => marginInnerDetailed.top.value = value"
:min="0"
:step="1"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginInnerDetailed.top.unit === 'mm' }"
@click="updateMarginInnerUnit('mm')"
>mm</button>
<button
type="button"
:class="{ active: marginInnerDetailed.top.unit === 'px' }"
@click="updateMarginInnerUnit('px')"
>px</button>
<button
type="button"
:class="{ active: marginInnerDetailed.top.unit === 'rem' }"
@click="updateMarginInnerUnit('rem')"
>rem</button>
</div>
</div>
</div>
<div class="field field-margin">
<label for="margin-inner-bottom" class="label-with-tooltip" data-css="padding-bottom">Bas</label>
<div class="input-with-unit">
<NumberInput
id="margin-inner-bottom"
:modelValue="marginInnerDetailed.bottom.value"
@update:modelValue="(value) => marginInnerDetailed.bottom.value = value"
:min="0"
:step="1"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginInnerDetailed.bottom.unit === 'mm' }"
@click="updateMarginInnerUnit('mm')"
>mm</button>
<button
type="button"
:class="{ active: marginInnerDetailed.bottom.unit === 'px' }"
@click="updateMarginInnerUnit('px')"
>px</button>
<button
type="button"
:class="{ active: marginInnerDetailed.bottom.unit === 'rem' }"
@click="updateMarginInnerUnit('rem')"
>rem</button>
</div>
</div>
</div>
<div class="field field-margin">
<label for="margin-inner-left" class="label-with-tooltip" data-css="padding-left">Gauche</label>
<div class="input-with-unit">
<NumberInput
id="margin-inner-left"
:modelValue="marginInnerDetailed.left.value"
@update:modelValue="(value) => marginInnerDetailed.left.value = value"
:min="0"
:step="1"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginInnerDetailed.left.unit === 'mm' }"
@click="updateMarginInnerUnit('mm')"
>mm</button>
<button
type="button"
:class="{ active: marginInnerDetailed.left.unit === 'px' }"
@click="updateMarginInnerUnit('px')"
>px</button>
<button
type="button"
:class="{ active: marginInnerDetailed.left.unit === 'rem' }"
@click="updateMarginInnerUnit('rem')"
>rem</button>
</div>
</div>
</div>
<div class="field field-margin">
<label for="margin-inner-right" class="label-with-tooltip" data-css="padding-right">Droite</label>
<div class="input-with-unit">
<NumberInput
id="margin-inner-right"
:modelValue="marginInnerDetailed.right.value"
@update:modelValue="(value) => marginInnerDetailed.right.value = value"
:min="0"
:step="1"
/>
<div class="unit-toggle">
<button
type="button"
:class="{ active: marginInnerDetailed.right.unit === 'mm' }"
@click="updateMarginInnerUnit('mm')"
>mm</button>
<button
type="button"
:class="{ active: marginInnerDetailed.right.unit === 'px' }"
@click="updateMarginInnerUnit('px')"
>px</button>
<button
type="button"
:class="{ active: marginInnerDetailed.right.unit === 'rem' }"
@click="updateMarginInnerUnit('rem')"
>rem</button>
</div>
</div>
</div>
<div class="settings-subsection">
<MarginEditor
ref="marginInnerEditor"
id="margin-inner"
label="Marges intérieures"
cssProperty="padding"
v-model:simple="marginInner"
v-model:detailed="marginInnerDetailed"
:units="['mm', 'px', 'rem']"
@change="handleMarginInnerChange"
/>
</div>
</div>
@ -373,7 +148,7 @@ import { ref, watch, onMounted } from 'vue';
import Coloris from '@melloware/coloris';
import UnitToggle from '../ui/UnitToggle.vue';
import InputWithUnit from '../ui/InputWithUnit.vue';
import NumberInput from '../ui/NumberInput.vue';
import MarginEditor from '../ui/MarginEditor.vue';
import { useCssUpdater } from '../../composables/useCssUpdater';
import { useCssSync } from '../../composables/useCssSync';
import { useDebounce } from '../../composables/useDebounce';
@ -397,10 +172,12 @@ const font = ref('Alegreya Sans');
const italic = ref(false);
const weight = ref('400');
const fontSize = ref({ value: 16, unit: 'px' });
const lineHeight = ref({ value: 1.2, unit: 'em' });
const alignment = ref('left');
const color = ref('rgb(0, 0, 0)');
const background = ref('transparent');
const marginOuter = ref({ value: 0, unit: 'mm' });
const marginOuterDetailed = ref({
top: { value: 0, unit: 'mm' },
right: { value: 0, unit: 'mm' },
@ -408,6 +185,7 @@ const marginOuterDetailed = ref({
left: { value: 0, unit: 'mm' }
});
const marginInner = ref({ value: 0, unit: 'mm' });
const marginInnerDetailed = ref({
top: { value: 0, unit: 'mm' },
right: { value: 0, unit: 'mm' },
@ -415,48 +193,12 @@ const marginInnerDetailed = ref({
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
});
const marginOuterEditor = ref(null);
const marginInnerEditor = ref(null);
let isUpdatingFromStore = false;
// Update margin outer unit for all sides
const updateMarginOuterUnit = (unit) => {
marginOuterDetailed.value.top.unit = unit;
marginOuterDetailed.value.right.unit = unit;
marginOuterDetailed.value.bottom.unit = unit;
marginOuterDetailed.value.left.unit = unit;
};
// Update margin inner unit for all sides
const updateMarginInnerUnit = (unit) => {
marginInnerDetailed.value.top.unit = unit;
marginInnerDetailed.value.right.unit = unit;
marginInnerDetailed.value.bottom.unit = unit;
marginInnerDetailed.value.left.unit = unit;
};
// Watchers for body styles
watch(font, (val) => {
if (isUpdatingFromStore) return;
updateStyle('body', 'font-family', `"${val}"`);
});
watch(italic, (val) => {
if (isUpdatingFromStore) return;
updateStyle('body', 'font-style', val ? 'italic' : 'normal');
@ -483,167 +225,117 @@ watch(weight, (val) => {
updateStyle('p', 'font-weight', val);
});
watch(fontSize, (val) => {
watch(fontSize, (newVal, oldVal) => {
if (isUpdatingFromStore) return;
// If unit changed, convert the value
if (oldVal && newVal.unit !== oldVal.unit) {
const baseFontSize = 16; // Default base font size for rem
let convertedValue = newVal.value;
// Convert from px to em/rem
if (oldVal.unit === 'px' && (newVal.unit === 'em' || newVal.unit === 'rem')) {
convertedValue = newVal.value / baseFontSize;
// Round to 1 decimal place
convertedValue = Math.round(convertedValue * 10) / 10;
}
// Convert from em/rem to px
else if ((oldVal.unit === 'em' || oldVal.unit === 'rem') && newVal.unit === 'px') {
convertedValue = newVal.value * baseFontSize;
// Round to whole number for px
convertedValue = Math.round(convertedValue);
}
// Clamp to valid range based on new unit
if (newVal.unit === 'em' || newVal.unit === 'rem') {
convertedValue = Math.max(0, Math.min(4, convertedValue));
} else {
convertedValue = Math.max(8, Math.min(72, convertedValue));
}
// Update with converted value - create new object to trigger reactivity
fontSize.value = { value: convertedValue, unit: newVal.unit };
return; // Exit early to avoid double update
}
debouncedUpdate(() => {
updateStyle('p', 'font-size', `${val.value}${val.unit}`);
updateStyle('p', 'font-size', `${newVal.value}${newVal.unit}`);
});
}, { 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,
], () => {
watch(lineHeight, (newVal, oldVal) => {
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,
};
// If unit changed, convert the value
if (oldVal && newVal.unit !== oldVal.unit) {
const baseFontSize = 16; // Default base font size for rem
let convertedValue = newVal.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;
// Convert from px to em/rem
if (oldVal.unit === 'px' && (newVal.unit === 'em' || newVal.unit === 'rem')) {
const divisor = newVal.unit === 'em' ? fontSize.value.value : baseFontSize;
convertedValue = newVal.value / divisor;
// Round to 1 decimal place
convertedValue = Math.round(convertedValue * 10) / 10;
}
} 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;
// Convert from em/rem to px
else if ((oldVal.unit === 'em' || oldVal.unit === 'rem') && newVal.unit === 'px') {
const multiplier = oldVal.unit === 'em' ? fontSize.value.value : baseFontSize;
convertedValue = newVal.value * multiplier;
// Round to whole number for px
convertedValue = Math.round(convertedValue);
}
// Convert between em and rem
else if (oldVal.unit === 'em' && newVal.unit === 'rem') {
convertedValue = (newVal.value * fontSize.value.value) / baseFontSize;
// Round to 1 decimal place
convertedValue = Math.round(convertedValue * 10) / 10;
}
else if (oldVal.unit === 'rem' && newVal.unit === 'em') {
convertedValue = (newVal.value * baseFontSize) / fontSize.value.value;
// Round to 1 decimal place
convertedValue = Math.round(convertedValue * 10) / 10;
}
// Clamp to valid range based on new unit
if (newVal.unit === 'em' || newVal.unit === 'rem') {
convertedValue = Math.max(0, Math.min(4, convertedValue));
} else {
convertedValue = Math.max(0, Math.min(72, convertedValue));
}
// Update with converted value - create new object to trigger reactivity
lineHeight.value = { value: convertedValue, unit: newVal.unit };
return; // Exit early to avoid double update
}
debouncedUpdate(() => {
setDetailedMargins('p',
marginOuterDetailed.value.top,
marginOuterDetailed.value.right,
marginOuterDetailed.value.bottom,
marginOuterDetailed.value.left
);
updateStyle('p', 'line-height', `${newVal.value}${newVal.unit}`);
});
});
}, { deep: true });
// Watch margin inner values
watch(() => [
marginInnerDetailed.value.top.value,
marginInnerDetailed.value.bottom.value,
marginInnerDetailed.value.left.value,
marginInnerDetailed.value.right.value,
], () => {
// Margin/Padding handlers
const handleMarginOuterChange = ({ type, simple, detailed }) => {
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
);
if (type === 'simple') {
setMargin('p', simple.value, simple.unit);
} else {
setDetailedMargins('p', detailed.top, detailed.right, detailed.bottom, detailed.left);
}
});
});
};
const handleMarginInnerChange = ({ type, simple, detailed }) => {
if (isUpdatingFromStore) return;
debouncedUpdate(() => {
if (type === 'simple') {
setPadding('p', simple.value, simple.unit);
} else {
setDetailedPadding('p', detailed.top, detailed.right, detailed.bottom, detailed.left);
}
});
};
// Sync from store
const syncFromStore = () => {
@ -669,21 +361,24 @@ const syncFromStore = () => {
const fontSizeVal = extractNumericValue('p', 'font-size', ['px', 'em', 'rem']);
if (fontSizeVal) fontSize.value = fontSizeVal;
const lineHeightVal = extractNumericValue('p', 'line-height', ['px', 'em', 'rem']);
if (lineHeightVal) lineHeight.value = lineHeightVal;
// Margins
const margins = extractSpacing('p', 'margin');
if (margins) {
if (margins.simple) {
// All margins are the same
marginOuter.value = margins.simple;
// Sync detailed from simple
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
// Check if all values are the same to set simple value
const allSame =
margins.detailed.top.value === margins.detailed.right.value &&
margins.detailed.top.value === margins.detailed.bottom.value &&
@ -691,7 +386,19 @@ const syncFromStore = () => {
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 (allSame) {
marginOuter.value = margins.detailed.top;
} else {
// Values are different, open the detailed editor and use first value for simple
marginOuter.value = margins.detailed.top;
// Open detailed view after mount
setTimeout(() => {
if (marginOuterEditor.value) {
marginOuterEditor.value.expanded = true;
}
}, 0);
}
}
}
@ -699,17 +406,17 @@ const syncFromStore = () => {
const padding = extractSpacing('p', 'padding');
if (padding) {
if (padding.simple) {
// All paddings are the same
marginInner.value = padding.simple;
// Sync detailed from simple
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
// Check if all values are the same to set simple value
const allSame =
padding.detailed.top.value === padding.detailed.right.value &&
padding.detailed.top.value === padding.detailed.bottom.value &&
@ -717,25 +424,22 @@ const syncFromStore = () => {
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;
if (allSame) {
marginInner.value = padding.detailed.top;
} else {
// Values are different, open the detailed editor and use first value for simple
marginInner.value = padding.detailed.top;
// Open detailed view after mount
setTimeout(() => {
if (marginInnerEditor.value) {
marginInnerEditor.value.expanded = true;
}
}, 0);
}
}
}
// 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
};
isUpdatingFromStore = false;
};
@ -751,49 +455,3 @@ onMounted(() => {
syncFromStore();
});
</script>
<style scoped>
.subsection-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.subsection-header h3 {
margin: 0;
}
.link-button {
background: none;
border: 1px solid var(--color-border, #ddd);
border-radius: 4px;
padding: 0.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
width: 24px;
height: 24px;
}
.link-button svg {
width: 16px;
height: 16px;
color: var(--color-text-secondary, #666);
}
.link-button:hover {
background: var(--color-hover, #f0f0f0);
}
.link-button.active {
background: var(--color-primary, #007bff);
border-color: var(--color-primary, #007bff);
}
.link-button.active svg {
color: white;
}
</style>

View file

@ -1,159 +0,0 @@
<template>
<div
class="css-import"
:class="{ 'is-dragging': isDragging }"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<input
ref="fileInput"
type="file"
accept=".css"
@change="handleFileSelect"
style="display: none"
/>
<button class="import-button" @click="openFileDialog" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/>
</svg>
<span>Importer un fichier CSS</span>
</button>
<span class="import-hint">ou glisser-déposer ici</span>
<div v-if="error" class="import-error">{{ error }}</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const emit = defineEmits(['import']);
const fileInput = ref(null);
const isDragging = ref(false);
const error = ref('');
const openFileDialog = () => {
fileInput.value?.click();
};
const handleDragOver = (e) => {
isDragging.value = true;
};
const handleDragLeave = (e) => {
isDragging.value = false;
};
const handleDrop = (e) => {
isDragging.value = false;
const files = e.dataTransfer.files;
if (files.length > 0) {
processFile(files[0]);
}
};
const handleFileSelect = (e) => {
const files = e.target.files;
if (files.length > 0) {
processFile(files[0]);
}
};
const processFile = (file) => {
error.value = '';
// Validate file type
if (!file.name.endsWith('.css')) {
error.value = 'Seuls les fichiers .css sont acceptés';
return;
}
// Validate file size (max 1MB)
if (file.size > 1024 * 1024) {
error.value = 'Le fichier est trop volumineux (max 1MB)';
return;
}
// Read file content
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
emit('import', content);
// Reset input
if (fileInput.value) {
fileInput.value.value = '';
}
};
reader.onerror = () => {
error.value = 'Erreur lors de la lecture du fichier';
};
reader.readAsText(file);
};
</script>
<style scoped>
.css-import {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #21252b;
border: 2px dashed transparent;
border-radius: 0.25rem;
transition: all 0.2s ease;
}
.css-import.is-dragging {
border-color: #61afef;
background: #2c313c;
}
.import-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #2c313c;
color: #abb2bf;
border: 1px solid #3e4451;
border-radius: 0.25rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.import-button:hover {
background: #3e4451;
border-color: #61afef;
color: #61afef;
}
.import-button svg {
width: 1rem;
height: 1rem;
}
.import-hint {
font-size: 0.75rem;
color: #5c6370;
font-style: italic;
}
.import-error {
font-size: 0.75rem;
color: #e06c75;
background: rgba(224, 108, 117, 0.1);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
</style>

View file

@ -1,19 +1,19 @@
<template>
<div class="input-with-unit">
<div class="input-with-range">
<input
v-if="showRange"
type="range"
:value="modelValue.value"
:min="min"
:max="max"
:step="step"
:min="computedMin"
:max="computedMax"
:step="computedStep"
@input="updateValue(Number($event.target.value))"
/>
<NumberInput
:modelValue="modelValue.value"
:min="min"
:max="max"
:step="step"
:min="computedMin"
:max="computedMax"
:step="computedStep"
inputClass="size-input"
@update:modelValue="updateValue"
/>
@ -27,6 +27,7 @@
</template>
<script setup>
import { computed } from 'vue';
import NumberInput from './NumberInput.vue';
import UnitToggle from './UnitToggle.vue';
@ -60,11 +61,36 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']);
// Compute step based on unit
const computedStep = computed(() => {
if (props.modelValue.unit === 'em' || props.modelValue.unit === 'rem') {
return 0.1;
}
return props.step;
});
// Compute min based on unit
const computedMin = computed(() => {
if (props.modelValue.unit === 'em' || props.modelValue.unit === 'rem') {
return 0;
}
return props.min;
});
// Compute max based on unit
const computedMax = computed(() => {
if (props.modelValue.unit === 'em' || props.modelValue.unit === 'rem') {
return 4;
}
return props.max;
});
const updateValue = (value) => {
emit('update:modelValue', { ...props.modelValue, value });
};
const updateUnit = (unit) => {
// Just emit the unit change, let parent handle any conversion
emit('update:modelValue', { ...props.modelValue, unit });
};
</script>

View file

@ -2,7 +2,7 @@
<div class="margin-editor">
<div class="field field-margin-all">
<label :for="id" class="label-with-tooltip" :data-css="cssProperty">{{ label }}</label>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
:id="id"
:modelValue="simple.value"
@ -30,7 +30,7 @@
<div v-if="expanded" class="subsection collapsed-section">
<div v-for="side in sides" :key="side.key" class="field field-margin">
<label :for="`${id}-${side.key}`" class="label-with-tooltip" :data-css="`${cssProperty}-${side.key}`">{{ side.label }}</label>
<div class="input-with-unit">
<div class="input-with-range">
<NumberInput
:id="`${id}-${side.key}`"
:modelValue="detailed[side.key].value"

View file

@ -1,8 +1,9 @@
<template>
<div class="number-input">
<input
ref="inputRef"
type="number"
:value="modelValue"
:value="displayValue"
:min="min"
:max="max"
:step="step"
@ -15,35 +16,25 @@
<button
type="button"
class="spinner-btn spinner-up"
@mousedown.prevent
@click="increment"
:disabled="disabled || (max !== undefined && modelValue >= max)"
tabindex="-1"
>
<svg
width="8"
height="6"
viewBox="0 0 8 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4 0L7.4641 6H0.535898L4 0Z" fill="currentColor" />
<svg width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 0L7.4641 6H0.535898L4 0Z" fill="currentColor"/>
</svg>
</button>
<button
type="button"
class="spinner-btn spinner-down"
@mousedown.prevent
@click="decrement"
:disabled="disabled || (min !== undefined && modelValue <= min)"
tabindex="-1"
>
<svg
width="8"
height="6"
viewBox="0 0 8 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4 6L0.535898 0H7.4641L4 6Z" fill="currentColor" />
<svg width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6L0.535898 0H7.4641L4 6Z" fill="currentColor"/>
</svg>
</button>
</div>
@ -51,55 +42,83 @@
</template>
<script setup>
import { computed, ref } from 'vue';
const inputRef = ref(null);
const props = defineProps({
modelValue: {
type: Number,
required: true,
required: true
},
min: {
type: Number,
default: undefined,
default: undefined
},
max: {
type: Number,
default: undefined,
default: undefined
},
step: {
type: Number,
default: 1,
default: 1
},
id: {
type: String,
default: undefined,
default: undefined
},
inputClass: {
type: String,
default: '',
default: ''
},
disabled: {
type: Boolean,
default: false,
},
default: false
}
});
const emit = defineEmits(['update:modelValue']);
// Round to 1 decimal place for display, but show whole numbers without decimals
const displayValue = computed(() => {
const rounded = Math.round(props.modelValue * 10) / 10;
// If it's a whole number, return it as is (no decimal)
if (Number.isInteger(rounded)) {
return rounded;
}
return rounded;
});
const handleInput = (event) => {
const value = Number(event.target.value);
let value = Number(event.target.value);
// Round to 1 decimal place
value = Math.round(value * 10) / 10;
emit('update:modelValue', value);
};
const increment = () => {
const newValue = props.modelValue + props.step;
let newValue = props.modelValue + props.step;
// Round to 1 decimal place
newValue = Math.round(newValue * 10) / 10;
if (props.max === undefined || newValue <= props.max) {
emit('update:modelValue', newValue);
}
// Give focus to input
if (inputRef.value) {
inputRef.value.focus();
}
};
const decrement = () => {
const newValue = props.modelValue - props.step;
let newValue = props.modelValue - props.step;
// Round to 1 decimal place
newValue = Math.round(newValue * 10) / 10;
if (props.min === undefined || newValue >= props.min) {
emit('update:modelValue', newValue);
}
// Give focus to input
if (inputRef.value) {
inputRef.value.focus();
}
};
</script>

View file

@ -14,13 +14,13 @@ export function useCssUpdater() {
new RegExp(`(${property}:\\s*)[^;]+`, 'i'),
`$1${value}`
);
store.replaceBlock(currentBlock, updatedBlock);
store.content = store.content.replace(currentBlock, updatedBlock);
} else {
const updatedBlock = currentBlock.replace(
/(\s*})$/,
` ${property}: ${value};\n$1`
);
store.replaceBlock(currentBlock, updatedBlock);
store.content = store.content.replace(currentBlock, updatedBlock);
}
};
@ -37,7 +37,7 @@ export function useCssUpdater() {
);
if (updatedBlock !== currentBlock) {
store.replaceBlock(currentBlock, updatedBlock);
store.content = store.content.replace(currentBlock, updatedBlock);
}
};
@ -57,7 +57,7 @@ export function useCssUpdater() {
}
if (updatedBlock !== currentBlock) {
store.replaceBlock(currentBlock, updatedBlock);
store.content = store.content.replace(currentBlock, updatedBlock);
}
};
@ -65,7 +65,7 @@ export function useCssUpdater() {
* Create a new CSS rule for a selector
*/
const createRule = (selector) => {
store.customCss += `\n\n${selector} {\n}\n`;
store.content += `\n\n${selector} {\n}\n`;
return `${selector} {\n}`;
};

View file

@ -1,372 +0,0 @@
import { ref } from 'vue';
/**
* Composable for managing interactions with pages and elements in the iframe
* Handles hover effects, labels, and click events for both pages and content elements
*/
export function useIframeInteractions({ elementPopup, pagePopup }) {
// Page interaction state
const hoveredPage = ref(null);
const selectedPages = ref([]); // Pages with active border (when popup is open)
const hoveredElement = ref(null); // Currently hovered content element
const selectedElement = ref(null); // Selected element (when popup is open)
const EDGE_THRESHOLD = 30; // px from edge to trigger hover
// Text elements that can trigger ElementPopup (excluding containers, images, etc.)
const CONTENT_ELEMENTS = [
'P',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'BLOCKQUOTE',
'LI',
'A',
'STRONG',
'EM',
'B',
'I',
'U',
'CODE',
'PRE',
'FIGCAPTION',
];
// Check if mouse position is near the edges of a page element
const isNearPageEdge = (pageElement, mouseX, mouseY) => {
const rect = pageElement.getBoundingClientRect();
const nearLeft = mouseX >= rect.left && mouseX <= rect.left + EDGE_THRESHOLD;
const nearRight =
mouseX >= rect.right - EDGE_THRESHOLD && mouseX <= rect.right;
const nearTop = mouseY >= rect.top && mouseY <= rect.top + EDGE_THRESHOLD;
const nearBottom =
mouseY >= rect.bottom - EDGE_THRESHOLD && mouseY <= rect.bottom;
const inHorizontalRange = mouseY >= rect.top && mouseY <= rect.bottom;
const inVerticalRange = mouseX >= rect.left && mouseX <= rect.right;
return (
(nearLeft && inHorizontalRange) ||
(nearRight && inHorizontalRange) ||
(nearTop && inVerticalRange) ||
(nearBottom && inVerticalRange)
);
};
// Get all pages using the same template as the given page
const getPagesWithSameTemplate = (page, doc) => {
const pageType = page.getAttribute('data-page-type') || 'default';
const allPages = doc.querySelectorAll('.pagedjs_page');
return Array.from(allPages).filter(
(p) => (p.getAttribute('data-page-type') || 'default') === pageType
);
};
// Get selector for element (same logic as ElementPopup)
const getSelectorFromElement = (element) => {
if (element.id) {
return `#${element.id}`;
}
const tagName = element.tagName.toLowerCase();
// Filter out state classes (element-hovered, element-selected, page-hovered, page-selected)
const classes = Array.from(element.classList).filter(
(cls) =>
![
'element-hovered',
'element-selected',
'page-hovered',
'page-selected',
].includes(cls)
);
if (classes.length > 0) {
return `${tagName}.${classes[0]}`;
}
return tagName;
};
// Create and position element label on hover
const createElementLabel = (element) => {
const doc = element.ownerDocument;
const existingLabel = doc.querySelector('.element-hover-label');
if (existingLabel) {
existingLabel.remove();
}
const rect = element.getBoundingClientRect();
const scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop;
const scrollLeft = doc.documentElement.scrollLeft || doc.body.scrollLeft;
const label = doc.createElement('div');
label.className = 'element-hover-label';
label.textContent = getSelectorFromElement(element);
label.style.top = `${rect.top + scrollTop - 32}px`;
label.style.left = `${rect.left + scrollLeft}px`;
doc.body.appendChild(label);
return label;
};
// Remove element label
const removeElementLabel = (doc) => {
const label = doc.querySelector('.element-hover-label');
if (label) {
label.remove();
}
};
// Create and position page label on hover
const createPageLabel = (page) => {
const doc = page.ownerDocument;
const existingLabel = doc.querySelector('.page-hover-label');
if (existingLabel) {
existingLabel.remove();
}
const rect = page.getBoundingClientRect();
const scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop;
const scrollLeft = doc.documentElement.scrollLeft || doc.body.scrollLeft;
const templateName = page.getAttribute('data-page-type') || 'default';
const label = doc.createElement('div');
label.className = 'page-hover-label';
label.textContent = `@page ${templateName}`;
label.style.top = `${rect.top + scrollTop - 32}px`;
label.style.left = `${rect.left + scrollLeft}px`;
doc.body.appendChild(label);
return label;
};
// Remove page label
const removePageLabel = (doc) => {
const label = doc.querySelector('.page-hover-label');
if (label) {
label.remove();
}
};
// Check if element is a content element (or find closest content parent)
const getContentElement = (element) => {
let current = element;
while (current && current.tagName !== 'BODY') {
if (CONTENT_ELEMENTS.includes(current.tagName)) {
return current;
}
current = current.parentElement;
}
return null;
};
// Clear selection highlight from all selected pages
const clearSelectedPages = () => {
selectedPages.value.forEach((page) => {
page.classList.remove('page-selected');
});
selectedPages.value = [];
};
// Clear selected element highlight
const clearSelectedElement = () => {
if (selectedElement.value) {
selectedElement.value.classList.remove('element-selected');
const doc = selectedElement.value.ownerDocument;
removeElementLabel(doc);
selectedElement.value = null;
}
};
// Get count of similar elements (same tag)
const getSimilarElementsCount = (element, doc) => {
const tagName = element.tagName;
const allElements = doc.querySelectorAll(tagName);
return allElements.length;
};
// Handle mouse movement in iframe
const handleIframeMouseMove = (event) => {
const pages = event.target.ownerDocument.querySelectorAll('.pagedjs_page');
let foundPage = null;
// Check if hovering near page edge
for (const page of pages) {
if (isNearPageEdge(page, event.clientX, event.clientY)) {
foundPage = page;
break;
}
}
// Update page hover state
if (foundPage !== hoveredPage.value) {
// Remove highlight from previous page (only if not in selectedPages)
if (hoveredPage.value && !selectedPages.value.includes(hoveredPage.value)) {
hoveredPage.value.classList.remove('page-hovered');
}
// Remove previous page label
removePageLabel(event.target.ownerDocument);
// Add highlight to new page (only if not already selected)
if (foundPage && !selectedPages.value.includes(foundPage)) {
foundPage.classList.add('page-hovered');
createPageLabel(foundPage);
}
hoveredPage.value = foundPage;
}
// If not near page edge, check for content element hover
if (!foundPage) {
const contentElement = getContentElement(event.target);
const doc = event.target.ownerDocument;
if (contentElement !== hoveredElement.value) {
// Remove highlight from previous element (only if not selected)
if (
hoveredElement.value &&
hoveredElement.value !== selectedElement.value
) {
hoveredElement.value.classList.remove('element-hovered');
}
// Remove previous labels
removeElementLabel(doc);
removePageLabel(doc);
// Add highlight to new element (only if not already selected)
if (contentElement && contentElement !== selectedElement.value) {
contentElement.classList.add('element-hovered');
createElementLabel(contentElement);
}
hoveredElement.value = contentElement;
}
} else {
// Clear element hover when hovering page edge
if (
hoveredElement.value &&
hoveredElement.value !== selectedElement.value
) {
hoveredElement.value.classList.remove('element-hovered');
hoveredElement.value = null;
}
// Remove element label when hovering page edge
removeElementLabel(event.target.ownerDocument);
}
};
// Handle click in iframe
const handleIframeClick = (event) => {
const element = event.target;
// Check if clicking near a page edge
if (hoveredPage.value) {
event.stopPropagation();
// Clear previous selections
clearSelectedPages();
clearSelectedElement();
// Get all pages with same template and highlight them
const doc = event.target.ownerDocument;
const sameTemplatePages = getPagesWithSameTemplate(hoveredPage.value, doc);
sameTemplatePages.forEach((page) => {
page.classList.add('page-selected');
});
selectedPages.value = sameTemplatePages;
// Remove labels when opening popup
removePageLabel(doc);
removeElementLabel(doc);
pagePopup.value.open(hoveredPage.value, event, sameTemplatePages.length);
elementPopup.value.close();
return;
}
// Only show popup for elements inside the page template
const isInsidePage = element.closest('.pagedjs_page');
if (!isInsidePage) {
clearSelectedPages();
clearSelectedElement();
elementPopup.value.close();
pagePopup.value.close();
return;
}
// Only show ElementPopup for content elements, not divs
const contentElement = getContentElement(element);
if (!contentElement) {
clearSelectedPages();
clearSelectedElement();
elementPopup.value.close();
pagePopup.value.close();
return;
}
// Clear page selections
clearSelectedPages();
// If popup is already open and we're clicking another element, close it
if (elementPopup.value.visible) {
clearSelectedElement();
elementPopup.value.close();
pagePopup.value.close();
return;
}
// Clear previous element selection
clearSelectedElement();
// Remove hovered class from the element we're about to select
contentElement.classList.remove('element-hovered');
// Clear the hoveredElement ref if it's the same as what we're selecting
if (hoveredElement.value === contentElement) {
hoveredElement.value = null;
}
// Get document and remove labels when opening popup
const doc = event.target.ownerDocument;
removeElementLabel(doc);
removePageLabel(doc);
// Select the new element
selectedElement.value = contentElement;
contentElement.classList.add('element-selected');
// Get count of similar elements
const count = getSimilarElementsCount(contentElement, doc);
elementPopup.value.handleIframeClick(event, contentElement, count);
pagePopup.value.close();
};
// Handlers for popup close events
const handlePagePopupClose = () => {
clearSelectedPages();
};
const handleElementPopupClose = () => {
clearSelectedElement();
};
return {
// State
hoveredPage,
selectedPages,
hoveredElement,
selectedElement,
// Handlers
handleIframeMouseMove,
handleIframeClick,
handlePagePopupClose,
handleElementPopupClose,
// Utilities
clearSelectedPages,
clearSelectedElement,
};
}

View file

@ -1,81 +0,0 @@
import { onMounted, onUnmounted } from 'vue';
/**
* Composable for managing global keyboard shortcuts
* Handles Cmd/Ctrl+S (save), Cmd/Ctrl+P (print), Escape (close popups), \ (toggle panel)
*/
export function useKeyboardShortcuts({
stylesheetStore,
elementPopup,
pagePopup,
activeTab,
printPreview
}) {
// Detect platform for keyboard shortcut display
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
// Handle keyboard shortcuts
const handleKeyboardShortcut = (event) => {
// Escape key - close any open popup
if (event.key === 'Escape') {
if (elementPopup.value?.visible) {
elementPopup.value.close();
return;
}
if (pagePopup.value?.visible) {
pagePopup.value.close();
return;
}
}
// Backslash key - toggle editor panel
if (event.key === '\\') {
event.preventDefault();
// Toggle: if panel is closed, open to 'document' tab; if open, close it
activeTab.value = activeTab.value.length > 0 ? '' : 'document';
return;
}
// Cmd+P (Mac) or Ctrl+P (Windows/Linux) - print
if ((event.metaKey || event.ctrlKey) && event.key === 'p') {
event.preventDefault();
printPreview();
return;
}
// Cmd+S (Mac) or Ctrl+S (Windows/Linux) - save
if ((event.metaKey || event.ctrlKey) && event.key === 's') {
event.preventDefault();
// Only save if there are changes and not currently saving
if (stylesheetStore.isDirty && !stylesheetStore.isSaving) {
stylesheetStore.saveCustomCss();
}
}
};
// Attach keyboard listener to iframe document
const attachToIframe = (iframe) => {
if (iframe && iframe.contentDocument) {
iframe.contentDocument.addEventListener('keydown', handleKeyboardShortcut);
}
};
// Setup keyboard listeners on mount
onMounted(() => {
// Add keyboard shortcut listener to document (for when focus is outside iframe)
document.addEventListener('keydown', handleKeyboardShortcut);
});
// Cleanup on unmount
onUnmounted(() => {
// Clean up keyboard shortcut listener
document.removeEventListener('keydown', handleKeyboardShortcut);
});
return {
handleKeyboardShortcut,
attachToIframe,
isMac
};
}

View file

@ -1,157 +0,0 @@
import { ref, watch } from 'vue';
import Coloris from '@melloware/coloris';
/**
* Composable for managing preview rendering with double buffering
* Handles iframe transitions, scroll persistence, and PagedJS rendering
*/
export function usePreviewRenderer({
previewFrame1,
previewFrame2,
stylesheetStore,
narrativeStore,
handleIframeMouseMove,
handleIframeClick,
}) {
let savedScrollPercentage = 0;
const currentFrameIndex = ref(1); // 1 or 2, which iframe is currently visible
const isTransitioning = ref(false);
let keyboardShortcutHandler = null;
/**
* Render preview to hidden iframe with crossfade transition
*/
const renderPreview = async (shouldReloadFromFile = false) => {
if (isTransitioning.value) return;
isTransitioning.value = true;
// Determine which iframe is currently visible and which to render to
const visibleFrame =
currentFrameIndex.value === 1 ? previewFrame1.value : previewFrame2.value;
const hiddenFrame =
currentFrameIndex.value === 1 ? previewFrame2.value : previewFrame1.value;
if (!hiddenFrame) {
isTransitioning.value = false;
return;
}
// Save scroll position from visible frame
if (
visibleFrame &&
visibleFrame.contentWindow &&
visibleFrame.contentDocument
) {
const scrollTop = visibleFrame.contentWindow.scrollY || 0;
const scrollHeight =
visibleFrame.contentDocument.documentElement.scrollHeight;
const clientHeight = visibleFrame.contentWindow.innerHeight;
const maxScroll = scrollHeight - clientHeight;
savedScrollPercentage = maxScroll > 0 ? scrollTop / maxScroll : 0;
}
if (shouldReloadFromFile || !stylesheetStore.content) {
await stylesheetStore.loadStylesheet();
}
// Render to the hidden frame
hiddenFrame.srcdoc = `
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/assets/css/pagedjs-interface.css">
<style id="dynamic-styles">${stylesheetStore.content}</style>
<script src="https://unpkg.com/pagedjs/dist/paged.polyfill.js"><\/script>
</head>
<body>${document.getElementById('content-source').innerHTML}</body>
</html>
`;
hiddenFrame.onload = () => {
// Add event listeners for page and element interactions
hiddenFrame.contentDocument.addEventListener(
'mousemove',
handleIframeMouseMove
);
hiddenFrame.contentDocument.addEventListener('click', handleIframeClick);
// Add keyboard shortcut listener to iframe (for when focus is inside iframe)
if (keyboardShortcutHandler) {
hiddenFrame.contentDocument.addEventListener('keydown', keyboardShortcutHandler);
}
// Close Coloris when clicking in the iframe
hiddenFrame.contentDocument.addEventListener('click', () => {
Coloris.close();
});
// Wait for PagedJS to finish rendering
setTimeout(() => {
// Restore scroll position
const scrollHeight =
hiddenFrame.contentDocument.documentElement.scrollHeight;
const clientHeight = hiddenFrame.contentWindow.innerHeight;
const maxScroll = scrollHeight - clientHeight;
const targetScroll = savedScrollPercentage * maxScroll;
hiddenFrame.contentWindow.scrollTo(0, targetScroll);
// Start crossfade transition
setTimeout(() => {
// Make hidden frame visible (it's already behind)
hiddenFrame.style.opacity = '1';
hiddenFrame.style.zIndex = '1';
// Fade out visible frame
if (visibleFrame) {
visibleFrame.style.opacity = '0';
}
// After fade completes, swap the frames
setTimeout(() => {
if (visibleFrame) {
visibleFrame.style.zIndex = '0';
}
// Swap current frame
currentFrameIndex.value = currentFrameIndex.value === 1 ? 2 : 1;
isTransitioning.value = false;
}, 200); // Match CSS transition duration
}, 50); // Small delay to ensure scroll is set
}, 200); // Wait for PagedJS
};
};
// Watch for stylesheet changes and re-render
watch(
() => stylesheetStore.content,
() => {
renderPreview();
}
);
// Re-render when narrative data changes
watch(
() => narrativeStore.data,
() => {
if (narrativeStore.data) {
renderPreview();
}
}
);
/**
* Set the keyboard shortcut handler (called after keyboard shortcuts composable is initialized)
*/
const setKeyboardShortcutHandler = (handler) => {
keyboardShortcutHandler = handler;
};
return {
renderPreview,
currentFrameIndex,
isTransitioning,
setKeyboardShortcutHandler,
};
}

View file

@ -1,70 +0,0 @@
/**
* Composable for handling print preview functionality
* Collects styles from iframe and triggers browser print dialog
*/
export function usePrintPreview(activeFrame) {
/**
* Print the PagedJS content from the active frame
*/
const printPreview = async () => {
const frame = activeFrame.value;
if (!frame || !frame.contentDocument) return;
const doc = frame.contentDocument;
// Collect all styles
let allStyles = '';
// Get inline <style> tags content
doc.querySelectorAll('style').forEach((style) => {
allStyles += style.innerHTML + '\n';
});
// Get rules from stylesheets
for (const sheet of doc.styleSheets) {
try {
for (const rule of sheet.cssRules) {
allStyles += rule.cssText + '\n';
}
} catch (e) {
// Cross-origin stylesheet, try to fetch it
if (sheet.href) {
try {
const response = await fetch(sheet.href);
const css = await response.text();
allStyles += css;
} catch (fetchError) {
console.warn('Could not fetch stylesheet:', sheet.href);
}
}
}
}
// Save current page content
const originalContent = document.body.innerHTML;
const originalStyles = document.head.innerHTML;
// Replace page content with iframe content
document.head.innerHTML = `
<meta charset="UTF-8">
<title>Impression</title>
<style>${allStyles}</style>
`;
document.body.innerHTML = doc.body.innerHTML;
// Print
window.print();
// Restore original content after print dialog closes
setTimeout(() => {
document.head.innerHTML = originalStyles;
document.body.innerHTML = originalContent;
// Re-mount Vue app would be needed, so we reload instead
window.location.reload();
}, 100);
};
return {
printPreview
};
}

View file

@ -1,7 +1,7 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useNarrativeStore = defineStore('narrative', () => {
export const useRecitStore = defineStore('recit', () => {
const data = ref(null);
const loading = ref(false);
const error = ref(null);
@ -19,14 +19,14 @@ export const useNarrativeStore = defineStore('narrative', () => {
const items = [];
// Add narrative intro as first section
// Add recit intro as first section
items.push({
id: data.value.id,
template: 'narrative',
template: 'recit',
title: data.value.title,
author: data.value.author,
cover: data.value.cover,
introduction: data.value.introduction,
introduction: data.value.introduction
});
// Recursively flatten children
@ -41,7 +41,7 @@ export const useNarrativeStore = defineStore('narrative', () => {
subtitle: child.subtitle,
tags: child.tags,
cover: child.cover,
text: child.text,
text: child.text
});
// Add geoformat chapters
@ -53,7 +53,7 @@ export const useNarrativeStore = defineStore('narrative', () => {
id: child.id,
template: 'chapitre',
title: child.title,
blocks: child.blocks,
blocks: child.blocks
});
} else if (child.template === 'carte') {
items.push({
@ -61,7 +61,7 @@ export const useNarrativeStore = defineStore('narrative', () => {
template: 'carte',
title: child.title,
tags: child.tags,
text: child.text,
text: child.text
});
}
}
@ -72,10 +72,10 @@ export const useNarrativeStore = defineStore('narrative', () => {
return items;
});
// Load narrative data from URL
const loadNarrative = async (url) => {
// Load recit data from URL
const loadRecit = async (url) => {
if (!url) {
error.value = 'No narrative URL provided';
error.value = 'No recit URL provided';
return;
}
@ -91,7 +91,7 @@ export const useNarrativeStore = defineStore('narrative', () => {
data.value = await response.json();
} catch (e) {
console.error('Error loading narrative:', e);
console.error('Error loading recit:', e);
error.value = e.message;
data.value = null;
} finally {
@ -121,7 +121,7 @@ export const useNarrativeStore = defineStore('narrative', () => {
flattenedContent,
// Actions
loadNarrative,
reset,
loadRecit,
reset
};
});

View file

@ -1,49 +1,29 @@
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';
import { ref, watch } from 'vue';
import cssParsingUtils from '../utils/css-parsing';
import * as cssComments from '../utils/css-comments';
import prettier from 'prettier/standalone';
import parserPostcss from 'prettier/plugins/postcss';
import { getCsrfToken } from '../utils/kirby-auth';
export const useStylesheetStore = defineStore('stylesheet', () => {
// Base state
const baseCss = ref('');
const customCss = ref('');
const content = ref('');
const isEditing = ref(false);
let formatTimer = null;
let isFormatting = false;
let isInitializing = false;
// Save/load state
const isDirty = ref(false);
const isSaving = ref(false);
const lastSaved = ref(null);
const lastSavedFormatted = ref('');
const saveError = ref(null);
const narrativeId = ref(null);
// Computed: combined CSS for preview
const content = computed(() => {
if (!baseCss.value) return customCss.value;
if (!customCss.value) return baseCss.value;
return baseCss.value + '\n\n/* Custom CSS */\n' + customCss.value;
});
// Format custom CSS with Prettier
const formatCustomCss = async () => {
if (isFormatting || !customCss.value) return;
// Format CSS with Prettier
const formatContent = async () => {
if (isFormatting || !content.value) return;
try {
isFormatting = true;
const formatted = await prettier.format(customCss.value, {
const formatted = await prettier.format(content.value, {
parser: 'css',
plugins: [parserPostcss],
printWidth: 80,
tabWidth: 2,
useTabs: false,
});
customCss.value = formatted;
content.value = formatted;
} catch (error) {
console.error('CSS formatting error:', error);
} finally {
@ -51,236 +31,46 @@ export const useStylesheetStore = defineStore('stylesheet', () => {
}
};
// Watch customCss and format after 500ms of inactivity (only when not editing)
watch(customCss, () => {
if (isFormatting || isEditing.value || isInitializing) return;
// Mark as dirty when customCss changes (unless we're saving)
if (!isSaving.value) {
isDirty.value = true;
}
// Watch content and format after 500ms of inactivity (only when not editing)
watch(content, () => {
if (isFormatting || isEditing.value) return;
clearTimeout(formatTimer);
formatTimer = setTimeout(() => {
formatCustomCss();
formatContent();
}, 500);
});
const loadStylesheet = async () => {
const response = await fetch('/assets/css/stylesheet.print.css');
baseCss.value = await response.text();
const response = await fetch('/assets/css/stylesheet.css');
content.value = await response.text();
};
const updateProperty = (selector, property, value, unit) => {
// Update custom CSS, not the combined content
customCss.value = cssParsingUtils.updateCssValue({
css: customCss.value,
content.value = cssParsingUtils.updateCssValue({
css: content.value,
selector,
property,
value,
unit,
unit
});
};
const extractValue = (selector, property, includeCommented = true) => {
// Try to extract from active custom CSS first
const customValue = cssParsingUtils.extractCssValue(customCss.value, selector, property);
if (customValue) return customValue;
// If includeCommented, try to extract from commented block
if (includeCommented) {
const commentedBlock = cssComments.extractCommentedBlock(customCss.value, selector);
if (commentedBlock) {
const commentedValue = cssComments.extractValueFromCommentedBlock(commentedBlock, property);
if (commentedValue) {
// Parse value with unit if needed
return cssParsingUtils.parseValueWithUnit(commentedValue);
}
}
}
// Finally, try base CSS
return cssParsingUtils.extractCssValue(baseCss.value, selector, property);
const extractValue = (selector, property) => {
return cssParsingUtils.extractCssValue(content.value, selector, property);
};
const extractBlock = (selector) => {
// Try to extract from custom CSS first, then from base CSS
const customBlock = cssParsingUtils.extractCssBlock(customCss.value, selector);
if (customBlock) return customBlock;
return cssParsingUtils.extractCssBlock(baseCss.value, selector);
};
// Replace a CSS block in custom CSS (handles blocks from base CSS too)
const replaceBlock = (oldBlock, newBlock) => {
// Check if the old block exists in custom CSS
if (customCss.value.includes(oldBlock)) {
// Replace in custom CSS
customCss.value = customCss.value.replace(oldBlock, newBlock);
} else {
// Block is from base CSS, append new block to custom CSS (will override via cascade)
customCss.value = customCss.value.trim() + '\n\n' + newBlock;
}
};
// Replace content in custom CSS (for more complex string replacements)
const replaceInCustomCss = (searchValue, replaceValue) => {
customCss.value = customCss.value.replace(searchValue, replaceValue);
};
// Set custom CSS directly (for complex transformations)
const setCustomCss = (newCss) => {
customCss.value = newCss;
};
// Comment a CSS block in custom CSS
const commentCssBlock = (selector) => {
const block = cssParsingUtils.extractCssBlock(customCss.value, selector);
if (!block) return;
customCss.value = cssComments.commentBlock(customCss.value, block);
};
// Uncomment a CSS block in custom CSS
const uncommentCssBlock = (selector) => {
customCss.value = cssComments.uncommentBlock(customCss.value, selector);
};
// Check if a CSS block is commented
const isBlockCommented = (selector) => {
return cssComments.isBlockCommented(customCss.value, selector);
};
// Get the state of a CSS block
const getBlockState = (selector) => {
return cssComments.getBlockState(customCss.value, selector);
};
// Load base CSS from stylesheet.print.css
const loadBaseCss = async () => {
const response = await fetch('/assets/css/stylesheet.print.css');
baseCss.value = await response.text();
return baseCss.value;
};
// Initialize from narrative data (base + custom CSS)
const initializeFromNarrative = async (narrativeData) => {
// Set initializing flag to prevent marking as dirty during init
isInitializing = true;
try {
// Set narrative ID for API calls
narrativeId.value = narrativeData.id;
// Load base CSS
await loadBaseCss();
// Get custom CSS if exists
customCss.value = narrativeData.customCss || '';
// Set last saved info
if (narrativeData.modified) {
lastSaved.value = narrativeData.modified;
lastSavedFormatted.value = narrativeData.modifiedFormatted || '';
}
// Mark as not dirty initially
isDirty.value = false;
} finally {
// Always clear initializing flag
isInitializing = false;
}
};
// Save custom CSS to Kirby
const saveCustomCss = async () => {
if (!narrativeId.value) {
saveError.value = 'No narrative ID available';
return { status: 'error', message: saveError.value };
}
isSaving.value = true;
saveError.value = null;
try {
// Get CSRF token
const csrfToken = getCsrfToken();
if (!csrfToken) {
throw new Error(
'No CSRF token available. Please log in to Kirby Panel.'
);
}
// Make POST request to save CSS (save customCss directly)
const response = await fetch(`/narratives/${narrativeId.value}/css`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF': csrfToken,
},
body: JSON.stringify({
customCss: customCss.value,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(
result.message || `HTTP error! status: ${response.status}`
);
}
if (result.status === 'success') {
// Update last saved info
lastSaved.value = result.data.modified;
lastSavedFormatted.value = result.data.modifiedFormatted;
// Mark as not dirty
isDirty.value = false;
return { status: 'success' };
} else {
throw new Error(result.message || 'Failed to save CSS');
}
} catch (error) {
console.error('Error saving CSS:', error);
saveError.value = error.message;
return { status: 'error', message: error.message };
} finally {
isSaving.value = false;
}
return cssParsingUtils.extractCssBlock(content.value, selector);
};
return {
// Core state
content, // computed: baseCss + customCss
baseCss,
customCss,
content,
isEditing,
// Methods
loadStylesheet,
updateProperty,
extractValue,
extractBlock,
replaceBlock,
replaceInCustomCss,
setCustomCss,
commentCssBlock,
uncommentCssBlock,
isBlockCommented,
getBlockState,
formatCustomCss,
loadBaseCss,
initializeFromNarrative,
// Save/load
isDirty,
isSaving,
lastSaved,
lastSavedFormatted,
saveError,
narrativeId,
saveCustomCss,
formatContent
};
});

View file

@ -1,155 +0,0 @@
/**
* CSS Comments Utility
* Handles commenting/uncommenting CSS blocks for inheritance control
*/
/**
* Check if a CSS block is commented
* @param {string} css - The CSS content
* @param {string} selector - The CSS selector to check
* @returns {boolean}
*/
export function isBlockCommented(css, selector) {
const commentedBlock = extractCommentedBlock(css, selector);
return commentedBlock !== null;
}
/**
* Extract a commented CSS block for a selector
* @param {string} css - The CSS content
* @param {string} selector - The CSS selector
* @returns {string|null} - The commented block or null if not found
*/
export function extractCommentedBlock(css, selector) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(
`/\\*\\s*\\n?${escaped}\\s*\\{[^}]*\\}\\s*\\n?\\*/`,
'g'
);
const match = css.match(regex);
return match ? match[0] : null;
}
/**
* Comment a CSS block
* @param {string} css - The CSS content
* @param {string} blockContent - The CSS block to comment
* @returns {string} - CSS with the block commented
*/
export function commentBlock(css, blockContent) {
if (!blockContent || !css.includes(blockContent)) return css;
const commented = `/*\n${blockContent}\n*/`;
return css.replace(blockContent, commented);
}
/**
* Uncomment a CSS block for a selector
* @param {string} css - The CSS content
* @param {string} selector - The CSS selector
* @returns {string} - CSS with the block uncommented
*/
export function uncommentBlock(css, selector) {
const commentedBlock = extractCommentedBlock(css, selector);
if (!commentedBlock) return css;
// Remove comment delimiters /* and */
const uncommented = commentedBlock
.replace(/^\/\*\s*\n?/, '')
.replace(/\s*\n?\*\/$/, '');
return css.replace(commentedBlock, uncommented);
}
/**
* Get the state of a CSS block
* @param {string} css - The CSS content
* @param {string} selector - The CSS selector
* @returns {'active'|'commented'|'none'}
*/
export function getBlockState(css, selector) {
// Check for commented block
if (isBlockCommented(css, selector)) {
return 'commented';
}
// Check for active block
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`${escaped}\\s*\\{[^}]*\\}`, 'g');
const activeMatch = css.match(regex);
if (activeMatch && activeMatch.length > 0) {
return 'active';
}
return 'none';
}
/**
* Extract a value from a commented CSS block
* @param {string} commentedBlock - The commented CSS block
* @param {string} property - The CSS property to extract
* @returns {string|null} - The value or null if not found
*/
export function extractValueFromCommentedBlock(commentedBlock, property) {
if (!commentedBlock) return null;
// Remove comment delimiters
const cssContent = commentedBlock
.replace(/^\/\*\s*\n?/, '')
.replace(/\s*\n?\*\/$/, '');
// Extract property value
const regex = new RegExp(`${property}\\s*:\\s*([^;]+)`, 'i');
const match = cssContent.match(regex);
return match ? match[1].trim() : null;
}
/**
* Extract all commented blocks from CSS
* Used for preserving comments during formatting
* @param {string} css - The CSS content
* @returns {Array<{selector: string, block: string}>}
*/
export function extractAllCommentedBlocks(css) {
const blocks = [];
const regex = /\/\*\s*\n?([.\w\s>#:-]+)\s*\{[^}]*\}\s*\n?\*\//g;
let match;
while ((match = regex.exec(css)) !== null) {
blocks.push({
selector: match[1].trim(),
block: match[0]
});
}
return blocks;
}
/**
* Remove all commented blocks from CSS
* Used before formatting
* @param {string} css - The CSS content
* @returns {string} - CSS without commented blocks
*/
export function removeAllCommentedBlocks(css) {
return css.replace(/\/\*\s*\n?[.\w\s>#:-]+\s*\{[^}]*\}\s*\n?\*\//g, '');
}
/**
* Reinsert commented blocks into formatted CSS
* @param {string} css - The formatted CSS
* @param {Array<{selector: string, block: string}>} blocks - Commented blocks to reinsert
* @returns {string} - CSS with commented blocks reinserted
*/
export function reinsertCommentedBlocks(css, blocks) {
let result = css;
// Append commented blocks at the end
blocks.forEach(({ block }) => {
result += '\n' + block;
});
return result;
}

View file

@ -44,24 +44,6 @@ const updateCssValue = ({ css, selector, property, value, unit }) => {
return css.replace(selectorRegex, `${selector} {${newBlockContent}}`);
};
const parseValueWithUnit = (cssValue) => {
if (!cssValue) return null;
// Match number with optional unit
const match = cssValue.match(/([\d.]+)(px|rem|em|mm|cm|in)?/);
if (!match) return cssValue; // Return as-is if no match
return {
value: parseFloat(match[1]),
unit: match[2] || ''
};
};
const cssParsingUtils = {
extractCssBlock,
extractCssValue,
updateCssValue,
parseValueWithUnit
};
const cssParsingUtils = { extractCssBlock, extractCssValue, updateCssValue };
export default cssParsingUtils;

View file

@ -1,37 +0,0 @@
/**
* Kirby Authentication Utilities
*
* Helper functions for authentication and CSRF token management
*/
/**
* Get CSRF token from meta tag
* @returns {string|null} CSRF token or null if not found
*/
export function getCsrfToken() {
// Check for meta tag (added by header.php when user is logged in)
const metaTag = document.querySelector('meta[name="csrf"]');
if (metaTag) {
return metaTag.getAttribute('content');
}
// Alternatively, could be in a cookie
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'kirby_csrf') {
return decodeURIComponent(value);
}
}
return null;
}
/**
* Check if user is authenticated (has Kirby session)
* @returns {boolean} True if authenticated, false otherwise
*/
export function isAuthenticated() {
// Check for kirby session cookie
return document.cookie.includes('kirby_session');
}

View file

@ -1,25 +1,7 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
base: '/',
build: {
outDir: 'dist',
minify: false,
rollupOptions: {
output: {
entryFileNames: 'assets/dist/index.js',
chunkFileNames: 'assets/dist/[name].js',
assetFileNames: (assetInfo) => {
// Le CSS principal doit s'appeler index.css pour correspondre à header.php
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
return 'assets/dist/index.css';
}
return 'assets/dist/[name].[ext]';
},
},
},
},
});
})