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,6 +90,7 @@ 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>
|
||||
|
|
@ -102,6 +103,7 @@ import ChevronDownIcon from '$icons/chevron-down.svg?raw'
|
|||
```
|
||||
|
||||
**Option B: Create an Icon component with name prop**
|
||||
|
||||
```svelte
|
||||
<!-- $lib/components/Icon.svelte -->
|
||||
<script>
|
||||
|
|
@ -113,7 +115,7 @@ import ChevronDownIcon from '$icons/chevron-down.svg?raw'
|
|||
|
||||
const icons = {
|
||||
close: CloseIcon,
|
||||
loading: LoadingIcon,
|
||||
loading: LoadingIcon
|
||||
// ... other icons
|
||||
}
|
||||
|
||||
|
|
@ -128,6 +130,7 @@ import ChevronDownIcon from '$icons/chevron-down.svg?raw'
|
|||
### 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
|
||||
|
|
@ -156,10 +161,25 @@ Establish a consistent pattern:
|
|||
</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
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@
|
|||
showCaptions?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
photos = [],
|
||||
showCaptions = true
|
||||
}: Props = $props()
|
||||
let { photos = [], showCaptions = true }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="horizontal-scroll">
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@
|
|||
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 }, () => [])
|
||||
|
|
@ -34,9 +32,10 @@
|
|||
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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,10 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -84,9 +83,13 @@
|
|||
|
||||
const target = event.target as HTMLElement
|
||||
// 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
|
||||
openSubmenuId = null // Reset submenu state
|
||||
onClose?.()
|
||||
|
|
@ -197,7 +200,7 @@
|
|||
isOpen={true}
|
||||
triggerElement={submenuElements.get(item.id)}
|
||||
items={item.children}
|
||||
onClose={onClose}
|
||||
{onClose}
|
||||
isSubmenu={true}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,13 +27,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<BaseModal
|
||||
bind:isOpen
|
||||
{size}
|
||||
{closeOnBackdrop}
|
||||
{closeOnEscape}
|
||||
{onClose}
|
||||
>
|
||||
<BaseModal bind:isOpen {size} {closeOnBackdrop} {closeOnEscape} {onClose}>
|
||||
{#if showCloseButton}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -26,14 +26,17 @@
|
|||
// 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') {
|
||||
|
|
@ -121,7 +124,7 @@
|
|||
}
|
||||
|
||||
// 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',
|
||||
|
|
@ -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,16 +190,22 @@
|
|||
// 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)
|
||||
const { pos, node } = nodeToConvert
|
||||
console.log('Converting node:', node.type.name, 'at pos:', pos)
|
||||
|
||||
// Focus the editor first
|
||||
editor.commands.focus()
|
||||
// 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') {
|
||||
|
|
@ -208,9 +220,10 @@
|
|||
}
|
||||
|
||||
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)
|
||||
|
||||
// Get the position of the list item
|
||||
|
|
@ -235,9 +248,10 @@
|
|||
}
|
||||
|
||||
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 title = node.attrs.title || url
|
||||
|
||||
|
|
@ -246,22 +260,27 @@
|
|||
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: [{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: title,
|
||||
marks: [{
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: url,
|
||||
target: '_blank'
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
.run()
|
||||
|
||||
|
|
@ -269,15 +288,16 @@
|
|||
}
|
||||
|
||||
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 }[] = []
|
||||
|
||||
// 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,
|
||||
|
|
@ -290,7 +310,7 @@
|
|||
// 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,
|
||||
|
|
@ -298,24 +318,23 @@
|
|||
}
|
||||
}))
|
||||
|
||||
editor.chain()
|
||||
.focus()
|
||||
.insertContentAt(nodeEnd, embeds)
|
||||
.run()
|
||||
editor.chain().focus().insertContentAt(nodeEnd, embeds).run()
|
||||
}
|
||||
|
||||
isMenuOpen = false
|
||||
}
|
||||
|
||||
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 nodeStart = resolvedPos.before(resolvedPos.depth)
|
||||
const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize
|
||||
|
||||
editor.chain()
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection({ from: nodeStart, to: nodeEnd })
|
||||
.clearNodes()
|
||||
|
|
@ -326,56 +345,47 @@
|
|||
}
|
||||
|
||||
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 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 nodeToUse = menuNode || currentNode
|
||||
if (!nodeToUse) return
|
||||
|
||||
const { pos } = currentNode
|
||||
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 nodeToUse = menuNode || currentNode
|
||||
if (!nodeToUse) return
|
||||
|
||||
const { pos } = currentNode
|
||||
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
|
||||
}
|
||||
|
|
@ -387,8 +397,13 @@
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,6 @@
|
|||
font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono';
|
||||
}
|
||||
|
||||
|
||||
/* List Styling */
|
||||
.tiptap ul,
|
||||
.tiptap ol {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -341,7 +343,7 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
// 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: () => {
|
||||
|
|
|
|||
|
|
@ -23,15 +23,7 @@
|
|||
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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue