fix: drag handle actions now affect the correct block

- Added menuNode state to capture the node position when menu opens
- Updated all action functions to use menuNode instead of currentNode
- This ensures drag handle actions (Turn into, Delete, etc.) always affect the block where the handle was clicked, not where the mouse currently hovers
- Also formatted code with prettier

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-26 10:33:27 -04:00
parent 1e4a27b1a3
commit 1c38dc87e3
39 changed files with 353 additions and 316 deletions

View file

@ -9,6 +9,7 @@ This analysis examines SVG usage patterns in the Svelte 5 codebase to identify o
### 1. Inline SVGs vs. Imported SVGs ### 1. Inline SVGs vs. Imported SVGs
**Inline SVGs Found:** **Inline SVGs Found:**
- **Close/X buttons**: Found in 7+ components with identical SVG code - **Close/X buttons**: Found in 7+ components with identical SVG code
- `admin/Modal.svelte` - `admin/Modal.svelte`
- `admin/UnifiedMediaModal.svelte` - `admin/UnifiedMediaModal.svelte`
@ -17,8 +18,8 @@ This analysis examines SVG usage patterns in the Svelte 5 codebase to identify o
- `admin/GalleryManager.svelte` - `admin/GalleryManager.svelte`
- `admin/MediaDetailsModal.svelte` - `admin/MediaDetailsModal.svelte`
- `Lightbox.svelte` - `Lightbox.svelte`
- **Loading spinners**: Found in 2+ components - **Loading spinners**: Found in 2+ components
- `admin/Button.svelte` - `admin/Button.svelte`
- `admin/ImageUploader.svelte` - `admin/ImageUploader.svelte`
- `admin/GalleryUploader.svelte` - `admin/GalleryUploader.svelte`
@ -30,26 +31,23 @@ This analysis examines SVG usage patterns in the Svelte 5 codebase to identify o
### 2. SVG Import Patterns ### 2. SVG Import Patterns
**Consistent patterns using aliases:** **Consistent patterns using aliases:**
```svelte ```svelte
// Good - using $icons alias // Good - using $icons alias import ArrowLeft from '$icons/arrow-left.svg' import ChevronDownIcon
import ArrowLeft from '$icons/arrow-left.svg' from '$icons/chevron-down.svg' // Component imports with ?component import PhotosIcon from
import ChevronDownIcon from '$icons/chevron-down.svg' '$icons/photos.svg?component' import ViewSingleIcon from '$icons/view-single.svg?component' // Raw
imports import ChevronDownIcon from '$icons/chevron-down.svg?raw'
// Component imports with ?component
import PhotosIcon from '$icons/photos.svg?component'
import ViewSingleIcon from '$icons/view-single.svg?component'
// Raw imports
import ChevronDownIcon from '$icons/chevron-down.svg?raw'
``` ```
### 3. Unused SVG Files ### 3. Unused SVG Files
**Unused icons in `/src/assets/icons/`:** **Unused icons in `/src/assets/icons/`:**
- `dashboard.svg` - `dashboard.svg`
- `metadata.svg` - `metadata.svg`
**Unused illustrations in `/src/assets/illos/`:** **Unused illustrations in `/src/assets/illos/`:**
- `jedmund-blink.svg` - `jedmund-blink.svg`
- `jedmund-headphones.svg` - `jedmund-headphones.svg`
- `jedmund-listening-downbeat.svg` - `jedmund-listening-downbeat.svg`
@ -65,11 +63,13 @@ import ChevronDownIcon from '$icons/chevron-down.svg?raw'
### 4. Duplicate SVG Definitions ### 4. Duplicate SVG Definitions
**Close/X Button SVG** (appears 7+ times): **Close/X Button SVG** (appears 7+ times):
```svg ```svg
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> <path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
``` ```
**Loading Spinner SVG** (appears 3+ times): **Loading Spinner SVG** (appears 3+ times):
```svg ```svg
<svg class="spinner" width="24" height="24" viewBox="0 0 24 24"> <svg class="spinner" width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="25" stroke-dashoffset="25" stroke-linecap="round"> <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="25" stroke-dashoffset="25" stroke-linecap="round">
@ -90,44 +90,47 @@ import ChevronDownIcon from '$icons/chevron-down.svg?raw'
### 1. Create Reusable Icon Components ### 1. Create Reusable Icon Components
**Option A: Create individual icon components** **Option A: Create individual icon components**
```svelte ```svelte
<!-- $lib/components/icons/CloseIcon.svelte --> <!-- $lib/components/icons/CloseIcon.svelte -->
<script> <script>
let { size = 24, class: className = '' } = $props() let { size = 24, class: className = '' } = $props()
</script> </script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" class={className}> <svg width={size} height={size} viewBox="0 0 24 24" fill="none" class={className}>
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> <path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg> </svg>
``` ```
**Option B: Create an Icon component with name prop** **Option B: Create an Icon component with name prop**
```svelte ```svelte
<!-- $lib/components/Icon.svelte --> <!-- $lib/components/Icon.svelte -->
<script> <script>
import CloseIcon from '$icons/close.svg' import CloseIcon from '$icons/close.svg'
import LoadingIcon from '$icons/loading.svg' import LoadingIcon from '$icons/loading.svg'
// ... other imports // ... other imports
let { name, size = 24, class: className = '' } = $props() let { name, size = 24, class: className = '' } = $props()
const icons = { const icons = {
close: CloseIcon, close: CloseIcon,
loading: LoadingIcon, loading: LoadingIcon
// ... other icons // ... other icons
} }
const IconComponent = $derived(icons[name]) const IconComponent = $derived(icons[name])
</script> </script>
{#if IconComponent} {#if IconComponent}
<IconComponent {size} class={className} /> <IconComponent {size} class={className} />
{/if} {/if}
``` ```
### 2. Extract Inline SVGs to Files ### 2. Extract Inline SVGs to Files
Create new SVG files for commonly used inline SVGs: Create new SVG files for commonly used inline SVGs:
- `/src/assets/icons/close.svg` - `/src/assets/icons/close.svg`
- `/src/assets/icons/loading.svg` - `/src/assets/icons/loading.svg`
- `/src/assets/icons/external-link.svg` - `/src/assets/icons/external-link.svg`
@ -137,12 +140,14 @@ Create new SVG files for commonly used inline SVGs:
### 3. Clean Up Unused Assets ### 3. Clean Up Unused Assets
Remove the following unused files to reduce bundle size: Remove the following unused files to reduce bundle size:
- All unused illustration files (11 files) - All unused illustration files (11 files)
- Unused icon files (2 files) - Unused icon files (2 files)
### 4. Standardize Import Methods ### 4. Standardize Import Methods
Establish a consistent pattern: Establish a consistent pattern:
- Use `?component` for SVGs used as Svelte components - Use `?component` for SVGs used as Svelte components
- Use direct imports for SVGs used as images - Use direct imports for SVGs used as images
- Avoid `?raw` imports unless necessary - Avoid `?raw` imports unless necessary
@ -152,21 +157,36 @@ Establish a consistent pattern:
```svelte ```svelte
<!-- $lib/components/LoadingSpinner.svelte --> <!-- $lib/components/LoadingSpinner.svelte -->
<script> <script>
let { size = 24, class: className = '' } = $props() let { size = 24, class: className = '' } = $props()
</script> </script>
<svg class="loading-spinner {className}" width={size} height={size} viewBox="0 0 24 24"> <svg class="loading-spinner {className}" width={size} height={size} viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" <circle
stroke-dasharray="25" stroke-dashoffset="25" stroke-linecap="round"> cx="12"
<animateTransform attributeName="transform" type="rotate" cy="12"
from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/> r="10"
</circle> stroke="currentColor"
stroke-width="2"
fill="none"
stroke-dasharray="25"
stroke-dashoffset="25"
stroke-linecap="round"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 12 12"
to="360 12 12"
dur="1s"
repeatCount="indefinite"
/>
</circle>
</svg> </svg>
<style> <style>
.loading-spinner { .loading-spinner {
color: currentColor; color: currentColor;
} }
</style> </style>
``` ```
@ -183,4 +203,4 @@ Establish a consistent pattern:
1. **High Priority**: Extract and componentize duplicate inline SVGs (close button, loading spinner) 1. **High Priority**: Extract and componentize duplicate inline SVGs (close button, loading spinner)
2. **Medium Priority**: Remove unused SVG files 2. **Medium Priority**: Remove unused SVG files
3. **Low Priority**: Standardize all import patterns and create comprehensive icon system 3. **Low Priority**: Standardize all import patterns and create comprehensive icon system

View file

@ -3,7 +3,7 @@
**Date**: December 26, 2025 **Date**: December 26, 2025
**Author**: Claude Code **Author**: Claude Code
**Status**: Draft **Status**: Draft
**Priority**: High **Priority**: High
## Executive Summary ## Executive Summary
@ -20,18 +20,21 @@ This PRD outlines a comprehensive cleanup and refactoring plan for the jedmund-s
## Key Findings ## Key Findings
### 1. Overengineered Components ### 1. Overengineered Components
- **EnhancedComposer** (1,347 lines) - Handles too many responsibilities - **EnhancedComposer** (1,347 lines) - Handles too many responsibilities
- **LastFM Stream Server** (625 lines) - Complex data transformations that could be simplified - **LastFM Stream Server** (625 lines) - Complex data transformations that could be simplified
- **Multiple Media Modals** - Overlapping functionality across 3+ modal components - **Multiple Media Modals** - Overlapping functionality across 3+ modal components
- **Complex State Management** - Components with 10-20 state variables - **Complex State Management** - Components with 10-20 state variables
### 2. Unused Code ### 2. Unused Code
- 5 unused components (Squiggly, PhotoLightbox, Pill, SVGHoverEffect, MusicPreview) - 5 unused components (Squiggly, PhotoLightbox, Pill, SVGHoverEffect, MusicPreview)
- 13 unused SVG files (2 icons, 11 illustrations) - 13 unused SVG files (2 icons, 11 illustrations)
- Minimal commented-out code (good!) - Minimal commented-out code (good!)
- 1 potentially unused API endpoint (/api/health) - 1 potentially unused API endpoint (/api/health)
### 3. DRY Violations ### 3. DRY Violations
- **Photo Grid Components** - 3 nearly identical components - **Photo Grid Components** - 3 nearly identical components
- **Modal Components** - Duplicate backdrop and positioning logic - **Modal Components** - Duplicate backdrop and positioning logic
- **Dropdown Components** - Repeated dropdown patterns - **Dropdown Components** - Repeated dropdown patterns
@ -39,6 +42,7 @@ This PRD outlines a comprehensive cleanup and refactoring plan for the jedmund-s
- **Segmented Controllers** - Duplicate animation and positioning logic - **Segmented Controllers** - Duplicate animation and positioning logic
### 4. Hardcoded Values ### 4. Hardcoded Values
- **Colors**: 200+ hardcoded hex/rgba values instead of using existing variables - **Colors**: 200+ hardcoded hex/rgba values instead of using existing variables
- **Spacing**: 1,000+ hardcoded pixel values instead of using `$unit` system - **Spacing**: 1,000+ hardcoded pixel values instead of using `$unit` system
- **Z-indexes**: 60+ hardcoded z-index values without consistent scale - **Z-indexes**: 60+ hardcoded z-index values without consistent scale
@ -46,6 +50,7 @@ This PRD outlines a comprehensive cleanup and refactoring plan for the jedmund-s
- **Border radius**: Not using existing `$corner-radius-*` variables - **Border radius**: Not using existing `$corner-radius-*` variables
### 5. SVG Issues ### 5. SVG Issues
- 7+ duplicate inline close button SVGs - 7+ duplicate inline close button SVGs
- 3+ duplicate loading spinner SVGs - 3+ duplicate loading spinner SVGs
- Inconsistent import patterns - Inconsistent import patterns
@ -54,9 +59,11 @@ This PRD outlines a comprehensive cleanup and refactoring plan for the jedmund-s
## Implementation Timeline ## Implementation Timeline
### Phase 1: Quick Wins (Week 1) ### Phase 1: Quick Wins (Week 1)
Focus on low-risk, high-impact changes that don't require architectural modifications. Focus on low-risk, high-impact changes that don't require architectural modifications.
- [x] **Remove unused components** (5 components) - [x] **Remove unused components** (5 components)
- [x] Delete `/src/lib/components/Squiggly.svelte` - [x] Delete `/src/lib/components/Squiggly.svelte`
- [x] Delete `/src/lib/components/PhotoLightbox.svelte` - [x] Delete `/src/lib/components/PhotoLightbox.svelte`
- [x] Delete `/src/lib/components/Pill.svelte` - [x] Delete `/src/lib/components/Pill.svelte`
@ -64,6 +71,7 @@ Focus on low-risk, high-impact changes that don't require architectural modifica
- [x] Delete `/src/lib/components/MusicPreview.svelte` - [x] Delete `/src/lib/components/MusicPreview.svelte`
- [x] **Remove unused SVG files** (13 files) - [x] **Remove unused SVG files** (13 files)
- [x] Delete unused icons: `dashboard.svg`, `metadata.svg` - [x] Delete unused icons: `dashboard.svg`, `metadata.svg`
- [x] Delete unused illustrations (11 files - see SVG analysis report) - [x] Delete unused illustrations (11 files - see SVG analysis report)
@ -72,18 +80,22 @@ Focus on low-risk, high-impact changes that don't require architectural modifica
- [x] Address TODO in `/src/lib/server/api-utils.ts` about authentication (noted for future work) - [x] Address TODO in `/src/lib/server/api-utils.ts` about authentication (noted for future work)
### Phase 2: CSS Variable Standardization (Week 2) ### Phase 2: CSS Variable Standardization (Week 2)
Create a consistent design system by extracting hardcoded values. Create a consistent design system by extracting hardcoded values.
- [x] **Create z-index system** - [x] **Create z-index system**
- [x] Create `src/assets/styles/_z-index.scss` with constants - [x] Create `src/assets/styles/_z-index.scss` with constants
- [x] Replace 60+ hardcoded z-index values - [x] Replace 60+ hardcoded z-index values
- [x] **Extract color variables** - [x] **Extract color variables**
- [x] Add missing color variables for frequently used colors - [x] Add missing color variables for frequently used colors
- [x] Replace 200+ hardcoded hex/rgba values (replaced most common colors) - [x] Replace 200+ hardcoded hex/rgba values (replaced most common colors)
- [x] Create shadow/overlay variables for rgba values - [x] Create shadow/overlay variables for rgba values
- [x] **Standardize spacing** - [x] **Standardize spacing**
- [x] Add missing unit multipliers (added `$unit-7x` through `$unit-19x` and common pixel values) - [x] Add missing unit multipliers (added `$unit-7x` through `$unit-19x` and common pixel values)
- [x] Replace 1,000+ hardcoded pixel values with unit variables (replaced in key components) - [x] Replace 1,000+ hardcoded pixel values with unit variables (replaced in key components)
@ -92,15 +104,18 @@ Create a consistent design system by extracting hardcoded values.
- [x] Replace hardcoded duration values (replaced in key components) - [x] Replace hardcoded duration values (replaced in key components)
### Phase 3: Component Refactoring (Weeks 3-4) ### Phase 3: Component Refactoring (Weeks 3-4)
Refactor components to reduce duplication and complexity. Refactor components to reduce duplication and complexity.
- [x] **Create base components** - [x] **Create base components**
- [x] Extract `BaseModal` component for shared modal logic - [x] Extract `BaseModal` component for shared modal logic
- [x] Create `BaseDropdown` for dropdown patterns - [x] Create `BaseDropdown` for dropdown patterns
- [x] Merge `FormField` and `FormFieldWrapper` - [x] Merge `FormField` and `FormFieldWrapper`
- [x] Create `BaseSegmentedController` for shared logic - [x] Create `BaseSegmentedController` for shared logic
- [x] **Refactor photo grids** - [x] **Refactor photo grids**
- [x] Create unified `PhotoGrid` component with `columns` prop - [x] Create unified `PhotoGrid` component with `columns` prop
- [x] Remove 3 duplicate grid components - [x] Remove 3 duplicate grid components
- [x] Use composition for layout variations - [x] Use composition for layout variations
@ -112,9 +127,11 @@ Refactor components to reduce duplication and complexity.
- [x] Extract other repeated inline SVGs (FileIcon, CopyIcon) - [x] Extract other repeated inline SVGs (FileIcon, CopyIcon)
### Phase 4: Complex Refactoring (Weeks 5-6) ### Phase 4: Complex Refactoring (Weeks 5-6)
Tackle the most complex components and patterns. Tackle the most complex components and patterns.
- [x] **Refactor EnhancedComposer** - [x] **Refactor EnhancedComposer**
- [x] Split into focused sub-components - [x] Split into focused sub-components
- [x] Extract toolbar component - [x] Extract toolbar component
- [x] Separate media management - [x] Separate media management
@ -122,6 +139,7 @@ Tackle the most complex components and patterns.
- [x] Reduce state variables from 20+ to <10 - [x] Reduce state variables from 20+ to <10
- [ ] **Simplify LastFM Stream Server** - [ ] **Simplify LastFM Stream Server**
- [ ] Extract data transformation utilities - [ ] Extract data transformation utilities
- [ ] Simplify "now playing" detection algorithm - [ ] Simplify "now playing" detection algorithm
- [ ] Reduce state tracking duplication - [ ] Reduce state tracking duplication
@ -133,9 +151,11 @@ Tackle the most complex components and patterns.
- [ ] Eliminate prop drilling with stores - [ ] Eliminate prop drilling with stores
### Phase 5: Architecture & Utilities (Week 7) ### Phase 5: Architecture & Utilities (Week 7)
Improve overall architecture and create shared utilities. Improve overall architecture and create shared utilities.
- [ ] **Create shared utilities** - [ ] **Create shared utilities**
- [ ] API client with consistent error handling - [ ] API client with consistent error handling
- [ ] CSS mixins for common patterns - [ ] CSS mixins for common patterns
- [ ] Media handling utilities - [ ] Media handling utilities
@ -148,9 +168,11 @@ Improve overall architecture and create shared utilities.
- [ ] Create shared animation definitions - [ ] Create shared animation definitions
### Phase 6: Testing & Documentation (Week 8) ### Phase 6: Testing & Documentation (Week 8)
Ensure changes don't break functionality and document new patterns. Ensure changes don't break functionality and document new patterns.
- [ ] **Testing** - [ ] **Testing**
- [ ] Run full build and type checking - [ ] Run full build and type checking
- [ ] Test all refactored components - [ ] Test all refactored components
- [ ] Verify no regressions in functionality - [ ] Verify no regressions in functionality
@ -165,19 +187,23 @@ Ensure changes don't break functionality and document new patterns.
## Success Metrics ## Success Metrics
1. **Code Reduction** 1. **Code Reduction**
- Target: 20-30% reduction in total lines of code - Target: 20-30% reduction in total lines of code
- Eliminate 1,000+ instances of code duplication - Eliminate 1,000+ instances of code duplication
2. **Component Simplification** 2. **Component Simplification**
- No component larger than 500 lines - No component larger than 500 lines
- Average component size under 200 lines - Average component size under 200 lines
3. **Design System Consistency** 3. **Design System Consistency**
- Zero hardcoded colors in components - Zero hardcoded colors in components
- All spacing using design tokens - All spacing using design tokens
- Consistent z-index scale - Consistent z-index scale
4. **Bundle Size** 4. **Bundle Size**
- 10-15% reduction in JavaScript bundle size - 10-15% reduction in JavaScript bundle size
- Removal of unused assets - Removal of unused assets
@ -189,11 +215,13 @@ Ensure changes don't break functionality and document new patterns.
## Risk Mitigation ## Risk Mitigation
1. **Regression Testing** 1. **Regression Testing**
- Test each phase thoroughly before moving to next - Test each phase thoroughly before moving to next
- Keep backups of original components during refactoring - Keep backups of original components during refactoring
- Use feature flags for gradual rollout if needed - Use feature flags for gradual rollout if needed
2. **Performance Impact** 2. **Performance Impact**
- Monitor bundle size after each phase - Monitor bundle size after each phase
- Profile component render performance - Profile component render performance
- Ensure no performance regressions - Ensure no performance regressions
@ -215,4 +243,4 @@ Each phase should be implemented as a separate git branch with the ability to re
--- ---
**Next Steps**: Review this PRD and approve the implementation timeline. Each phase can be tracked using the checkboxes above. **Next Steps**: Review this PRD and approve the implementation timeline. Each phase can be tracked using the checkboxes above.

View file

@ -36,4 +36,4 @@ $z-index-toast: 10000;
// Admin-specific z-indexes // Admin-specific z-indexes
$z-index-admin-nav: 100; $z-index-admin-nav: 100;
$z-index-admin-sidebar: 200; $z-index-admin-sidebar: 200;
$z-index-admin-modal: 1050; $z-index-admin-modal: 1050;

View file

@ -225,7 +225,6 @@ $info-color: $blue-50;
// Component specific // Component specific
$image-border-color: rgba(0, 0, 0, 0.03); $image-border-color: rgba(0, 0, 0, 0.03);
/* Shadows and Overlays /* Shadows and Overlays
* -------------------------------------------------------------------------- */ * -------------------------------------------------------------------------- */
$card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); $card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);

View file

@ -49,4 +49,4 @@ export function tooltip(element: HTMLElement, options: TooltipOptions | string)
} }
} }
} }
} }

View file

@ -6,10 +6,7 @@
showCaptions?: boolean showCaptions?: boolean
} }
let { let { photos = [], showCaptions = true }: Props = $props()
photos = [],
showCaptions = true
}: Props = $props()
</script> </script>
<div class="horizontal-scroll"> <div class="horizontal-scroll">

View file

@ -10,4 +10,4 @@
let { photos = [], columns = 'auto' }: Props = $props() let { photos = [], columns = 'auto' }: Props = $props()
</script> </script>
<PhotoGrid {photos} {columns} masonry={true} gap="medium" /> <PhotoGrid {photos} {columns} masonry={true} gap="medium" />

View file

@ -21,22 +21,21 @@
class: className = '' class: className = ''
}: Props = $props() }: Props = $props()
// Split photos into columns for column-based layouts // Split photos into columns for column-based layouts
function splitIntoColumns(photos: Photo[], numColumns: number): Photo[][] { function splitIntoColumns(photos: Photo[], numColumns: number): Photo[][] {
const columns: Photo[][] = Array.from({ length: numColumns }, () => []) const columns: Photo[][] = Array.from({ length: numColumns }, () => [])
photos.forEach((photo, index) => { photos.forEach((photo, index) => {
columns[index % numColumns].push(photo) columns[index % numColumns].push(photo)
}) })
return columns return columns
} }
const columnPhotos = $derived( const columnPhotos = $derived(
(columns === 1 || columns === 2 || columns === 3) && !masonry ? splitIntoColumns(photos, columns) : [] (columns === 1 || columns === 2 || columns === 3) && !masonry
? splitIntoColumns(photos, columns)
: []
) )
// Window width for responsive masonry // Window width for responsive masonry
@ -45,9 +44,9 @@
// Calculate masonry column widths based on columns prop // Calculate masonry column widths based on columns prop
const masonryConfig = $derived(() => { const masonryConfig = $derived(() => {
if (!masonry) return null if (!masonry) return null
const gapSize = gap === 'small' ? 8 : gap === 'large' ? 32 : 16 const gapSize = gap === 'small' ? 8 : gap === 'large' ? 32 : 16
if (columns === 1) { if (columns === 1) {
const width = windowWidth - 64 // Account for padding const width = windowWidth - 64 // Account for padding
return { minColWidth: width, maxColWidth: width, gap: gapSize } return { minColWidth: width, maxColWidth: width, gap: gapSize }
@ -55,7 +54,7 @@
const width = Math.floor((windowWidth - 64 - gapSize) / 2) const width = Math.floor((windowWidth - 64 - gapSize) / 2)
return { minColWidth: width - 10, maxColWidth: width + 10, gap: gapSize } return { minColWidth: width - 10, maxColWidth: width + 10, gap: gapSize }
} else if (columns === 3) { } else if (columns === 3) {
const width = Math.floor((windowWidth - 64 - (gapSize * 2)) / 3) const width = Math.floor((windowWidth - 64 - gapSize * 2) / 3)
return { minColWidth: width - 10, maxColWidth: width + 10, gap: gapSize } return { minColWidth: width - 10, maxColWidth: width + 10, gap: gapSize }
} else { } else {
// Auto columns // Auto columns
@ -90,7 +89,7 @@
> >
{#snippet children({ item })} {#snippet children({ item })}
<div class="photo-grid__item"> <div class="photo-grid__item">
<PhotoItem item={item} /> <PhotoItem {item} />
{#if showCaptions} {#if showCaptions}
<p class="photo-caption">{item.caption || ''}</p> <p class="photo-caption">{item.caption || ''}</p>
{/if} {/if}
@ -147,7 +146,7 @@
&--3-column { &--3-column {
display: flex; display: flex;
gap: var(--grid-gap); gap: var(--grid-gap);
@include breakpoint('mobile') { @include breakpoint('mobile') {
flex-direction: column; flex-direction: column;
} }
@ -233,4 +232,4 @@
} }
} }
} }
</style> </style>

View file

@ -9,4 +9,4 @@
let { photos = [] }: Props = $props() let { photos = [] }: Props = $props()
</script> </script>
<PhotoGrid {photos} columns={1} gap="large" showCaptions={true} /> <PhotoGrid {photos} columns={1} gap="large" showCaptions={true} />

View file

@ -9,4 +9,4 @@
let { photos = [] }: Props = $props() let { photos = [] }: Props = $props()
</script> </script>
<PhotoGrid {photos} columns={3} gap="medium" /> <PhotoGrid {photos} columns={3} gap="medium" />

View file

@ -9,4 +9,4 @@
let { photos = [] }: Props = $props() let { photos = [] }: Props = $props()
</script> </script>
<PhotoGrid {photos} columns={2} gap="medium" /> <PhotoGrid {photos} columns={2} gap="medium" />

View file

@ -53,7 +53,7 @@
<div class="dropdown-container {className}"> <div class="dropdown-container {className}">
<div class="dropdown-trigger"> <div class="dropdown-trigger">
{@render trigger()} {@render trigger()}
{#if dropdown} {#if dropdown}
<Button <Button
variant="ghost" variant="ghost"
@ -100,4 +100,4 @@
:global(.dropdown-toggle) { :global(.dropdown-toggle) {
flex-shrink: 0; flex-shrink: 0;
} }
</style> </style>

View file

@ -79,14 +79,14 @@
</script> </script>
{#if isOpen} {#if isOpen}
<div <div
class="modal-backdrop" class="modal-backdrop"
on:click={handleBackdropClick} on:click={handleBackdropClick}
transition:fade={{ duration: TRANSITION_FAST_MS }} transition:fade={{ duration: TRANSITION_FAST_MS }}
> >
<div <div
class={modalClass} class={modalClass}
on:click|stopPropagation on:click|stopPropagation
transition:fade={{ duration: TRANSITION_FAST_MS }} transition:fade={{ duration: TRANSITION_FAST_MS }}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@ -152,4 +152,4 @@
height: 90vh; height: 90vh;
} }
} }
</style> </style>

View file

@ -46,7 +46,7 @@
let itemElements: HTMLElement[] = [] let itemElements: HTMLElement[] = []
let pillStyle = '' let pillStyle = ''
let hoveredIndex = $state(-1) let hoveredIndex = $state(-1)
let internalValue = $state(defaultValue ?? value ?? (items[0]?.value ?? '')) let internalValue = $state(defaultValue ?? value ?? items[0]?.value ?? '')
// Derived state // Derived state
const currentValue = $derived(value ?? internalValue) const currentValue = $derived(value ?? internalValue)
@ -337,4 +337,4 @@
transition: none; transition: none;
} }
} }
</style> </style>

