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:
parent
1e4a27b1a3
commit
1c38dc87e3
39 changed files with 353 additions and 316 deletions
|
|
@ -9,6 +9,7 @@ This analysis examines SVG usage patterns in the Svelte 5 codebase to identify o
|
|||
### 1. Inline SVGs vs. Imported SVGs
|
||||
|
||||
**Inline SVGs Found:**
|
||||
|
||||
- **Close/X buttons**: Found in 7+ components with identical SVG code
|
||||
- `admin/Modal.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/MediaDetailsModal.svelte`
|
||||
- `Lightbox.svelte`
|
||||
|
||||
- **Loading spinners**: Found in 2+ components
|
||||
|
||||
- `admin/Button.svelte`
|
||||
- `admin/ImageUploader.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
|
||||
|
||||
**Consistent patterns using aliases:**
|
||||
|
||||
```svelte
|
||||
// Good - using $icons alias
|
||||
import ArrowLeft from '$icons/arrow-left.svg'
|
||||
import ChevronDownIcon from '$icons/chevron-down.svg'
|
||||
|
||||
// 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'
|
||||
// Good - using $icons alias import ArrowLeft from '$icons/arrow-left.svg' import ChevronDownIcon
|
||||
from '$icons/chevron-down.svg' // 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
|
||||
|
||||
**Unused icons in `/src/assets/icons/`:**
|
||||
|
||||
- `dashboard.svg`
|
||||
- `metadata.svg`
|
||||
|
||||
**Unused illustrations in `/src/assets/illos/`:**
|
||||
|
||||
- `jedmund-blink.svg`
|
||||
- `jedmund-headphones.svg`
|
||||
- `jedmund-listening-downbeat.svg`
|
||||
|
|
@ -65,11 +63,13 @@ import ChevronDownIcon from '$icons/chevron-down.svg?raw'
|
|||
### 4. Duplicate SVG Definitions
|
||||
|
||||
**Close/X Button SVG** (appears 7+ times):
|
||||
|
||||
```svg
|
||||
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
```
|
||||
|
||||
**Loading Spinner SVG** (appears 3+ times):
|
||||
|
||||
```svg
|
||||
<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">
|
||||
|
|
@ -90,44 +90,47 @@ import ChevronDownIcon from '$icons/chevron-down.svg?raw'
|
|||
### 1. Create Reusable Icon Components
|
||||
|
||||
**Option A: Create individual icon components**
|
||||
|
||||
```svelte
|
||||
<!-- $lib/components/icons/CloseIcon.svelte -->
|
||||
<script>
|
||||
let { size = 24, class: className = '' } = $props()
|
||||
let { size = 24, class: className = '' } = $props()
|
||||
</script>
|
||||
|
||||
<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>
|
||||
```
|
||||
|
||||
**Option B: Create an Icon component with name prop**
|
||||
|
||||
```svelte
|
||||
<!-- $lib/components/Icon.svelte -->
|
||||
<script>
|
||||
import CloseIcon from '$icons/close.svg'
|
||||
import LoadingIcon from '$icons/loading.svg'
|
||||
// ... other imports
|
||||
|
||||
let { name, size = 24, class: className = '' } = $props()
|
||||
|
||||
const icons = {
|
||||
close: CloseIcon,
|
||||
loading: LoadingIcon,
|
||||
// ... other icons
|
||||
}
|
||||
|
||||
const IconComponent = $derived(icons[name])
|
||||
import CloseIcon from '$icons/close.svg'
|
||||
import LoadingIcon from '$icons/loading.svg'
|
||||
// ... other imports
|
||||
|
||||
let { name, size = 24, class: className = '' } = $props()
|
||||
|
||||
const icons = {
|
||||
close: CloseIcon,
|
||||
loading: LoadingIcon
|
||||
// ... other icons
|
||||
}
|
||||
|
||||
const IconComponent = $derived(icons[name])
|
||||
</script>
|
||||
|
||||
{#if IconComponent}
|
||||
<IconComponent {size} class={className} />
|
||||
<IconComponent {size} class={className} />
|
||||
{/if}
|
||||
```
|
||||
|
||||
### 2. Extract Inline SVGs to Files
|
||||
|
||||
Create new SVG files for commonly used inline SVGs:
|
||||
|
||||
- `/src/assets/icons/close.svg`
|
||||
- `/src/assets/icons/loading.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
|
||||
|
||||
Remove the following unused files to reduce bundle size:
|
||||
|
||||
- All unused illustration files (11 files)
|
||||
- Unused icon files (2 files)
|
||||
|
||||
### 4. Standardize Import Methods
|
||||
|
||||
Establish a consistent pattern:
|
||||
|
||||
- Use `?component` for SVGs used as Svelte components
|
||||
- Use direct imports for SVGs used as images
|
||||
- Avoid `?raw` imports unless necessary
|
||||
|
|
@ -152,21 +157,36 @@ Establish a consistent pattern:
|
|||
```svelte
|
||||
<!-- $lib/components/LoadingSpinner.svelte -->
|
||||
<script>
|
||||
let { size = 24, class: className = '' } = $props()
|
||||
let { size = 24, class: className = '' } = $props()
|
||||
</script>
|
||||
|
||||
<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"
|
||||
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>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
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>
|
||||
|
||||
<style>
|
||||
.loading-spinner {
|
||||
color: currentColor;
|
||||
}
|
||||
.loading-spinner {
|
||||
color: currentColor;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
|
|
@ -183,4 +203,4 @@ Establish a consistent pattern:
|
|||
|
||||
1. **High Priority**: Extract and componentize duplicate inline SVGs (close button, loading spinner)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
**Date**: December 26, 2025
|
||||
**Author**: Claude Code
|
||||
**Status**: Draft
|
||||
**Priority**: High
|
||||
**Priority**: High
|
||||
|
||||
## Executive Summary
|
||||
|
||||
|
|
@ -20,18 +20,21 @@ This PRD outlines a comprehensive cleanup and refactoring plan for the jedmund-s
|
|||
## Key Findings
|
||||
|
||||
### 1. Overengineered Components
|
||||
|
||||
- **EnhancedComposer** (1,347 lines) - Handles too many responsibilities
|
||||
- **LastFM Stream Server** (625 lines) - Complex data transformations that could be simplified
|
||||
- **Multiple Media Modals** - Overlapping functionality across 3+ modal components
|
||||
- **Complex State Management** - Components with 10-20 state variables
|
||||
|
||||
### 2. Unused Code
|
||||
|
||||
- 5 unused components (Squiggly, PhotoLightbox, Pill, SVGHoverEffect, MusicPreview)
|
||||
- 13 unused SVG files (2 icons, 11 illustrations)
|
||||
- Minimal commented-out code (good!)
|
||||
- 1 potentially unused API endpoint (/api/health)
|
||||
|
||||
### 3. DRY Violations
|
||||
|
||||
- **Photo Grid Components** - 3 nearly identical components
|
||||
- **Modal Components** - Duplicate backdrop and positioning logic
|
||||
- **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
|
||||
|
||||
### 4. Hardcoded Values
|
||||
|
||||
- **Colors**: 200+ hardcoded hex/rgba values instead of using existing variables
|
||||
- **Spacing**: 1,000+ hardcoded pixel values instead of using `$unit` system
|
||||
- **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
|
||||
|
||||
### 5. SVG Issues
|
||||
|
||||
- 7+ duplicate inline close button SVGs
|
||||
- 3+ duplicate loading spinner SVGs
|
||||
- Inconsistent import patterns
|
||||
|
|
@ -54,9 +59,11 @@ This PRD outlines a comprehensive cleanup and refactoring plan for the jedmund-s
|
|||
## Implementation Timeline
|
||||
|
||||
### Phase 1: Quick Wins (Week 1)
|
||||
|
||||
Focus on low-risk, high-impact changes that don't require architectural modifications.
|
||||
|
||||
- [x] **Remove unused components** (5 components)
|
||||
|
||||
- [x] Delete `/src/lib/components/Squiggly.svelte`
|
||||
- [x] Delete `/src/lib/components/PhotoLightbox.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] **Remove unused SVG files** (13 files)
|
||||
|
||||
- [x] Delete unused icons: `dashboard.svg`, `metadata.svg`
|
||||
- [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)
|
||||
|
||||
### Phase 2: CSS Variable Standardization (Week 2)
|
||||
|
||||
Create a consistent design system by extracting hardcoded values.
|
||||
|
||||
- [x] **Create z-index system**
|
||||
|
||||
- [x] Create `src/assets/styles/_z-index.scss` with constants
|
||||
- [x] Replace 60+ hardcoded z-index values
|
||||
|
||||
- [x] **Extract color variables**
|
||||
|
||||
- [x] Add missing color variables for frequently used colors
|
||||
- [x] Replace 200+ hardcoded hex/rgba values (replaced most common colors)
|
||||
- [x] Create shadow/overlay variables for rgba values
|
||||
|
||||
- [x] **Standardize spacing**
|
||||
|
||||
- [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)
|
||||
|
||||
|
|
@ -92,15 +104,18 @@ Create a consistent design system by extracting hardcoded values.
|
|||
- [x] Replace hardcoded duration values (replaced in key components)
|
||||
|
||||
### Phase 3: Component Refactoring (Weeks 3-4)
|
||||
|
||||
Refactor components to reduce duplication and complexity.
|
||||
|
||||
- [x] **Create base components**
|
||||
|
||||
- [x] Extract `BaseModal` component for shared modal logic
|
||||
- [x] Create `BaseDropdown` for dropdown patterns
|
||||
- [x] Merge `FormField` and `FormFieldWrapper`
|
||||
- [x] Create `BaseSegmentedController` for shared logic
|
||||
|
||||
- [x] **Refactor photo grids**
|
||||
|
||||
- [x] Create unified `PhotoGrid` component with `columns` prop
|
||||
- [x] Remove 3 duplicate grid components
|
||||
- [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)
|
||||
|
||||
### Phase 4: Complex Refactoring (Weeks 5-6)
|
||||
|
||||
Tackle the most complex components and patterns.
|
||||
|
||||
- [x] **Refactor EnhancedComposer**
|
||||
|
||||
- [x] Split into focused sub-components
|
||||
- [x] Extract toolbar component
|
||||
- [x] Separate media management
|
||||
|
|
@ -122,6 +139,7 @@ Tackle the most complex components and patterns.
|
|||
- [x] Reduce state variables from 20+ to <10
|
||||
|
||||
- [ ] **Simplify LastFM Stream Server**
|
||||
|
||||
- [ ] Extract data transformation utilities
|
||||
- [ ] Simplify "now playing" detection algorithm
|
||||
- [ ] Reduce state tracking duplication
|
||||
|
|
@ -133,9 +151,11 @@ Tackle the most complex components and patterns.
|
|||
- [ ] Eliminate prop drilling with stores
|
||||
|
||||
### Phase 5: Architecture & Utilities (Week 7)
|
||||
|
||||
Improve overall architecture and create shared utilities.
|
||||
|
||||
- [ ] **Create shared utilities**
|
||||
|
||||
- [ ] API client with consistent error handling
|
||||
- [ ] CSS mixins for common patterns
|
||||
- [ ] Media handling utilities
|
||||
|
|
@ -148,9 +168,11 @@ Improve overall architecture and create shared utilities.
|
|||
- [ ] Create shared animation definitions
|
||||
|
||||
### Phase 6: Testing & Documentation (Week 8)
|
||||
|
||||
Ensure changes don't break functionality and document new patterns.
|
||||
|
||||
- [ ] **Testing**
|
||||
|
||||
- [ ] Run full build and type checking
|
||||
- [ ] Test all refactored components
|
||||
- [ ] Verify no regressions in functionality
|
||||
|
|
@ -165,19 +187,23 @@ Ensure changes don't break functionality and document new patterns.
|
|||
## Success Metrics
|
||||
|
||||
1. **Code Reduction**
|
||||
|
||||
- Target: 20-30% reduction in total lines of code
|
||||
- Eliminate 1,000+ instances of code duplication
|
||||
|
||||
2. **Component Simplification**
|
||||
|
||||
- No component larger than 500 lines
|
||||
- Average component size under 200 lines
|
||||
|
||||
3. **Design System Consistency**
|
||||
|
||||
- Zero hardcoded colors in components
|
||||
- All spacing using design tokens
|
||||
- Consistent z-index scale
|
||||
|
||||
4. **Bundle Size**
|
||||
|
||||
- 10-15% reduction in JavaScript bundle size
|
||||
- Removal of unused assets
|
||||
|
||||
|
|
@ -189,11 +215,13 @@ Ensure changes don't break functionality and document new patterns.
|
|||
## Risk Mitigation
|
||||
|
||||
1. **Regression Testing**
|
||||
|
||||
- Test each phase thoroughly before moving to next
|
||||
- Keep backups of original components during refactoring
|
||||
- Use feature flags for gradual rollout if needed
|
||||
|
||||
2. **Performance Impact**
|
||||
|
||||
- Monitor bundle size after each phase
|
||||
- Profile component render performance
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -36,4 +36,4 @@ $z-index-toast: 10000;
|
|||
// Admin-specific z-indexes
|
||||
$z-index-admin-nav: 100;
|
||||
$z-index-admin-sidebar: 200;
|
||||
$z-index-admin-modal: 1050;
|
||||
$z-index-admin-modal: 1050;
|
||||
|
|
|
|||
|
|
@ -225,7 +225,6 @@ $info-color: $blue-50;
|
|||
// Component specific
|
||||
$image-border-color: rgba(0, 0, 0, 0.03);
|
||||
|
||||
|
||||
/* Shadows and Overlays
|
||||
* -------------------------------------------------------------------------- */
|
||||
$card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
|
|
|
|||
|
|
@ -49,4 +49,4 @@ export function tooltip(element: HTMLElement, options: TooltipOptions | string)
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@
|
|||
showCaptions?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
photos = [],
|
||||
showCaptions = true
|
||||
}: Props = $props()
|
||||
let { photos = [], showCaptions = true }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="horizontal-scroll">
|
||||
|
|
|
|||
|
|
@ -10,4 +10,4 @@
|
|||
let { photos = [], columns = 'auto' }: Props = $props()
|
||||
</script>
|
||||
|
||||
<PhotoGrid {photos} {columns} masonry={true} gap="medium" />
|
||||
<PhotoGrid {photos} {columns} masonry={true} gap="medium" />
|
||||
|
|
|
|||
|
|
@ -21,22 +21,21 @@
|
|||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
|
||||
|
||||
// Split photos into columns for column-based layouts
|
||||
function splitIntoColumns(photos: Photo[], numColumns: number): Photo[][] {
|
||||
const columns: Photo[][] = Array.from({ length: numColumns }, () => [])
|
||||
|
||||
|
||||
photos.forEach((photo, index) => {
|
||||
columns[index % numColumns].push(photo)
|
||||
})
|
||||
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -45,9 +44,9 @@
|
|||
// Calculate masonry column widths based on columns prop
|
||||
const masonryConfig = $derived(() => {
|
||||
if (!masonry) return null
|
||||
|
||||
|
||||
const gapSize = gap === 'small' ? 8 : gap === 'large' ? 32 : 16
|
||||
|
||||
|
||||
if (columns === 1) {
|
||||
const width = windowWidth - 64 // Account for padding
|
||||
return { minColWidth: width, maxColWidth: width, gap: gapSize }
|
||||
|
|
@ -55,7 +54,7 @@
|
|||
const width = Math.floor((windowWidth - 64 - gapSize) / 2)
|
||||
return { minColWidth: width - 10, maxColWidth: width + 10, gap: gapSize }
|
||||
} 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 }
|
||||
} else {
|
||||
// Auto columns
|
||||
|
|
@ -90,7 +89,7 @@
|
|||
>
|
||||
{#snippet children({ item })}
|
||||
<div class="photo-grid__item">
|
||||
<PhotoItem item={item} />
|
||||
<PhotoItem {item} />
|
||||
{#if showCaptions}
|
||||
<p class="photo-caption">{item.caption || ''}</p>
|
||||
{/if}
|
||||
|
|
@ -147,7 +146,7 @@
|
|||
&--3-column {
|
||||
display: flex;
|
||||
gap: var(--grid-gap);
|
||||
|
||||
|
||||
@include breakpoint('mobile') {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -233,4 +232,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@
|
|||
let { photos = [] }: Props = $props()
|
||||
</script>
|
||||
|
||||
<PhotoGrid {photos} columns={1} gap="large" showCaptions={true} />
|
||||
<PhotoGrid {photos} columns={1} gap="large" showCaptions={true} />
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@
|
|||
let { photos = [] }: Props = $props()
|
||||
</script>
|
||||
|
||||
<PhotoGrid {photos} columns={3} gap="medium" />
|
||||
<PhotoGrid {photos} columns={3} gap="medium" />
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@
|
|||
let { photos = [] }: Props = $props()
|
||||
</script>
|
||||
|
||||
<PhotoGrid {photos} columns={2} gap="medium" />
|
||||
<PhotoGrid {photos} columns={2} gap="medium" />
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
<div class="dropdown-container {className}">
|
||||
<div class="dropdown-trigger">
|
||||
{@render trigger()}
|
||||
|
||||
|
||||
{#if dropdown}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -100,4 +100,4 @@
|
|||
:global(.dropdown-toggle) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -79,14 +79,14 @@
|
|||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
on:click={handleBackdropClick}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
on:click={handleBackdropClick}
|
||||
transition:fade={{ duration: TRANSITION_FAST_MS }}
|
||||
>
|
||||
<div
|
||||
class={modalClass}
|
||||
on:click|stopPropagation
|
||||
<div
|
||||
class={modalClass}
|
||||
on:click|stopPropagation
|
||||
transition:fade={{ duration: TRANSITION_FAST_MS }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
|
@ -152,4 +152,4 @@
|
|||
height: 90vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
let itemElements: HTMLElement[] = []
|
||||
let pillStyle = ''
|
||||
let hoveredIndex = $state(-1)
|
||||
let internalValue = $state(defaultValue ?? value ?? (items[0]?.value ?? ''))
|
||||
let internalValue = $state(defaultValue ?? value ?? items[0]?.value ?? '')
|
||||
|
||||
// Derived state
|
||||
const currentValue = $derived(value ?? internalValue)
|
||||
|
|
@ -337,4 +337,4 @@
|
|||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -33,12 +33,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<BaseModal
|
||||
bind:isOpen
|
||||
size="small"
|
||||
onClose={handleCancel}
|
||||
class="delete-confirmation-modal"
|
||||
>
|
||||
<BaseModal bind:isOpen size="small" onClose={handleCancel} class="delete-confirmation-modal">
|
||||
<div class="modal-body">
|
||||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
|
|
@ -78,4 +73,4 @@
|
|||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
let dropdownElement: HTMLDivElement
|
||||
let cleanup: (() => void) | null = null
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
||||
// Track which submenu is open
|
||||
let openSubmenuId = $state<string | null>(null)
|
||||
let submenuElements = $state<Map<string, HTMLElement>>(new Map())
|
||||
|
|
@ -36,13 +36,16 @@
|
|||
// Position state
|
||||
let x = $state(0)
|
||||
let y = $state(0)
|
||||
|
||||
|
||||
// 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) {
|
||||
params.submenuElements.set(params.item.id, node)
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
if (params.item.children) {
|
||||
|
|
@ -58,11 +61,7 @@
|
|||
|
||||
const { x: newX, y: newY } = await computePosition(triggerElement, dropdownElement, {
|
||||
placement: isSubmenu ? 'right-start' : 'bottom-end',
|
||||
middleware: [
|
||||
offset(isSubmenu ? 0 : 4),
|
||||
flip(),
|
||||
shift({ padding: 8 })
|
||||
]
|
||||
middleware: [offset(isSubmenu ? 0 : 4), flip(), shift({ padding: 8 })]
|
||||
})
|
||||
|
||||
x = newX
|
||||
|
|
@ -74,7 +73,7 @@
|
|||
if (item.action && !item.children) {
|
||||
item.action()
|
||||
isOpen = false
|
||||
openSubmenuId = null // Reset submenu state
|
||||
openSubmenuId = null // Reset submenu state
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
|
@ -84,28 +83,32 @@
|
|||
|
||||
const target = event.target as HTMLElement
|
||||
// Check if click is inside any submenu
|
||||
const clickedInSubmenu = Array.from(submenuElements.values()).some(el => el.contains(target))
|
||||
|
||||
if (!dropdownElement.contains(target) && !triggerElement?.contains(target) && !clickedInSubmenu) {
|
||||
const clickedInSubmenu = Array.from(submenuElements.values()).some((el) => el.contains(target))
|
||||
|
||||
if (
|
||||
!dropdownElement.contains(target) &&
|
||||
!triggerElement?.contains(target) &&
|
||||
!clickedInSubmenu
|
||||
) {
|
||||
isOpen = false
|
||||
openSubmenuId = null // Reset submenu state
|
||||
openSubmenuId = null // Reset submenu state
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleItemMouseEnter(item: DropdownItem) {
|
||||
if (submenuCloseTimeout) {
|
||||
clearTimeout(submenuCloseTimeout)
|
||||
submenuCloseTimeout = null
|
||||
}
|
||||
|
||||
|
||||
if (item.children) {
|
||||
openSubmenuId = item.id
|
||||
} else {
|
||||
openSubmenuId = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleItemMouseLeave(item: DropdownItem) {
|
||||
if (item.children) {
|
||||
// Add delay before closing submenu
|
||||
|
|
@ -116,14 +119,14 @@
|
|||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleSubmenuMouseEnter() {
|
||||
if (submenuCloseTimeout) {
|
||||
clearTimeout(submenuCloseTimeout)
|
||||
submenuCloseTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleSubmenuMouseLeave(itemId: string) {
|
||||
submenuCloseTimeout = window.setTimeout(() => {
|
||||
if (openSubmenuId === itemId) {
|
||||
|
|
@ -137,13 +140,13 @@
|
|||
if (browser && isOpen && triggerElement && dropdownElement) {
|
||||
// Initial position update
|
||||
updatePosition()
|
||||
|
||||
|
||||
// Set up auto-update
|
||||
cleanup = autoUpdate(triggerElement, dropdownElement, updatePosition)
|
||||
|
||||
|
||||
// Add outside click listener
|
||||
document.addEventListener('click', handleOutsideClick)
|
||||
|
||||
|
||||
return () => {
|
||||
cleanup?.()
|
||||
cleanup = null
|
||||
|
|
@ -151,7 +154,7 @@
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// Reset submenu state when dropdown closes
|
||||
$effect(() => {
|
||||
if (!isOpen) {
|
||||
|
|
@ -187,7 +190,7 @@
|
|||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
|
||||
{#if item.children && openSubmenuId === item.id}
|
||||
<div
|
||||
onmouseenter={handleSubmenuMouseEnter}
|
||||
|
|
@ -197,7 +200,7 @@
|
|||
isOpen={true}
|
||||
triggerElement={submenuElements.get(item.id)}
|
||||
items={item.children}
|
||||
onClose={onClose}
|
||||
{onClose}
|
||||
isSubmenu={true}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -243,16 +246,16 @@
|
|||
&.danger {
|
||||
color: $red-60;
|
||||
}
|
||||
|
||||
|
||||
&.has-children {
|
||||
padding-right: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.item-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
.submenu-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
|
@ -261,13 +264,13 @@
|
|||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
|
||||
:global(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
|
||||
:global(path) {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
|
|
|
|||
|
|
@ -148,4 +148,4 @@
|
|||
color: $gray-40;
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -27,13 +27,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<BaseModal
|
||||
bind:isOpen
|
||||
{size}
|
||||
{closeOnBackdrop}
|
||||
{closeOnEscape}
|
||||
{onClose}
|
||||
>
|
||||
<BaseModal bind:isOpen {size} {closeOnBackdrop} {closeOnEscape} {onClose}>
|
||||
{#if showCloseButton}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -63,4 +57,4 @@
|
|||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -37,12 +37,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<BaseDropdown
|
||||
bind:isOpen={isDropdownOpen}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
class="publish-dropdown"
|
||||
>
|
||||
<BaseDropdown bind:isOpen={isDropdownOpen} {disabled} {isLoading} class="publish-dropdown">
|
||||
<Button
|
||||
slot="trigger"
|
||||
variant="primary"
|
||||
|
|
@ -60,4 +55,4 @@
|
|||
</DropdownItem>
|
||||
</div>
|
||||
{/if}
|
||||
</BaseDropdown>
|
||||
</BaseDropdown>
|
||||
|
|
|
|||
|
|
@ -49,12 +49,7 @@
|
|||
const hasDropdownContent = $derived(availableActions.length > 0 || showViewInDropdown)
|
||||
</script>
|
||||
|
||||
<BaseDropdown
|
||||
bind:isOpen={isDropdownOpen}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
class="status-dropdown"
|
||||
>
|
||||
<BaseDropdown bind:isOpen={isDropdownOpen} {disabled} {isLoading} class="status-dropdown">
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
variant="primary"
|
||||
|
|
@ -79,12 +74,7 @@
|
|||
{#if availableActions.length > 0}
|
||||
<div class="dropdown-divider"></div>
|
||||
{/if}
|
||||
<a
|
||||
href={viewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="dropdown-item view-link"
|
||||
>
|
||||
<a href={viewUrl} target="_blank" rel="noopener noreferrer" class="dropdown-item view-link">
|
||||
View on site
|
||||
</a>
|
||||
{/if}
|
||||
|
|
@ -116,4 +106,4 @@
|
|||
background-color: $gray-95;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
import UnifiedMediaModal from '../UnifiedMediaModal.svelte'
|
||||
import { mediaSelectionStore } from '$lib/stores/media-selection'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
|
||||
// Import new components
|
||||
import ComposerToolbar from './ComposerToolbar.svelte'
|
||||
import TextStyleDropdown from './TextStyleDropdown.svelte'
|
||||
|
|
@ -162,26 +162,34 @@
|
|||
}
|
||||
|
||||
// Simple effect to load content once when editor is ready
|
||||
let contentLoaded = false;
|
||||
let contentLoaded = false
|
||||
$effect(() => {
|
||||
if (editor && data && !contentLoaded) {
|
||||
// Check if the data has actual content (not just empty doc)
|
||||
const hasContent = data.content && data.content.length > 0 &&
|
||||
!(data.content.length === 1 && data.content[0].type === 'paragraph' && !data.content[0].content);
|
||||
|
||||
const hasContent =
|
||||
data.content &&
|
||||
data.content.length > 0 &&
|
||||
!(
|
||||
data.content.length === 1 &&
|
||||
data.content[0].type === 'paragraph' &&
|
||||
!data.content[0].content
|
||||
)
|
||||
|
||||
if (hasContent) {
|
||||
// Set the content once
|
||||
editor.commands.setContent(data);
|
||||
contentLoaded = true;
|
||||
editor.commands.setContent(data)
|
||||
contentLoaded = true
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
// Get extensions with custom options
|
||||
const extensions = getEditorExtensions({
|
||||
showSlashCommands,
|
||||
onShowUrlConvertDropdown: features.urlEmbed ? linkManagerRef?.handleShowUrlConvertDropdown : undefined,
|
||||
onShowUrlConvertDropdown: features.urlEmbed
|
||||
? linkManagerRef?.handleShowUrlConvertDropdown
|
||||
: undefined,
|
||||
onShowLinkContextMenu: linkManagerRef?.handleShowLinkContextMenu,
|
||||
imagePlaceholderComponent: EnhancedImagePlaceholder
|
||||
})
|
||||
|
|
@ -365,7 +373,7 @@
|
|||
// More generous padding for full variant
|
||||
.composer--full & {
|
||||
padding: $unit-4x 0;
|
||||
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x 0;
|
||||
}
|
||||
|
|
@ -402,4 +410,4 @@
|
|||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -206,4 +206,4 @@
|
|||
linkEditUrl = ''
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -177,4 +177,4 @@ export class ComposerMediaHandler {
|
|||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@
|
|||
gap: 4px;
|
||||
padding: 0 $unit-6x;
|
||||
flex-wrap: wrap;
|
||||
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: 0 $unit-4x;
|
||||
}
|
||||
|
|
@ -210,4 +210,4 @@
|
|||
background: $gray-85;
|
||||
margin: 0 4px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -115,4 +115,4 @@
|
|||
background: $gray-90;
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -131,4 +131,4 @@
|
|||
background: $gray-90;
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@ export function getCurrentTextStyle(editor: Editor): string {
|
|||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
// Remove groups based on variant
|
||||
|
|
@ -165,4 +168,4 @@ export function getDefaultFeatures(variant: ComposerVariant): ComposerFeatures {
|
|||
tables: true,
|
||||
codeBlocks: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,4 @@ export { default as ComposerLinkManager } from './ComposerLinkManager.svelte'
|
|||
export { ComposerMediaHandler } from './ComposerMediaHandler.svelte'
|
||||
export { useComposerEvents } from './useComposerEvents.svelte'
|
||||
export { useDropdown } from './useDropdown.svelte'
|
||||
export * from './editorConfig'
|
||||
export * from './editorConfig'
|
||||
|
|
|
|||
|
|
@ -37,4 +37,4 @@ export interface MediaSelectionOptions {
|
|||
albumId?: number
|
||||
onSelect: (media: any) => void
|
||||
onClose: () => void
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,9 @@ export function useComposerEvents(options: UseComposerEventsOptions) {
|
|||
|
||||
// Set cursor position to drop location
|
||||
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)
|
||||
|
||||
// Upload the image
|
||||
|
|
@ -135,4 +137,4 @@ export function useComposerEvents(options: UseComposerEventsOptions) {
|
|||
handleDrop,
|
||||
selectImageFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,4 +62,4 @@ export function useDropdown(options: UseDropdownOptions) {
|
|||
updatePosition,
|
||||
toggle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
interface Props {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
|
||||
interface DropdownItem {
|
||||
id: string
|
||||
label?: string
|
||||
|
|
@ -22,23 +22,26 @@
|
|||
const { editor }: Props = $props()
|
||||
|
||||
const pluginKey = 'globalDragHandle'
|
||||
|
||||
|
||||
// State
|
||||
let isMenuOpen = $state(false)
|
||||
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>()
|
||||
|
||||
// Generate menu items based on current node
|
||||
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 nodeType = currentNode.node.type.name
|
||||
|
||||
const nodeType = activeNode.node.type.name
|
||||
|
||||
// Block type conversion options
|
||||
if (nodeType === 'paragraph' || nodeType === 'heading') {
|
||||
const turnIntoChildren = []
|
||||
|
||||
|
||||
turnIntoChildren.push({
|
||||
id: 'convert-paragraph',
|
||||
label: 'Paragraph',
|
||||
|
|
@ -64,7 +67,7 @@
|
|||
label: 'Quote',
|
||||
action: () => convertBlockType('blockquote')
|
||||
})
|
||||
|
||||
|
||||
items.push({
|
||||
id: 'turn-into',
|
||||
label: 'Turn into',
|
||||
|
|
@ -75,11 +78,11 @@
|
|||
divider: true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// List-specific actions
|
||||
if (nodeType === 'listItem') {
|
||||
const turnIntoChildren = []
|
||||
|
||||
|
||||
turnIntoChildren.push({
|
||||
id: 'convert-bullet',
|
||||
label: 'Bullet List',
|
||||
|
|
@ -95,7 +98,7 @@
|
|||
label: 'Task List',
|
||||
action: () => convertToList('taskList')
|
||||
})
|
||||
|
||||
|
||||
items.push({
|
||||
id: 'turn-into',
|
||||
label: 'Turn into',
|
||||
|
|
@ -106,7 +109,7 @@
|
|||
divider: true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// URL embed specific actions
|
||||
if (nodeType === 'urlEmbed') {
|
||||
items.push({
|
||||
|
|
@ -119,9 +122,9 @@
|
|||
divider: true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 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({
|
||||
id: 'add-link-cards',
|
||||
label: 'Add cards for links',
|
||||
|
|
@ -132,20 +135,20 @@
|
|||
divider: true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Common actions
|
||||
items.push({
|
||||
id: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
action: () => duplicateBlock()
|
||||
})
|
||||
|
||||
|
||||
items.push({
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
action: () => copyBlock()
|
||||
})
|
||||
|
||||
|
||||
// Text formatting removal
|
||||
if (nodeType === 'paragraph' || nodeType === 'heading') {
|
||||
items.push({
|
||||
|
|
@ -154,19 +157,19 @@
|
|||
action: () => removeFormatting()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
items.push({
|
||||
id: 'divider-2',
|
||||
divider: true
|
||||
})
|
||||
|
||||
|
||||
items.push({
|
||||
id: 'delete',
|
||||
label: 'Delete',
|
||||
action: () => deleteBlock(),
|
||||
variant: 'danger' as const
|
||||
})
|
||||
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
|
|
@ -174,7 +177,10 @@
|
|||
function hasLinks(node: Node): boolean {
|
||||
let hasLink = false
|
||||
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
|
||||
}
|
||||
})
|
||||
|
|
@ -184,17 +190,23 @@
|
|||
// Block manipulation functions
|
||||
function convertBlockType(type: string, attrs?: any) {
|
||||
console.log('convertBlockType called:', type, attrs)
|
||||
if (!currentNode) {
|
||||
console.log('No current node')
|
||||
// Use menuNode which was captured when menu was opened
|
||||
const nodeToConvert = menuNode || currentNode
|
||||
if (!nodeToConvert) {
|
||||
console.log('No node to convert')
|
||||
return
|
||||
}
|
||||
|
||||
const { pos } = currentNode
|
||||
console.log('Current node:', currentNode.node.type.name, 'at pos:', pos)
|
||||
|
||||
// Focus the editor first
|
||||
editor.commands.focus()
|
||||
|
||||
|
||||
const { pos, node } = nodeToConvert
|
||||
console.log('Converting node:', node.type.name, 'at pos:', pos)
|
||||
|
||||
// Calculate the actual position of the node
|
||||
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
|
||||
if (type === 'paragraph') {
|
||||
editor.chain().focus().setParagraph().run()
|
||||
|
|
@ -203,25 +215,26 @@
|
|||
} else if (type === 'blockquote') {
|
||||
editor.chain().focus().setBlockquote().run()
|
||||
}
|
||||
|
||||
|
||||
isMenuOpen = false
|
||||
}
|
||||
|
||||
function convertToList(listType: string) {
|
||||
if (!currentNode) return
|
||||
|
||||
const { pos } = currentNode
|
||||
const nodeToConvert = menuNode || currentNode
|
||||
if (!nodeToConvert) return
|
||||
|
||||
const { pos } = nodeToConvert
|
||||
const resolvedPos = editor.state.doc.resolve(pos)
|
||||
|
||||
|
||||
// Get the position of the list item
|
||||
let nodePos = pos
|
||||
if (resolvedPos.parent.type.name === 'listItem') {
|
||||
nodePos = resolvedPos.before(resolvedPos.depth)
|
||||
}
|
||||
|
||||
|
||||
// Set selection to the list item
|
||||
editor.commands.setNodeSelection(nodePos)
|
||||
|
||||
|
||||
// Convert to the appropriate list type
|
||||
if (listType === 'bulletList') {
|
||||
editor.commands.toggleBulletList()
|
||||
|
|
@ -230,54 +243,61 @@
|
|||
} else if (listType === 'taskList') {
|
||||
editor.commands.toggleTaskList()
|
||||
}
|
||||
|
||||
|
||||
isMenuOpen = false
|
||||
}
|
||||
|
||||
function convertEmbedToLink() {
|
||||
if (!currentNode) return
|
||||
|
||||
const { node, pos } = currentNode
|
||||
const nodeToConvert = menuNode || currentNode
|
||||
if (!nodeToConvert) return
|
||||
|
||||
const { node, pos } = nodeToConvert
|
||||
const url = node.attrs.url
|
||||
const title = node.attrs.title || url
|
||||
|
||||
|
||||
// Get the actual position of the urlEmbed node
|
||||
const nodePos = pos
|
||||
const nodeSize = node.nodeSize
|
||||
|
||||
|
||||
// Replace embed with a paragraph containing a link
|
||||
editor.chain()
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: nodePos, to: nodePos + nodeSize })
|
||||
.insertContentAt(nodePos, {
|
||||
type: 'paragraph',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: title,
|
||||
marks: [{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: url,
|
||||
target: '_blank'
|
||||
}
|
||||
}]
|
||||
}]
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: title,
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: url,
|
||||
target: '_blank'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
.run()
|
||||
|
||||
|
||||
isMenuOpen = false
|
||||
}
|
||||
|
||||
function addCardsForLinks() {
|
||||
if (!currentNode) return
|
||||
|
||||
const { node, pos } = currentNode
|
||||
const nodeToUse = menuNode || currentNode
|
||||
if (!nodeToUse) return
|
||||
|
||||
const { node, pos } = nodeToUse
|
||||
const links: { url: string; text: string }[] = []
|
||||
|
||||
|
||||
// Collect all links in the current block
|
||||
node.descendants((child) => {
|
||||
if (child.isText && child.marks.some(mark => mark.type.name === 'link')) {
|
||||
const linkMark = child.marks.find(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')
|
||||
if (linkMark && linkMark.attrs.href) {
|
||||
links.push({
|
||||
url: linkMark.attrs.href,
|
||||
|
|
@ -286,97 +306,87 @@
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// Insert embeds after the current block
|
||||
if (links.length > 0) {
|
||||
const nodeEnd = pos + node.nodeSize
|
||||
const embeds = links.map(link => ({
|
||||
const embeds = links.map((link) => ({
|
||||
type: 'urlEmbed',
|
||||
attrs: {
|
||||
url: link.url,
|
||||
title: link.text
|
||||
}
|
||||
}))
|
||||
|
||||
editor.chain()
|
||||
.focus()
|
||||
.insertContentAt(nodeEnd, embeds)
|
||||
.run()
|
||||
|
||||
editor.chain().focus().insertContentAt(nodeEnd, embeds).run()
|
||||
}
|
||||
|
||||
|
||||
isMenuOpen = false
|
||||
}
|
||||
|
||||
function removeFormatting() {
|
||||
if (!currentNode) return
|
||||
|
||||
const { pos } = currentNode
|
||||
const nodeToUse = menuNode || currentNode
|
||||
if (!nodeToUse) return
|
||||
|
||||
const { pos } = nodeToUse
|
||||
const resolvedPos = editor.state.doc.resolve(pos)
|
||||
const nodeStart = resolvedPos.before(resolvedPos.depth)
|
||||
const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize
|
||||
|
||||
editor.chain()
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection({ from: nodeStart, to: nodeEnd })
|
||||
.clearNodes()
|
||||
.unsetAllMarks()
|
||||
.run()
|
||||
|
||||
|
||||
isMenuOpen = false
|
||||
}
|
||||
|
||||
function duplicateBlock() {
|
||||
if (!currentNode) return
|
||||
|
||||
const { node, pos } = currentNode
|
||||
const nodeToUse = menuNode || currentNode
|
||||
if (!nodeToUse) return
|
||||
|
||||
const { node, pos } = nodeToUse
|
||||
const resolvedPos = editor.state.doc.resolve(pos)
|
||||
const nodeEnd = resolvedPos.after(resolvedPos.depth)
|
||||
|
||||
editor.chain()
|
||||
.focus()
|
||||
.insertContentAt(nodeEnd, node.toJSON())
|
||||
.run()
|
||||
|
||||
|
||||
editor.chain().focus().insertContentAt(nodeEnd, node.toJSON()).run()
|
||||
|
||||
isMenuOpen = false
|
||||
}
|
||||
|
||||
function copyBlock() {
|
||||
if (!currentNode) return
|
||||
|
||||
const { pos } = currentNode
|
||||
const nodeToUse = menuNode || currentNode
|
||||
if (!nodeToUse) return
|
||||
|
||||
const { pos } = nodeToUse
|
||||
const resolvedPos = editor.state.doc.resolve(pos)
|
||||
const nodeStart = resolvedPos.before(resolvedPos.depth)
|
||||
const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize
|
||||
|
||||
editor.chain()
|
||||
.focus()
|
||||
.setTextSelection({ from: nodeStart, to: nodeEnd })
|
||||
.run()
|
||||
|
||||
|
||||
editor.chain().focus().setTextSelection({ from: nodeStart, to: nodeEnd }).run()
|
||||
|
||||
document.execCommand('copy')
|
||||
|
||||
|
||||
// Clear selection after copy
|
||||
editor.chain()
|
||||
.focus()
|
||||
.setTextSelection(nodeEnd)
|
||||
.run()
|
||||
|
||||
editor.chain().focus().setTextSelection(nodeEnd).run()
|
||||
|
||||
isMenuOpen = false
|
||||
}
|
||||
|
||||
function deleteBlock() {
|
||||
if (!currentNode) return
|
||||
|
||||
const { pos } = currentNode
|
||||
const nodeToUse = menuNode || currentNode
|
||||
if (!nodeToUse) return
|
||||
|
||||
const { pos } = nodeToUse
|
||||
const resolvedPos = editor.state.doc.resolve(pos)
|
||||
const nodeStart = resolvedPos.before(resolvedPos.depth)
|
||||
const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize
|
||||
|
||||
editor.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: nodeStart, to: nodeEnd })
|
||||
.run()
|
||||
|
||||
|
||||
editor.chain().focus().deleteRange({ from: nodeStart, to: nodeEnd }).run()
|
||||
|
||||
isMenuOpen = false
|
||||
}
|
||||
|
||||
|
|
@ -386,9 +396,14 @@
|
|||
console.log('Menu items:', menuItems())
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Only toggle if we're clicking on the same node
|
||||
// If clicking on a different node while menu is open, just update the menu
|
||||
|
||||
// Capture the current node when opening the menu
|
||||
if (!isMenuOpen) {
|
||||
menuNode = currentNode
|
||||
} else {
|
||||
menuNode = null
|
||||
}
|
||||
|
||||
isMenuOpen = !isMenuOpen
|
||||
console.log('Menu open state:', isMenuOpen)
|
||||
}
|
||||
|
|
@ -416,7 +431,7 @@
|
|||
}
|
||||
})
|
||||
editor.registerPlugin(plugin)
|
||||
|
||||
|
||||
// Find the existing drag handle created by the plugin and add click listener
|
||||
const checkForDragHandle = setInterval(() => {
|
||||
const existingDragHandle = document.querySelector('.drag-handle')
|
||||
|
|
@ -424,13 +439,13 @@
|
|||
console.log('Found drag handle, adding click listener')
|
||||
existingDragHandle.addEventListener('click', handleMenuClick)
|
||||
;(existingDragHandle as any).__menuListener = true
|
||||
|
||||
|
||||
// Update our reference to use the existing drag handle
|
||||
dragHandleContainer = existingDragHandle as HTMLElement
|
||||
clearInterval(checkForDragHandle)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
|
||||
return () => {
|
||||
editor.unregisterPlugin(pluginKey)
|
||||
clearInterval(checkForDragHandle)
|
||||
|
|
|
|||
|
|
@ -145,7 +145,6 @@
|
|||
font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono';
|
||||
}
|
||||
|
||||
|
||||
/* List Styling */
|
||||
.tiptap ul,
|
||||
.tiptap ol {
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
dragHandleElement.draggable = true
|
||||
dragHandleElement.dataset.dragHandle = ''
|
||||
dragHandleElement.classList.add('drag-handle')
|
||||
|
||||
|
||||
// Add custom drag handle SVG if element was created (not selected)
|
||||
if (!handleBySelector) {
|
||||
dragHandleElement.innerHTML = DragHandleIcon
|
||||
|
|
@ -273,7 +273,7 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
// Keep the handle visible when hovering over it or the dropdown
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Don't move the drag handle if the menu is open
|
||||
if (dragHandleElement?.classList.contains('menu-open')) {
|
||||
return
|
||||
|
|
@ -314,7 +314,9 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
const rect = absoluteRect(node)
|
||||
|
||||
// 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) {
|
||||
// For NodeView components, position handle at top with small offset
|
||||
rect.top += 8
|
||||
|
|
@ -337,11 +339,11 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
|
||||
// Get the computed padding of the element to position handle correctly
|
||||
const paddingLeft = parseInt(compStyle.paddingLeft, 10) || 0
|
||||
|
||||
|
||||
// Add 12px gap between drag handle and content
|
||||
// Position the handle inside the padding area, close to the text
|
||||
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()
|
||||
},
|
||||
keydown: () => {
|
||||
|
|
|
|||
|
|
@ -25,4 +25,4 @@
|
|||
:global(.tippy-box[data-theme~='link-tooltip'][data-animation='scale'][data-state='hidden']) {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,4 +30,4 @@
|
|||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -23,18 +23,10 @@
|
|||
class={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x="9"
|
||||
y="9"
|
||||
width="13"
|
||||
height="13"
|
||||
rx="2"
|
||||
stroke={color}
|
||||
stroke-width={strokeWidth}
|
||||
/>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" stroke={color} stroke-width={strokeWidth} />
|
||||
<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"
|
||||
stroke={color}
|
||||
stroke-width={strokeWidth}
|
||||
/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -37,4 +37,4 @@
|
|||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
caption: 'Beautiful mountain view',
|
||||
width: 1600,
|
||||
height: 900,
|
||||
aspectRatio: 16/9
|
||||
aspectRatio: 16 / 9
|
||||
},
|
||||
{
|
||||
id: 'photo-2',
|
||||
|
|
@ -20,16 +20,16 @@
|
|||
caption: 'Green valley',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
aspectRatio: 3/2
|
||||
aspectRatio: 3 / 2
|
||||
},
|
||||
{
|
||||
id: 'photo-3',
|
||||
id: 'photo-3',
|
||||
src: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e',
|
||||
alt: 'Forest path',
|
||||
caption: 'Winding forest trail',
|
||||
width: 2400,
|
||||
height: 800,
|
||||
aspectRatio: 3/1 // Ultrawide
|
||||
aspectRatio: 3 / 1 // Ultrawide
|
||||
},
|
||||
{
|
||||
id: 'photo-4',
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
caption: 'Cascading waterfall',
|
||||
width: 800,
|
||||
height: 1200,
|
||||
aspectRatio: 2/3
|
||||
aspectRatio: 2 / 3
|
||||
},
|
||||
{
|
||||
id: 'photo-5',
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
caption: 'Sandy desert landscape',
|
||||
width: 1000,
|
||||
height: 1500,
|
||||
aspectRatio: 2/3
|
||||
aspectRatio: 2 / 3
|
||||
},
|
||||
{
|
||||
id: 'photo-7',
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
caption: 'Snow-capped mountains',
|
||||
width: 1800,
|
||||
height: 1200,
|
||||
aspectRatio: 3/2
|
||||
aspectRatio: 3 / 2
|
||||
},
|
||||
{
|
||||
id: 'photo-8',
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
caption: 'Crashing waves',
|
||||
width: 1600,
|
||||
height: 900,
|
||||
aspectRatio: 16/9
|
||||
aspectRatio: 16 / 9
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -87,7 +87,7 @@
|
|||
|
||||
<div class="test-container">
|
||||
<h1>PhotoGrid Component Test</h1>
|
||||
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label>
|
||||
|
|
@ -128,17 +128,13 @@
|
|||
</div>
|
||||
|
||||
<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 class="grid-preview">
|
||||
<PhotoGrid
|
||||
photos={samplePhotos}
|
||||
{columns}
|
||||
{gap}
|
||||
{masonry}
|
||||
{showCaptions}
|
||||
/>
|
||||
<PhotoGrid photos={samplePhotos} {columns} {gap} {masonry} {showCaptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -187,7 +183,7 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
|
|
@ -230,4 +226,4 @@
|
|||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue