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
**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
import CloseIcon from '$icons/close.svg'
import LoadingIcon from '$icons/loading.svg'
// ... other imports
let { name, size = 24, class: className = '' } = $props()
let { name, size = 24, class: className = '' } = $props()
const icons = {
close: CloseIcon,
loading: LoadingIcon,
// ... other icons
}
const icons = {
close: CloseIcon,
loading: LoadingIcon
// ... other icons
}
const IconComponent = $derived(icons[name])
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>
```

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -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,11 +83,15 @@
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
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>

View file

@ -27,13 +27,7 @@
}
</script>
<BaseModal
bind:isOpen
{size}
{closeOnBackdrop}
{closeOnEscape}
{onClose}
>
<BaseModal bind:isOpen {size} {closeOnBackdrop} {closeOnEscape} {onClose}>
{#if showCloseButton}
<Button
variant="ghost"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: [{
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()
@ -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)
}

View file

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

View file

@ -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: () => {

View file

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

View file

@ -11,7 +11,7 @@
caption: 'Beautiful mountain view',
width: 1600,
height: 900,
aspectRatio: 16/9
aspectRatio: 16 / 9
},
{
id: 'photo-2',
@ -20,7 +20,7 @@
caption: 'Green valley',
width: 1200,
height: 800,
aspectRatio: 3/2
aspectRatio: 3 / 2
},
{
id: 'photo-3',
@ -29,7 +29,7 @@
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
}
]
@ -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;