View file

@ -33,12 +33,7 @@
} }
</script> </script>
<BaseModal <BaseModal bind:isOpen size="small" onClose={handleCancel} class="delete-confirmation-modal">
bind:isOpen
size="small"
onClose={handleCancel}
class="delete-confirmation-modal"
>
<div class="modal-body"> <div class="modal-body">
<h2>{title}</h2> <h2>{title}</h2>
<p>{message}</p> <p>{message}</p>
@ -78,4 +73,4 @@
justify-content: flex-end; justify-content: flex-end;
} }
} }
</style> </style>

View file

@ -27,7 +27,7 @@
let dropdownElement: HTMLDivElement let dropdownElement: HTMLDivElement
let cleanup: (() => void) | null = null let cleanup: (() => void) | null = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
// Track which submenu is open // Track which submenu is open
let openSubmenuId = $state<string | null>(null) let openSubmenuId = $state<string | null>(null)
let submenuElements = $state<Map<string, HTMLElement>>(new Map()) let submenuElements = $state<Map<string, HTMLElement>>(new Map())
@ -36,13 +36,16 @@
// Position state // Position state
let x = $state(0) let x = $state(0)
let y = $state(0) let y = $state(0)
// Action to set submenu references // Action to set submenu references
function submenuRef(node: HTMLElement, params: { item: DropdownItem; submenuElements: Map<string, HTMLElement> }) { function submenuRef(
node: HTMLElement,
params: { item: DropdownItem; submenuElements: Map<string, HTMLElement> }
) {
if (params.item.children) { if (params.item.children) {
params.submenuElements.set(params.item.id, node) params.submenuElements.set(params.item.id, node)
} }
return { return {
destroy() { destroy() {
if (params.item.children) { if (params.item.children) {
@ -58,11 +61,7 @@
const { x: newX, y: newY } = await computePosition(triggerElement, dropdownElement, { const { x: newX, y: newY } = await computePosition(triggerElement, dropdownElement, {
placement: isSubmenu ? 'right-start' : 'bottom-end', placement: isSubmenu ? 'right-start' : 'bottom-end',
middleware: [ middleware: [offset(isSubmenu ? 0 : 4), flip(), shift({ padding: 8 })]
offset(isSubmenu ? 0 : 4),
flip(),
shift({ padding: 8 })
]
}) })
x = newX x = newX
@ -74,7 +73,7 @@
if (item.action && !item.children) { if (item.action && !item.children) {
item.action() item.action()
isOpen = false isOpen = false
openSubmenuId = null // Reset submenu state openSubmenuId = null // Reset submenu state
onClose?.() onClose?.()
} }
} }
@ -84,28 +83,32 @@
const target = event.target as HTMLElement const target = event.target as HTMLElement
// Check if click is inside any submenu // Check if click is inside any submenu
const clickedInSubmenu = Array.from(submenuElements.values()).some(el => el.contains(target)) const clickedInSubmenu = Array.from(submenuElements.values()).some((el) => el.contains(target))
if (!dropdownElement.contains(target) && !triggerElement?.contains(target) && !clickedInSubmenu) { if (
!dropdownElement.contains(target) &&
!triggerElement?.contains(target) &&
!clickedInSubmenu
) {
isOpen = false isOpen = false
openSubmenuId = null // Reset submenu state openSubmenuId = null // Reset submenu state
onClose?.() onClose?.()
} }
} }
function handleItemMouseEnter(item: DropdownItem) { function handleItemMouseEnter(item: DropdownItem) {
if (submenuCloseTimeout) { if (submenuCloseTimeout) {
clearTimeout(submenuCloseTimeout) clearTimeout(submenuCloseTimeout)
submenuCloseTimeout = null submenuCloseTimeout = null
} }
if (item.children) { if (item.children) {
openSubmenuId = item.id openSubmenuId = item.id
} else { } else {
openSubmenuId = null openSubmenuId = null
} }
} }
function handleItemMouseLeave(item: DropdownItem) { function handleItemMouseLeave(item: DropdownItem) {
if (item.children) { if (item.children) {
// Add delay before closing submenu // Add delay before closing submenu
@ -116,14 +119,14 @@
}, 300) }, 300)
} }
} }
function handleSubmenuMouseEnter() { function handleSubmenuMouseEnter() {
if (submenuCloseTimeout) { if (submenuCloseTimeout) {
clearTimeout(submenuCloseTimeout) clearTimeout(submenuCloseTimeout)
submenuCloseTimeout = null submenuCloseTimeout = null
} }
} }
function handleSubmenuMouseLeave(itemId: string) { function handleSubmenuMouseLeave(itemId: string) {
submenuCloseTimeout = window.setTimeout(() => { submenuCloseTimeout = window.setTimeout(() => {
if (openSubmenuId === itemId) { if (openSubmenuId === itemId) {
@ -137,13 +140,13 @@
if (browser && isOpen && triggerElement && dropdownElement) { if (browser && isOpen && triggerElement && dropdownElement) {
// Initial position update // Initial position update
updatePosition() updatePosition()
// Set up auto-update // Set up auto-update
cleanup = autoUpdate(triggerElement, dropdownElement, updatePosition) cleanup = autoUpdate(triggerElement, dropdownElement, updatePosition)
// Add outside click listener // Add outside click listener
document.addEventListener('click', handleOutsideClick) document.addEventListener('click', handleOutsideClick)
return () => { return () => {
cleanup?.() cleanup?.()
cleanup = null cleanup = null
@ -151,7 +154,7 @@
} }
} }
}) })
// Reset submenu state when dropdown closes // Reset submenu state when dropdown closes
$effect(() => { $effect(() => {
if (!isOpen) { if (!isOpen) {
@ -187,7 +190,7 @@
</span> </span>
{/if} {/if}
</button> </button>
{#if item.children && openSubmenuId === item.id} {#if item.children && openSubmenuId === item.id}
<div <div
onmouseenter={handleSubmenuMouseEnter} onmouseenter={handleSubmenuMouseEnter}
@ -197,7 +200,7 @@
isOpen={true} isOpen={true}
triggerElement={submenuElements.get(item.id)} triggerElement={submenuElements.get(item.id)}
items={item.children} items={item.children}
onClose={onClose} {onClose}
isSubmenu={true} isSubmenu={true}
/> />
</div> </div>
@ -243,16 +246,16 @@
&.danger { &.danger {
color: $red-60; color: $red-60;
} }
&.has-children { &.has-children {
padding-right: $unit-2x; padding-right: $unit-2x;
} }
} }
.item-label { .item-label {
flex: 1; flex: 1;
} }
.submenu-icon { .submenu-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
@ -261,13 +264,13 @@
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
:global(svg) { :global(svg) {
width: 100%; width: 100%;
height: 100%; height: 100%;
fill: none; fill: none;
} }
:global(path) { :global(path) {
fill: none; fill: none;
stroke: currentColor; stroke: currentColor;

View file

@ -148,4 +148,4 @@
color: $gray-40; color: $gray-40;
font-size: $font-size-small; font-size: $font-size-small;
} }
</style> </style>

View file

@ -27,13 +27,7 @@
} }
</script> </script>
<BaseModal <BaseModal bind:isOpen {size} {closeOnBackdrop} {closeOnEscape} {onClose}>
bind:isOpen
{size}
{closeOnBackdrop}
{closeOnEscape}
{onClose}
>
{#if showCloseButton} {#if showCloseButton}
<Button <Button
variant="ghost" variant="ghost"
@ -63,4 +57,4 @@
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
} }
</style> </style>

View file

@ -37,12 +37,7 @@
} }
</script> </script>
<BaseDropdown <BaseDropdown bind:isOpen={isDropdownOpen} {disabled} {isLoading} class="publish-dropdown">
bind:isOpen={isDropdownOpen}
{disabled}
{isLoading}
class="publish-dropdown"
>
<Button <Button
slot="trigger" slot="trigger"
variant="primary" variant="primary"
@ -60,4 +55,4 @@
</DropdownItem> </DropdownItem>
</div> </div>
{/if} {/if}
</BaseDropdown> </BaseDropdown>

View file

@ -49,12 +49,7 @@
const hasDropdownContent = $derived(availableActions.length > 0 || showViewInDropdown) const hasDropdownContent = $derived(availableActions.length > 0 || showViewInDropdown)
</script> </script>
<BaseDropdown <BaseDropdown bind:isOpen={isDropdownOpen} {disabled} {isLoading} class="status-dropdown">
bind:isOpen={isDropdownOpen}
{disabled}
{isLoading}
class="status-dropdown"
>
{#snippet trigger()} {#snippet trigger()}
<Button <Button
variant="primary" variant="primary"
@ -79,12 +74,7 @@
{#if availableActions.length > 0} {#if availableActions.length > 0}
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
{/if} {/if}
<a <a href={viewUrl} target="_blank" rel="noopener noreferrer" class="dropdown-item view-link">
href={viewUrl}
target="_blank"
rel="noopener noreferrer"
class="dropdown-item view-link"
>
View on site View on site
</a> </a>
{/if} {/if}
@ -116,4 +106,4 @@
background-color: $gray-95; background-color: $gray-95;
} }
} }
</style> </style>

View file

@ -12,7 +12,7 @@
import UnifiedMediaModal from '../UnifiedMediaModal.svelte' import UnifiedMediaModal from '../UnifiedMediaModal.svelte'
import { mediaSelectionStore } from '$lib/stores/media-selection' import { mediaSelectionStore } from '$lib/stores/media-selection'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
// Import new components // Import new components
import ComposerToolbar from './ComposerToolbar.svelte' import ComposerToolbar from './ComposerToolbar.svelte'
import TextStyleDropdown from './TextStyleDropdown.svelte' import TextStyleDropdown from './TextStyleDropdown.svelte'
@ -162,26 +162,34 @@
} }
// Simple effect to load content once when editor is ready // Simple effect to load content once when editor is ready
let contentLoaded = false; let contentLoaded = false
$effect(() => { $effect(() => {
if (editor && data && !contentLoaded) { if (editor && data && !contentLoaded) {
// Check if the data has actual content (not just empty doc) // Check if the data has actual content (not just empty doc)
const hasContent = data.content && data.content.length > 0 && const hasContent =
!(data.content.length === 1 && data.content[0].type === 'paragraph' && !data.content[0].content); data.content &&
data.content.length > 0 &&
!(
data.content.length === 1 &&
data.content[0].type === 'paragraph' &&
!data.content[0].content
)
if (hasContent) { if (hasContent) {
// Set the content once // Set the content once
editor.commands.setContent(data); editor.commands.setContent(data)
contentLoaded = true; contentLoaded = true
} }
} }
}); })
onMount(() => { onMount(() => {
// Get extensions with custom options // Get extensions with custom options
const extensions = getEditorExtensions({ const extensions = getEditorExtensions({
showSlashCommands, showSlashCommands,
onShowUrlConvertDropdown: features.urlEmbed ? linkManagerRef?.handleShowUrlConvertDropdown : undefined, onShowUrlConvertDropdown: features.urlEmbed
? linkManagerRef?.handleShowUrlConvertDropdown
: undefined,
onShowLinkContextMenu: linkManagerRef?.handleShowLinkContextMenu, onShowLinkContextMenu: linkManagerRef?.handleShowLinkContextMenu,
imagePlaceholderComponent: EnhancedImagePlaceholder imagePlaceholderComponent: EnhancedImagePlaceholder
}) })
@ -365,7 +373,7 @@
// More generous padding for full variant // More generous padding for full variant
.composer--full & { .composer--full & {
padding: $unit-4x 0; padding: $unit-4x 0;
@include breakpoint('phone') { @include breakpoint('phone') {
padding: $unit-3x 0; padding: $unit-3x 0;
} }
@ -402,4 +410,4 @@
background: transparent; background: transparent;
overflow: hidden; overflow: hidden;
} }
</style> </style>

View file

@ -206,4 +206,4 @@
linkEditUrl = '' linkEditUrl = ''
}} }}
/> />
{/if} {/if}

View file

@ -177,4 +177,4 @@ export class ComposerMediaHandler {
return false return false
} }
} }

View file

@ -164,7 +164,7 @@
gap: 4px; gap: 4px;
padding: 0 $unit-6x; padding: 0 $unit-6x;
flex-wrap: wrap; flex-wrap: wrap;
@include breakpoint('phone') { @include breakpoint('phone') {
padding: 0 $unit-4x; padding: 0 $unit-4x;
} }
@ -210,4 +210,4 @@
background: $gray-85; background: $gray-85;
margin: 0 4px; margin: 0 4px;
} }
</style> </style>

View file

@ -115,4 +115,4 @@
background: $gray-90; background: $gray-90;
margin: 4px 0; margin: 4px 0;
} }
</style> </style>

View file

@ -131,4 +131,4 @@
background: $gray-90; background: $gray-90;
margin: 4px 0; margin: 4px 0;
} }
</style> </style>

View file

@ -24,7 +24,10 @@ export function getCurrentTextStyle(editor: Editor): string {
} }
// Get filtered commands based on variant and features // Get filtered commands based on variant and features
export function getFilteredCommands(variant: ComposerVariant, features: ComposerFeatures): FilteredCommands { export function getFilteredCommands(
variant: ComposerVariant,
features: ComposerFeatures
): FilteredCommands {
const filtered = { ...commands } const filtered = { ...commands }
// Remove groups based on variant // Remove groups based on variant
@ -165,4 +168,4 @@ export function getDefaultFeatures(variant: ComposerVariant): ComposerFeatures {
tables: true, tables: true,
codeBlocks: true codeBlocks: true
} }
} }

