Compare commits
52 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75d3b557fe | ||
|
|
b19635f324 | ||
|
|
925e98aea7 | ||
|
|
bad465406d | ||
|
|
63dc136309 | ||
|
|
818506fcfa | ||
|
|
cc44a68e66 | ||
|
|
32e8301d91 | ||
|
|
b47195488a | ||
|
|
2b0f4f8742 | ||
|
|
437349cd2b | ||
|
|
dc84ff63a2 | ||
|
|
7e42c4baec | ||
|
|
c7e751695f | ||
|
|
be7bb66e70 | ||
|
|
dac532a932 | ||
|
|
4d39a83a63 | ||
|
|
8e2f0a10e2 | ||
|
|
91ef119697 | ||
|
|
e229deb0f6 | ||
|
|
83455b7098 | ||
|
|
9127520ff7 | ||
|
|
cb5d056b51 | ||
|
|
e42eeab437 | ||
|
|
f8ac1ec8fc | ||
|
|
e88c217b1e | ||
|
|
b692047ff2 | ||
|
|
93df05c49f | ||
|
|
b123e92da8 | ||
|
|
0f46618066 | ||
|
|
4d1183d1af | ||
|
|
ccaec7cfed | ||
|
|
3b59127fa9 | ||
|
|
af788ad1e0 | ||
|
|
ea0994ed45 | ||
|
|
18e4efc59d | ||
|
|
16f01681dc | ||
|
|
3cc4da63fb | ||
|
|
a7918a35e2 | ||
|
|
10660e92bb | ||
|
|
bb215b04da | ||
|
|
236a606a42 | ||
|
|
d484915c16 | ||
|
|
8ddac25d5c | ||
|
|
0b1a759e5e | ||
|
|
6c421ce628 | ||
|
|
052c6958f3 | ||
|
|
5fb9cf68a3 | ||
|
|
4ae4a6d509 | ||
|
|
06aef5beb3 | ||
|
|
a42f5e48ca | ||
|
|
d88758b226 |
98 changed files with 11629 additions and 1113 deletions
43
.forgejo/workflows/README.md
Normal file
43
.forgejo/workflows/README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# 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).
|
||||
57
.forgejo/workflows/deploy.yml
Normal file
57
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -24,3 +24,9 @@ dist-ssr
|
|||
*.sw?
|
||||
|
||||
.claude
|
||||
# Variables d'environnement Brevo
|
||||
api/.env
|
||||
|
||||
# Claude settings
|
||||
.claude
|
||||
/.claude/*
|
||||
|
|
|
|||
12
Dockerfile.ci
Normal file
12
Dockerfile.ci
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# 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
|
||||
243
IMPLEMENTATION_SUMMARY.md
Normal file
243
IMPLEMENTATION_SUMMARY.md
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
# 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
206
README.md
|
|
@ -1,5 +1,205 @@
|
|||
# Vue 3 + Vite
|
||||
# GeoProject - Web-to-Print Interface
|
||||
|
||||
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.
|
||||
A web-to-print application for creating printable narratives with real-time layout editing.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
## 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`.
|
||||
|
|
|
|||
276
TESTING_CHECKLIST.md
Normal file
276
TESTING_CHECKLIST.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
# 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
123
claude.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Vue d'ensemble
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Stack technique
|
||||
|
||||
|
|
@ -14,19 +14,47 @@ Application web-to-print permettant la mise en page de récits imprimables. L'é
|
|||
## Architecture
|
||||
|
||||
```
|
||||
/src # Vue 3 SPA
|
||||
├── main.js # Bootstrap Vue
|
||||
├── App.vue # Root + init PagedJS
|
||||
└── components/
|
||||
└── PagedJsWrapper.vue # Contenu print
|
||||
/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.)
|
||||
|
||||
/public # Kirby CMS
|
||||
├── index.php # Entry Kirby
|
||||
/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
|
||||
├── site/
|
||||
│ ├── blueprints/ # Schémas de contenu
|
||||
│ ├── templates/ # Templates PHP
|
||||
│ └── snippets/ # header.php injecte Vue
|
||||
└── content/ # Contenus markdown
|
||||
│ ├── 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)
|
||||
```
|
||||
|
||||
## Flux de données
|
||||
|
|
@ -45,25 +73,82 @@ Application web-to-print permettant la mise en page de récits imprimables. L'é
|
|||
|
||||
### 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
|
||||
|
||||
### À 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
|
||||
### 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/`
|
||||
|
||||
## Commandes
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server (5173)
|
||||
npm run build # Build prod
|
||||
npm run build # Build prod dans /public/assets/dist/
|
||||
# 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
5
public/.gitignore
vendored
|
|
@ -48,3 +48,8 @@ Icon
|
|||
# ---------------
|
||||
|
||||
/site/config/.license
|
||||
|
||||
# Content
|
||||
# ---------------
|
||||
content
|
||||
/content/*
|
||||
|
|
|
|||
|
|
@ -1,43 +1,34 @@
|
|||
.settings-section {
|
||||
|
||||
margin-top: 3em;
|
||||
|
||||
|
||||
// .cons
|
||||
|
||||
|
||||
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{
|
||||
.infos {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-interface-400);
|
||||
}
|
||||
|
||||
.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);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,29 +5,25 @@ input[type="number"] {
|
|||
border: 1px solid var(--color-interface-200);
|
||||
background-color: var(--color-interface-100);
|
||||
font-family: var(--sans-serif);
|
||||
color: var(--color-txt);
|
||||
font-size: 1rem;
|
||||
padding-left: 0.5ch;
|
||||
// min-width: var(--input-w);
|
||||
// width: 100%;
|
||||
// padding: 0 1ch;
|
||||
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: var(--color-800);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.input-with-unit {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.unit-toggle {
|
||||
|
|
@ -42,76 +38,84 @@ input[type="number"] {
|
|||
.clr-field {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: var(--input-h);
|
||||
grid-template-columns: var(--input-h) 1fr;
|
||||
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{
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: var(--label-w) 1fr;
|
||||
label{
|
||||
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{
|
||||
.field-checkbox {
|
||||
grid-column: 2;
|
||||
padding-top: var(--space-xs);
|
||||
label{
|
||||
label {
|
||||
font-weight: 400;
|
||||
margin-left: 0.75ch;
|
||||
color: var(--color-txt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field-margin, .field-size{
|
||||
.field-text-size {
|
||||
input[type="number"] {
|
||||
width: var(--input-w-small);
|
||||
padding-left: 0.75ch;
|
||||
}
|
||||
input[type="range"] {
|
||||
flex-grow: 2;
|
||||
flex-shrink: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.checkbox-field {
|
||||
margin: calc(var(--space-xs) * 2) 0;
|
||||
grid-template-columns: 3ch 1fr;
|
||||
input{
|
||||
input {
|
||||
justify-self: left;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.field--view-only {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
|
@ -150,12 +154,6 @@ input[type="number"] {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// INPUT NUMBER ===============================================
|
||||
|
||||
// Masquer les spinners natifs partout
|
||||
|
|
@ -170,46 +168,46 @@ 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.6);
|
||||
button {
|
||||
height: calc(var(--input-h) * 0.5);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
svg path{
|
||||
fill: var(--color-interface-600);
|
||||
svg {
|
||||
width: 10px;
|
||||
height: auto;
|
||||
}
|
||||
&:hover{
|
||||
svg path{
|
||||
svg path {
|
||||
fill: var(--color-interface-600);
|
||||
}
|
||||
&:hover {
|
||||
svg path {
|
||||
fill: var(--color-interface-900);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.spinner-down{
|
||||
svg{
|
||||
position: relative;
|
||||
top: -2px;
|
||||
.spinner-down {
|
||||
svg {
|
||||
// position: relative;
|
||||
// top: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
// Composant NumberInput avec boutons personnalisés
|
||||
// .number-input {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10000;
|
||||
width: 71rem;
|
||||
z-index: 10;
|
||||
width: 860px;
|
||||
max-height: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -47,10 +47,7 @@
|
|||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
background-color: var(--color-panel-bg);
|
||||
}
|
||||
|
||||
.settings-subsection h4 {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
|
||||
|
||||
--color-txt: var(--color-interface-800);
|
||||
--color-txt: var(--color-interface-900);
|
||||
--color-panel-bg: var(--color-interface-050);
|
||||
|
||||
--color-page-highlight: #ff8a50;
|
||||
|
|
@ -20,6 +20,8 @@
|
|||
--space-xs: 0.5rem;
|
||||
--space-s: 1rem;
|
||||
--space: 1.5rem;
|
||||
--space-m: 2rem;
|
||||
--space-big: 3em;
|
||||
|
||||
--curve: cubic-bezier(0.86, 0, 0.07, 1);
|
||||
|
||||
|
|
@ -34,4 +36,5 @@
|
|||
font-size: 14px;
|
||||
|
||||
--panel-w: 540px;
|
||||
--panel-nav-h: 60px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ img {
|
|||
:root {
|
||||
--color-browngray-050: #f5f3f0;
|
||||
--color-browngray-200: #d0c4ba;
|
||||
--color-txt: var(--color-interface-800);
|
||||
--color-txt: var(--color-interface-900);
|
||||
--color-panel-bg: var(--color-interface-050);
|
||||
--color-page-highlight: #ff8a50;
|
||||
--color-purple: #7136ff;
|
||||
|
|
@ -231,6 +231,8 @@ img {
|
|||
--space-xs: 0.5rem;
|
||||
--space-s: 1rem;
|
||||
--space: 1.5rem;
|
||||
--space-m: 2rem;
|
||||
--space-big: 3em;
|
||||
--curve: cubic-bezier(0.86, 0, 0.07, 1);
|
||||
--sans-serif: "DM Sans", sans-serif;
|
||||
--mono: "Inconsolata", monospace;
|
||||
|
|
@ -240,6 +242,7 @@ img {
|
|||
--label-w: 18ch;
|
||||
font-size: 14px;
|
||||
--panel-w: 540px;
|
||||
--panel-nav-h: 60px;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -273,7 +276,9 @@ input[type=number] {
|
|||
border: 1px solid var(--color-interface-200);
|
||||
background-color: var(--color-interface-100);
|
||||
font-family: var(--sans-serif);
|
||||
color: var(--color-txt);
|
||||
font-size: 1rem;
|
||||
padding-left: 0.5ch;
|
||||
}
|
||||
|
||||
.field {
|
||||
|
|
@ -299,13 +304,15 @@ input[type=number] {
|
|||
.field .input-with-color .clr-field {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: var(--input-h);
|
||||
grid-template-columns: var(--input-h) 1fr;
|
||||
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;
|
||||
|
|
@ -328,24 +335,38 @@ input[type=number] {
|
|||
width: 100%;
|
||||
}
|
||||
.field-font .field-checkbox {
|
||||
grid-column: 2;
|
||||
padding-top: var(--space-xs);
|
||||
}
|
||||
.field-font .field-checkbox label {
|
||||
font-weight: 400;
|
||||
margin-left: 0.75ch;
|
||||
color: var(--color-txt);
|
||||
}
|
||||
|
||||
.field-margin, .field-size {
|
||||
.field-text-size input[type=number] {
|
||||
width: var(--input-w-small);
|
||||
padding-left: 0.75ch;
|
||||
}
|
||||
.field-text-size input[type=range] {
|
||||
flex-grow: 2;
|
||||
flex-shrink: 2;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
|
@ -421,23 +442,22 @@ input[type=number] {
|
|||
top: 0;
|
||||
}
|
||||
.number-input .spinner-buttons button {
|
||||
height: calc(var(--input-h) * 0.6);
|
||||
height: calc(var(--input-h) * 0.5);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.number-input .spinner-buttons button svg {
|
||||
width: 10px;
|
||||
height: auto;
|
||||
}
|
||||
.number-input .spinner-buttons button svg path {
|
||||
fill: var(--color-interface-600);
|
||||
}
|
||||
.number-input .spinner-buttons button:hover svg path {
|
||||
fill: var(--color-interface-900);
|
||||
}
|
||||
.number-input .spinner-buttons .spinner-down svg {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-top: 3em;
|
||||
margin: var(--space-m) 0;
|
||||
}
|
||||
.settings-section h2 {
|
||||
margin-bottom: var(--space);
|
||||
|
|
@ -450,13 +470,15 @@ input[type=number] {
|
|||
font-size: 0.8rem;
|
||||
color: var(--color-interface-400);
|
||||
}
|
||||
.settings-section .settings-subsection:not(:last-child) {
|
||||
|
||||
.settings-subsection:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-interface-100);
|
||||
}
|
||||
.settings-section .settings-subsection {
|
||||
|
||||
.settings-subsection {
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
.settings-section .settings-subsection h3 {
|
||||
.settings-subsection h3 {
|
||||
margin-top: calc(var(--space-xs) * 1.5);
|
||||
margin-bottom: calc(var(--space-xs) * 2);
|
||||
font-size: 1rem;
|
||||
|
|
@ -507,8 +529,8 @@ input[type=number] {
|
|||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10000;
|
||||
width: 71rem;
|
||||
z-index: 10;
|
||||
width: 860px;
|
||||
max-height: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -549,10 +571,7 @@ input[type=number] {
|
|||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
background-color: var(--color-panel-bg);
|
||||
}
|
||||
|
||||
.settings-subsection h4 {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -22,7 +22,8 @@
|
|||
},
|
||||
"require": {
|
||||
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
|
||||
"getkirby/cms": "^5.0"
|
||||
"getkirby/cms": "^5.0",
|
||||
"sylvainjule/code-editor": "^1.1"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
|
|
|
|||
40
public/composer.lock
generated
40
public/composer.lock
generated
|
|
@ -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": "0b7fb803e22a45eb87e24172337208aa",
|
||||
"content-hash": "82adb49b472cb54cd88e72b31f49ada3",
|
||||
"packages": [
|
||||
{
|
||||
"name": "christian-riesen/base32",
|
||||
|
|
@ -725,6 +725,44 @@
|
|||
},
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
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 d’un récit.</p>
|
||||
|
||||
----
|
||||
|
||||
Uuid: dcesbtdkfuilhqsw
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
Title: Es la Patagonia
|
||||
|
||||
----
|
||||
|
||||
Author:
|
||||
|
||||
----
|
||||
|
||||
Tags: voyage
|
||||
|
||||
----
|
||||
|
||||
Text: <p>J’ai parcouru le monde de mes 2 à mes 13 ans. D’abord 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 s’en 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
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
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
|
||||
|
|
@ -14,4 +14,46 @@ 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
|
||||
|
|
@ -4,5 +4,5 @@ fields:
|
|||
map:
|
||||
label: Choisir la carte
|
||||
type: pages
|
||||
query: page.parent.parent.children.filterBy('intendedTemplate', 'carte')
|
||||
query: page.parent.parent.children.filterBy('intendedTemplate', 'map')
|
||||
multiple: false
|
||||
|
|
@ -13,18 +13,15 @@ columns:
|
|||
text:
|
||||
label: Présentation de la carte
|
||||
type: writer
|
||||
map:
|
||||
mapdata:
|
||||
label: Carte
|
||||
type: info
|
||||
text: |
|
||||
Ici le plugin pour la carte et les marqueurs
|
||||
Avoir la possibilité de changer le fond de carte en image
|
||||
type: map-editor
|
||||
defaultCenter: [43.836699, 4.360054]
|
||||
defaultZoom: 13
|
||||
maxMarkers: 50
|
||||
sidebar:
|
||||
width: 1/3
|
||||
sections:
|
||||
files:
|
||||
label: Fichiers
|
||||
type: files
|
||||
|
||||
|
||||
|
||||
78
public/site/blueprints/pages/marker.yml
Normal file
78
public/site/blueprints/pages/marker.yml
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
title: Récit
|
||||
title: Narrative
|
||||
|
||||
columns:
|
||||
main:
|
||||
|
|
@ -22,11 +22,18 @@ 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:
|
||||
- carte
|
||||
- map
|
||||
- geoformat
|
||||
sidebar:
|
||||
width: 1/3
|
||||
|
|
@ -17,9 +17,9 @@ columns:
|
|||
multiple: false
|
||||
width: 1/2
|
||||
pages:
|
||||
label: Récits
|
||||
label: Narratives
|
||||
type: pages
|
||||
template: recit
|
||||
template: narrative
|
||||
sidebar:
|
||||
width: 1/3
|
||||
sections:
|
||||
|
|
|
|||
12
public/site/plugins/code-editor/.editorconfig
Normal file
12
public/site/plugins/code-editor/.editorconfig
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
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
|
||||
6
public/site/plugins/code-editor/.gitignore
vendored
Normal file
6
public/site/plugins/code-editor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.DS_Store
|
||||
.cache
|
||||
node_modules
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
composer.lock
|
||||
21
public/site/plugins/code-editor/LICENSE
Normal file
21
public/site/plugins/code-editor/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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.
|
||||
111
public/site/plugins/code-editor/README.md
Normal file
111
public/site/plugins/code-editor/README.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Kirby – Code editor
|
||||
|
||||
Code editor field for Kirby 3, 4 and 5.
|
||||
|
||||

|
||||
|
||||
<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)
|
||||
20
public/site/plugins/code-editor/composer.json
Normal file
20
public/site/plugins/code-editor/composer.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
28
public/site/plugins/code-editor/eslint.config.mjs
Normal file
28
public/site/plugins/code-editor/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
];
|
||||
1
public/site/plugins/code-editor/index.css
Normal file
1
public/site/plugins/code-editor/index.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
.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}
|
||||
14
public/site/plugins/code-editor/index.js
Normal file
14
public/site/plugins/code-editor/index.js
Normal file
File diff suppressed because one or more lines are too long
15
public/site/plugins/code-editor/index.php
Normal file
15
public/site/plugins/code-editor/index.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?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',
|
||||
),
|
||||
]);
|
||||
32
public/site/plugins/code-editor/lib/fields/code-editor.php
Normal file
32
public/site/plugins/code-editor/lib/fields/code-editor.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?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;
|
||||
30
public/site/plugins/code-editor/package.json
Normal file
30
public/site/plugins/code-editor/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<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>
|
||||
7
public/site/plugins/code-editor/src/index.js
Normal file
7
public/site/plugins/code-editor/src/index.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import CodeEditor from "./components/field/CodeEditor.vue";
|
||||
|
||||
window.panel.plugin("sylvainjule/code-editor", {
|
||||
fields: {
|
||||
"code-editor": CodeEditor,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
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));
|
||||
91
public/site/plugins/map-editor/README.md
Normal file
91
public/site/plugins/map-editor/README.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# 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
|
||||
441
public/site/plugins/map-editor/api/routes.php
Normal file
441
public/site/plugins/map-editor/api/routes.php
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
<?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
|
||||
];
|
||||
}
|
||||
}
|
||||
]
|
||||
];
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 307 B |
1
public/site/plugins/map-editor/index.css
Normal file
1
public/site/plugins/map-editor/index.css
Normal file
File diff suppressed because one or more lines are too long
611
public/site/plugins/map-editor/index.js
Normal file
611
public/site/plugins/map-editor/index.js
Normal file
File diff suppressed because one or more lines are too long
55
public/site/plugins/map-editor/index.php
Normal file
55
public/site/plugins/map-editor/index.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?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'
|
||||
]
|
||||
]);
|
||||
3958
public/site/plugins/map-editor/package-lock.json
generated
Normal file
3958
public/site/plugins/map-editor/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
17
public/site/plugins/map-editor/package.json
Normal file
17
public/site/plugins/map-editor/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,489 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
<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>
|
||||
479
public/site/plugins/map-editor/src/components/map/MapPreview.vue
Normal file
479
public/site/plugins/map-editor/src/components/map/MapPreview.vue
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
<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:
|
||||
'© <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>
|
||||
236
public/site/plugins/map-editor/src/components/map/MarkerList.vue
Normal file
236
public/site/plugins/map-editor/src/components/map/MarkerList.vue
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
<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>
|
||||
117
public/site/plugins/map-editor/src/composables/useMapData.js
Normal file
117
public/site/plugins/map-editor/src/composables/useMapData.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
194
public/site/plugins/map-editor/src/composables/useMarkersApi.js
Normal file
194
public/site/plugins/map-editor/src/composables/useMarkersApi.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
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
|
||||
};
|
||||
}
|
||||
7
public/site/plugins/map-editor/src/index.js
Normal file
7
public/site/plugins/map-editor/src/index.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import MapEditor from "./components/field/MapEditor.vue";
|
||||
|
||||
window.panel.plugin("geoproject/map-editor", {
|
||||
fields: {
|
||||
"map-editor": MapEditor
|
||||
}
|
||||
});
|
||||
76
public/site/plugins/map-editor/src/utils/api/nominatim.js
Normal file
76
public/site/plugins/map-editor/src/utils/api/nominatim.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
26
public/site/plugins/map-editor/src/utils/constants.js
Normal file
26
public/site/plugins/map-editor/src/utils/constants.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* 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,
|
||||
};
|
||||
24
public/site/plugins/map-editor/src/utils/helpers/debounce.js
Normal file
24
public/site/plugins/map-editor/src/utils/helpers/debounce.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* 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);
|
||||
};
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
/**
|
||||
* Virtual Print Page Plugin
|
||||
*
|
||||
* Crée une page virtuelle /print pour chaque récit
|
||||
* Permet d'accéder à l'éditeur d'impression via /projet/recit/print
|
||||
* Creates a virtual /print page for each narrative
|
||||
* Allows access to print editor via /projet/narrative/print
|
||||
*/
|
||||
|
||||
use Kirby\Cms\Page;
|
||||
|
|
@ -14,20 +14,20 @@ Kirby::plugin('geoproject/virtual-print-page', [
|
|||
[
|
||||
'pattern' => '(:all)/print',
|
||||
'action' => function ($parentPath) {
|
||||
// Trouver la page parente (le récit)
|
||||
// Find parent page (the narrative)
|
||||
$parent = page($parentPath);
|
||||
|
||||
if (!$parent || $parent->intendedTemplate()->name() !== 'recit') {
|
||||
if (!$parent || $parent->intendedTemplate()->name() !== 'narrative') {
|
||||
return $this->next();
|
||||
}
|
||||
|
||||
// Créer la page virtuelle avec Page::factory()
|
||||
// Create virtual page with Page::factory()
|
||||
return Page::factory([
|
||||
'slug' => 'print',
|
||||
'template' => 'print',
|
||||
'parent' => $parent,
|
||||
'content' => [
|
||||
'title' => 'Impression - ' . $parent->title()->value(),
|
||||
'title' => 'Print - ' . $parent->title()->value(),
|
||||
'uuid' => Uuid::generate()
|
||||
]
|
||||
]);
|
||||
|
|
|
|||
116
public/site/plugins/web2print/index.php
Normal file
116
public/site/plugins/web2print/index.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?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')
|
||||
]
|
||||
]);
|
||||
}
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
|
@ -14,17 +14,22 @@
|
|||
<!-- À 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($recitJsonUrl)): ?> data-recit-url="<?= $recitJsonUrl ?>"<?php endif ?>>
|
||||
<body data-template="<?= $page->template() ?>"<?php if (isset($narrativeJsonUrl)): ?> data-narrative-url="<?= $narrativeJsonUrl ?>"<?php endif ?>>
|
||||
<div id="app">
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
/**
|
||||
* Template JSON pour exposer les données d'un récit
|
||||
* Accessible via /projet/recit.json ou /projet/recit?format=json
|
||||
* JSON template to expose narrative data
|
||||
* Accessible via /projet/narrative.json or /projet/narrative?format=json
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
|
@ -175,20 +175,23 @@ function parseGeoformat($geoformat) {
|
|||
];
|
||||
}
|
||||
|
||||
// Construction de la réponse JSON
|
||||
// Build JSON response
|
||||
$data = [
|
||||
'id' => $page->id(),
|
||||
'uuid' => $page->uuid()->toString(),
|
||||
'template' => 'recit',
|
||||
'template' => 'narrative',
|
||||
'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' => []
|
||||
];
|
||||
|
||||
// Parser les enfants (geoformats et cartes)
|
||||
// Parse children (geoformats and maps)
|
||||
foreach ($page->children()->listed() as $child) {
|
||||
$template = $child->intendedTemplate()->name();
|
||||
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
<?php
|
||||
/**
|
||||
* Template pour afficher un récit
|
||||
* Ce template est requis pour que recit.json.php fonctionne
|
||||
* Template to display a narrative
|
||||
* This template is required for narrative.json.php to work
|
||||
*/
|
||||
?>
|
||||
<?php snippet('header') ?>
|
||||
|
||||
<article class="recit">
|
||||
<article class="narrative">
|
||||
<h1><?= $page->title() ?></h1>
|
||||
|
||||
<?php if ($page->author()->isNotEmpty()): ?>
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
</div>
|
||||
<?php endif ?>
|
||||
|
||||
<p><a href="<?= $page->url() ?>/print">Ouvrir l'éditeur d'impression</a></p>
|
||||
<p><a href="<?= $page->url() ?>/print">Open print editor</a></p>
|
||||
</article>
|
||||
|
||||
<?php snippet('footer') ?>
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
<?php
|
||||
/**
|
||||
* Template pour l'éditeur d'impression Vue.js
|
||||
* Route: /projet/recit/print
|
||||
* Template for Vue.js print editor
|
||||
* Route: /projet/narrative/print
|
||||
*
|
||||
* Ce template charge l'app Vue et lui passe l'URL JSON du récit parent
|
||||
* This template loads the Vue app and passes the parent narrative JSON URL
|
||||
*/
|
||||
|
||||
// Récupérer le récit parent
|
||||
$recit = $page->parent();
|
||||
// Get parent narrative
|
||||
$narrative = $page->parent();
|
||||
|
||||
// Construire l'URL JSON du récit
|
||||
$recitJsonUrl = $recit->url() . '.json';
|
||||
// Build narrative JSON URL
|
||||
$narrativeJsonUrl = $narrative->url() . '.json';
|
||||
?>
|
||||
<?php snippet('header', ['recitJsonUrl' => $recitJsonUrl]) ?>
|
||||
<?php snippet('header', ['narrativeJsonUrl' => $narrativeJsonUrl]) ?>
|
||||
|
||||
<?php snippet('footer') ?>
|
||||
|
|
|
|||
2
public/vendor/composer/autoload_psr4.php
vendored
2
public/vendor/composer/autoload_psr4.php
vendored
|
|
@ -16,7 +16,7 @@ return array(
|
|||
'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'),
|
||||
'League\\ColorExtractor\\' => array($vendorDir . '/league/color-extractor/src'),
|
||||
'Laminas\\Escaper\\' => array($vendorDir . '/laminas/laminas-escaper/src'),
|
||||
'Kirby\\' => array($vendorDir . '/getkirby/composer-installer/src', $baseDir . '/kirby/src'),
|
||||
'Kirby\\' => array($baseDir . '/kirby/src', $vendorDir . '/getkirby/composer-installer/src'),
|
||||
'Composer\\Semver\\' => array($vendorDir . '/composer/semver/src'),
|
||||
'Base32\\' => array($vendorDir . '/christian-riesen/base32/src'),
|
||||
);
|
||||
|
|
|
|||
4
public/vendor/composer/autoload_static.php
vendored
4
public/vendor/composer/autoload_static.php
vendored
|
|
@ -96,8 +96,8 @@ class ComposerStaticInit0b7fb803e22a45eb87e24172337208aa
|
|||
),
|
||||
'Kirby\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/getkirby/composer-installer/src',
|
||||
1 => __DIR__ . '/../..' . '/kirby/src',
|
||||
0 => __DIR__ . '/../..' . '/kirby/src',
|
||||
1 => __DIR__ . '/..' . '/getkirby/composer-installer/src',
|
||||
),
|
||||
'Composer\\Semver\\' =>
|
||||
array (
|
||||
|
|
|
|||
41
public/vendor/composer/installed.json
vendored
41
public/vendor/composer/installed.json
vendored
|
|
@ -752,6 +752,47 @@
|
|||
},
|
||||
"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",
|
||||
|
|
|
|||
21
public/vendor/composer/installed.php
vendored
21
public/vendor/composer/installed.php
vendored
|
|
@ -1,9 +1,9 @@
|
|||
<?php return array(
|
||||
'root' => array(
|
||||
'name' => 'getkirby/plainkit',
|
||||
'pretty_version' => '5.1.4',
|
||||
'version' => '5.1.4.0',
|
||||
'reference' => null,
|
||||
'pretty_version' => 'dev-main',
|
||||
'version' => 'dev-main',
|
||||
'reference' => '4d1183d1afd90517610e742ea0bc6553e8312bc6',
|
||||
'type' => 'project',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
|
|
@ -65,9 +65,9 @@
|
|||
'dev_requirement' => false,
|
||||
),
|
||||
'getkirby/plainkit' => array(
|
||||
'pretty_version' => '5.1.4',
|
||||
'version' => '5.1.4.0',
|
||||
'reference' => null,
|
||||
'pretty_version' => 'dev-main',
|
||||
'version' => 'dev-main',
|
||||
'reference' => '4d1183d1afd90517610e742ea0bc6553e8312bc6',
|
||||
'type' => 'project',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
|
|
@ -124,6 +124,15 @@
|
|||
'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',
|
||||
|
|
|
|||
606
src/App.vue
606
src/App.vue
|
|
@ -4,16 +4,18 @@ 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 { onMounted, ref, watch, computed, provide } from 'vue';
|
||||
import SaveButton from './components/SaveButton.vue';
|
||||
import { onMounted, ref, computed, provide } from 'vue';
|
||||
import { useStylesheetStore } from './stores/stylesheet';
|
||||
import { useRecitStore } from './stores/recit';
|
||||
import Coloris from '@melloware/coloris';
|
||||
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';
|
||||
|
||||
const stylesheetStore = useStylesheetStore();
|
||||
const recitStore = useRecitStore();
|
||||
const narrativeStore = useNarrativeStore();
|
||||
|
||||
// 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);
|
||||
|
|
@ -22,18 +24,32 @@ const activeTab = ref('');
|
|||
|
||||
provide('activeTab', activeTab);
|
||||
|
||||
// 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 iframe interactions (hover, click, labels)
|
||||
const {
|
||||
hoveredPage,
|
||||
selectedPages,
|
||||
hoveredElement,
|
||||
selectedElement,
|
||||
handleIframeMouseMove,
|
||||
handleIframeClick,
|
||||
handlePagePopupClose,
|
||||
handleElementPopupClose,
|
||||
} = useIframeInteractions({ elementPopup, pagePopup });
|
||||
|
||||
let savedScrollPercentage = 0;
|
||||
const currentFrameIndex = ref(1); // 1 or 2, which iframe is currently visible
|
||||
const isTransitioning = ref(false);
|
||||
// Setup preview renderer with double buffering
|
||||
const {
|
||||
renderPreview,
|
||||
currentFrameIndex,
|
||||
isTransitioning,
|
||||
setKeyboardShortcutHandler,
|
||||
} = usePreviewRenderer({
|
||||
previewFrame1,
|
||||
previewFrame2,
|
||||
stylesheetStore,
|
||||
narrativeStore,
|
||||
handleIframeMouseMove,
|
||||
handleIframeClick,
|
||||
});
|
||||
|
||||
const activeFrame = computed(() => {
|
||||
return currentFrameIndex.value === 1
|
||||
|
|
@ -41,526 +57,32 @@ const activeFrame = computed(() => {
|
|||
: previewFrame2.value;
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
// Setup print preview
|
||||
const { printPreview } = usePrintPreview(activeFrame);
|
||||
|
||||
// Setup keyboard shortcuts (depends on printPreview)
|
||||
const {
|
||||
handleKeyboardShortcut,
|
||||
isMac
|
||||
} = useKeyboardShortcuts({
|
||||
stylesheetStore,
|
||||
elementPopup,
|
||||
pagePopup,
|
||||
activeTab,
|
||||
printPreview,
|
||||
});
|
||||
|
||||
// Attach keyboard shortcut handler to renderer
|
||||
setKeyboardShortcutHandler(handleKeyboardShortcut);
|
||||
|
||||
// Lifecycle: Initialize app on mount
|
||||
onMounted(async () => {
|
||||
// Load recit data if URL is provided (print mode)
|
||||
if (recitUrl) {
|
||||
await recitStore.loadRecit(recitUrl);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Render preview after data is loaded
|
||||
|
|
@ -588,6 +110,8 @@ onMounted(async () => {
|
|||
|
||||
<PreviewLoader :isLoading="isTransitioning" :shifted="activeTab.length > 0" />
|
||||
|
||||
<SaveButton />
|
||||
|
||||
<ElementPopup
|
||||
ref="elementPopup"
|
||||
:iframeRef="activeFrame"
|
||||
|
|
@ -599,7 +123,7 @@ onMounted(async () => {
|
|||
@close="handlePagePopupClose"
|
||||
/>
|
||||
|
||||
<button class="print-btn" @click="printPreview" title="Imprimer">
|
||||
<button class="print-btn" @click="printPreview" :title="`Imprimer (${isMac ? '⌘' : 'Ctrl'}+P)`">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
@ -673,6 +197,14 @@ 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,
|
||||
|
|
|
|||
|
|
@ -18,19 +18,17 @@
|
|||
<div class="popup-controls">
|
||||
<!-- Font Family -->
|
||||
<div class="settings-subsection">
|
||||
<div class="field" :class="{ 'field--view-only': inheritanceLocked }">
|
||||
<div class="field field-font" :class="{ 'field--view-only': inheritanceLocked }">
|
||||
<label class="label-with-tooltip" data-css="font-family">Police</label>
|
||||
<select v-model="fontFamily.value" :disabled="inheritanceLocked">
|
||||
<option value="Alegreya Sans">Alegreya Sans</option>
|
||||
<option value="Alegreya">Alegreya</option>
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
</select>
|
||||
<label class="checkbox-inline label-with-tooltip" data-css="font-style">
|
||||
<input type="checkbox" v-model="fontStyle.italic" :disabled="inheritanceLocked" />
|
||||
<span>Italique</span>
|
||||
</label>
|
||||
<div class="field-with-option">
|
||||
<select v-model="fontFamily.value" :disabled="inheritanceLocked">
|
||||
<option v-for="f in fonts" :key="f" :value="f">{{ f }}</option>
|
||||
</select>
|
||||
<div class="field-checkbox">
|
||||
<input type="checkbox" v-model="fontStyle.italic" :disabled="inheritanceLocked" />
|
||||
<label class="label-with-tooltip" data-css="font-style">Italique</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -38,18 +36,7 @@
|
|||
<div class="settings-subsection">
|
||||
<div class="field" :class="{ 'field--view-only': inheritanceLocked }">
|
||||
<label class="label-with-tooltip" data-css="font-weight">Graisse</label>
|
||||
<div class="button-group">
|
||||
<button
|
||||
v-for="weight in [200, 300, 400, 600, 800]"
|
||||
:key="weight"
|
||||
type="button"
|
||||
:class="{ active: fontWeight.value === weight }"
|
||||
:disabled="inheritanceLocked"
|
||||
@click="fontWeight.value = weight"
|
||||
>
|
||||
{{ weight }}
|
||||
</button>
|
||||
</div>
|
||||
<UnitToggle v-model="fontWeightString" :units="weights" :disabled="inheritanceLocked" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -271,11 +258,12 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useStylesheetStore } from '../stores/stylesheet';
|
||||
import { usePopupPosition } from '../composables/usePopupPosition';
|
||||
import { useDebounce } from '../composables/useDebounce';
|
||||
import NumberInput from './ui/NumberInput.vue';
|
||||
import UnitToggle from './ui/UnitToggle.vue';
|
||||
import Coloris from '@melloware/coloris';
|
||||
import '@melloware/coloris/dist/coloris.css';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
|
|
@ -321,6 +309,18 @@ const background = ref({ value: 'transparent' });
|
|||
const marginOuter = ref({ value: 0, unit: 'mm' });
|
||||
const paddingInner = ref({ value: 0, unit: 'mm' });
|
||||
|
||||
// Constants
|
||||
const fonts = ['Alegreya Sans', 'Alegreya', 'Arial', 'Georgia', 'Times New Roman'];
|
||||
const weights = ['200', '300', '400', '600', '800'];
|
||||
|
||||
// Computed to adapt fontWeight for UnitToggle
|
||||
const fontWeightString = computed({
|
||||
get: () => String(fontWeight.value.value),
|
||||
set: (val) => {
|
||||
fontWeight.value.value = parseInt(val);
|
||||
}
|
||||
});
|
||||
|
||||
const immediateUpdate = (callback) => {
|
||||
callback();
|
||||
};
|
||||
|
|
@ -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.content = stylesheetStore.content.replace(
|
||||
stylesheetStore.replaceInCustomCss(
|
||||
new RegExp(`\\n?${escaped}\\s*\\{[^}]*\\}\\n?`),
|
||||
'\n'
|
||||
);
|
||||
|
|
@ -581,7 +581,7 @@ const handleCssInput = (event) => {
|
|||
cssDebounceTimer = setTimeout(() => {
|
||||
const oldBlock = elementCss.value;
|
||||
if (oldBlock) {
|
||||
stylesheetStore.content = stylesheetStore.content.replace(oldBlock, newCss);
|
||||
stylesheetStore.replaceInCustomCss(oldBlock, newCss);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
|
@ -592,16 +592,35 @@ watch(isEditable, async (newValue, oldValue) => {
|
|||
|
||||
// Format when exiting editing mode
|
||||
if (oldValue && !newValue) {
|
||||
await stylesheetStore.formatContent();
|
||||
await stylesheetStore.formatCustomCss();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch stylesheet changes to sync values
|
||||
watch(
|
||||
() => stylesheetStore.content,
|
||||
() => stylesheetStore.customCss,
|
||||
() => {
|
||||
if (visible.value && !stylesheetStore.isEditing) {
|
||||
if (visible.value && !isUpdatingFromStore) {
|
||||
isUpdatingFromStore = true;
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -610,8 +629,6 @@ const loadValuesFromStylesheet = () => {
|
|||
if (!selector.value) return;
|
||||
|
||||
try {
|
||||
isUpdatingFromStore = true;
|
||||
|
||||
// Extract font-family
|
||||
const fontFamilyData = stylesheetStore.extractValue(selector.value, 'font-family');
|
||||
if (fontFamilyData) {
|
||||
|
|
@ -676,12 +693,13 @@ 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);
|
||||
|
|
@ -689,14 +707,30 @@ const open = (element, event, count = null) => {
|
|||
// Store instance count if provided, otherwise calculate it
|
||||
elementInstanceCount.value = count !== null ? count : getInstanceCount(selector.value);
|
||||
|
||||
// Read inheritance state from element's data attribute
|
||||
inheritanceLocked.value = element.dataset.inheritanceUnlocked !== 'true';
|
||||
// Detect inheritance state from CSS block state
|
||||
const blockState = stylesheetStore.getBlockState(selector.value);
|
||||
|
||||
// Load values from stylesheet
|
||||
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)
|
||||
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();
|
||||
|
|
@ -755,24 +789,69 @@ const handleIframeClick = (event, targetElement = null, elementCount = null) =>
|
|||
};
|
||||
|
||||
const toggleInheritance = () => {
|
||||
const wasLocked = inheritanceLocked.value;
|
||||
inheritanceLocked.value = !inheritanceLocked.value;
|
||||
const blockState = stylesheetStore.getBlockState(selector.value);
|
||||
|
||||
// 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';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// Now create the block with captured values
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -794,43 +873,4 @@ defineExpose({ handleIframeClick, close, visible });
|
|||
color: var(--color-purple);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button-group button.active {
|
||||
background: #61afef;
|
||||
color: white;
|
||||
border-color: #61afef;
|
||||
}
|
||||
|
||||
.button-group button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.checkbox-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: 1rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-inline input[type="checkbox"] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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.content = stylesheetStore.content.replace(
|
||||
stylesheetStore.replaceInCustomCss(
|
||||
baseBlock,
|
||||
baseBlock + newBlock
|
||||
);
|
||||
|
|
@ -376,7 +376,7 @@ const removeTemplateBlock = () => {
|
|||
|
||||
if (block) {
|
||||
// Remove the block and any surrounding whitespace
|
||||
stylesheetStore.content = stylesheetStore.content.replace(
|
||||
stylesheetStore.replaceInCustomCss(
|
||||
new RegExp(`\\n?${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}\\n?`),
|
||||
'\n'
|
||||
);
|
||||
|
|
@ -399,7 +399,7 @@ const updateMargins = (force = false) => {
|
|||
/(margin:\s*)[^;]+/,
|
||||
`$1${marginValue}`
|
||||
);
|
||||
stylesheetStore.content = stylesheetStore.content.replace(
|
||||
stylesheetStore.replaceInCustomCss(
|
||||
currentBlock,
|
||||
updatedBlock
|
||||
);
|
||||
|
|
@ -408,7 +408,7 @@ const updateMargins = (force = false) => {
|
|||
/(\s*})$/,
|
||||
` margin: ${marginValue};\n$1`
|
||||
);
|
||||
stylesheetStore.content = stylesheetStore.content.replace(
|
||||
stylesheetStore.replaceInCustomCss(
|
||||
currentBlock,
|
||||
updatedBlock
|
||||
);
|
||||
|
|
@ -428,7 +428,7 @@ const updateBackground = (force = false) => {
|
|||
/(background:\s*)[^;]+/,
|
||||
`$1${background.value.value}`
|
||||
);
|
||||
stylesheetStore.content = stylesheetStore.content.replace(
|
||||
stylesheetStore.replaceInCustomCss(
|
||||
currentBlock,
|
||||
updatedBlock
|
||||
);
|
||||
|
|
@ -437,7 +437,7 @@ const updateBackground = (force = false) => {
|
|||
/(\s*})$/,
|
||||
` background: ${background.value.value};\n$1`
|
||||
);
|
||||
stylesheetStore.content = stylesheetStore.content.replace(
|
||||
stylesheetStore.replaceInCustomCss(
|
||||
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.content = stylesheetStore.content.replace(
|
||||
stylesheetStore.replaceInCustomCss(
|
||||
oldBlock,
|
||||
newCss
|
||||
);
|
||||
|
|
@ -688,7 +688,7 @@ watch(isEditable, async (newValue, oldValue) => {
|
|||
|
||||
// Format when exiting editing mode
|
||||
if (oldValue && !newValue) {
|
||||
await stylesheetStore.formatContent();
|
||||
await stylesheetStore.formatCustomCss();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<!-- Fallback static content when no recit data -->
|
||||
<template v-if="!hasRecitData">
|
||||
<!-- Fallback static content when no narrative data -->
|
||||
<template v-if="!hasNarrativeData">
|
||||
<section class="chapter">
|
||||
<p>
|
||||
Accumsan arcu tristique purus eros pellentesque rutrum hendrerit
|
||||
|
|
@ -10,13 +10,13 @@
|
|||
</section>
|
||||
</template>
|
||||
|
||||
<!-- Dynamic content from recit -->
|
||||
<!-- Dynamic content from narrative -->
|
||||
<template v-else>
|
||||
<template v-for="item in flattenedContent" :key="item.id">
|
||||
<!-- Récit (cover page) -->
|
||||
<!-- Narrative (cover page) -->
|
||||
<section
|
||||
v-if="item.template === 'recit'"
|
||||
class="recit-cover"
|
||||
v-if="item.template === 'narrative'"
|
||||
class="narrative-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 { useRecitStore } from '../stores/recit';
|
||||
import { useNarrativeStore } from '../stores/narrative';
|
||||
import {
|
||||
TextBlock,
|
||||
HeadingBlock,
|
||||
|
|
@ -87,10 +87,10 @@ import {
|
|||
blockComponents
|
||||
} from './blocks';
|
||||
|
||||
const recitStore = useRecitStore();
|
||||
const narrativeStore = useNarrativeStore();
|
||||
|
||||
const hasRecitData = computed(() => recitStore.data !== null);
|
||||
const flattenedContent = computed(() => recitStore.flattenedContent);
|
||||
const hasNarrativeData = computed(() => narrativeStore.data !== null);
|
||||
const flattenedContent = computed(() => narrativeStore.flattenedContent);
|
||||
|
||||
// Filter out hidden blocks
|
||||
const visibleBlocks = (blocks) => {
|
||||
|
|
@ -114,24 +114,24 @@ const getBlockComponent = (type) => {
|
|||
|
||||
<style>
|
||||
/* Base print styles for content sections */
|
||||
.recit-cover,
|
||||
.narrative-cover,
|
||||
.geoformat,
|
||||
.chapitre,
|
||||
.carte {
|
||||
break-before: page;
|
||||
}
|
||||
|
||||
.recit-cover .cover-image,
|
||||
.narrative-cover .cover-image,
|
||||
.geoformat .cover-image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.recit-cover h1 {
|
||||
.narrative-cover h1 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.recit-cover .author {
|
||||
.narrative-cover .author {
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}
|
||||
|
|
|
|||
200
src/components/SaveButton.vue
Normal file
200
src/components/SaveButton.vue
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<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>
|
||||
|
|
@ -1,31 +1,80 @@
|
|||
<template>
|
||||
<div id="stylesheet-viewer">
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<pre
|
||||
v-if="!isEditable"
|
||||
class="readonly"
|
||||
><code class="hljs language-css" v-html="highlightedCss"></code></pre>
|
||||
<!-- 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>
|
||||
|
||||
<textarea
|
||||
v-else
|
||||
:value="stylesheetStore.content"
|
||||
@input="handleInput"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, inject } from 'vue';
|
||||
import { ref, computed, watch } 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';
|
||||
|
|
@ -33,16 +82,22 @@ import 'highlight.js/styles/atom-one-dark.css';
|
|||
hljs.registerLanguage('css', css);
|
||||
|
||||
const stylesheetStore = useStylesheetStore();
|
||||
const activeTab = inject('activeTab');
|
||||
const isEditable = ref(false);
|
||||
const narrativeStore = useNarrativeStore();
|
||||
const isBaseCssExpanded = ref(false);
|
||||
const isCustomCssEditable = ref(false);
|
||||
let debounceTimer = null;
|
||||
|
||||
const highlightedCss = computed(() => {
|
||||
if (!stylesheetStore.content) return '';
|
||||
return hljs.highlight(stylesheetStore.content, { language: 'css' }).value;
|
||||
const highlightedBaseCss = computed(() => {
|
||||
if (!stylesheetStore.baseCss) return '';
|
||||
return hljs.highlight(stylesheetStore.baseCss, { language: 'css' }).value;
|
||||
});
|
||||
|
||||
const handleInput = (event) => {
|
||||
const highlightedCustomCss = computed(() => {
|
||||
if (!stylesheetStore.customCss) return '';
|
||||
return hljs.highlight(stylesheetStore.customCss, { language: 'css' }).value;
|
||||
});
|
||||
|
||||
const handleCustomCssInput = (event) => {
|
||||
const newContent = event.target.value;
|
||||
|
||||
if (debounceTimer) {
|
||||
|
|
@ -50,24 +105,85 @@ const handleInput = (event) => {
|
|||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
stylesheetStore.content = newContent;
|
||||
stylesheetStore.customCss = newContent;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Sync editing mode with store
|
||||
watch(isEditable, async (newValue, oldValue) => {
|
||||
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) => {
|
||||
stylesheetStore.isEditing = newValue;
|
||||
|
||||
// Format when exiting editing mode
|
||||
if (oldValue && !newValue) {
|
||||
await stylesheetStore.formatContent();
|
||||
}
|
||||
});
|
||||
|
||||
// Disable editing mode when changing tabs
|
||||
watch(activeTab, (newTab) => {
|
||||
if (newTab !== 'code' && isEditable.value) {
|
||||
isEditable.value = false;
|
||||
await stylesheetStore.formatCustomCss();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -79,18 +195,44 @@ watch(activeTab, (newTab) => {
|
|||
height: 100%;
|
||||
background: #282c34;
|
||||
color: #fff;
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
.css-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #21252b;
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-section {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #2c313c;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.css-section.custom-section .section-header {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
|
|
@ -144,11 +286,28 @@ 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: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #1e1e1e;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 0.875rem;
|
||||
|
|
@ -162,14 +321,51 @@ h3 {
|
|||
textarea {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
background: #1e1e1e;
|
||||
color: #abb2bf;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
padding: 1rem;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
class="tab"
|
||||
:class="{ active: activeTab === 'document' }"
|
||||
@click="activeTab = 'document'"
|
||||
title="Ouvrir l'onglet Document (\)"
|
||||
>
|
||||
Document
|
||||
</button>
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
class="tab"
|
||||
:class="{ active: activeTab === 'code' }"
|
||||
@click="activeTab = 'code'"
|
||||
title="Ouvrir l'onglet Code"
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
|
|
@ -22,6 +24,7 @@
|
|||
class="tab"
|
||||
:class="{ active: activeTab === 'contenu' }"
|
||||
@click="activeTab = 'contenu'"
|
||||
title="Ouvrir l'onglet Contenu"
|
||||
>
|
||||
Contenu
|
||||
</button>
|
||||
|
|
@ -32,7 +35,7 @@
|
|||
type="button"
|
||||
class="close-button"
|
||||
@click="activeTab = ''"
|
||||
title="Fermer le panneau"
|
||||
title="Fermer le panneau (\)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -128,11 +131,11 @@ nav {
|
|||
position: relative;
|
||||
left: calc(var(--panel-w) * -1);
|
||||
|
||||
padding: 4rem 0;
|
||||
|
||||
background-color: var(--color-panel-bg);
|
||||
box-shadow: -5px 0px 12px;
|
||||
|
||||
|
||||
transition: left 0.3s var(--curve);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
|
@ -142,10 +145,10 @@ nav {
|
|||
}
|
||||
|
||||
.tab-panel {
|
||||
height: 100%;
|
||||
height: calc(100% - var(--panel-nav-h)*2);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 2em;
|
||||
// padding-left: 1em;
|
||||
margin-top: var(--panel-nav-h);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -57,9 +57,10 @@
|
|||
<div class="input-with-unit">
|
||||
<NumberInput
|
||||
id="margin-top"
|
||||
v-model="margins.top.value"
|
||||
:modelValue="margins.top.value"
|
||||
:min="0"
|
||||
:step="1"
|
||||
@update:modelValue="(value) => margins.top.value = value"
|
||||
/>
|
||||
<div class="unit-toggle">
|
||||
<button
|
||||
|
|
@ -97,9 +98,10 @@
|
|||
<div class="input-with-unit">
|
||||
<NumberInput
|
||||
id="margin-bottom"
|
||||
v-model="margins.bottom.value"
|
||||
:modelValue="margins.bottom.value"
|
||||
:min="0"
|
||||
:step="1"
|
||||
@update:modelValue="(value) => margins.bottom.value = value"
|
||||
/>
|
||||
<div class="unit-toggle">
|
||||
<button
|
||||
|
|
@ -137,9 +139,10 @@
|
|||
<div class="input-with-unit">
|
||||
<NumberInput
|
||||
id="margin-left"
|
||||
v-model="margins.left.value"
|
||||
:modelValue="margins.left.value"
|
||||
:min="0"
|
||||
:step="1"
|
||||
@update:modelValue="(value) => margins.left.value = value"
|
||||
/>
|
||||
<div class="unit-toggle">
|
||||
<button
|
||||
|
|
@ -177,9 +180,10 @@
|
|||
<div class="input-with-unit">
|
||||
<NumberInput
|
||||
id="margin-right"
|
||||
v-model="margins.right.value"
|
||||
:modelValue="margins.right.value"
|
||||
:min="0"
|
||||
:step="1"
|
||||
@update:modelValue="(value) => margins.right.value = value"
|
||||
/>
|
||||
<div class="unit-toggle">
|
||||
<button
|
||||
|
|
@ -350,10 +354,7 @@ const updateMargins = () => {
|
|||
`$1${marginValue}`
|
||||
);
|
||||
|
||||
stylesheetStore.content = stylesheetStore.content.replace(
|
||||
currentBlock,
|
||||
updatedBlock
|
||||
);
|
||||
stylesheetStore.replaceBlock(currentBlock, updatedBlock);
|
||||
};
|
||||
|
||||
// Watch margin values (number inputs) with debounce
|
||||
|
|
@ -394,19 +395,13 @@ const updateBackground = () => {
|
|||
/(background:\s*)[^;]+/,
|
||||
`$1${background.value.value}`
|
||||
);
|
||||
stylesheetStore.content = stylesheetStore.content.replace(
|
||||
currentBlock,
|
||||
updatedBlock
|
||||
);
|
||||
stylesheetStore.replaceBlock(currentBlock, updatedBlock);
|
||||
} else {
|
||||
const updatedBlock = currentBlock.replace(
|
||||
/(\s*})$/,
|
||||
` background: ${background.value.value};\n$1`
|
||||
);
|
||||
stylesheetStore.content = stylesheetStore.content.replace(
|
||||
currentBlock,
|
||||
updatedBlock
|
||||
);
|
||||
stylesheetStore.replaceBlock(currentBlock, updatedBlock);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -453,7 +448,7 @@ watch(runningTitle, (enabled) => {
|
|||
});
|
||||
|
||||
const updatePageFooters = () => {
|
||||
let currentCss = stylesheetStore.content;
|
||||
let currentCss = stylesheetStore.customCss;
|
||||
|
||||
// Remove existing @page:left and @page:right rules
|
||||
currentCss = currentCss.replace(/@page:left\s*\{[^}]*\}/g, '');
|
||||
|
|
@ -535,7 +530,7 @@ const updatePageFooters = () => {
|
|||
currentCss.slice(insertPosition);
|
||||
}
|
||||
|
||||
stylesheetStore.content = currentCss;
|
||||
stylesheetStore.setCustomCss(currentCss);
|
||||
};
|
||||
|
||||
const syncFromStore = () => {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
|
||||
<!-- Taille du texte -->
|
||||
<div class="settings-subsection">
|
||||
<div class="field">
|
||||
<div class="field field-text-size">
|
||||
<label for="text-size-range" class="label-with-tooltip" data-css="font-size">Taille du texte</label>
|
||||
<InputWithUnit
|
||||
v-model="fontSize"
|
||||
|
|
@ -91,31 +91,277 @@
|
|||
</div>
|
||||
|
||||
<!-- Marges extérieures -->
|
||||
<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 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>
|
||||
|
||||
<!-- Marges intérieures -->
|
||||
<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 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>
|
||||
|
||||
</div>
|
||||
|
|
@ -127,7 +373,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 MarginEditor from '../ui/MarginEditor.vue';
|
||||
import NumberInput from '../ui/NumberInput.vue';
|
||||
import { useCssUpdater } from '../../composables/useCssUpdater';
|
||||
import { useCssSync } from '../../composables/useCssSync';
|
||||
import { useDebounce } from '../../composables/useDebounce';
|
||||
|
|
@ -155,7 +401,6 @@ 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' },
|
||||
|
|
@ -163,7 +408,6 @@ 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' },
|
||||
|
|
@ -171,12 +415,48 @@ const marginInnerDetailed = ref({
|
|||
left: { value: 0, unit: 'mm' }
|
||||
});
|
||||
|
||||
const marginOuterEditor = ref(null);
|
||||
const marginInnerEditor = ref(null);
|
||||
const marginOuterLinked = ref(false);
|
||||
const marginInnerLinked = ref(false);
|
||||
|
||||
// Track previous values to detect which one changed
|
||||
const prevMarginOuter = ref({
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 24,
|
||||
left: 0
|
||||
});
|
||||
|
||||
const prevMarginInner = ref({
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
});
|
||||
|
||||
let isUpdatingFromStore = false;
|
||||
|
||||
// Update margin outer unit for all sides
|
||||
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');
|
||||
|
|
@ -210,28 +490,160 @@ watch(fontSize, (val) => {
|
|||
});
|
||||
}, { deep: true });
|
||||
|
||||
// Margin/Padding handlers
|
||||
const handleMarginOuterChange = ({ type, simple, detailed }) => {
|
||||
if (isUpdatingFromStore) return;
|
||||
debouncedUpdate(() => {
|
||||
if (type === 'simple') {
|
||||
setMargin('p', simple.value, simple.unit);
|
||||
} else {
|
||||
setDetailedMargins('p', detailed.top, detailed.right, detailed.bottom, detailed.left);
|
||||
}
|
||||
});
|
||||
};
|
||||
// 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;
|
||||
|
||||
const handleMarginInnerChange = ({ type, simple, detailed }) => {
|
||||
isUpdatingFromStore = true;
|
||||
marginOuterDetailed.value.top.value = syncValue;
|
||||
marginOuterDetailed.value.bottom.value = syncValue;
|
||||
marginOuterDetailed.value.left.value = syncValue;
|
||||
marginOuterDetailed.value.right.value = syncValue;
|
||||
|
||||
prevMarginOuter.value.top = syncValue;
|
||||
prevMarginOuter.value.bottom = syncValue;
|
||||
prevMarginOuter.value.left = syncValue;
|
||||
prevMarginOuter.value.right = syncValue;
|
||||
isUpdatingFromStore = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(marginInnerLinked, (isLinked) => {
|
||||
if (isLinked) {
|
||||
// When linking, sync all to the first non-zero value or top value
|
||||
const current = marginInnerDetailed.value;
|
||||
const syncValue = current.top.value || current.bottom.value || current.left.value || current.right.value;
|
||||
|
||||
isUpdatingFromStore = true;
|
||||
marginInnerDetailed.value.top.value = syncValue;
|
||||
marginInnerDetailed.value.bottom.value = syncValue;
|
||||
marginInnerDetailed.value.left.value = syncValue;
|
||||
marginInnerDetailed.value.right.value = syncValue;
|
||||
|
||||
prevMarginInner.value.top = syncValue;
|
||||
prevMarginInner.value.bottom = syncValue;
|
||||
prevMarginInner.value.left = syncValue;
|
||||
prevMarginInner.value.right = syncValue;
|
||||
isUpdatingFromStore = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Watch margin outer values
|
||||
watch(() => [
|
||||
marginOuterDetailed.value.top.value,
|
||||
marginOuterDetailed.value.bottom.value,
|
||||
marginOuterDetailed.value.left.value,
|
||||
marginOuterDetailed.value.right.value,
|
||||
], () => {
|
||||
if (isUpdatingFromStore) return;
|
||||
debouncedUpdate(() => {
|
||||
if (type === 'simple') {
|
||||
setPadding('p', simple.value, simple.unit);
|
||||
} else {
|
||||
setDetailedPadding('p', detailed.top, detailed.right, detailed.bottom, detailed.left);
|
||||
|
||||
// If linked, sync all values to the one that changed
|
||||
if (marginOuterLinked.value) {
|
||||
const current = {
|
||||
top: marginOuterDetailed.value.top.value,
|
||||
bottom: marginOuterDetailed.value.bottom.value,
|
||||
left: marginOuterDetailed.value.left.value,
|
||||
right: marginOuterDetailed.value.right.value,
|
||||
};
|
||||
|
||||
// Find which value actually changed by comparing with previous
|
||||
let changedValue = null;
|
||||
if (current.top !== prevMarginOuter.value.top) changedValue = current.top;
|
||||
else if (current.bottom !== prevMarginOuter.value.bottom) changedValue = current.bottom;
|
||||
else if (current.left !== prevMarginOuter.value.left) changedValue = current.left;
|
||||
else if (current.right !== prevMarginOuter.value.right) changedValue = current.right;
|
||||
|
||||
if (changedValue !== null) {
|
||||
isUpdatingFromStore = true;
|
||||
marginOuterDetailed.value.top.value = changedValue;
|
||||
marginOuterDetailed.value.bottom.value = changedValue;
|
||||
marginOuterDetailed.value.left.value = changedValue;
|
||||
marginOuterDetailed.value.right.value = changedValue;
|
||||
|
||||
// Update previous values
|
||||
prevMarginOuter.value.top = changedValue;
|
||||
prevMarginOuter.value.bottom = changedValue;
|
||||
prevMarginOuter.value.left = changedValue;
|
||||
prevMarginOuter.value.right = changedValue;
|
||||
isUpdatingFromStore = false;
|
||||
}
|
||||
} else {
|
||||
// Update previous values even when not linked
|
||||
prevMarginOuter.value.top = marginOuterDetailed.value.top.value;
|
||||
prevMarginOuter.value.bottom = marginOuterDetailed.value.bottom.value;
|
||||
prevMarginOuter.value.left = marginOuterDetailed.value.left.value;
|
||||
prevMarginOuter.value.right = marginOuterDetailed.value.right.value;
|
||||
}
|
||||
|
||||
debouncedUpdate(() => {
|
||||
setDetailedMargins('p',
|
||||
marginOuterDetailed.value.top,
|
||||
marginOuterDetailed.value.right,
|
||||
marginOuterDetailed.value.bottom,
|
||||
marginOuterDetailed.value.left
|
||||
);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
// Watch margin inner values
|
||||
watch(() => [
|
||||
marginInnerDetailed.value.top.value,
|
||||
marginInnerDetailed.value.bottom.value,
|
||||
marginInnerDetailed.value.left.value,
|
||||
marginInnerDetailed.value.right.value,
|
||||
], () => {
|
||||
if (isUpdatingFromStore) return;
|
||||
|
||||
// If linked, sync all values to the one that changed
|
||||
if (marginInnerLinked.value) {
|
||||
const current = {
|
||||
top: marginInnerDetailed.value.top.value,
|
||||
bottom: marginInnerDetailed.value.bottom.value,
|
||||
left: marginInnerDetailed.value.left.value,
|
||||
right: marginInnerDetailed.value.right.value,
|
||||
};
|
||||
|
||||
// Find which value actually changed by comparing with previous
|
||||
let changedValue = null;
|
||||
if (current.top !== prevMarginInner.value.top) changedValue = current.top;
|
||||
else if (current.bottom !== prevMarginInner.value.bottom) changedValue = current.bottom;
|
||||
else if (current.left !== prevMarginInner.value.left) changedValue = current.left;
|
||||
else if (current.right !== prevMarginInner.value.right) changedValue = current.right;
|
||||
|
||||
if (changedValue !== null) {
|
||||
isUpdatingFromStore = true;
|
||||
marginInnerDetailed.value.top.value = changedValue;
|
||||
marginInnerDetailed.value.bottom.value = changedValue;
|
||||
marginInnerDetailed.value.left.value = changedValue;
|
||||
marginInnerDetailed.value.right.value = changedValue;
|
||||
|
||||
// Update previous values
|
||||
prevMarginInner.value.top = changedValue;
|
||||
prevMarginInner.value.bottom = changedValue;
|
||||
prevMarginInner.value.left = changedValue;
|
||||
prevMarginInner.value.right = changedValue;
|
||||
isUpdatingFromStore = false;
|
||||
}
|
||||
} else {
|
||||
// Update previous values even when not linked
|
||||
prevMarginInner.value.top = marginInnerDetailed.value.top.value;
|
||||
prevMarginInner.value.bottom = marginInnerDetailed.value.bottom.value;
|
||||
prevMarginInner.value.left = marginInnerDetailed.value.left.value;
|
||||
prevMarginInner.value.right = marginInnerDetailed.value.right.value;
|
||||
}
|
||||
|
||||
debouncedUpdate(() => {
|
||||
setDetailedPadding('p',
|
||||
marginInnerDetailed.value.top,
|
||||
marginInnerDetailed.value.right,
|
||||
marginInnerDetailed.value.bottom,
|
||||
marginInnerDetailed.value.left
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Sync from store
|
||||
const syncFromStore = () => {
|
||||
|
|
@ -261,17 +673,17 @@ const syncFromStore = () => {
|
|||
const margins = extractSpacing('p', 'margin');
|
||||
if (margins) {
|
||||
if (margins.simple) {
|
||||
marginOuter.value = margins.simple;
|
||||
// Sync detailed from simple
|
||||
// All margins are the same
|
||||
marginOuterDetailed.value = {
|
||||
top: { ...margins.simple },
|
||||
right: { ...margins.simple },
|
||||
bottom: { ...margins.simple },
|
||||
left: { ...margins.simple }
|
||||
};
|
||||
marginOuterLinked.value = true;
|
||||
} else if (margins.detailed) {
|
||||
marginOuterDetailed.value = margins.detailed;
|
||||
// Check if all values are the same to set simple value
|
||||
// Check if all values are the same
|
||||
const allSame =
|
||||
margins.detailed.top.value === margins.detailed.right.value &&
|
||||
margins.detailed.top.value === margins.detailed.bottom.value &&
|
||||
|
|
@ -279,19 +691,7 @@ 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;
|
||||
|
||||
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);
|
||||
}
|
||||
marginOuterLinked.value = allSame;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -299,17 +699,17 @@ const syncFromStore = () => {
|
|||
const padding = extractSpacing('p', 'padding');
|
||||
if (padding) {
|
||||
if (padding.simple) {
|
||||
marginInner.value = padding.simple;
|
||||
// Sync detailed from simple
|
||||
// All paddings are the same
|
||||
marginInnerDetailed.value = {
|
||||
top: { ...padding.simple },
|
||||
right: { ...padding.simple },
|
||||
bottom: { ...padding.simple },
|
||||
left: { ...padding.simple }
|
||||
};
|
||||
marginInnerLinked.value = true;
|
||||
} else if (padding.detailed) {
|
||||
marginInnerDetailed.value = padding.detailed;
|
||||
// Check if all values are the same to set simple value
|
||||
// Check if all values are the same
|
||||
const allSame =
|
||||
padding.detailed.top.value === padding.detailed.right.value &&
|
||||
padding.detailed.top.value === padding.detailed.bottom.value &&
|
||||
|
|
@ -317,22 +717,25 @@ 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;
|
||||
|
||||
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);
|
||||
}
|
||||
marginInnerLinked.value = allSame;
|
||||
}
|
||||
}
|
||||
|
||||
// Update previous values to match current state
|
||||
prevMarginOuter.value = {
|
||||
top: marginOuterDetailed.value.top.value,
|
||||
right: marginOuterDetailed.value.right.value,
|
||||
bottom: marginOuterDetailed.value.bottom.value,
|
||||
left: marginOuterDetailed.value.left.value
|
||||
};
|
||||
|
||||
prevMarginInner.value = {
|
||||
top: marginInnerDetailed.value.top.value,
|
||||
right: marginInnerDetailed.value.right.value,
|
||||
bottom: marginInnerDetailed.value.bottom.value,
|
||||
left: marginInnerDetailed.value.left.value
|
||||
};
|
||||
|
||||
isUpdatingFromStore = false;
|
||||
};
|
||||
|
||||
|
|
@ -348,3 +751,49 @@ 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>
|
||||
|
|
|
|||
159
src/components/ui/CssFileImport.vue
Normal file
159
src/components/ui/CssFileImport.vue
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<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>
|
||||
|
|
@ -19,8 +19,14 @@
|
|||
: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
|
||||
|
|
@ -30,8 +36,14 @@
|
|||
: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>
|
||||
|
|
@ -42,32 +54,32 @@
|
|||
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']);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
:key="unit"
|
||||
type="button"
|
||||
:class="{ active: modelValue === unit }"
|
||||
:disabled="disabled"
|
||||
@click="$emit('update:modelValue', unit)"
|
||||
>
|
||||
{{ unit }}
|
||||
|
|
@ -21,6 +22,10 @@ defineProps({
|
|||
units: {
|
||||
type: Array,
|
||||
default: () => ['mm', 'px']
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ export function useCssUpdater() {
|
|||
new RegExp(`(${property}:\\s*)[^;]+`, 'i'),
|
||||
`$1${value}`
|
||||
);
|
||||
store.content = store.content.replace(currentBlock, updatedBlock);
|
||||
store.replaceBlock(currentBlock, updatedBlock);
|
||||
} else {
|
||||
const updatedBlock = currentBlock.replace(
|
||||
/(\s*})$/,
|
||||
` ${property}: ${value};\n$1`
|
||||
);
|
||||
store.content = store.content.replace(currentBlock, updatedBlock);
|
||||
store.replaceBlock(currentBlock, updatedBlock);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ export function useCssUpdater() {
|
|||
);
|
||||
|
||||
if (updatedBlock !== currentBlock) {
|
||||
store.content = store.content.replace(currentBlock, updatedBlock);
|
||||
store.replaceBlock(currentBlock, updatedBlock);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ export function useCssUpdater() {
|
|||
}
|
||||
|
||||
if (updatedBlock !== currentBlock) {
|
||||
store.content = store.content.replace(currentBlock, updatedBlock);
|
||||
store.replaceBlock(currentBlock, updatedBlock);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ export function useCssUpdater() {
|
|||
* Create a new CSS rule for a selector
|
||||
*/
|
||||
const createRule = (selector) => {
|
||||
store.content += `\n\n${selector} {\n}\n`;
|
||||
store.customCss += `\n\n${selector} {\n}\n`;
|
||||
return `${selector} {\n}`;
|
||||
};
|
||||
|
||||
|
|
|
|||
372
src/composables/useIframeInteractions.js
Normal file
372
src/composables/useIframeInteractions.js
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
81
src/composables/useKeyboardShortcuts.js
Normal file
81
src/composables/useKeyboardShortcuts.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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
|
||||
};
|
||||
}
|
||||
157
src/composables/usePreviewRenderer.js
Normal file
157
src/composables/usePreviewRenderer.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
70
src/composables/usePrintPreview.js
Normal file
70
src/composables/usePrintPreview.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export const useRecitStore = defineStore('recit', () => {
|
||||
export const useNarrativeStore = defineStore('narrative', () => {
|
||||
const data = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
|
@ -19,14 +19,14 @@ export const useRecitStore = defineStore('recit', () => {
|
|||
|
||||
const items = [];
|
||||
|
||||
// Add recit intro as first section
|
||||
// Add narrative intro as first section
|
||||
items.push({
|
||||
id: data.value.id,
|
||||
template: 'recit',
|
||||
template: 'narrative',
|
||||
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 useRecitStore = defineStore('recit', () => {
|
|||
subtitle: child.subtitle,
|
||||
tags: child.tags,
|
||||
cover: child.cover,
|
||||
text: child.text
|
||||
text: child.text,
|
||||
});
|
||||
|
||||
// Add geoformat chapters
|
||||
|
|
@ -53,7 +53,7 @@ export const useRecitStore = defineStore('recit', () => {
|
|||
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 useRecitStore = defineStore('recit', () => {
|
|||
template: 'carte',
|
||||
title: child.title,
|
||||
tags: child.tags,
|
||||
text: child.text
|
||||
text: child.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -72,10 +72,10 @@ export const useRecitStore = defineStore('recit', () => {
|
|||
return items;
|
||||
});
|
||||
|
||||
// Load recit data from URL
|
||||
const loadRecit = async (url) => {
|
||||
// Load narrative data from URL
|
||||
const loadNarrative = async (url) => {
|
||||
if (!url) {
|
||||
error.value = 'No recit URL provided';
|
||||
error.value = 'No narrative URL provided';
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ export const useRecitStore = defineStore('recit', () => {
|
|||
|
||||
data.value = await response.json();
|
||||
} catch (e) {
|
||||
console.error('Error loading recit:', e);
|
||||
console.error('Error loading narrative:', e);
|
||||
error.value = e.message;
|
||||
data.value = null;
|
||||
} finally {
|
||||
|
|
@ -121,7 +121,7 @@ export const useRecitStore = defineStore('recit', () => {
|
|||
flattenedContent,
|
||||
|
||||
// Actions
|
||||
loadRecit,
|
||||
reset
|
||||
loadNarrative,
|
||||
reset,
|
||||
};
|
||||
});
|
||||
|
|
@ -1,29 +1,49 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, computed, 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', () => {
|
||||
const content = ref('');
|
||||
// Base state
|
||||
const baseCss = ref('');
|
||||
const customCss = ref('');
|
||||
const isEditing = ref(false);
|
||||
let formatTimer = null;
|
||||
let isFormatting = false;
|
||||
let isInitializing = false;
|
||||
|
||||
// Format CSS with Prettier
|
||||
const formatContent = async () => {
|
||||
if (isFormatting || !content.value) return;
|
||||
// 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;
|
||||
|
||||
try {
|
||||
isFormatting = true;
|
||||
const formatted = await prettier.format(content.value, {
|
||||
const formatted = await prettier.format(customCss.value, {
|
||||
parser: 'css',
|
||||
plugins: [parserPostcss],
|
||||
printWidth: 80,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
});
|
||||
content.value = formatted;
|
||||
customCss.value = formatted;
|
||||
} catch (error) {
|
||||
console.error('CSS formatting error:', error);
|
||||
} finally {
|
||||
|
|
@ -31,46 +51,236 @@ export const useStylesheetStore = defineStore('stylesheet', () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Watch content and format after 500ms of inactivity (only when not editing)
|
||||
watch(content, () => {
|
||||
if (isFormatting || isEditing.value) return;
|
||||
// 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;
|
||||
}
|
||||
|
||||
clearTimeout(formatTimer);
|
||||
formatTimer = setTimeout(() => {
|
||||
formatContent();
|
||||
formatCustomCss();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
const loadStylesheet = async () => {
|
||||
const response = await fetch('/assets/css/stylesheet.css');
|
||||
content.value = await response.text();
|
||||
const response = await fetch('/assets/css/stylesheet.print.css');
|
||||
baseCss.value = await response.text();
|
||||
};
|
||||
|
||||
const updateProperty = (selector, property, value, unit) => {
|
||||
content.value = cssParsingUtils.updateCssValue({
|
||||
css: content.value,
|
||||
// Update custom CSS, not the combined content
|
||||
customCss.value = cssParsingUtils.updateCssValue({
|
||||
css: customCss.value,
|
||||
selector,
|
||||
property,
|
||||
value,
|
||||
unit
|
||||
unit,
|
||||
});
|
||||
};
|
||||
|
||||
const extractValue = (selector, property) => {
|
||||
return cssParsingUtils.extractCssValue(content.value, selector, property);
|
||||
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 extractBlock = (selector) => {
|
||||
return cssParsingUtils.extractCssBlock(content.value, 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 {
|
||||
content,
|
||||
// Core state
|
||||
content, // computed: baseCss + customCss
|
||||
baseCss,
|
||||
customCss,
|
||||
isEditing,
|
||||
|
||||
// Methods
|
||||
loadStylesheet,
|
||||
updateProperty,
|
||||
extractValue,
|
||||
extractBlock,
|
||||
formatContent
|
||||
replaceBlock,
|
||||
replaceInCustomCss,
|
||||
setCustomCss,
|
||||
commentCssBlock,
|
||||
uncommentCssBlock,
|
||||
isBlockCommented,
|
||||
getBlockState,
|
||||
formatCustomCss,
|
||||
loadBaseCss,
|
||||
initializeFromNarrative,
|
||||
|
||||
// Save/load
|
||||
isDirty,
|
||||
isSaving,
|
||||
lastSaved,
|
||||
lastSavedFormatted,
|
||||
saveError,
|
||||
narrativeId,
|
||||
saveCustomCss,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
155
src/utils/css-comments.js
Normal file
155
src/utils/css-comments.js
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -44,6 +44,24 @@ const updateCssValue = ({ css, selector, property, value, unit }) => {
|
|||
return css.replace(selectorRegex, `${selector} {${newBlockContent}}`);
|
||||
};
|
||||
|
||||
const cssParsingUtils = { extractCssBlock, extractCssValue, updateCssValue };
|
||||
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
|
||||
};
|
||||
|
||||
export default cssParsingUtils;
|
||||
|
|
|
|||
37
src/utils/kirby-auth.js
Normal file
37
src/utils/kirby-auth.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* 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');
|
||||
}
|
||||
|
|
@ -1,7 +1,25 @@
|
|||
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]';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue