Compare commits

...
Sign in to create a new pull request.

52 commits
styles ... main

Author SHA1 Message Date
isUnknown
75d3b557fe plugin : search input color
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 18s
2026-01-29 16:21:12 +01:00
isUnknown
b19635f324 feat: add custom marker icons with configurable size
- Add markerIcon files field to marker.yml for custom JPG/PNG/SVG icons
- Add markerIconSize range field (20-500px, default 40px) with unit display
- Layout icon fields side-by-side (50/50 width) in marker blueprint
- Add markerIconUrl prop in index.php to auto-detect uploaded icon
- Add markerIconSize prop in index.php to read size from page data
- Update MapPreview.vue to display custom images instead of default pins
- Set icon dimensions dynamically based on markerIconSize value
- Icon size updates on save/reload (reactive implementation deferred)
- Remove custom tiles background functionality (not needed)

Note: Custom icons show uploaded image, may have white background on
transparent PNGs depending on image processing. Size is non-reactive
and requires save + reload to update in preview.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 16:14:33 +01:00
isUnknown
925e98aea7 refactor: use Kirby page title for markers and add button tooltips
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 19s
- Remove custom title field from marker.yml blueprint
- Use default Kirby page title for marker names in MarkerList
- Add French tooltips to MarkerList buttons (Ajouter, Modifier, Supprimer)
- API already uses page title via $marker->title()->value()

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 15:17:17 +01:00
isUnknown
bad465406d fix: resolve marker positioning bug and integrate Kirby design system
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 17s
- Fix marker positioning issue where markers would glide and misalign during zoom
- Implement two-wrapper structure to isolate CSS transforms from MapLibre positioning
- Outer .custom-marker: MapLibre handles positioning via translate3d()
- Inner .marker-inner: Visual transforms (rotate, scale) isolated from MapLibre
- Remove debug console.log statements
- Integrate Kirby design system in MarkerList and GeocodeSearch components
- Use Kirby CSS variables (--input-color-back, --color-border, etc.)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 15:10:32 +01:00
isUnknown
63dc136309 change help message 2026-01-29 14:35:51 +01:00
isUnknown
818506fcfa fix: add polling and reset handling for single mode coordinates
Enhanced coordinate synchronization in single mode to handle Panel
actions like "Supprimer" (reset to saved values).

Issues Fixed:
- Marker not updating when clicking "Supprimer" button in Panel
- Panel "Supprimer" restores saved coordinates but marker didn't move
- No detection of programmatic field value changes

Solution:
- Add MutationObserver to detect attribute changes on input fields
- Add 500ms polling as fallback for value detection
- Add nextTick() for reactive updates to ensure proper timing
- Handle coordinate reset: when invalid, return to default center
- Proper cleanup with onBeforeUnmount for observers and intervals

Behavior:
- User changes field → marker updates immediately
- User drags marker → fields update immediately
- User clicks "Supprimer" → marker returns to saved position
- Fields cleared → marker disappears, map resets to default center

Technical Details:
- MutationObserver watches 'value' attribute on lat/lon inputs
- Polling checks every 500ms for changes missed by events
- Watcher uses nextTick() to ensure DOM updates complete
- All event listeners and observers properly cleaned up on unmount

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 14:34:28 +01:00
isUnknown
cc44a68e66 fix: implement form-based coordinate sync for single mode map
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 19s
Fixed marker display and centering in single mode (marker pages) by
changing from props-based to form-based coordinate synchronization.

Issues Fixed:
- Kirby blueprint query syntax {{ page.field }} passed literal strings
  instead of values to component props
- Invalid coordinates (NaN, NaN) caused map initialization errors
- Marker not displaying in marker page position tab
- Map not centering on marker location

Solution:
- Remove latitude/longitude props from marker.yml blueprint
- Read coordinates directly from Panel form fields via DOM
- Add event listeners to sync form changes with map
- Bidirectional sync: drag marker → updates form fields
- Robust coordinate validation (check for NaN, null, 0)

Changes:
- MapEditor.vue: Add form field reading and event listeners
- MapEditor.vue: Replace props-based coords with reactive refs
- MapEditor.vue: Update marker drag handler to modify form inputs
- marker.yml: Remove non-functional query string props
- routes.php: Use data() instead of body() for all routes

Single Mode Flow:
1. Component reads latitude/longitude from form inputs on mount
2. Creates marker and centers map on valid coordinates
3. Form changes → updates marker position
4. Marker drag → updates form fields (triggers save on user action)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 14:17:01 +01:00
isUnknown
32e8301d91 feat: transform map-editor markers into Kirby subpages
Some checks failed
Deploy / Build and Deploy to Production (push) Has been cancelled
Major refactoring of the map-editor plugin to store markers as Kirby
subpages instead of YAML data, enabling extensible block content.

Backend Changes:
- Add API routes for marker CRUD operations (GET, POST, PATCH, DELETE)
- Create marker.yml blueprint with content & position tabs
- Add markers section to map.yml blueprint
- Update useMapData to only handle center/zoom/background
- Create useMarkersApi composable for API communication

Frontend Changes:
- Refactor MapEditor.vue to support multi/single modes
- Multi mode: loads markers via API, redirects to Panel for editing
- Single mode: displays single marker for position tab in marker page
- Remove MarkerEditor.vue modal (replaced by Panel editing)
- Normalize position format handling (lon vs lng)

API Features:
- Session-based auth for Panel requests (no CSRF needed)
- Proper error handling and validation
- Markers created as listed pages (not drafts)
- Uses Kirby's data() method for JSON parsing

Documentation:
- Add IMPLEMENTATION_SUMMARY.md with technical details
- Add TESTING_CHECKLIST.md with 38 test cases

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 14:08:40 +01:00
isUnknown
b47195488a map plugin : improve styles 2026-01-28 16:33:48 +01:00
isUnknown
2b0f4f8742 refactor: comprehensive map-editor plugin refactoring (phases 1-3)
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 18s
This commit implements a complete refactoring of the map-editor plugin to
improve code organization, reusability, and maintainability.

## Phase 1: Extraction of composables and factory functions

**New composables:**
- `useMarkers.js`: Centralized marker state and CRUD operations
  - Exports: markers, selectedMarkerId, editingMarker refs
  - Computed: canAddMarker, hasMarkers, selectedMarker
  - Methods: addMarker, updateMarker, deleteMarker, selectMarker, etc.
  - Includes createMarker() factory to eliminate code duplication

- `useMapData.js`: Map data persistence (YAML load/save)
  - Exports: center, zoom refs
  - Methods: loadMapData, saveMapData, debouncedSave
  - Handles lifecycle cleanup of debounce timeouts

**Benefits:**
- Eliminated code duplication (2 identical marker creation blocks)
- Separated business logic from UI concerns
- Improved testability with pure functions
- Added JSDoc documentation throughout

## Phase 2: Component extraction

**New components:**
- `MarkerList.vue`: Extracted sidebar UI from MapEditor.vue
  - Props: markers, selectedMarkerId, maxMarkers
  - Emits: add-marker, select-marker, edit-marker, delete-marker, select-location
  - Includes integrated GeocodeSearch component
  - Self-contained styles with scoped CSS

**Benefits:**
- MapEditor.vue reduced from 370 → 230 lines (-40%)
- Clear separation of concerns (orchestration vs presentation)
- Reusable component for potential future use
- Easier to test and maintain

## Phase 3: Utils restructuring with JSDoc

**New structure:**
```
utils/
├── constants.js           # NOMINATIM_API, MAP_DEFAULTS, DEBOUNCE_DELAYS
├── api/
│   └── nominatim.js      # geocode() with full JSDoc typedefs
└── helpers/
    └── debounce.js       # Generic debounce utility
```

**Removed:**
- `utils/geocoding.js` (replaced by modular structure)

**Benefits:**
- Constants centralized for easy configuration
- API layer separated from helpers
- Complete JSDoc type annotations for better IDE support
- Better organization following standard patterns

## Updated components

**MapEditor.vue:**
- Now uses useMarkers and useMapData composables
- Uses MarkerList component instead of inline template
- Cleaner setup function with better separation
- Reduced from 537 → 256 lines (CSS moved to MarkerList)

**GeocodeSearch.vue:**
- Updated imports to use new utils structure
- Uses DEBOUNCE_DELAYS constant instead of hardcoded value

## Build verification

-  npm run build successful
-  Bundle size unchanged (806.10 kB / 223.46 KiB gzipped)
-  All functionality preserved
-  No breaking changes

## Documentation

- Added comprehensive README.md with:
  - Architecture overview
  - Composables usage examples
  - Component API documentation
  - Data flow diagrams
  - Development guide

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 16:29:15 +01:00
isUnknown
437349cd2b feat: add Phase 2 features to map-editor plugin (rich marker content)
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 16s
Implement marker editing modal with comprehensive content management:
- MarkerEditor.vue modal with custom overlay (replaces k-dialog)
- Edit marker on double-click or via edit button in list
- Required fields: title (validated), optional description
- Editable position (lat/lon) and custom icon support
- Content blocks system: add/remove/reorder text and image blocks
- French translations for all UI elements
- Click marker in list to center map on it with smooth animation
- Fix marker anchor to bottom (pin tip) for accurate positioning
- Auto-save with isDirty flag to detect any form changes

Modal features:
- Title field (required)
- Description textarea (optional)
- Position inputs (latitude/longitude)
- Icon selector (default or custom via UUID/filename)
- Content builder with text and image blocks
- Block reordering (up/down) and deletion
- Validation: save button enabled only when title filled and form modified

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 16:16:19 +01:00
isUnknown
dc84ff63a2 feat: add map-editor plugin with interactive OSM map and markers
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 19s
Implement Phase 1 of custom Kirby plugin for editing interactive maps:
- OpenStreetMap base layer with MapLibre GL JS
- Click to add markers, drag to reposition
- Marker list sidebar with selection and deletion
- Auto-save with debounce (YAML format)
- Add marker button creates marker at current map center
- Max 50 markers per map (configurable)
- Clean UI with marker counter

Blueprint updated to use new map-editor field type instead of placeholder.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 15:43:23 +01:00
isUnknown
7e42c4baec refactor: rename carte template to map for consistency
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 15s
Rename all carte.yml files to map.yml and update references in blueprints to use English naming convention consistently across the codebase. This includes renaming 5 content files and updating template references in narrative and block blueprints.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 14:07:40 +01:00
isUnknown
c7e751695f fix build process
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 17s
2026-01-27 18:15:34 +01:00
isUnknown
be7bb66e70 refactor: extract App.vue logic into composables (762→230 lines)
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 14s
Extracted complex logic from App.vue into focused, reusable composables:

New composables:
- useKeyboardShortcuts.js (~80 lines): Keyboard shortcuts (Cmd/Ctrl+S, P, Escape, \)
- useIframeInteractions.js (~370 lines): Page/element hover, labels, clicks, popups
- usePreviewRenderer.js (~160 lines): Double buffering, transitions, scroll persistence
- usePrintPreview.js (~70 lines): Print dialog and style collection

Benefits:
- 70% reduction in App.vue size (532 lines extracted)
- Better separation of concerns
- Improved maintainability and testability
- Clearer code organization

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:20:10 +01:00
isUnknown
dac532a932 feat: add Cmd/Ctrl+P shortcut to trigger print preview
Added Cmd+P (Mac) or Ctrl+P (Windows/Linux) to trigger printPreview():
- Prevents default browser print dialog
- Triggers custom print preview function
- Updated print button tooltip to show keyboard shortcut
- Added platform detection for correct symbol display (⌘ or Ctrl)

Works in all contexts (main document and iframe).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:11:18 +01:00
isUnknown
4d39a83a63 feat: add backslash shortcut to toggle editor panel
Added \ key to toggle the editor panel open/closed:
- Opens to 'document' tab when panel is closed
- Closes panel when it's open
- Updated button tooltips to indicate the keyboard shortcut

Works in all contexts (main document and iframe).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:10:26 +01:00
isUnknown
8e2f0a10e2 feat: add Escape key shortcut to close popups
Added Escape key handler to close ElementPopup or PagePopup when open.
The handler checks which popup is visible and calls its close method.

Works in all contexts (main document and iframe) using the existing
handleKeyboardShortcut function.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:09:28 +01:00
isUnknown
91ef119697 fix: keyboard shortcut Cmd/Ctrl+S now works when focus is in preview iframe
Added keyboard event listener to iframe document to capture shortcuts
when user is focused inside the preview. Previously, keyboard events
inside iframes didn't bubble up to the parent document.

Changes:
- Add handleKeyboardShortcut function in App.vue
- Attach keydown listener to main document (for focus outside iframe)
- Attach keydown listener to iframe document (for focus inside iframe)
- Clean up listener on unmount

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:07:02 +01:00
isUnknown
e229deb0f6 fix color picker z-index (always above)
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 16s
2026-01-09 17:03:11 +01:00
isUnknown
83455b7098 fix: improve Coloris color picker visibility and button clickability
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 16s
- Fix grid layout: add second column for input (grid-template-columns: var(--input-h) 1fr)
- Ensure color picker button is clickable with cursor pointer and pointer-events auto
- Set color picker z-index to 10000 to display above all UI elements
- Add global styles to ensure Coloris button is always clickable

Fixes issues where:
- Color picker appeared behind ElementPopup
- Button was not consistently clickable
- Grid layout was missing second column definition

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 16:54:10 +01:00
isUnknown
9127520ff7 feat: add keyboard shortcut Cmd/Ctrl+S to SaveButton
- Add Cmd+S (Mac) or Ctrl+S (Windows/Linux) keyboard shortcut to save CSS
- Detect platform to display correct shortcut symbol (⌘ or Ctrl)
- Prevent default browser save behavior
- Translate tooltips to French
- Show shortcut in button tooltip

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 16:46:48 +01:00
isUnknown
cb5d056b51 fix: restore TextSettings functionality after store refactoring
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 16s
Fixed TextSettings fields not updating the stylesheet and preview after
the store refactoring that made content a computed property.

- Add missing font watcher in TextSettings.vue
- Update useCssUpdater.js to use store.replaceBlock() instead of
  writing to readonly store.content
- Update createRule() to append to store.customCss instead of store.content

All TextSettings fields (font, size, margins, padding, alignment) now
correctly update the stylesheet and preview.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 16:42:34 +01:00
isUnknown
e42eeab437 feat: add scrollable CSS editor and complete stylesheet export
Add two improvements to StylesheetViewer:

1. Scrollable CSS sections
   - Add max-height (500px) and overflow-y to custom CSS editor
   - Applies to both read-only and editable modes
   - Improves UX when content exceeds viewport

2. Complete stylesheet export button
   - Exports merged base CSS + custom CSS to single file
   - Filename format: <narrative-slug>-style.print.css
   - Includes informative comments:
     * Header with narrative title and download date
     * Section markers for base CSS and custom CSS
   - Full-width button below custom CSS section
   - Download via blob + automatic cleanup

Export file structure:
- Header comment (narrative info, date)
- Base CSS section with comment
- Custom CSS section with comment

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 16:30:18 +01:00
isUnknown
f8ac1ec8fc untrack content 2026-01-09 16:22:56 +01:00
isUnknown
e88c217b1e feat: add CSS file import with drag & drop support
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 16s
Add CssFileImport component to StylesheetViewer allowing users to
import CSS files to replace custom CSS content.

Features:
- Click to select file via file dialog
- Drag & drop support with visual feedback
- File validation (.css only, max 1MB)
- Error messages for invalid files
- Direct replacement of customCss content

New component:
- src/components/ui/CssFileImport.vue

Integration:
- Added at top of StylesheetViewer
- Emits 'import' event with file content
- Content replaces customCss in store

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 14:41:56 +01:00
isUnknown
b692047ff2 fix: improve bidirectional sync between stylesheet and ElementPopup fields
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 17s
Replace content watcher with customCss watcher and add isEditing watcher
to properly sync field values when CSS is edited in StylesheetViewer.

Changes:
- Watch customCss instead of content for real-time updates
- Watch isEditing to reload values when exiting edit mode
- Use isUpdatingFromStore + nextTick to prevent circular updates
- Ensure popup fields stay in sync with stylesheet changes

Now when editing CSS manually in the Code tab, ElementPopup fields
update automatically when exiting edit mode.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 14:35:23 +01:00
isUnknown
93df05c49f feat: implement inheritance lock/unlock with CSS commenting system
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 16s
Add ability to lock/unlock inheritance for element styles while preserving
custom values. Locked styles are commented in the CSS and restored when unlocked.

New utilities:
- Create css-comments.js with comment/uncomment functions
- Add parseValueWithUnit to css-parsing.js for value parsing
- Add getBlockState, commentCssBlock, uncommentCssBlock to stylesheet store

ElementPopup improvements:
- Detect inheritance state from CSS block state (active/commented/none)
- Capture computed styles from iframe when unlocking with no custom CSS
- Comment/uncomment CSS blocks instead of deleting them on lock toggle
- Use nextTick to prevent race condition with watchers during popup init
- Extract values from both active and commented CSS blocks

Workflow:
1. First unlock: Capture computed styles → create CSS block
2. Lock: Comment the CSS block (styles preserved in comments)
3. Unlock again: Uncomment the block (styles restored)

Fixes issue where CSS rules were created on popup open due to
watcher race conditions during initialization.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 14:31:42 +01:00
isUnknown
b123e92da8 ci: add --delete flag to FTP mirror commands
Enable automatic deletion of remote files that no longer exist locally.
This ensures the production server stays in sync with the repository,
removing obsolete files like the renamed stylesheet.css.

Protected directories (accounts, cache, sessions) remain excluded.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 13:59:54 +01:00
isUnknown
0f46618066 feat: add custom CSS save system with dual-editor interface
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 16s
Implement complete custom CSS management system:
- Separate base CSS (readonly) and custom CSS (editable)
- Save custom CSS to Kirby backend per narrative
- Visual save button with state indicators (dirty/saving/success/error)
- CSRF-protected API endpoint for CSS operations
- Dual-editor StylesheetViewer (base + custom with edit mode toggle)
- Auto-format custom CSS with Prettier on edit mode exit

Backend changes:
- Add web2print Kirby plugin with POST/GET routes
- Add customCss field to narrative blueprint
- Add CSRF token meta tag in header
- Include customCss and modified timestamps in JSON template
- Install code-editor plugin for Kirby panel

Frontend changes:
- Refactor stylesheet store with baseCss/customCss refs
- Make content a computed property (baseCss + customCss)
- Add helper methods: replaceBlock, replaceInCustomCss, setCustomCss
- Update all components to use new store API
- Create SaveButton component with FAB design
- Redesign StylesheetViewer with collapsable sections
- Initialize store from narrative data on app mount

File changes:
- Rename stylesheet.css → stylesheet.print.css
- Update all references to new filename

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 13:39:25 +01:00
isUnknown
4d1183d1af narrative : fix data fetching (build URL from location)
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 17s
2026-01-09 10:53:08 +01:00
isUnknown
ccaec7cfed refactor: rename content files from recit.txt to narrative.txt
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 16s
Update all Kirby content files to use the new narrative template name.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 10:38:07 +01:00
isUnknown
3b59127fa9 untrack claude settings
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 17s
2026-01-09 10:36:14 +01:00
isUnknown
af788ad1e0 refactor: rename 'recit' to 'narrative' for English code naming
- Rename store: recit.js → narrative.js (useRecitStore → useNarrativeStore)
- Rename templates: recit.php/json.php → narrative.php/json.php
- Rename blueprint: recit.yml → narrative.yml
- Update all imports and references in Vue/JS files
- Update PHP template references and data attributes
- Update CLAUDE.md documentation
- Create comprehensive README.md with English-French dictionary

The dictionary section maps English code terms to French content terms
for easier navigation between codebase and CMS content.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 10:34:10 +01:00
isUnknown
ea0994ed45 Edit panel > numberInput : fix decrement function
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 17s
2026-01-09 10:14:45 +01:00
isUnknown
18e4efc59d docs: update CLAUDE.md with detailed project structure and conventions
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 15s
- Add comprehensive architecture documentation with all component folders
- Document placement conventions for blocks, editor panels, UI components
- Add CI/CD deployment details and excluded files
- Remove donorbox cache file from repository

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-08 16:22:32 +01:00
isUnknown
16f01681dc fix build : copy some styles
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 15s
2026-01-08 16:07:15 +01:00
isUnknown
3cc4da63fb add image docker
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 16s
2026-01-08 15:56:11 +01:00
isUnknown
a7918a35e2 update gitignore
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 29s
2025-12-12 13:09:06 +01:00
isUnknown
10660e92bb merge
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 28s
2025-12-12 13:08:02 +01:00
isUnknown
bb215b04da merge styles
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 29s
2025-12-12 12:26:39 +01:00
isUnknown
236a606a42 chore: exclude CSS source files from production deployment
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 29s
2025-12-11 13:48:09 +01:00
isUnknown
d484915c16 Revert "fix: use HEREDOC for lftp script to handle special chars in password"
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 28s
This reverts commit 6c421ce628.
2025-12-11 13:45:46 +01:00
isUnknown
8ddac25d5c Revert "fix: pass FTP credentials as lftp arguments instead of script"
This reverts commit 0b1a759e5e.
2025-12-11 13:45:46 +01:00
isUnknown
0b1a759e5e fix: pass FTP credentials as lftp arguments instead of script 2025-12-11 13:42:41 +01:00
isUnknown
6c421ce628 fix: use HEREDOC for lftp script to handle special chars in password
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 27s
2025-12-11 13:41:24 +01:00
isUnknown
052c6958f3 feat: configure CI/CD with Forgejo Actions
Some checks failed
Deploy / Build and Deploy to Production (push) Failing after 26s
- Add Forgejo workflow for automated build and deploy
- Build creates dist/ from public/ then adds Vue app build
- Configure Vite to build to dist/assets/dist with fixed filenames
- Deploy entire dist/ directory to production via FTP
- Add workflow documentation with FTP setup instructions

The workflow mirrors the GitLab CI approach: dist/ is created during build
and synchronized to production root on every push to main.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 13:39:32 +01:00
isUnknown
5fb9cf68a3 merge 2025-12-11 13:39:23 +01:00
isUnknown
4ae4a6d509 merge 2025-12-11 13:38:27 +01:00
isUnknown
06aef5beb3 refactor: replace MarginEditor with linked margin fields in TextSettings
- Replace MarginEditor component with individual fields (top/bottom/left/right)
- Add link/unlink button with SVG icons to sync margin values
- When linked, all fields share the same value
- Auto-detect linked state when loading from stylesheet
- Match PageSettings UI pattern for consistency

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 13:37:59 +01:00
Julie Blanc
a42f5e48ca popup style 2025-12-10 13:48:45 +01:00
Julie Blanc
d88758b226 filed font-size 2025-12-10 13:29:14 +01:00
98 changed files with 11629 additions and 1113 deletions

View 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).

View 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
View file

@ -24,3 +24,9 @@ dist-ssr
*.sw? *.sw?
.claude .claude
# Variables d'environnement Brevo
api/.env
# Claude settings
.claude
/.claude/*

12
Dockerfile.ci Normal file
View 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
View 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
View file

@ -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
View 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

115
claude.md
View file

@ -2,7 +2,7 @@
## Vue d'ensemble ## 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 ## Stack technique
@ -17,16 +17,44 @@ Application web-to-print permettant la mise en page de récits imprimables. L'é
/src # Vue 3 SPA /src # Vue 3 SPA
├── main.js # Bootstrap Vue ├── main.js # Bootstrap Vue
├── App.vue # Root + init PagedJS ├── App.vue # Root + init PagedJS
└── components/ ├── style.css # Styles globaux
└── PagedJsWrapper.vue # Contenu print ├── 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 /api/cache # Cache des données API (donorbox_data.json, etc.)
/public # Kirby CMS + assets statiques
├── index.php # Entry Kirby ├── index.php # Entry Kirby
├── composer.json # Dépendances PHP
├── assets/
│ ├── css/ # CSS sources (exclus du déploiement)
│ ├── fonts/ # Webfonts
│ └── svg/ # Icônes SVG
├── site/ ├── site/
│ ├── blueprints/ # Schémas de contenu │ ├── blueprints/ # Schémas de contenu Kirby
│ ├── templates/ # Templates PHP │ ├── templates/ # Templates PHP Kirby
│ └── snippets/ # header.php injecte Vue │ ├── snippets/ # Snippets PHP (header.php injecte Vue)
└── content/ # Contenus markdown │ ├── 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 ## Flux de données
@ -45,25 +73,82 @@ Application web-to-print permettant la mise en page de récits imprimables. L'é
### PagedJS ### PagedJS
- CSS `@page` rules dans les composants Vue - CSS `@page` rules dans les composants Vue
- Interface preview dans `/src/assets/pagedjs-interface.css`
- Initialisé via `Previewer` dans `App.vue` - Initialisé via `Previewer` dans `App.vue`
- Preview navigable avec interface de navigation
### À implémenter ### State Management
- API REST Kirby pour exposer le contenu en JSON - **Pinia** utilisé pour la gestion d'état
- Fetch dynamique dans Vue - `stores/narrative.js` : État du récit/narrative (contenu, navigation)
- Panneaux/popups d'édition réactive - `stores/stylesheet.js` : État des feuilles de style CSS
- State management si nécessaire
### 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 ## Commandes
```bash ```bash
npm run dev # Vite dev server (5173) 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é # 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 ## Conventions
- Composants Vue : PascalCase - Composants Vue : PascalCase
- CSS : Variables pour theming, scoped styles - CSS : Variables pour theming, scoped styles
- Print CSS : W3C Paged Media spec - Print CSS : W3C Paged Media spec
- Stores Pinia : camelCase pour les fichiers, PascalCase pour les noms (ex: `useNarrativeStore`)
- Composables : Préfixe `use` (ex: `useCssSync`)
- Code naming : English preferred (ex: `narrative` instead of `recit`)

5
public/.gitignore vendored
View file

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

View file

@ -1,26 +1,21 @@
.settings-section { .settings-section {
margin: var(--space-m) 0;
margin-top: 3em;
// .cons
h2 { h2 {
margin-bottom: var(--space); margin-bottom: var(--space);
font-weight: 600; font-weight: 600;
font-size: 1.4rem; font-size: 1.4rem;
border-bottom: 1px solid var(--color-200); border-bottom: 1px solid var(--color-200);
color: var(--color-800); color: var(--color-800);
} }
.infos { .infos {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-interface-400); color: var(--color-interface-400);
} }
}
.settings-subsection:not(:last-child) { .settings-subsection:not(:last-child) {
border-bottom: 1px solid var(--color-interface-100); border-bottom: 1px solid var(--color-interface-100);
@ -32,12 +27,8 @@
h3 { h3 {
margin-top: calc(var(--space-xs) * 1.5); margin-top: calc(var(--space-xs) * 1.5);
margin-bottom: calc(var(--space-xs) * 2); margin-bottom: calc(var(--space-xs) * 2);
// color: var(--color-600);
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
} }
} }
}

View file

@ -5,29 +5,25 @@ input[type="number"] {
border: 1px solid var(--color-interface-200); border: 1px solid var(--color-interface-200);
background-color: var(--color-interface-100); background-color: var(--color-interface-100);
font-family: var(--sans-serif); font-family: var(--sans-serif);
color: var(--color-txt);
font-size: 1rem; font-size: 1rem;
padding-left: 0.5ch;
// min-width: var(--input-w); // min-width: var(--input-w);
// width: 100%; // width: 100%;
// padding: 0 1ch; // padding: 0 1ch;
} }
.field { .field {
display: flex; display: flex;
label { label {
font-weight: 600; font-weight: 600;
color: var(--color-800); color: var(--color-800);
} }
.input-with-unit { .input-with-unit {
display: flex; display: flex;
gap: 0.3rem; gap: 0.3rem;
} }
.unit-toggle { .unit-toggle {
@ -42,23 +38,22 @@ input[type="number"] {
.clr-field { .clr-field {
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-columns: var(--input-h); grid-template-columns: var(--input-h) 1fr;
grid-gap: 1ch; grid-gap: 1ch;
button { button {
grid-column: 1; grid-column: 1;
position: relative; position: relative;
border-radius: var(--border-radius); border-radius: var(--border-radius);
cursor: pointer;
pointer-events: auto;
} }
input { input {
grid-column: 2; grid-column: 2;
} }
} }
} }
} }
.field { .field {
display: grid; display: grid;
grid-template-columns: var(--label-w) 1fr; grid-template-columns: var(--label-w) 1fr;
@ -76,15 +71,29 @@ input[type="number"] {
width: 100%; width: 100%;
} }
.field-checkbox { .field-checkbox {
grid-column: 2;
padding-top: var(--space-xs); padding-top: var(--space-xs);
label { label {
font-weight: 400; font-weight: 400;
margin-left: 0.75ch; 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; display: inline-grid;
width: calc(50% - 1ch); width: calc(50% - 1ch);
grid-template-columns: 6.5ch var(--input-w-small) 1fr; grid-template-columns: 6.5ch var(--input-w-small) 1fr;
@ -95,9 +104,7 @@ input[type="number"] {
} }
&:nth-of-type(odd) { &:nth-of-type(odd) {
margin-right: 2ch; margin-right: 2ch;
} }
} }
.checkbox-field { .checkbox-field {
@ -109,9 +116,6 @@ input[type="number"] {
} }
} }
.field--view-only { .field--view-only {
opacity: 0.3; opacity: 0.3;
} }
@ -150,12 +154,6 @@ input[type="number"] {
} }
} }
// INPUTNUMBER =============================================== // INPUTNUMBER ===============================================
// Masquer les spinners natifs partout // Masquer les spinners natifs partout
@ -170,7 +168,6 @@ input[type="number"] {
-moz-appearance: textfield; -moz-appearance: textfield;
} }
.number-input { .number-input {
position: relative; position: relative;
// padding: 0 1ch!important; // padding: 0 1ch!important;
@ -188,9 +185,13 @@ input[type="number"] {
top: 0; top: 0;
button { button {
height: calc(var(--input-h)*0.6); height: calc(var(--input-h) * 0.5);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
svg {
width: 10px;
height: auto;
}
svg path { svg path {
fill: var(--color-interface-600); fill: var(--color-interface-600);
} }
@ -202,14 +203,11 @@ input[type="number"] {
} }
.spinner-down { .spinner-down {
svg { svg {
position: relative; // position: relative;
top: -2px; // top: -2px;
} }
} }
} }
} }
// Composant NumberInput avec boutons personnalisés // Composant NumberInput avec boutons personnalisés
// .number-input { // .number-input {

View file

@ -5,8 +5,8 @@
background: white; background: white;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
z-index: 10000; z-index: 10;
width: 71rem; width: 860px;
max-height: 600px; max-height: 600px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -47,10 +47,7 @@
flex: 1; flex: 1;
padding: 1rem; padding: 1rem;
overflow-y: auto; overflow-y: auto;
background: white; background-color: var(--color-panel-bg);
display: flex;
flex-direction: column;
gap: 1rem;
} }
.settings-subsection h4 { .settings-subsection h4 {

View file

@ -9,7 +9,7 @@
--color-txt: var(--color-interface-800); --color-txt: var(--color-interface-900);
--color-panel-bg: var(--color-interface-050); --color-panel-bg: var(--color-interface-050);
--color-page-highlight: #ff8a50; --color-page-highlight: #ff8a50;
@ -20,6 +20,8 @@
--space-xs: 0.5rem; --space-xs: 0.5rem;
--space-s: 1rem; --space-s: 1rem;
--space: 1.5rem; --space: 1.5rem;
--space-m: 2rem;
--space-big: 3em;
--curve: cubic-bezier(0.86, 0, 0.07, 1); --curve: cubic-bezier(0.86, 0, 0.07, 1);
@ -34,4 +36,5 @@
font-size: 14px; font-size: 14px;
--panel-w: 540px; --panel-w: 540px;
--panel-nav-h: 60px;
} }

View file

@ -223,7 +223,7 @@ img {
:root { :root {
--color-browngray-050: #f5f3f0; --color-browngray-050: #f5f3f0;
--color-browngray-200: #d0c4ba; --color-browngray-200: #d0c4ba;
--color-txt: var(--color-interface-800); --color-txt: var(--color-interface-900);
--color-panel-bg: var(--color-interface-050); --color-panel-bg: var(--color-interface-050);
--color-page-highlight: #ff8a50; --color-page-highlight: #ff8a50;
--color-purple: #7136ff; --color-purple: #7136ff;
@ -231,6 +231,8 @@ img {
--space-xs: 0.5rem; --space-xs: 0.5rem;
--space-s: 1rem; --space-s: 1rem;
--space: 1.5rem; --space: 1.5rem;
--space-m: 2rem;
--space-big: 3em;
--curve: cubic-bezier(0.86, 0, 0.07, 1); --curve: cubic-bezier(0.86, 0, 0.07, 1);
--sans-serif: "DM Sans", sans-serif; --sans-serif: "DM Sans", sans-serif;
--mono: "Inconsolata", monospace; --mono: "Inconsolata", monospace;
@ -240,6 +242,7 @@ img {
--label-w: 18ch; --label-w: 18ch;
font-size: 14px; font-size: 14px;
--panel-w: 540px; --panel-w: 540px;
--panel-nav-h: 60px;
} }
body { body {
@ -273,7 +276,9 @@ input[type=number] {
border: 1px solid var(--color-interface-200); border: 1px solid var(--color-interface-200);
background-color: var(--color-interface-100); background-color: var(--color-interface-100);
font-family: var(--sans-serif); font-family: var(--sans-serif);
color: var(--color-txt);
font-size: 1rem; font-size: 1rem;
padding-left: 0.5ch;
} }
.field { .field {
@ -299,13 +304,15 @@ input[type=number] {
.field .input-with-color .clr-field { .field .input-with-color .clr-field {
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-columns: var(--input-h); grid-template-columns: var(--input-h) 1fr;
grid-gap: 1ch; grid-gap: 1ch;
} }
.field .input-with-color .clr-field button { .field .input-with-color .clr-field button {
grid-column: 1; grid-column: 1;
position: relative; position: relative;
border-radius: var(--border-radius); border-radius: var(--border-radius);
cursor: pointer;
pointer-events: auto;
} }
.field .input-with-color .clr-field input { .field .input-with-color .clr-field input {
grid-column: 2; grid-column: 2;
@ -328,24 +335,38 @@ input[type=number] {
width: 100%; width: 100%;
} }
.field-font .field-checkbox { .field-font .field-checkbox {
grid-column: 2;
padding-top: var(--space-xs); padding-top: var(--space-xs);
} }
.field-font .field-checkbox label { .field-font .field-checkbox label {
font-weight: 400; font-weight: 400;
margin-left: 0.75ch; 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; display: inline-grid;
width: calc(50% - 1ch); width: calc(50% - 1ch);
grid-template-columns: 6.5ch var(--input-w-small) 1fr; grid-template-columns: 6.5ch var(--input-w-small) 1fr;
margin-bottom: var(--space-xs); margin-bottom: var(--space-xs);
} }
.field-margin input, .field-size input { .field-margin input,
.field-size input {
width: var(--input-w-small); width: var(--input-w-small);
padding-left: 0.75ch; 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; margin-right: 2ch;
} }
@ -421,23 +442,22 @@ input[type=number] {
top: 0; top: 0;
} }
.number-input .spinner-buttons button { .number-input .spinner-buttons button {
height: calc(var(--input-h) * 0.6); height: calc(var(--input-h) * 0.5);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
} }
.number-input .spinner-buttons button svg {
width: 10px;
height: auto;
}
.number-input .spinner-buttons button svg path { .number-input .spinner-buttons button svg path {
fill: var(--color-interface-600); fill: var(--color-interface-600);
} }
.number-input .spinner-buttons button:hover svg path { .number-input .spinner-buttons button:hover svg path {
fill: var(--color-interface-900); fill: var(--color-interface-900);
} }
.number-input .spinner-buttons .spinner-down svg {
position: relative;
top: -2px;
}
.settings-section { .settings-section {
margin-top: 3em; margin: var(--space-m) 0;
} }
.settings-section h2 { .settings-section h2 {
margin-bottom: var(--space); margin-bottom: var(--space);
@ -450,13 +470,15 @@ input[type=number] {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-interface-400); 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); border-bottom: 1px solid var(--color-interface-100);
} }
.settings-section .settings-subsection {
.settings-subsection {
padding: var(--space-xs) 0; padding: var(--space-xs) 0;
} }
.settings-section .settings-subsection h3 { .settings-subsection h3 {
margin-top: calc(var(--space-xs) * 1.5); margin-top: calc(var(--space-xs) * 1.5);
margin-bottom: calc(var(--space-xs) * 2); margin-bottom: calc(var(--space-xs) * 2);
font-size: 1rem; font-size: 1rem;
@ -507,8 +529,8 @@ input[type=number] {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
z-index: 10000; z-index: 10;
width: 71rem; width: 860px;
max-height: 600px; max-height: 600px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -549,10 +571,7 @@ input[type=number] {
flex: 1; flex: 1;
padding: 1rem; padding: 1rem;
overflow-y: auto; overflow-y: auto;
background: white; background-color: var(--color-panel-bg);
display: flex;
flex-direction: column;
gap: 1rem;
} }
.settings-subsection h4 { .settings-subsection h4 {

File diff suppressed because one or more lines are too long

View file

@ -22,7 +22,8 @@
}, },
"require": { "require": {
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", "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": { "config": {
"allow-plugins": { "allow-plugins": {

40
public/composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "0b7fb803e22a45eb87e24172337208aa", "content-hash": "82adb49b472cb54cd88e72b31f49ada3",
"packages": [ "packages": [
{ {
"name": "christian-riesen/base32", "name": "christian-riesen/base32",
@ -725,6 +725,44 @@
}, },
"time": "2024-09-11T13:17:53+00:00" "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", "name": "symfony/deprecation-contracts",
"version": "v3.6.0", "version": "v3.6.0",

View file

@ -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 dun récit.</p>
----
Uuid: dcesbtdkfuilhqsw

View file

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 Uuid: xi60pjkz5bp1nlwp

View file

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

View file

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

View 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

View file

@ -1,4 +1,4 @@
title: Récit title: Narrative
columns: columns:
main: main:
@ -22,11 +22,18 @@ columns:
introduction: introduction:
label: Introduction label: Introduction
type: writer 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: pages:
label: Pages label: Pages
type: pages type: pages
template: template:
- carte - map
- geoformat - geoformat
sidebar: sidebar:
width: 1/3 width: 1/3

View file

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

View 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

View file

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

View 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.

View file

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

View file

@ -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"
}

View 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
}
}
];

View 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}

File diff suppressed because one or more lines are too long

View 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',
),
]);

View 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;

View 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"
}
}

View file

@ -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>

View file

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

View file

@ -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));

View 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

View 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
];
}
}
]
];

View file

@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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'
]
]);

File diff suppressed because it is too large Load diff

View 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"
}
}

View file

@ -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>

View file

@ -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>

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

View file

@ -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>

View 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,
};
}

View 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
};
}

View file

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

View 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;
}
}

View 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,
};

View 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);
};
}

View file

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

View 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')
]
]);
}
]
]
]);

View file

@ -14,6 +14,11 @@
<!-- À SUPPRIMER EN PRODUCTION --> <!-- À SUPPRIMER EN PRODUCTION -->
<meta name="robots" content="noindex, nofollow, noarchive"> <meta name="robots" content="noindex, nofollow, noarchive">
<!-- CSRF Token for API calls -->
<?php if ($kirby->user()): ?>
<meta name="csrf" content="<?= csrf() ?>">
<?php endif ?>
<!-- APP --> <!-- APP -->
<?php if (Dir::exists('assets/dist')): ?> <?php if (Dir::exists('assets/dist')): ?>
<script type="module" <script type="module"
@ -26,5 +31,5 @@
<?php endif ?> <?php endif ?>
</head> </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"> <div id="app">

View file

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

View file

@ -1,12 +1,12 @@
<?php <?php
/** /**
* Template pour afficher un récit * Template to display a narrative
* Ce template est requis pour que recit.json.php fonctionne * This template is required for narrative.json.php to work
*/ */
?> ?>
<?php snippet('header') ?> <?php snippet('header') ?>
<article class="recit"> <article class="narrative">
<h1><?= $page->title() ?></h1> <h1><?= $page->title() ?></h1>
<?php if ($page->author()->isNotEmpty()): ?> <?php if ($page->author()->isNotEmpty()): ?>
@ -27,7 +27,7 @@
</div> </div>
<?php endif ?> <?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> </article>
<?php snippet('footer') ?> <?php snippet('footer') ?>

View file

@ -1,17 +1,17 @@
<?php <?php
/** /**
* Template pour l'éditeur d'impression Vue.js * Template for Vue.js print editor
* Route: /projet/recit/print * 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 // Get parent narrative
$recit = $page->parent(); $narrative = $page->parent();
// Construire l'URL JSON du récit // Build narrative JSON URL
$recitJsonUrl = $recit->url() . '.json'; $narrativeJsonUrl = $narrative->url() . '.json';
?> ?>
<?php snippet('header', ['recitJsonUrl' => $recitJsonUrl]) ?> <?php snippet('header', ['narrativeJsonUrl' => $narrativeJsonUrl]) ?>
<?php snippet('footer') ?> <?php snippet('footer') ?>

View file

@ -16,7 +16,7 @@ return array(
'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'), 'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'),
'League\\ColorExtractor\\' => array($vendorDir . '/league/color-extractor/src'), 'League\\ColorExtractor\\' => array($vendorDir . '/league/color-extractor/src'),
'Laminas\\Escaper\\' => array($vendorDir . '/laminas/laminas-escaper/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'), 'Composer\\Semver\\' => array($vendorDir . '/composer/semver/src'),
'Base32\\' => array($vendorDir . '/christian-riesen/base32/src'), 'Base32\\' => array($vendorDir . '/christian-riesen/base32/src'),
); );

View file

@ -96,8 +96,8 @@ class ComposerStaticInit0b7fb803e22a45eb87e24172337208aa
), ),
'Kirby\\' => 'Kirby\\' =>
array ( array (
0 => __DIR__ . '/..' . '/getkirby/composer-installer/src', 0 => __DIR__ . '/../..' . '/kirby/src',
1 => __DIR__ . '/../..' . '/kirby/src', 1 => __DIR__ . '/..' . '/getkirby/composer-installer/src',
), ),
'Composer\\Semver\\' => 'Composer\\Semver\\' =>
array ( array (

View file

@ -752,6 +752,47 @@
}, },
"install-path": "../psr/log" "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", "name": "symfony/deprecation-contracts",
"version": "v3.6.0", "version": "v3.6.0",

View file

@ -1,9 +1,9 @@
<?php return array( <?php return array(
'root' => array( 'root' => array(
'name' => 'getkirby/plainkit', 'name' => 'getkirby/plainkit',
'pretty_version' => '5.1.4', 'pretty_version' => 'dev-main',
'version' => '5.1.4.0', 'version' => 'dev-main',
'reference' => null, 'reference' => '4d1183d1afd90517610e742ea0bc6553e8312bc6',
'type' => 'project', 'type' => 'project',
'install_path' => __DIR__ . '/../../', 'install_path' => __DIR__ . '/../../',
'aliases' => array(), 'aliases' => array(),
@ -65,9 +65,9 @@
'dev_requirement' => false, 'dev_requirement' => false,
), ),
'getkirby/plainkit' => array( 'getkirby/plainkit' => array(
'pretty_version' => '5.1.4', 'pretty_version' => 'dev-main',
'version' => '5.1.4.0', 'version' => 'dev-main',
'reference' => null, 'reference' => '4d1183d1afd90517610e742ea0bc6553e8312bc6',
'type' => 'project', 'type' => 'project',
'install_path' => __DIR__ . '/../../', 'install_path' => __DIR__ . '/../../',
'aliases' => array(), 'aliases' => array(),
@ -124,6 +124,15 @@
'aliases' => array(), 'aliases' => array(),
'dev_requirement' => false, '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( 'symfony/deprecation-contracts' => array(
'pretty_version' => 'v3.6.0', 'pretty_version' => 'v3.6.0',
'version' => '3.6.0.0', 'version' => '3.6.0.0',

View file

@ -4,16 +4,18 @@ import EditorPanel from './components/editor/EditorPanel.vue';
import ElementPopup from './components/ElementPopup.vue'; import ElementPopup from './components/ElementPopup.vue';
import PagePopup from './components/PagePopup.vue'; import PagePopup from './components/PagePopup.vue';
import PreviewLoader from './components/PreviewLoader.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 { useStylesheetStore } from './stores/stylesheet';
import { useRecitStore } from './stores/recit'; import { useNarrativeStore } from './stores/narrative';
import Coloris from '@melloware/coloris'; import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
import { useIframeInteractions } from './composables/useIframeInteractions';
import { usePreviewRenderer } from './composables/usePreviewRenderer';
import { usePrintPreview } from './composables/usePrintPreview';
const stylesheetStore = useStylesheetStore(); 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 previewFrame1 = ref(null);
const previewFrame2 = ref(null); const previewFrame2 = ref(null);
const elementPopup = ref(null); const elementPopup = ref(null);
@ -22,18 +24,32 @@ const activeTab = ref('');
provide('activeTab', activeTab); provide('activeTab', activeTab);
// Page interaction state // Setup iframe interactions (hover, click, labels)
const hoveredPage = ref(null); const {
const selectedPages = ref([]); // Pages with active border (when popup is open) hoveredPage,
const hoveredElement = ref(null); // Currently hovered content element selectedPages,
const selectedElement = ref(null); // Selected element (when popup is open) hoveredElement,
const EDGE_THRESHOLD = 30; // px from edge to trigger hover selectedElement,
const PAGE_HIGHLIGHT_COLOR = '#ff8a50'; handleIframeMouseMove,
const ELEMENT_HIGHLIGHT_COLOR = '#7136ff'; handleIframeClick,
handlePagePopupClose,
handleElementPopupClose,
} = useIframeInteractions({ elementPopup, pagePopup });
let savedScrollPercentage = 0; // Setup preview renderer with double buffering
const currentFrameIndex = ref(1); // 1 or 2, which iframe is currently visible const {
const isTransitioning = ref(false); renderPreview,
currentFrameIndex,
isTransitioning,
setKeyboardShortcutHandler,
} = usePreviewRenderer({
previewFrame1,
previewFrame2,
stylesheetStore,
narrativeStore,
handleIframeMouseMove,
handleIframeClick,
});
const activeFrame = computed(() => { const activeFrame = computed(() => {
return currentFrameIndex.value === 1 return currentFrameIndex.value === 1
@ -41,526 +57,32 @@ const activeFrame = computed(() => {
: previewFrame2.value; : previewFrame2.value;
}); });
// Check if mouse position is near the edges of a page element // Setup print preview
const isNearPageEdge = (pageElement, mouseX, mouseY) => { const { printPreview } = usePrintPreview(activeFrame);
const rect = pageElement.getBoundingClientRect();
const nearLeft = mouseX >= rect.left && mouseX <= rect.left + EDGE_THRESHOLD; // Setup keyboard shortcuts (depends on printPreview)
const nearRight = const {
mouseX >= rect.right - EDGE_THRESHOLD && mouseX <= rect.right; handleKeyboardShortcut,
const nearTop = mouseY >= rect.top && mouseY <= rect.top + EDGE_THRESHOLD; isMac
const nearBottom = } = useKeyboardShortcuts({
mouseY >= rect.bottom - EDGE_THRESHOLD && mouseY <= rect.bottom; stylesheetStore,
elementPopup,
const inHorizontalRange = mouseY >= rect.top && mouseY <= rect.bottom; pagePopup,
const inVerticalRange = mouseX >= rect.left && mouseX <= rect.right; activeTab,
printPreview,
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 // Attach keyboard shortcut handler to renderer
setTimeout(() => { setKeyboardShortcutHandler(handleKeyboardShortcut);
// Restore scroll position
const scrollHeight =
hiddenFrame.contentDocument.documentElement.scrollHeight;
const clientHeight = hiddenFrame.contentWindow.innerHeight;
const maxScroll = scrollHeight - clientHeight;
const targetScroll = savedScrollPercentage * maxScroll;
hiddenFrame.contentWindow.scrollTo(0, targetScroll);
// Start crossfade transition
setTimeout(() => {
// Make hidden frame visible (it's already behind)
hiddenFrame.style.opacity = '1';
hiddenFrame.style.zIndex = '1';
// Fade out visible frame
if (visibleFrame) {
visibleFrame.style.opacity = '0';
}
// After fade completes, swap the frames
setTimeout(() => {
if (visibleFrame) {
visibleFrame.style.zIndex = '0';
}
// Swap current frame
currentFrameIndex.value = currentFrameIndex.value === 1 ? 2 : 1;
isTransitioning.value = false;
}, 200); // Match CSS transition duration
}, 50); // Small delay to ensure scroll is set
}, 200); // Wait for PagedJS
};
};
watch(
() => stylesheetStore.content,
() => {
renderPreview();
}
);
// Re-render when recit data changes
watch(
() => recitStore.data,
() => {
if (recitStore.data) {
renderPreview();
}
}
);
// Print the PagedJS content
const printPreview = async () => {
const frame = activeFrame.value;
if (!frame || !frame.contentDocument) return;
const doc = frame.contentDocument;
// Collect all styles
let allStyles = '';
// Get inline <style> tags content
doc.querySelectorAll('style').forEach((style) => {
allStyles += style.innerHTML + '\n';
});
// Get rules from stylesheets
for (const sheet of doc.styleSheets) {
try {
for (const rule of sheet.cssRules) {
allStyles += rule.cssText + '\n';
}
} catch (e) {
// Cross-origin stylesheet, try to fetch it
if (sheet.href) {
try {
const response = await fetch(sheet.href);
const css = await response.text();
allStyles += css;
} catch (fetchError) {
console.warn('Could not fetch stylesheet:', sheet.href);
}
}
}
}
// Save current page content
const originalContent = document.body.innerHTML;
const originalStyles = document.head.innerHTML;
// Replace page content with iframe content
document.head.innerHTML = `
<meta charset="UTF-8">
<title>Impression</title>
<style>${allStyles}</style>
`;
document.body.innerHTML = doc.body.innerHTML;
// Print
window.print();
// Restore original content after print dialog closes
setTimeout(() => {
document.head.innerHTML = originalStyles;
document.body.innerHTML = originalContent;
// Re-mount Vue app would be needed, so we reload instead
window.location.reload();
}, 100);
};
// Lifecycle: Initialize app on mount
onMounted(async () => { onMounted(async () => {
// Load recit data if URL is provided (print mode) // Load narrative data (narrativeUrl constructed from location, always present)
if (recitUrl) { await narrativeStore.loadNarrative(location.href + '.json');
await recitStore.loadRecit(recitUrl);
// Initialize stylesheet with custom CSS
if (narrativeStore.data) {
await stylesheetStore.initializeFromNarrative(narrativeStore.data);
} }
// Render preview after data is loaded // Render preview after data is loaded
@ -588,6 +110,8 @@ onMounted(async () => {
<PreviewLoader :isLoading="isTransitioning" :shifted="activeTab.length > 0" /> <PreviewLoader :isLoading="isTransitioning" :shifted="activeTab.length > 0" />
<SaveButton />
<ElementPopup <ElementPopup
ref="elementPopup" ref="elementPopup"
:iframeRef="activeFrame" :iframeRef="activeFrame"
@ -599,7 +123,7 @@ onMounted(async () => {
@close="handlePagePopupClose" @close="handlePagePopupClose"
/> />
<button class="print-btn" @click="printPreview" title="Imprimer"> <button class="print-btn" @click="printPreview" :title="`Imprimer (${isMac ? '⌘' : 'Ctrl'}+P)`">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -673,6 +197,14 @@ onMounted(async () => {
height: 1.5rem; 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 */ /* Hide UI elements when printing */
@media print { @media print {
#editor-panel, #editor-panel,

View file

@ -18,19 +18,17 @@
<div class="popup-controls"> <div class="popup-controls">
<!-- Font Family --> <!-- Font Family -->
<div class="settings-subsection"> <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> <label class="label-with-tooltip" data-css="font-family">Police</label>
<div class="field-with-option">
<select v-model="fontFamily.value" :disabled="inheritanceLocked"> <select v-model="fontFamily.value" :disabled="inheritanceLocked">
<option value="Alegreya Sans">Alegreya Sans</option> <option v-for="f in fonts" :key="f" :value="f">{{ f }}</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> </select>
<label class="checkbox-inline label-with-tooltip" data-css="font-style"> <div class="field-checkbox">
<input type="checkbox" v-model="fontStyle.italic" :disabled="inheritanceLocked" /> <input type="checkbox" v-model="fontStyle.italic" :disabled="inheritanceLocked" />
<span>Italique</span> <label class="label-with-tooltip" data-css="font-style">Italique</label>
</label> </div>
</div>
</div> </div>
</div> </div>
@ -38,18 +36,7 @@
<div class="settings-subsection"> <div class="settings-subsection">
<div class="field" :class="{ 'field--view-only': inheritanceLocked }"> <div class="field" :class="{ 'field--view-only': inheritanceLocked }">
<label class="label-with-tooltip" data-css="font-weight">Graisse</label> <label class="label-with-tooltip" data-css="font-weight">Graisse</label>
<div class="button-group"> <UnitToggle v-model="fontWeightString" :units="weights" :disabled="inheritanceLocked" />
<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>
</div> </div>
</div> </div>
@ -271,11 +258,12 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue'; import { ref, computed, watch, nextTick } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet'; import { useStylesheetStore } from '../stores/stylesheet';
import { usePopupPosition } from '../composables/usePopupPosition'; import { usePopupPosition } from '../composables/usePopupPosition';
import { useDebounce } from '../composables/useDebounce'; import { useDebounce } from '../composables/useDebounce';
import NumberInput from './ui/NumberInput.vue'; import NumberInput from './ui/NumberInput.vue';
import UnitToggle from './ui/UnitToggle.vue';
import Coloris from '@melloware/coloris'; import Coloris from '@melloware/coloris';
import '@melloware/coloris/dist/coloris.css'; import '@melloware/coloris/dist/coloris.css';
import hljs from 'highlight.js/lib/core'; import hljs from 'highlight.js/lib/core';
@ -321,6 +309,18 @@ const background = ref({ value: 'transparent' });
const marginOuter = ref({ value: 0, unit: 'mm' }); const marginOuter = ref({ value: 0, unit: 'mm' });
const paddingInner = 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) => { const immediateUpdate = (callback) => {
callback(); callback();
}; };
@ -435,7 +435,7 @@ const removeElementBlock = () => {
// Escape special regex characters in selector // Escape special regex characters in selector
const escaped = selector.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escaped = selector.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Remove the block and any surrounding whitespace // Remove the block and any surrounding whitespace
stylesheetStore.content = stylesheetStore.content.replace( stylesheetStore.replaceInCustomCss(
new RegExp(`\\n?${escaped}\\s*\\{[^}]*\\}\\n?`), new RegExp(`\\n?${escaped}\\s*\\{[^}]*\\}\\n?`),
'\n' '\n'
); );
@ -581,7 +581,7 @@ const handleCssInput = (event) => {
cssDebounceTimer = setTimeout(() => { cssDebounceTimer = setTimeout(() => {
const oldBlock = elementCss.value; const oldBlock = elementCss.value;
if (oldBlock) { if (oldBlock) {
stylesheetStore.content = stylesheetStore.content.replace(oldBlock, newCss); stylesheetStore.replaceInCustomCss(oldBlock, newCss);
} }
}, 500); }, 500);
}; };
@ -592,16 +592,35 @@ watch(isEditable, async (newValue, oldValue) => {
// Format when exiting editing mode // Format when exiting editing mode
if (oldValue && !newValue) { if (oldValue && !newValue) {
await stylesheetStore.formatContent(); await stylesheetStore.formatCustomCss();
} }
}); });
// Watch stylesheet changes to sync values // Watch stylesheet changes to sync values
watch( watch(
() => stylesheetStore.content, () => stylesheetStore.customCss,
() => { () => {
if (visible.value && !stylesheetStore.isEditing) { if (visible.value && !isUpdatingFromStore) {
isUpdatingFromStore = true;
loadValuesFromStylesheet(); 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; if (!selector.value) return;
try { try {
isUpdatingFromStore = true;
// Extract font-family // Extract font-family
const fontFamilyData = stylesheetStore.extractValue(selector.value, 'font-family'); const fontFamilyData = stylesheetStore.extractValue(selector.value, 'font-family');
if (fontFamilyData) { if (fontFamilyData) {
@ -676,12 +693,13 @@ const loadValuesFromStylesheet = () => {
} }
} catch (error) { } catch (error) {
console.error('Error loading values from stylesheet:', error); console.error('Error loading values from stylesheet:', error);
} finally {
isUpdatingFromStore = false;
} }
}; };
const open = (element, event, count = null) => { const open = (element, event, count = null) => {
// Block all watchers during initialization
isUpdatingFromStore = true;
selectedElement.value = element; selectedElement.value = element;
selector.value = getSelectorFromElement(element); selector.value = getSelectorFromElement(element);
position.value = calculatePosition(event); position.value = calculatePosition(event);
@ -689,14 +707,30 @@ const open = (element, event, count = null) => {
// Store instance count if provided, otherwise calculate it // Store instance count if provided, otherwise calculate it
elementInstanceCount.value = count !== null ? count : getInstanceCount(selector.value); elementInstanceCount.value = count !== null ? count : getInstanceCount(selector.value);
// Read inheritance state from element's data attribute // Detect inheritance state from CSS block state
inheritanceLocked.value = element.dataset.inheritanceUnlocked !== 'true'; 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(); loadValuesFromStylesheet();
visible.value = true; visible.value = true;
// Re-enable watchers after initialization (use nextTick to ensure watchers see the flag)
nextTick(() => {
isUpdatingFromStore = false;
});
// Initialize Coloris after opening // Initialize Coloris after opening
setTimeout(() => { setTimeout(() => {
Coloris.init(); Coloris.init();
@ -755,24 +789,69 @@ const handleIframeClick = (event, targetElement = null, elementCount = null) =>
}; };
const toggleInheritance = () => { const toggleInheritance = () => {
const wasLocked = inheritanceLocked.value; const blockState = stylesheetStore.getBlockState(selector.value);
inheritanceLocked.value = !inheritanceLocked.value;
// Store the inheritance state in the element's data attribute if (inheritanceLocked.value && blockState === 'commented') {
if (selectedElement.value) { // Case 1: Locked with commented block Uncomment to unlock
if (inheritanceLocked.value) { stylesheetStore.uncommentCssBlock(selector.value);
delete selectedElement.value.dataset.inheritanceUnlocked; inheritanceLocked.value = false;
} else { } else if (inheritanceLocked.value && blockState === 'none') {
selectedElement.value.dataset.inheritanceUnlocked = 'true'; // 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];
} }
if (inheritanceLocked.value && !wasLocked) { // Text align
// Re-locking: remove the element-specific CSS block to restore inheritance textAlign.value.value = computed.textAlign;
removeElementBlock();
} else if (!inheritanceLocked.value && wasLocked) { // Color
// Unlocking: apply all current field values to create the CSS block 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;
}
// Now create the block with captured values
applyAllStyles(); 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); color: var(--color-purple);
font-size: 0.875rem; 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> </style>

View file

@ -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 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`; 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,
baseBlock + newBlock baseBlock + newBlock
); );
@ -376,7 +376,7 @@ const removeTemplateBlock = () => {
if (block) { if (block) {
// Remove the block and any surrounding whitespace // Remove the block and any surrounding whitespace
stylesheetStore.content = stylesheetStore.content.replace( stylesheetStore.replaceInCustomCss(
new RegExp(`\\n?${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}\\n?`), new RegExp(`\\n?${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}\\n?`),
'\n' '\n'
); );
@ -399,7 +399,7 @@ const updateMargins = (force = false) => {
/(margin:\s*)[^;]+/, /(margin:\s*)[^;]+/,
`$1${marginValue}` `$1${marginValue}`
); );
stylesheetStore.content = stylesheetStore.content.replace( stylesheetStore.replaceInCustomCss(
currentBlock, currentBlock,
updatedBlock updatedBlock
); );
@ -408,7 +408,7 @@ const updateMargins = (force = false) => {
/(\s*})$/, /(\s*})$/,
` margin: ${marginValue};\n$1` ` margin: ${marginValue};\n$1`
); );
stylesheetStore.content = stylesheetStore.content.replace( stylesheetStore.replaceInCustomCss(
currentBlock, currentBlock,
updatedBlock updatedBlock
); );
@ -428,7 +428,7 @@ const updateBackground = (force = false) => {
/(background:\s*)[^;]+/, /(background:\s*)[^;]+/,
`$1${background.value.value}` `$1${background.value.value}`
); );
stylesheetStore.content = stylesheetStore.content.replace( stylesheetStore.replaceInCustomCss(
currentBlock, currentBlock,
updatedBlock updatedBlock
); );
@ -437,7 +437,7 @@ const updateBackground = (force = false) => {
/(\s*})$/, /(\s*})$/,
` background: ${background.value.value};\n$1` ` background: ${background.value.value};\n$1`
); );
stylesheetStore.content = stylesheetStore.content.replace( stylesheetStore.replaceInCustomCss(
currentBlock, currentBlock,
updatedBlock updatedBlock
); );
@ -674,7 +674,7 @@ const handleCssInput = (event) => {
// Get the actual CSS block (not the commented preview) // Get the actual CSS block (not the commented preview)
const oldBlock = pageCss.value; const oldBlock = pageCss.value;
if (oldBlock) { if (oldBlock) {
stylesheetStore.content = stylesheetStore.content.replace( stylesheetStore.replaceInCustomCss(
oldBlock, oldBlock,
newCss newCss
); );
@ -688,7 +688,7 @@ watch(isEditable, async (newValue, oldValue) => {
// Format when exiting editing mode // Format when exiting editing mode
if (oldValue && !newValue) { if (oldValue && !newValue) {
await stylesheetStore.formatContent(); await stylesheetStore.formatCustomCss();
} }
}); });

View file

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

View 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>

View file

@ -1,31 +1,80 @@
<template> <template>
<div id="stylesheet-viewer"> <div id="stylesheet-viewer">
<div class="header"> <!-- CSS File Import -->
<h3>Stylesheet</h3> <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>
<!-- Custom CSS Section (Editable with toggle) -->
<div class="css-section custom-section">
<div class="section-header">
<h3>CSS personnalisé</h3>
<label class="toggle"> <label class="toggle">
<span class="toggle-label">Mode édition</span> <span class="toggle-label">Mode édition</span>
<input type="checkbox" v-model="isEditable" /> <input type="checkbox" v-model="isCustomCssEditable" />
<span class="toggle-switch"></span> <span class="toggle-switch"></span>
</label> </label>
</div> </div>
<div class="section-content">
<pre <pre
v-if="!isEditable" v-if="!isCustomCssEditable"
class="readonly" class="readonly"
><code class="hljs language-css" v-html="highlightedCss"></code></pre> ><code class="hljs language-css" v-html="highlightedCustomCss"></code></pre>
<textarea <textarea
v-else v-else
:value="stylesheetStore.content" :value="stylesheetStore.customCss"
@input="handleInput" @input="handleCustomCssInput"
@focus="handleFocus"
spellcheck="false" spellcheck="false"
placeholder="Ajoutez votre CSS personnalisé ici..."
></textarea> ></textarea>
</div> </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> </template>
<script setup> <script setup>
import { ref, computed, watch, inject } from 'vue'; import { ref, computed, watch } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet'; import { useStylesheetStore } from '../stores/stylesheet';
import { useNarrativeStore } from '../stores/narrative';
import CssFileImport from './ui/CssFileImport.vue';
import hljs from 'highlight.js/lib/core'; import hljs from 'highlight.js/lib/core';
import css from 'highlight.js/lib/languages/css'; import css from 'highlight.js/lib/languages/css';
import 'highlight.js/styles/atom-one-dark.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); hljs.registerLanguage('css', css);
const stylesheetStore = useStylesheetStore(); const stylesheetStore = useStylesheetStore();
const activeTab = inject('activeTab'); const narrativeStore = useNarrativeStore();
const isEditable = ref(false); const isBaseCssExpanded = ref(false);
const isCustomCssEditable = ref(false);
let debounceTimer = null; let debounceTimer = null;
const highlightedCss = computed(() => { const highlightedBaseCss = computed(() => {
if (!stylesheetStore.content) return ''; if (!stylesheetStore.baseCss) return '';
return hljs.highlight(stylesheetStore.content, { language: 'css' }).value; 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; const newContent = event.target.value;
if (debounceTimer) { if (debounceTimer) {
@ -50,24 +105,85 @@ const handleInput = (event) => {
} }
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
stylesheetStore.content = newContent; stylesheetStore.customCss = newContent;
}, 500); }, 500);
}; };
// Sync editing mode with store const handleFocus = () => {
watch(isEditable, async (newValue, oldValue) => { 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; stylesheetStore.isEditing = newValue;
// Format when exiting editing mode // Format when exiting editing mode
if (oldValue && !newValue) { if (oldValue && !newValue) {
await stylesheetStore.formatContent(); await stylesheetStore.formatCustomCss();
}
});
// Disable editing mode when changing tabs
watch(activeTab, (newTab) => {
if (newTab !== 'code' && isEditable.value) {
isEditable.value = false;
} }
}); });
</script> </script>
@ -79,18 +195,44 @@ watch(activeTab, (newTab) => {
height: 100%; height: 100%;
background: #282c34; background: #282c34;
color: #fff; 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; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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 { h3 {
margin: 0; margin: 0;
color: #fff; color: #fff;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.toggle { .toggle {
@ -144,11 +286,28 @@ h3 {
transform: translateX(20px); 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 { .readonly {
margin: 0; margin: 0;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 0.5rem; padding: 1rem;
background: #1e1e1e; background: #1e1e1e;
font-family: 'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
font-size: 0.875rem; font-size: 0.875rem;
@ -162,14 +321,51 @@ h3 {
textarea { textarea {
width: 100%; width: 100%;
flex: 1; flex: 1;
min-height: 300px;
max-height: 500px;
overflow-y: auto;
background: #1e1e1e; background: #1e1e1e;
color: #abb2bf; color: #abb2bf;
border: none; border: none;
padding: 0.5rem; padding: 1rem;
font-family: 'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
resize: none; resize: none;
outline: 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> </style>

View file

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

View file

@ -57,9 +57,10 @@
<div class="input-with-unit"> <div class="input-with-unit">
<NumberInput <NumberInput
id="margin-top" id="margin-top"
v-model="margins.top.value" :modelValue="margins.top.value"
:min="0" :min="0"
:step="1" :step="1"
@update:modelValue="(value) => margins.top.value = value"
/> />
<div class="unit-toggle"> <div class="unit-toggle">
<button <button
@ -97,9 +98,10 @@
<div class="input-with-unit"> <div class="input-with-unit">
<NumberInput <NumberInput
id="margin-bottom" id="margin-bottom"
v-model="margins.bottom.value" :modelValue="margins.bottom.value"
:min="0" :min="0"
:step="1" :step="1"
@update:modelValue="(value) => margins.bottom.value = value"
/> />
<div class="unit-toggle"> <div class="unit-toggle">
<button <button
@ -137,9 +139,10 @@
<div class="input-with-unit"> <div class="input-with-unit">
<NumberInput <NumberInput
id="margin-left" id="margin-left"
v-model="margins.left.value" :modelValue="margins.left.value"
:min="0" :min="0"
:step="1" :step="1"
@update:modelValue="(value) => margins.left.value = value"
/> />
<div class="unit-toggle"> <div class="unit-toggle">
<button <button
@ -177,9 +180,10 @@
<div class="input-with-unit"> <div class="input-with-unit">
<NumberInput <NumberInput
id="margin-right" id="margin-right"
v-model="margins.right.value" :modelValue="margins.right.value"
:min="0" :min="0"
:step="1" :step="1"
@update:modelValue="(value) => margins.right.value = value"
/> />
<div class="unit-toggle"> <div class="unit-toggle">
<button <button
@ -350,10 +354,7 @@ const updateMargins = () => {
`$1${marginValue}` `$1${marginValue}`
); );
stylesheetStore.content = stylesheetStore.content.replace( stylesheetStore.replaceBlock(currentBlock, updatedBlock);
currentBlock,
updatedBlock
);
}; };
// Watch margin values (number inputs) with debounce // Watch margin values (number inputs) with debounce
@ -394,19 +395,13 @@ const updateBackground = () => {
/(background:\s*)[^;]+/, /(background:\s*)[^;]+/,
`$1${background.value.value}` `$1${background.value.value}`
); );
stylesheetStore.content = stylesheetStore.content.replace( stylesheetStore.replaceBlock(currentBlock, updatedBlock);
currentBlock,
updatedBlock
);
} else { } else {
const updatedBlock = currentBlock.replace( const updatedBlock = currentBlock.replace(
/(\s*})$/, /(\s*})$/,
` background: ${background.value.value};\n$1` ` background: ${background.value.value};\n$1`
); );
stylesheetStore.content = stylesheetStore.content.replace( stylesheetStore.replaceBlock(currentBlock, updatedBlock);
currentBlock,
updatedBlock
);
} }
}; };
@ -453,7 +448,7 @@ watch(runningTitle, (enabled) => {
}); });
const updatePageFooters = () => { const updatePageFooters = () => {
let currentCss = stylesheetStore.content; let currentCss = stylesheetStore.customCss;
// Remove existing @page:left and @page:right rules // Remove existing @page:left and @page:right rules
currentCss = currentCss.replace(/@page:left\s*\{[^}]*\}/g, ''); currentCss = currentCss.replace(/@page:left\s*\{[^}]*\}/g, '');
@ -535,7 +530,7 @@ const updatePageFooters = () => {
currentCss.slice(insertPosition); currentCss.slice(insertPosition);
} }
stylesheetStore.content = currentCss; stylesheetStore.setCustomCss(currentCss);
}; };
const syncFromStore = () => { const syncFromStore = () => {

View file

@ -34,7 +34,7 @@
<!-- Taille du texte --> <!-- Taille du texte -->
<div class="settings-subsection"> <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> <label for="text-size-range" class="label-with-tooltip" data-css="font-size">Taille du texte</label>
<InputWithUnit <InputWithUnit
v-model="fontSize" v-model="fontSize"
@ -91,31 +91,277 @@
</div> </div>
<!-- Marges extérieures --> <!-- Marges extérieures -->
<div class="settings-subsection"> <div class="settings-subsection margins">
<MarginEditor <div class="subsection-header">
ref="marginOuterEditor" <h3>Marges extérieures</h3>
id="margin-outer" <button
label="Marges extérieures" type="button"
cssProperty="margin" class="link-button"
v-model:simple="marginOuter" :class="{ active: marginOuterLinked }"
v-model:detailed="marginOuterDetailed" @click="marginOuterLinked = !marginOuterLinked"
:units="['mm', 'px', 'rem']" :title="marginOuterLinked ? 'Dissocier les marges' : 'Lier les marges'"
@change="handleMarginOuterChange" >
<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> </div>
<!-- Marges intérieures --> <!-- Marges intérieures -->
<div class="settings-subsection"> <div class="settings-subsection margins">
<MarginEditor <div class="subsection-header">
ref="marginInnerEditor" <h3>Marges intérieures</h3>
id="margin-inner" <button
label="Marges intérieures" type="button"
cssProperty="padding" class="link-button"
v-model:simple="marginInner" :class="{ active: marginInnerLinked }"
v-model:detailed="marginInnerDetailed" @click="marginInnerLinked = !marginInnerLinked"
:units="['mm', 'px', 'rem']" :title="marginInnerLinked ? 'Dissocier les marges' : 'Lier les marges'"
@change="handleMarginInnerChange" >
<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>
</div> </div>
@ -127,7 +373,7 @@ import { ref, watch, onMounted } from 'vue';
import Coloris from '@melloware/coloris'; import Coloris from '@melloware/coloris';
import UnitToggle from '../ui/UnitToggle.vue'; import UnitToggle from '../ui/UnitToggle.vue';
import InputWithUnit from '../ui/InputWithUnit.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 { useCssUpdater } from '../../composables/useCssUpdater';
import { useCssSync } from '../../composables/useCssSync'; import { useCssSync } from '../../composables/useCssSync';
import { useDebounce } from '../../composables/useDebounce'; import { useDebounce } from '../../composables/useDebounce';
@ -155,7 +401,6 @@ const alignment = ref('left');
const color = ref('rgb(0, 0, 0)'); const color = ref('rgb(0, 0, 0)');
const background = ref('transparent'); const background = ref('transparent');
const marginOuter = ref({ value: 0, unit: 'mm' });
const marginOuterDetailed = ref({ const marginOuterDetailed = ref({
top: { value: 0, unit: 'mm' }, top: { value: 0, unit: 'mm' },
right: { value: 0, unit: 'mm' }, right: { value: 0, unit: 'mm' },
@ -163,7 +408,6 @@ const marginOuterDetailed = ref({
left: { value: 0, unit: 'mm' } left: { value: 0, unit: 'mm' }
}); });
const marginInner = ref({ value: 0, unit: 'mm' });
const marginInnerDetailed = ref({ const marginInnerDetailed = ref({
top: { value: 0, unit: 'mm' }, top: { value: 0, unit: 'mm' },
right: { value: 0, unit: 'mm' }, right: { value: 0, unit: 'mm' },
@ -171,12 +415,48 @@ const marginInnerDetailed = ref({
left: { value: 0, unit: 'mm' } left: { value: 0, unit: 'mm' }
}); });
const marginOuterEditor = ref(null); const marginOuterLinked = ref(false);
const marginInnerEditor = ref(null); 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; 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 // Watchers for body styles
watch(font, (val) => {
if (isUpdatingFromStore) return;
updateStyle('body', 'font-family', `"${val}"`);
});
watch(italic, (val) => { watch(italic, (val) => {
if (isUpdatingFromStore) return; if (isUpdatingFromStore) return;
updateStyle('body', 'font-style', val ? 'italic' : 'normal'); updateStyle('body', 'font-style', val ? 'italic' : 'normal');
@ -210,29 +490,161 @@ watch(fontSize, (val) => {
}); });
}, { deep: true }); }, { deep: true });
// Margin/Padding handlers // Watch when link is toggled
const handleMarginOuterChange = ({ type, simple, detailed }) => { watch(marginOuterLinked, (isLinked) => {
if (isUpdatingFromStore) return; if (isLinked) {
debouncedUpdate(() => { // When linking, sync all to the first non-zero value or top value
if (type === 'simple') { const current = marginOuterDetailed.value;
setMargin('p', simple.value, simple.unit); const syncValue = current.top.value || current.bottom.value || current.left.value || current.right.value;
} else {
setDetailedMargins('p', detailed.top, detailed.right, detailed.bottom, detailed.left); isUpdatingFromStore = true;
marginOuterDetailed.value.top.value = syncValue;
marginOuterDetailed.value.bottom.value = syncValue;
marginOuterDetailed.value.left.value = syncValue;
marginOuterDetailed.value.right.value = syncValue;
prevMarginOuter.value.top = syncValue;
prevMarginOuter.value.bottom = syncValue;
prevMarginOuter.value.left = syncValue;
prevMarginOuter.value.right = syncValue;
isUpdatingFromStore = false;
} }
}); });
watch(marginInnerLinked, (isLinked) => {
if (isLinked) {
// When linking, sync all to the first non-zero value or top value
const current = marginInnerDetailed.value;
const syncValue = current.top.value || current.bottom.value || current.left.value || current.right.value;
isUpdatingFromStore = true;
marginInnerDetailed.value.top.value = syncValue;
marginInnerDetailed.value.bottom.value = syncValue;
marginInnerDetailed.value.left.value = syncValue;
marginInnerDetailed.value.right.value = syncValue;
prevMarginInner.value.top = syncValue;
prevMarginInner.value.bottom = syncValue;
prevMarginInner.value.left = syncValue;
prevMarginInner.value.right = syncValue;
isUpdatingFromStore = false;
}
});
// Watch margin outer values
watch(() => [
marginOuterDetailed.value.top.value,
marginOuterDetailed.value.bottom.value,
marginOuterDetailed.value.left.value,
marginOuterDetailed.value.right.value,
], () => {
if (isUpdatingFromStore) return;
// If linked, sync all values to the one that changed
if (marginOuterLinked.value) {
const current = {
top: marginOuterDetailed.value.top.value,
bottom: marginOuterDetailed.value.bottom.value,
left: marginOuterDetailed.value.left.value,
right: marginOuterDetailed.value.right.value,
}; };
const handleMarginInnerChange = ({ type, simple, detailed }) => { // Find which value actually changed by comparing with previous
if (isUpdatingFromStore) return; let changedValue = null;
debouncedUpdate(() => { if (current.top !== prevMarginOuter.value.top) changedValue = current.top;
if (type === 'simple') { else if (current.bottom !== prevMarginOuter.value.bottom) changedValue = current.bottom;
setPadding('p', simple.value, simple.unit); else if (current.left !== prevMarginOuter.value.left) changedValue = current.left;
} else { else if (current.right !== prevMarginOuter.value.right) changedValue = current.right;
setDetailedPadding('p', detailed.top, detailed.right, detailed.bottom, detailed.left);
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 // Sync from store
const syncFromStore = () => { const syncFromStore = () => {
isUpdatingFromStore = true; isUpdatingFromStore = true;
@ -261,17 +673,17 @@ const syncFromStore = () => {
const margins = extractSpacing('p', 'margin'); const margins = extractSpacing('p', 'margin');
if (margins) { if (margins) {
if (margins.simple) { if (margins.simple) {
marginOuter.value = margins.simple; // All margins are the same
// Sync detailed from simple
marginOuterDetailed.value = { marginOuterDetailed.value = {
top: { ...margins.simple }, top: { ...margins.simple },
right: { ...margins.simple }, right: { ...margins.simple },
bottom: { ...margins.simple }, bottom: { ...margins.simple },
left: { ...margins.simple } left: { ...margins.simple }
}; };
marginOuterLinked.value = true;
} else if (margins.detailed) { } else if (margins.detailed) {
marginOuterDetailed.value = 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 = const allSame =
margins.detailed.top.value === margins.detailed.right.value && margins.detailed.top.value === margins.detailed.right.value &&
margins.detailed.top.value === margins.detailed.bottom.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.right.unit &&
margins.detailed.top.unit === margins.detailed.bottom.unit && margins.detailed.top.unit === margins.detailed.bottom.unit &&
margins.detailed.top.unit === margins.detailed.left.unit; margins.detailed.top.unit === margins.detailed.left.unit;
marginOuterLinked.value = allSame;
if (allSame) {
marginOuter.value = margins.detailed.top;
} else {
// Values are different, open the detailed editor and use first value for simple
marginOuter.value = margins.detailed.top;
// Open detailed view after mount
setTimeout(() => {
if (marginOuterEditor.value) {
marginOuterEditor.value.expanded = true;
}
}, 0);
}
} }
} }
@ -299,17 +699,17 @@ const syncFromStore = () => {
const padding = extractSpacing('p', 'padding'); const padding = extractSpacing('p', 'padding');
if (padding) { if (padding) {
if (padding.simple) { if (padding.simple) {
marginInner.value = padding.simple; // All paddings are the same
// Sync detailed from simple
marginInnerDetailed.value = { marginInnerDetailed.value = {
top: { ...padding.simple }, top: { ...padding.simple },
right: { ...padding.simple }, right: { ...padding.simple },
bottom: { ...padding.simple }, bottom: { ...padding.simple },
left: { ...padding.simple } left: { ...padding.simple }
}; };
marginInnerLinked.value = true;
} else if (padding.detailed) { } else if (padding.detailed) {
marginInnerDetailed.value = 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 = const allSame =
padding.detailed.top.value === padding.detailed.right.value && padding.detailed.top.value === padding.detailed.right.value &&
padding.detailed.top.value === padding.detailed.bottom.value && padding.detailed.top.value === padding.detailed.bottom.value &&
@ -317,21 +717,24 @@ const syncFromStore = () => {
padding.detailed.top.unit === padding.detailed.right.unit && padding.detailed.top.unit === padding.detailed.right.unit &&
padding.detailed.top.unit === padding.detailed.bottom.unit && padding.detailed.top.unit === padding.detailed.bottom.unit &&
padding.detailed.top.unit === padding.detailed.left.unit; padding.detailed.top.unit === padding.detailed.left.unit;
marginInnerLinked.value = allSame;
}
}
if (allSame) { // Update previous values to match current state
marginInner.value = padding.detailed.top; prevMarginOuter.value = {
} else { top: marginOuterDetailed.value.top.value,
// Values are different, open the detailed editor and use first value for simple right: marginOuterDetailed.value.right.value,
marginInner.value = padding.detailed.top; bottom: marginOuterDetailed.value.bottom.value,
// Open detailed view after mount left: marginOuterDetailed.value.left.value
setTimeout(() => { };
if (marginInnerEditor.value) {
marginInnerEditor.value.expanded = true; prevMarginInner.value = {
} top: marginInnerDetailed.value.top.value,
}, 0); right: marginInnerDetailed.value.right.value,
} bottom: marginInnerDetailed.value.bottom.value,
} left: marginInnerDetailed.value.left.value
} };
isUpdatingFromStore = false; isUpdatingFromStore = false;
}; };
@ -348,3 +751,49 @@ onMounted(() => {
syncFromStore(); syncFromStore();
}); });
</script> </script>
<style scoped>
.subsection-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.subsection-header h3 {
margin: 0;
}
.link-button {
background: none;
border: 1px solid var(--color-border, #ddd);
border-radius: 4px;
padding: 0.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
width: 24px;
height: 24px;
}
.link-button svg {
width: 16px;
height: 16px;
color: var(--color-text-secondary, #666);
}
.link-button:hover {
background: var(--color-hover, #f0f0f0);
}
.link-button.active {
background: var(--color-primary, #007bff);
border-color: var(--color-primary, #007bff);
}
.link-button.active svg {
color: white;
}
</style>

View file

@ -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>

View file

@ -19,7 +19,13 @@
:disabled="disabled || (max !== undefined && modelValue >= max)" :disabled="disabled || (max !== undefined && modelValue >= max)"
tabindex="-1" tabindex="-1"
> >
<svg width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg"> <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" /> <path d="M4 0L7.4641 6H0.535898L4 0Z" fill="currentColor" />
</svg> </svg>
</button> </button>
@ -30,7 +36,13 @@
:disabled="disabled || (min !== undefined && modelValue <= min)" :disabled="disabled || (min !== undefined && modelValue <= min)"
tabindex="-1" tabindex="-1"
> >
<svg width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg"> <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" /> <path d="M4 6L0.535898 0H7.4641L4 6Z" fill="currentColor" />
</svg> </svg>
</button> </button>
@ -42,32 +54,32 @@
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Number, type: Number,
required: true required: true,
}, },
min: { min: {
type: Number, type: Number,
default: undefined default: undefined,
}, },
max: { max: {
type: Number, type: Number,
default: undefined default: undefined,
}, },
step: { step: {
type: Number, type: Number,
default: 1 default: 1,
}, },
id: { id: {
type: String, type: String,
default: undefined default: undefined,
}, },
inputClass: { inputClass: {
type: String, type: String,
default: '' default: '',
}, },
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false default: false,
} },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);

View file

@ -5,6 +5,7 @@
:key="unit" :key="unit"
type="button" type="button"
:class="{ active: modelValue === unit }" :class="{ active: modelValue === unit }"
:disabled="disabled"
@click="$emit('update:modelValue', unit)" @click="$emit('update:modelValue', unit)"
> >
{{ unit }} {{ unit }}
@ -21,6 +22,10 @@ defineProps({
units: { units: {
type: Array, type: Array,
default: () => ['mm', 'px'] default: () => ['mm', 'px']
},
disabled: {
type: Boolean,
default: false
} }
}); });

View file

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

View 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,
};
}

View 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
};
}

View 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,
};
}

View 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
};
}

View file

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

View file

@ -1,29 +1,49 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import cssParsingUtils from '../utils/css-parsing'; import cssParsingUtils from '../utils/css-parsing';
import * as cssComments from '../utils/css-comments';
import prettier from 'prettier/standalone'; import prettier from 'prettier/standalone';
import parserPostcss from 'prettier/plugins/postcss'; import parserPostcss from 'prettier/plugins/postcss';
import { getCsrfToken } from '../utils/kirby-auth';
export const useStylesheetStore = defineStore('stylesheet', () => { export const useStylesheetStore = defineStore('stylesheet', () => {
const content = ref(''); // Base state
const baseCss = ref('');
const customCss = ref('');
const isEditing = ref(false); const isEditing = ref(false);
let formatTimer = null; let formatTimer = null;
let isFormatting = false; let isFormatting = false;
let isInitializing = false;
// Format CSS with Prettier // Save/load state
const formatContent = async () => { const isDirty = ref(false);
if (isFormatting || !content.value) return; 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 { try {
isFormatting = true; isFormatting = true;
const formatted = await prettier.format(content.value, { const formatted = await prettier.format(customCss.value, {
parser: 'css', parser: 'css',
plugins: [parserPostcss], plugins: [parserPostcss],
printWidth: 80, printWidth: 80,
tabWidth: 2, tabWidth: 2,
useTabs: false, useTabs: false,
}); });
content.value = formatted; customCss.value = formatted;
} catch (error) { } catch (error) {
console.error('CSS formatting error:', error); console.error('CSS formatting error:', error);
} finally { } finally {
@ -31,46 +51,236 @@ export const useStylesheetStore = defineStore('stylesheet', () => {
} }
}; };
// Watch content and format after 500ms of inactivity (only when not editing) // Watch customCss and format after 500ms of inactivity (only when not editing)
watch(content, () => { watch(customCss, () => {
if (isFormatting || isEditing.value) return; if (isFormatting || isEditing.value || isInitializing) return;
// Mark as dirty when customCss changes (unless we're saving)
if (!isSaving.value) {
isDirty.value = true;
}
clearTimeout(formatTimer); clearTimeout(formatTimer);
formatTimer = setTimeout(() => { formatTimer = setTimeout(() => {
formatContent(); formatCustomCss();
}, 500); }, 500);
}); });
const loadStylesheet = async () => { const loadStylesheet = async () => {
const response = await fetch('/assets/css/stylesheet.css'); const response = await fetch('/assets/css/stylesheet.print.css');
content.value = await response.text(); baseCss.value = await response.text();
}; };
const updateProperty = (selector, property, value, unit) => { const updateProperty = (selector, property, value, unit) => {
content.value = cssParsingUtils.updateCssValue({ // Update custom CSS, not the combined content
css: content.value, customCss.value = cssParsingUtils.updateCssValue({
css: customCss.value,
selector, selector,
property, property,
value, value,
unit unit,
}); });
}; };
const extractValue = (selector, property) => { const extractValue = (selector, property, includeCommented = true) => {
return cssParsingUtils.extractCssValue(content.value, selector, property); // 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) => { 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 { return {
content, // Core state
content, // computed: baseCss + customCss
baseCss,
customCss,
isEditing, isEditing,
// Methods
loadStylesheet, loadStylesheet,
updateProperty, updateProperty,
extractValue, extractValue,
extractBlock, 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
View 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;
}

View file

@ -44,6 +44,24 @@ const updateCssValue = ({ css, selector, property, value, unit }) => {
return css.replace(selectorRegex, `${selector} {${newBlockContent}}`); 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; export default cssParsingUtils;

37
src/utils/kirby-auth.js Normal file
View 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');
}

View file

@ -1,7 +1,25 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], 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]';
},
},
},
},
});