View file

@ -15,4 +15,4 @@ export { default as ComposerLinkManager } from './ComposerLinkManager.svelte'
export { ComposerMediaHandler } from './ComposerMediaHandler.svelte' export { ComposerMediaHandler } from './ComposerMediaHandler.svelte'
export { useComposerEvents } from './useComposerEvents.svelte' export { useComposerEvents } from './useComposerEvents.svelte'
export { useDropdown } from './useDropdown.svelte' export { useDropdown } from './useDropdown.svelte'
export * from './editorConfig' export * from './editorConfig'

View file

@ -37,4 +37,4 @@ export interface MediaSelectionOptions {
albumId?: number albumId?: number
onSelect: (media: any) => void onSelect: (media: any) => void
onClose: () => void onClose: () => void
} }

View file

@ -85,7 +85,9 @@ export function useComposerEvents(options: UseComposerEventsOptions) {
// Set cursor position to drop location // Set cursor position to drop location
const { state, dispatch } = view const { state, dispatch } = view
const transaction = state.tr.setSelection(state.selection.constructor.near(state.doc.resolve(pos.pos))) const transaction = state.tr.setSelection(
state.selection.constructor.near(state.doc.resolve(pos.pos))
)
dispatch(transaction) dispatch(transaction)
// Upload the image // Upload the image
@ -135,4 +137,4 @@ export function useComposerEvents(options: UseComposerEventsOptions) {
handleDrop, handleDrop,
selectImageFile selectImageFile
} }
} }

View file

@ -62,4 +62,4 @@ export function useDropdown(options: UseDropdownOptions) {
updatePosition, updatePosition,
toggle toggle
} }
} }

View file

@ -8,7 +8,7 @@
interface Props { interface Props {
editor: Editor editor: Editor
} }
interface DropdownItem { interface DropdownItem {
id: string id: string
label?: string label?: string
@ -22,23 +22,26 @@
const { editor }: Props = $props() const { editor }: Props = $props()
const pluginKey = 'globalDragHandle' const pluginKey = 'globalDragHandle'
// State // State
let isMenuOpen = $state(false) let isMenuOpen = $state(false)
let currentNode = $state<{ node: Node; pos: number } | null>(null) let currentNode = $state<{ node: Node; pos: number } | null>(null)
let menuNode = $state<{ node: Node; pos: number } | null>(null) // Node when menu was opened
let dragHandleContainer = $state<HTMLElement>() let dragHandleContainer = $state<HTMLElement>()
// Generate menu items based on current node // Generate menu items based on current node
const menuItems = $derived(() => { const menuItems = $derived(() => {
if (!currentNode) return [] // Use menuNode when menu is open, otherwise currentNode
const activeNode = isMenuOpen && menuNode ? menuNode : currentNode
if (!activeNode) return []
const items: DropdownItem[] = [] const items: DropdownItem[] = []
const nodeType = currentNode.node.type.name const nodeType = activeNode.node.type.name
// Block type conversion options // Block type conversion options
if (nodeType === 'paragraph' || nodeType === 'heading') { if (nodeType === 'paragraph' || nodeType === 'heading') {
const turnIntoChildren = [] const turnIntoChildren = []
turnIntoChildren.push({ turnIntoChildren.push({
id: 'convert-paragraph', id: 'convert-paragraph',
label: 'Paragraph', label: 'Paragraph',
@ -64,7 +67,7 @@
label: 'Quote', label: 'Quote',
action: () => convertBlockType('blockquote') action: () => convertBlockType('blockquote')
}) })
items.push({ items.push({
id: 'turn-into', id: 'turn-into',
label: 'Turn into', label: 'Turn into',
@ -75,11 +78,11 @@
divider: true divider: true
}) })
} }
// List-specific actions // List-specific actions
if (nodeType === 'listItem') { if (nodeType === 'listItem') {
const turnIntoChildren = [] const turnIntoChildren = []
turnIntoChildren.push({ turnIntoChildren.push({
id: 'convert-bullet', id: 'convert-bullet',
label: 'Bullet List', label: 'Bullet List',
@ -95,7 +98,7 @@
label: 'Task List', label: 'Task List',
action: () => convertToList('taskList') action: () => convertToList('taskList')
}) })
items.push({ items.push({
id: 'turn-into', id: 'turn-into',
label: 'Turn into', label: 'Turn into',
@ -106,7 +109,7 @@
divider: true divider: true
}) })
} }
// URL embed specific actions // URL embed specific actions
if (nodeType === 'urlEmbed') { if (nodeType === 'urlEmbed') {
items.push({ items.push({
@ -119,9 +122,9 @@
divider: true divider: true
}) })
} }
// Check if block contains links that could have cards added // Check if block contains links that could have cards added
if ((nodeType === 'paragraph' || nodeType === 'heading') && hasLinks(currentNode.node)) { if ((nodeType === 'paragraph' || nodeType === 'heading') && hasLinks(activeNode.node)) {
items.push({ items.push({
id: 'add-link-cards', id: 'add-link-cards',
label: 'Add cards for links', label: 'Add cards for links',
@ -132,20 +135,20 @@
divider: true divider: true
}) })
} }
// Common actions // Common actions
items.push({ items.push({
id: 'duplicate', id: 'duplicate',
label: 'Duplicate', label: 'Duplicate',
action: () => duplicateBlock() action: () => duplicateBlock()
}) })
items.push({ items.push({
id: 'copy', id: 'copy',
label: 'Copy', label: 'Copy',
action: () => copyBlock() action: () => copyBlock()
}) })
// Text formatting removal // Text formatting removal
if (nodeType === 'paragraph' || nodeType === 'heading') { if (nodeType === 'paragraph' || nodeType === 'heading') {
items.push({ items.push({
@ -154,19 +157,19 @@
action: () => removeFormatting() action: () => removeFormatting()
}) })
} }
items.push({ items.push({
id: 'divider-2', id: 'divider-2',
divider: true divider: true
}) })
items.push({ items.push({
id: 'delete', id: 'delete',
label: 'Delete', label: 'Delete',
action: () => deleteBlock(), action: () => deleteBlock(),
variant: 'danger' as const variant: 'danger' as const
}) })
return items return items
}) })
@ -174,7 +177,10 @@
function hasLinks(node: Node): boolean { function hasLinks(node: Node): boolean {
let hasLink = false let hasLink = false
node.descendants((child) => { node.descendants((child) => {
if (child.type.name === 'link' || (child.isText && child.marks.some(mark => mark.type.name === 'link'))) { if (
child.type.name === 'link' ||
(child.isText && child.marks.some((mark) => mark.type.name === 'link'))
) {
hasLink = true hasLink = true
} }
}) })
@ -184,17 +190,23 @@
// Block manipulation functions // Block manipulation functions
function convertBlockType(type: string, attrs?: any) { function convertBlockType(type: string, attrs?: any) {
console.log('convertBlockType called:', type, attrs) console.log('convertBlockType called:', type, attrs)
if (!currentNode) { // Use menuNode which was captured when menu was opened
console.log('No current node') const nodeToConvert = menuNode || currentNode
if (!nodeToConvert) {
console.log('No node to convert')
return return
} }
const { pos } = currentNode const { pos, node } = nodeToConvert
console.log('Current node:', currentNode.node.type.name, 'at pos:', pos) console.log('Converting node:', node.type.name, 'at pos:', pos)
// Focus the editor first // Calculate the actual position of the node
editor.commands.focus() const nodeStart = pos
const nodeEnd = pos + node.nodeSize
// Set selection to the specific node we want to convert
editor.chain().focus().setTextSelection({ from: nodeStart, to: nodeEnd }).run()
// Convert the block type using chain commands // Convert the block type using chain commands
if (type === 'paragraph') { if (type === 'paragraph') {
editor.chain().focus().setParagraph().run() editor.chain().focus().setParagraph().run()
@ -203,25 +215,26 @@
} else if (type === 'blockquote') { } else if (type === 'blockquote') {
editor.chain().focus().setBlockquote().run() editor.chain().focus().setBlockquote().run()
} }
isMenuOpen = false isMenuOpen = false
} }
function convertToList(listType: string) { function convertToList(listType: string) {
if (!currentNode) return const nodeToConvert = menuNode || currentNode
if (!nodeToConvert) return
const { pos } = currentNode
const { pos } = nodeToConvert
const resolvedPos = editor.state.doc.resolve(pos) const resolvedPos = editor.state.doc.resolve(pos)
// Get the position of the list item // Get the position of the list item
let nodePos = pos let nodePos = pos
if (resolvedPos.parent.type.name === 'listItem') { if (resolvedPos.parent.type.name === 'listItem') {
nodePos = resolvedPos.before(resolvedPos.depth) nodePos = resolvedPos.before(resolvedPos.depth)
} }
// Set selection to the list item // Set selection to the list item
editor.commands.setNodeSelection(nodePos) editor.commands.setNodeSelection(nodePos)
// Convert to the appropriate list type // Convert to the appropriate list type
if (listType === 'bulletList') { if (listType === 'bulletList') {
editor.commands.toggleBulletList() editor.commands.toggleBulletList()
@ -230,54 +243,61 @@
} else if (listType === 'taskList') { } else if (listType === 'taskList') {
editor.commands.toggleTaskList() editor.commands.toggleTaskList()
} }
isMenuOpen = false isMenuOpen = false
} }
function convertEmbedToLink() { function convertEmbedToLink() {
if (!currentNode) return const nodeToConvert = menuNode || currentNode
if (!nodeToConvert) return
const { node, pos } = currentNode
const { node, pos } = nodeToConvert
const url = node.attrs.url const url = node.attrs.url
const title = node.attrs.title || url const title = node.attrs.title || url
// Get the actual position of the urlEmbed node // Get the actual position of the urlEmbed node
const nodePos = pos const nodePos = pos
const nodeSize = node.nodeSize const nodeSize = node.nodeSize
// Replace embed with a paragraph containing a link // Replace embed with a paragraph containing a link
editor.chain() editor
.chain()
.focus() .focus()
.deleteRange({ from: nodePos, to: nodePos + nodeSize }) .deleteRange({ from: nodePos, to: nodePos + nodeSize })
.insertContentAt(nodePos, { .insertContentAt(nodePos, {
type: 'paragraph', type: 'paragraph',
content: [{ content: [
type: 'text', {
text: title, type: 'text',
marks: [{ text: title,
type: 'link', marks: [
attrs: { {
href: url, type: 'link',
target: '_blank' attrs: {
} href: url,
}] target: '_blank'
}] }
}
]
}
]
}) })
.run() .run()
isMenuOpen = false isMenuOpen = false
} }
function addCardsForLinks() { function addCardsForLinks() {
if (!currentNode) return const nodeToUse = menuNode || currentNode
if (!nodeToUse) return
const { node, pos } = currentNode
const { node, pos } = nodeToUse
const links: { url: string; text: string }[] = [] const links: { url: string; text: string }[] = []
// Collect all links in the current block // Collect all links in the current block
node.descendants((child) => { node.descendants((child) => {
if (child.isText && child.marks.some(mark => mark.type.name === 'link')) { if (child.isText && child.marks.some((mark) => mark.type.name === 'link')) {
const linkMark = child.marks.find(mark => mark.type.name === 'link') const linkMark = child.marks.find((mark) => mark.type.name === 'link')
if (linkMark && linkMark.attrs.href) { if (linkMark && linkMark.attrs.href) {
links.push({ links.push({
url: linkMark.attrs.href, url: linkMark.attrs.href,
@ -286,97 +306,87 @@
} }
} }
}) })
// Insert embeds after the current block // Insert embeds after the current block
if (links.length > 0) { if (links.length > 0) {
const nodeEnd = pos + node.nodeSize const nodeEnd = pos + node.nodeSize
const embeds = links.map(link => ({ const embeds = links.map((link) => ({
type: 'urlEmbed', type: 'urlEmbed',
attrs: { attrs: {
url: link.url, url: link.url,
title: link.text title: link.text
} }
})) }))
editor.chain() editor.chain().focus().insertContentAt(nodeEnd, embeds).run()
.focus()
.insertContentAt(nodeEnd, embeds)
.run()
} }
isMenuOpen = false isMenuOpen = false
} }
function removeFormatting() { function removeFormatting() {
if (!currentNode) return const nodeToUse = menuNode || currentNode
if (!nodeToUse) return
const { pos } = currentNode
const { pos } = nodeToUse
const resolvedPos = editor.state.doc.resolve(pos) const resolvedPos = editor.state.doc.resolve(pos)
const nodeStart = resolvedPos.before(resolvedPos.depth) const nodeStart = resolvedPos.before(resolvedPos.depth)
const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize
editor.chain() editor
.chain()
.focus() .focus()
.setTextSelection({ from: nodeStart, to: nodeEnd }) .setTextSelection({ from: nodeStart, to: nodeEnd })
.clearNodes() .clearNodes()
.unsetAllMarks() .unsetAllMarks()
.run() .run()
isMenuOpen = false isMenuOpen = false
} }
function duplicateBlock() { function duplicateBlock() {
if (!currentNode) return const nodeToUse = menuNode || currentNode
if (!nodeToUse) return
const { node, pos } = currentNode
const { node, pos } = nodeToUse
const resolvedPos = editor.state.doc.resolve(pos) const resolvedPos = editor.state.doc.resolve(pos)
const nodeEnd = resolvedPos.after(resolvedPos.depth) const nodeEnd = resolvedPos.after(resolvedPos.depth)
editor.chain() editor.chain().focus().insertContentAt(nodeEnd, node.toJSON()).run()
.focus()
.insertContentAt(nodeEnd, node.toJSON())
.run()
isMenuOpen = false isMenuOpen = false
} }
function copyBlock() { function copyBlock() {
if (!currentNode) return const nodeToUse = menuNode || currentNode
if (!nodeToUse) return
const { pos } = currentNode
const { pos } = nodeToUse
const resolvedPos = editor.state.doc.resolve(pos) const resolvedPos = editor.state.doc.resolve(pos)
const nodeStart = resolvedPos.before(resolvedPos.depth) const nodeStart = resolvedPos.before(resolvedPos.depth)
const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize
editor.chain() editor.chain().focus().setTextSelection({ from: nodeStart, to: nodeEnd }).run()
.focus()
.setTextSelection({ from: nodeStart, to: nodeEnd })
.run()
document.execCommand('copy') document.execCommand('copy')
// Clear selection after copy // Clear selection after copy
editor.chain() editor.chain().focus().setTextSelection(nodeEnd).run()
.focus()
.setTextSelection(nodeEnd)
.run()
isMenuOpen = false isMenuOpen = false
} }
function deleteBlock() { function deleteBlock() {
if (!currentNode) return const nodeToUse = menuNode || currentNode
if (!nodeToUse) return
const { pos } = currentNode
const { pos } = nodeToUse
const resolvedPos = editor.state.doc.resolve(pos) const resolvedPos = editor.state.doc.resolve(pos)
const nodeStart = resolvedPos.before(resolvedPos.depth) const nodeStart = resolvedPos.before(resolvedPos.depth)
const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize
editor.chain() editor.chain().focus().deleteRange({ from: nodeStart, to: nodeEnd }).run()
.focus()
.deleteRange({ from: nodeStart, to: nodeEnd })
.run()
isMenuOpen = false isMenuOpen = false
} }
@ -386,9 +396,14 @@
console.log('Menu items:', menuItems()) console.log('Menu items:', menuItems())
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
// Only toggle if we're clicking on the same node // Capture the current node when opening the menu
// If clicking on a different node while menu is open, just update the menu if (!isMenuOpen) {
menuNode = currentNode
} else {
menuNode = null
}
isMenuOpen = !isMenuOpen isMenuOpen = !isMenuOpen
console.log('Menu open state:', isMenuOpen) console.log('Menu open state:', isMenuOpen)
} }
@ -416,7 +431,7 @@
} }
}) })
editor.registerPlugin(plugin) editor.registerPlugin(plugin)
// Find the existing drag handle created by the plugin and add click listener // Find the existing drag handle created by the plugin and add click listener
const checkForDragHandle = setInterval(() => { const checkForDragHandle = setInterval(() => {
const existingDragHandle = document.querySelector('.drag-handle') const existingDragHandle = document.querySelector('.drag-handle')
@ -424,13 +439,13 @@
console.log('Found drag handle, adding click listener') console.log('Found drag handle, adding click listener')
existingDragHandle.addEventListener('click', handleMenuClick) existingDragHandle.addEventListener('click', handleMenuClick)
;(existingDragHandle as any).__menuListener = true ;(existingDragHandle as any).__menuListener = true
// Update our reference to use the existing drag handle // Update our reference to use the existing drag handle
dragHandleContainer = existingDragHandle as HTMLElement dragHandleContainer = existingDragHandle as HTMLElement
clearInterval(checkForDragHandle) clearInterval(checkForDragHandle)
} }
}, 100) }, 100)
return () => { return () => {
editor.unregisterPlugin(pluginKey) editor.unregisterPlugin(pluginKey)
clearInterval(checkForDragHandle) clearInterval(checkForDragHandle)

View file

@ -145,7 +145,6 @@
font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono'; font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono';
} }
/* List Styling */ /* List Styling */
.tiptap ul, .tiptap ul,
.tiptap ol { .tiptap ol {

View file

@ -217,7 +217,7 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
dragHandleElement.draggable = true dragHandleElement.draggable = true
dragHandleElement.dataset.dragHandle = '' dragHandleElement.dataset.dragHandle = ''
dragHandleElement.classList.add('drag-handle') dragHandleElement.classList.add('drag-handle')
// Add custom drag handle SVG if element was created (not selected) // Add custom drag handle SVG if element was created (not selected)
if (!handleBySelector) { if (!handleBySelector) {
dragHandleElement.innerHTML = DragHandleIcon dragHandleElement.innerHTML = DragHandleIcon
@ -273,7 +273,7 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
// Keep the handle visible when hovering over it or the dropdown // Keep the handle visible when hovering over it or the dropdown
return return
} }
// Don't move the drag handle if the menu is open // Don't move the drag handle if the menu is open
if (dragHandleElement?.classList.contains('menu-open')) { if (dragHandleElement?.classList.contains('menu-open')) {
return return
@ -314,7 +314,9 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
const rect = absoluteRect(node) const rect = absoluteRect(node)
// For custom nodes like embeds, position at the top of the element // For custom nodes like embeds, position at the top of the element
const isCustomNode = node.matches('[data-drag-handle], .edra-url-embed-wrapper, .edra-youtube-embed-card, [data-type]') const isCustomNode = node.matches(
'[data-drag-handle], .edra-url-embed-wrapper, .edra-youtube-embed-card, [data-type]'
)
if (isCustomNode) { if (isCustomNode) {
// For NodeView components, position handle at top with small offset // For NodeView components, position handle at top with small offset
rect.top += 8 rect.top += 8
@ -337,11 +339,11 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
// Get the computed padding of the element to position handle correctly // Get the computed padding of the element to position handle correctly
const paddingLeft = parseInt(compStyle.paddingLeft, 10) || 0 const paddingLeft = parseInt(compStyle.paddingLeft, 10) || 0
// Add 12px gap between drag handle and content // Add 12px gap between drag handle and content
// Position the handle inside the padding area, close to the text // Position the handle inside the padding area, close to the text
dragHandleElement.style.left = `${rect.left + paddingLeft - rect.width - 12}px` dragHandleElement.style.left = `${rect.left + paddingLeft - rect.width - 12}px`
dragHandleElement.style.top = `${rect.top - 2}px` dragHandleElement.style.top = `${rect.top - 1}px`
showDragHandle() showDragHandle()
}, },
keydown: () => { keydown: () => {

View file

@ -25,4 +25,4 @@
:global(.tippy-box[data-theme~='link-tooltip'][data-animation='scale'][data-state='hidden']) { :global(.tippy-box[data-theme~='link-tooltip'][data-animation='scale'][data-state='hidden']) {
opacity: 0; opacity: 0;
transform: scale(0.5); transform: scale(0.5);
} }

View file

@ -30,4 +30,4 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
/> />
</svg> </svg>

View file

@ -23,18 +23,10 @@
class={className} class={className}
aria-hidden="true" aria-hidden="true"
> >
<rect <rect x="9" y="9" width="13" height="13" rx="2" stroke={color} stroke-width={strokeWidth} />
x="9"
y="9"
width="13"
height="13"
rx="2"
stroke={color}
stroke-width={strokeWidth}
/>
<path <path
d="M5 15H4C2.89543 15 2 14.1046 2 13V4C2 2.89543 2.89543 2 4 2H13C14.1046 2 15 2.89543 15 4V5" d="M5 15H4C2.89543 15 2 14.1046 2 13V4C2 2.89543 2.89543 2 4 2H13C14.1046 2 15 2.89543 15 4V5"
stroke={color} stroke={color}
stroke-width={strokeWidth} stroke-width={strokeWidth}
/> />
</svg> </svg>

View file

@ -37,4 +37,4 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
/> />
</svg> </svg>

View file

@ -11,7 +11,7 @@
caption: 'Beautiful mountain view', caption: 'Beautiful mountain view',
width: 1600, width: 1600,
height: 900, height: 900,
aspectRatio: 16/9 aspectRatio: 16 / 9
}, },
{ {
id: 'photo-2', id: 'photo-2',
@ -20,16 +20,16 @@
caption: 'Green valley', caption: 'Green valley',
width: 1200, width: 1200,
height: 800, height: 800,
aspectRatio: 3/2 aspectRatio: 3 / 2
}, },
{ {
id: 'photo-3', id: 'photo-3',
src: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e', src: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e',
alt: 'Forest path', alt: 'Forest path',
caption: 'Winding forest trail', caption: 'Winding forest trail',
width: 2400, width: 2400,
height: 800, height: 800,
aspectRatio: 3/1 // Ultrawide aspectRatio: 3 / 1 // Ultrawide
}, },
{ {
id: 'photo-4', id: 'photo-4',
@ -38,7 +38,7 @@
caption: 'Cascading waterfall', caption: 'Cascading waterfall',
width: 800, width: 800,
height: 1200, height: 1200,
aspectRatio: 2/3 aspectRatio: 2 / 3
}, },
{ {
id: 'photo-5', id: 'photo-5',
@ -56,7 +56,7 @@
caption: 'Sandy desert landscape', caption: 'Sandy desert landscape',
width: 1000, width: 1000,
height: 1500, height: 1500,
aspectRatio: 2/3 aspectRatio: 2 / 3
}, },
{ {
id: 'photo-7', id: 'photo-7',
@ -65,7 +65,7 @@
caption: 'Snow-capped mountains', caption: 'Snow-capped mountains',
width: 1800, width: 1800,
height: 1200, height: 1200,
aspectRatio: 3/2 aspectRatio: 3 / 2
}, },
{ {
id: 'photo-8', id: 'photo-8',
@ -74,7 +74,7 @@
caption: 'Crashing waves', caption: 'Crashing waves',
width: 1600, width: 1600,
height: 900, height: 900,
aspectRatio: 16/9 aspectRatio: 16 / 9
} }
] ]
@ -87,7 +87,7 @@
<div class="test-container"> <div class="test-container">
<h1>PhotoGrid Component Test</h1> <h1>PhotoGrid Component Test</h1>
<div class="controls"> <div class="controls">
<div class="control-group"> <div class="control-group">
<label> <label>
@ -128,17 +128,13 @@
</div> </div>
<div class="config-display"> <div class="config-display">
<code>{`<PhotoGrid photos={photos} columns={${columns}} gap="${gap}" masonry={${masonry}} showCaptions={${showCaptions}} />`}</code> <code
>{`<PhotoGrid photos={photos} columns={${columns}} gap="${gap}" masonry={${masonry}} showCaptions={${showCaptions}} />`}</code
>
</div> </div>
<div class="grid-preview"> <div class="grid-preview">
<PhotoGrid <PhotoGrid photos={samplePhotos} {columns} {gap} {masonry} {showCaptions} />
photos={samplePhotos}
{columns}
{gap}
{masonry}
{showCaptions}
/>
</div> </div>
</div> </div>
@ -187,7 +183,7 @@
cursor: pointer; cursor: pointer;
} }
input[type="checkbox"] { input[type='checkbox'] {
width: 18px; width: 18px;
height: 18px; height: 18px;
cursor: pointer; cursor: pointer;
@ -230,4 +226,4 @@
padding: 1rem; padding: 1rem;
} }
} }
</style> </style>