From 6077fa126b36e6301cea8bbc35bbdeeebd184046 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 26 Jun 2025 09:02:47 -0400 Subject: [PATCH] refactor: break down EnhancedComposer into focused components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract ComposerToolbar component for toolbar UI and logic - Create TextStyleDropdown and MediaInsertDropdown components - Extract ComposerMediaHandler for all media operations - Create ComposerLinkManager for link-related features - Extract useComposerEvents hook for event handling - Create editorConfig utility for configuration logic - Refactor main component from 1,347 to ~300 lines - Maintain backward compatibility with shim component - Improve maintainability with single-responsibility components 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- prd/PRD-codebase-cleanup-refactoring.md | 22 +- .../admin/EnhancedComposer.old.svelte | 1347 ++++++++++++++++ .../components/admin/EnhancedComposer.svelte | 1354 +---------------- .../admin/composer/ComposerCore.svelte | 377 +++++ .../admin/composer/ComposerLinkManager.svelte | 197 +++ .../composer/ComposerMediaHandler.svelte.ts | 180 +++ .../admin/composer/ComposerToolbar.svelte | 209 +++ .../admin/composer/MediaInsertDropdown.svelte | 118 ++ .../admin/composer/TextStyleDropdown.svelte | 134 ++ .../components/admin/composer/editorConfig.ts | 168 ++ src/lib/components/admin/composer/index.ts | 17 + src/lib/components/admin/composer/types.ts | 40 + .../composer/useComposerEvents.svelte.ts | 138 ++ .../admin/composer/useDropdown.svelte.ts | 65 + 14 files changed, 3025 insertions(+), 1341 deletions(-) create mode 100644 src/lib/components/admin/EnhancedComposer.old.svelte create mode 100644 src/lib/components/admin/composer/ComposerCore.svelte create mode 100644 src/lib/components/admin/composer/ComposerLinkManager.svelte create mode 100644 src/lib/components/admin/composer/ComposerMediaHandler.svelte.ts create mode 100644 src/lib/components/admin/composer/ComposerToolbar.svelte create mode 100644 src/lib/components/admin/composer/MediaInsertDropdown.svelte create mode 100644 src/lib/components/admin/composer/TextStyleDropdown.svelte create mode 100644 src/lib/components/admin/composer/editorConfig.ts create mode 100644 src/lib/components/admin/composer/index.ts create mode 100644 src/lib/components/admin/composer/types.ts create mode 100644 src/lib/components/admin/composer/useComposerEvents.svelte.ts create mode 100644 src/lib/components/admin/composer/useDropdown.svelte.ts diff --git a/prd/PRD-codebase-cleanup-refactoring.md b/prd/PRD-codebase-cleanup-refactoring.md index e2c51dc..906d863 100644 --- a/prd/PRD-codebase-cleanup-refactoring.md +++ b/prd/PRD-codebase-cleanup-refactoring.md @@ -94,22 +94,22 @@ Create a consistent design system by extracting hardcoded values. ### Phase 3: Component Refactoring (Weeks 3-4) Refactor components to reduce duplication and complexity. -- [-] **Create base components** +- [x] **Create base components** - [x] Extract `BaseModal` component for shared modal logic - [x] Create `BaseDropdown` for dropdown patterns - [x] Merge `FormField` and `FormFieldWrapper` - - [ ] Create `BaseSegmentedController` for shared logic + - [x] Create `BaseSegmentedController` for shared logic -- [ ] **Refactor photo grids** - - [ ] Create unified `PhotoGrid` component with `columns` prop - - [ ] Remove 3 duplicate grid components - - [ ] Use composition for layout variations +- [x] **Refactor photo grids** + - [x] Create unified `PhotoGrid` component with `columns` prop + - [x] Remove 3 duplicate grid components + - [x] Use composition for layout variations -- [ ] **Componentize inline SVGs** - - [ ] Create `CloseButton` icon component - - [ ] Create `LoadingSpinner` component - - [ ] Create `NavigationArrow` components - - [ ] Extract other repeated inline SVGs +- [x] **Componentize inline SVGs** + - [x] Create `CloseButton` icon component + - [x] Create `LoadingSpinner` component (already existed) + - [x] Create `NavigationArrow` components (using existing arrow SVGs) + - [x] Extract other repeated inline SVGs (FileIcon, CopyIcon) ### Phase 4: Complex Refactoring (Weeks 5-6) Tackle the most complex components and patterns. diff --git a/src/lib/components/admin/EnhancedComposer.old.svelte b/src/lib/components/admin/EnhancedComposer.old.svelte new file mode 100644 index 0000000..30abe9b --- /dev/null +++ b/src/lib/components/admin/EnhancedComposer.old.svelte @@ -0,0 +1,1347 @@ + + +
+ {#if showToolbar && editor && !isLoading} +
+
+ +
+ +
+ + + + {#each Object.keys(filteredCommands).filter((key) => !excludedCommands.includes(key)) as keys} + {@const groups = filteredCommands[keys].commands} + {#each groups as command} + + {/each} + + {/each} + + {#if features.mediaLibrary} + +
+ +
+ + + {/if} + + {#if colorCommands.length > 0} + { + const color = editor.getAttributes('textStyle').color + const hasColor = editor.isActive('textStyle', { color }) + if (hasColor) { + editor.chain().focus().unsetColor().run() + } else { + const color = prompt('Enter the color of the text:') + if (color !== null) { + editor.chain().focus().setColor(color).run() + } + } + }} + /> + { + const hasHightlight = editor.isActive('highlight') + if (hasHightlight) { + editor.chain().focus().unsetHighlight().run() + } else { + const color = prompt('Enter the color of the highlight:') + if (color !== null) { + editor.chain().focus().setHighlight({ color }).run() + } + } + }} + /> + {/if} +
+
+ {/if} + + {#if editor} + + {#if features.tables} + + + {/if} + {/if} + + {#if !editor} +
+ Loading... +
+ {/if} + +
focusEditor(editor, event)} + onkeydown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + focusEditor(editor, event) + } + }} + class="edra-editor" + class:with-toolbar={showToolbar} + style={`min-height: ${minHeight}px`} + >
+ + {#if editor} + + {/if} +
+ + +{#if showMediaDropdown && features.mediaLibrary} +
+ +
+{/if} + + +{#if showTextStyleDropdown} + +{/if} + + +{#if showUrlConvertDropdown && features.urlEmbed} + { + showUrlConvertDropdown = false + urlConvertPos = null + }} + /> +{/if} + + +{#if showLinkContextMenu && linkContextUrl} + { + showLinkContextMenu = false + linkContextPos = null + linkContextUrl = null + }} + /> +{/if} + + +{#if showLinkEditDialog} + { + showLinkEditDialog = false + linkEditPos = null + linkEditUrl = '' + }} + /> +{/if} + + +{#if mediaSelectionState.isOpen} + +{/if} + + diff --git a/src/lib/components/admin/EnhancedComposer.svelte b/src/lib/components/admin/EnhancedComposer.svelte index 30abe9b..1e6261d 100644 --- a/src/lib/components/admin/EnhancedComposer.svelte +++ b/src/lib/components/admin/EnhancedComposer.svelte @@ -1,1347 +1,41 @@ + -
- {#if showToolbar && editor && !isLoading} -
-
- -
- -
- - - - {#each Object.keys(filteredCommands).filter((key) => !excludedCommands.includes(key)) as keys} - {@const groups = filteredCommands[keys].commands} - {#each groups as command} - - {/each} - - {/each} - - {#if features.mediaLibrary} - -
- -
- - - {/if} - - {#if colorCommands.length > 0} - { - const color = editor.getAttributes('textStyle').color - const hasColor = editor.isActive('textStyle', { color }) - if (hasColor) { - editor.chain().focus().unsetColor().run() - } else { - const color = prompt('Enter the color of the text:') - if (color !== null) { - editor.chain().focus().setColor(color).run() - } - } - }} - /> - { - const hasHightlight = editor.isActive('highlight') - if (hasHightlight) { - editor.chain().focus().unsetHighlight().run() - } else { - const color = prompt('Enter the color of the highlight:') - if (color !== null) { - editor.chain().focus().setHighlight({ color }).run() - } - } - }} - /> - {/if} -
-
- {/if} - - {#if editor} - - {#if features.tables} - - - {/if} - {/if} - - {#if !editor} -
- Loading... -
- {/if} - -
focusEditor(editor, event)} - onkeydown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - focusEditor(editor, event) - } - }} - class="edra-editor" - class:with-toolbar={showToolbar} - style={`min-height: ${minHeight}px`} - >
- - {#if editor} - - {/if} -
- - -{#if showMediaDropdown && features.mediaLibrary} -
- -
-{/if} - - -{#if showTextStyleDropdown} - -{/if} - - -{#if showUrlConvertDropdown && features.urlEmbed} - { - showUrlConvertDropdown = false - urlConvertPos = null - }} - /> -{/if} - - -{#if showLinkContextMenu && linkContextUrl} - { - showLinkContextMenu = false - linkContextPos = null - linkContextUrl = null - }} - /> -{/if} - - -{#if showLinkEditDialog} - { - showLinkEditDialog = false - linkEditPos = null - linkEditUrl = '' - }} - /> -{/if} - - -{#if mediaSelectionState.isOpen} - -{/if} - - + \ No newline at end of file diff --git a/src/lib/components/admin/composer/ComposerCore.svelte b/src/lib/components/admin/composer/ComposerCore.svelte new file mode 100644 index 0000000..7d9d889 --- /dev/null +++ b/src/lib/components/admin/composer/ComposerCore.svelte @@ -0,0 +1,377 @@ + + +
+ {#if showToolbar && editor && !isLoading} + { + showTextStyleDropdown = !showTextStyleDropdown + textStyleDropdown.toggle() + }} + onMediaDropdownToggle={() => { + showMediaDropdown = !showMediaDropdown + mediaDropdown.toggle() + }} + /> + {/if} + + {#if editor} + + {#if features.tables} + + + {/if} + + {/if} + + {#if !editor} +
+ Loading... +
+ {/if} + +
+ + {#if editor} + + {/if} +
+ + +{#if showTextStyleDropdown && editor} + (showTextStyleDropdown = false)} + /> +{/if} + + +{#if showMediaDropdown && editor && features.mediaLibrary} + (showMediaDropdown = false)} + onOpenMediaLibrary={handleOpenMediaLibrary} + /> +{/if} + + +{#if mediaSelectionState.isOpen} + +{/if} + + \ No newline at end of file diff --git a/src/lib/components/admin/composer/ComposerLinkManager.svelte b/src/lib/components/admin/composer/ComposerLinkManager.svelte new file mode 100644 index 0000000..1a7b398 --- /dev/null +++ b/src/lib/components/admin/composer/ComposerLinkManager.svelte @@ -0,0 +1,197 @@ + + + +{#if showUrlConvertDropdown && features.urlEmbed} + { + showUrlConvertDropdown = false + urlConvertPos = null + }} + /> +{/if} + + +{#if showLinkContextMenu && linkContextUrl} + { + showLinkContextMenu = false + linkContextPos = null + linkContextUrl = null + }} + /> +{/if} + + +{#if showLinkEditDialog} + { + showLinkEditDialog = false + linkEditPos = null + linkEditUrl = '' + }} + /> +{/if} \ No newline at end of file diff --git a/src/lib/components/admin/composer/ComposerMediaHandler.svelte.ts b/src/lib/components/admin/composer/ComposerMediaHandler.svelte.ts new file mode 100644 index 0000000..79c50de --- /dev/null +++ b/src/lib/components/admin/composer/ComposerMediaHandler.svelte.ts @@ -0,0 +1,180 @@ +import type { Editor } from '@tiptap/core' +import type { Media } from '@prisma/client' + +export interface MediaHandlerOptions { + editor: Editor + albumId?: number + features: { + imageUpload?: boolean + mediaLibrary?: boolean + } +} + +export class ComposerMediaHandler { + private editor: Editor + private albumId?: number + private features: MediaHandlerOptions['features'] + + constructor(options: MediaHandlerOptions) { + this.editor = options.editor + this.albumId = options.albumId + this.features = options.features + } + + async uploadImage(file: File): Promise { + if (!this.editor || !this.features.imageUpload) return + + // Validate file size (2MB max) + const filesize = file.size / 1024 / 1024 + if (filesize > 2) { + alert(`Image too large! File size: ${filesize.toFixed(2)} MB (max 2MB)`) + return + } + + // Create a placeholder while uploading + const placeholderSrc = URL.createObjectURL(file) + this.editor.commands.insertContent({ + type: 'image', + attrs: { + src: placeholderSrc, + alt: '', + title: '', + mediaId: null + } + }) + + try { + const auth = localStorage.getItem('admin_auth') + if (!auth) { + throw new Error('Not authenticated') + } + + const formData = new FormData() + formData.append('file', file) + + // Add albumId if available + if (this.albumId) { + formData.append('albumId', this.albumId.toString()) + } + + const response = await fetch('/api/media/upload', { + method: 'POST', + headers: { + Authorization: `Basic ${auth}` + }, + body: formData + }) + + if (!response.ok) { + throw new Error('Upload failed') + } + + const media = await response.json() + + // Replace placeholder with actual URL + const displayWidth = media.width && media.width > 600 ? 600 : media.width + + this.editor.commands.insertContent({ + type: 'image', + attrs: { + src: media.url, + alt: media.filename || '', + title: media.description || '', + width: displayWidth, + height: media.height, + align: 'center', + mediaId: media.id?.toString() + } + }) + + // Clean up the object URL + URL.revokeObjectURL(placeholderSrc) + } catch (error) { + console.error('Image upload failed:', error) + alert('Failed to upload image. Please try again.') + // Remove the placeholder on error + this.editor.commands.undo() + URL.revokeObjectURL(placeholderSrc) + } + } + + handleMediaSelect(media: Media): void { + if (!this.editor) return + + // Remove placeholder if it exists + if (this.editor.storage.imageModal?.placeholderPos !== undefined) { + const pos = this.editor.storage.imageModal.placeholderPos + this.editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + 1 }) + .run() + this.editor.storage.imageModal.placeholderPos = undefined + } + + // Check if it's a video + const isVideo = media.mimeType?.startsWith('video/') + + if (isVideo) { + // Insert video + this.editor.commands.insertContent({ + type: 'video', + attrs: { + src: media.url, + title: media.description || media.filename || '', + mediaId: media.id.toString() + } + }) + } else { + // Calculate display dimensions + const displayWidth = media.width && media.width > 600 ? 600 : media.width + + // Insert image + this.editor.commands.insertContent({ + type: 'image', + attrs: { + src: media.url, + alt: media.filename || '', + title: media.description || '', + width: displayWidth, + height: media.height, + align: 'center', + mediaId: media.id.toString() + } + }) + } + } + + handleMediaClose(): void { + // Remove the placeholder if user cancelled + if (this.editor && this.editor.storage.imageModal?.placeholderPos !== undefined) { + const pos = this.editor.storage.imageModal.placeholderPos + this.editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + 1 }) + .run() + this.editor.storage.imageModal.placeholderPos = undefined + } + } + + handlePasteImage(clipboardData: DataTransfer): boolean { + if (!this.features.imageUpload) return false + + // Check for images + const imageItem = Array.from(clipboardData.items).find( + (item) => item.type.indexOf('image') === 0 + ) + + if (imageItem) { + const file = imageItem.getAsFile() + if (!file) return false + + // Upload the image + this.uploadImage(file) + return true // Prevent default paste behavior + } + + return false + } +} \ No newline at end of file diff --git a/src/lib/components/admin/composer/ComposerToolbar.svelte b/src/lib/components/admin/composer/ComposerToolbar.svelte new file mode 100644 index 0000000..4f349b4 --- /dev/null +++ b/src/lib/components/admin/composer/ComposerToolbar.svelte @@ -0,0 +1,209 @@ + + +
+
+ +
+ +
+ + + + {#each Object.keys(filteredCommands).filter((key) => !excludedCommands.includes(key)) as keys} + {@const groups = filteredCommands[keys].commands} + {#each groups as command} + + {/each} + + {/each} + + {#if showMediaLibrary} + +
+ +
+ + + {/if} + + {#if colorCommands.length > 0} + { + const color = editor.getAttributes('textStyle').color + const hasColor = editor.isActive('textStyle', { color }) + if (hasColor) { + editor.chain().focus().unsetColor().run() + } else { + const color = prompt('Enter the color of the text:') + if (color !== null) { + editor.chain().focus().setColor(color).run() + } + } + }} + /> + { + const hasHightlight = editor.isActive('highlight') + if (hasHightlight) { + editor.chain().focus().unsetHighlight().run() + } else { + const color = prompt('Enter the color of the highlight:') + if (color !== null) { + editor.chain().focus().setHighlight({ color }).run() + } + } + }} + /> + {/if} +
+
+ + \ No newline at end of file diff --git a/src/lib/components/admin/composer/MediaInsertDropdown.svelte b/src/lib/components/admin/composer/MediaInsertDropdown.svelte new file mode 100644 index 0000000..8fb39d5 --- /dev/null +++ b/src/lib/components/admin/composer/MediaInsertDropdown.svelte @@ -0,0 +1,118 @@ + + +
+ +
+ + \ No newline at end of file diff --git a/src/lib/components/admin/composer/TextStyleDropdown.svelte b/src/lib/components/admin/composer/TextStyleDropdown.svelte new file mode 100644 index 0000000..da1c1ad --- /dev/null +++ b/src/lib/components/admin/composer/TextStyleDropdown.svelte @@ -0,0 +1,134 @@ + + + + + \ No newline at end of file diff --git a/src/lib/components/admin/composer/editorConfig.ts b/src/lib/components/admin/composer/editorConfig.ts new file mode 100644 index 0000000..fe4a437 --- /dev/null +++ b/src/lib/components/admin/composer/editorConfig.ts @@ -0,0 +1,168 @@ +import type { Editor } from '@tiptap/core' +import type { ComposerVariant, ComposerFeatures } from './types' +import { commands } from '$lib/components/edra/commands/commands.js' + +export interface FilteredCommands { + [key: string]: { + name: string + label: string + commands: any[] + } +} + +// Get current text style for dropdown +export function getCurrentTextStyle(editor: Editor): string { + if (editor.isActive('heading', { level: 1 })) return 'Heading 1' + if (editor.isActive('heading', { level: 2 })) return 'Heading 2' + if (editor.isActive('heading', { level: 3 })) return 'Heading 3' + if (editor.isActive('bulletList')) return 'Bullet List' + if (editor.isActive('orderedList')) return 'Ordered List' + if (editor.isActive('taskList')) return 'Task List' + if (editor.isActive('codeBlock')) return 'Code Block' + if (editor.isActive('blockquote')) return 'Blockquote' + return 'Paragraph' +} + +// Get filtered commands based on variant and features +export function getFilteredCommands(variant: ComposerVariant, features: ComposerFeatures): FilteredCommands { + const filtered = { ...commands } + + // Remove groups based on variant + if (variant === 'minimal') { + delete filtered['undo-redo'] + delete filtered['headings'] + delete filtered['lists'] + delete filtered['alignment'] + delete filtered['table'] + delete filtered['media'] + delete filtered['fonts'] + } else if (variant === 'inline') { + delete filtered['undo-redo'] + delete filtered['headings'] + delete filtered['lists'] + delete filtered['alignment'] + delete filtered['table'] + delete filtered['media'] + } else { + // Full variant - reorganize for toolbar + delete filtered['undo-redo'] + delete filtered['headings'] // In text style dropdown + delete filtered['lists'] // In text style dropdown + delete filtered['alignment'] + delete filtered['table'] + delete filtered['media'] // In media dropdown + } + + // Reorganize text formatting for toolbar + if (filtered['text-formatting']) { + const allCommands = filtered['text-formatting'].commands + const basicFormatting: any[] = [] + const advancedFormatting: any[] = [] + + // Group basic formatting first + const basicOrder = ['bold', 'italic', 'underline', 'strike'] + basicOrder.forEach((name) => { + const cmd = allCommands.find((c: any) => c.name === name) + if (cmd) basicFormatting.push(cmd) + }) + + // Then link and code + const advancedOrder = ['link', 'code'] + advancedOrder.forEach((name) => { + const cmd = allCommands.find((c: any) => c.name === name) + if (cmd) advancedFormatting.push(cmd) + }) + + // Create two groups + filtered['basic-formatting'] = { + name: 'Basic Formatting', + label: 'Basic Formatting', + commands: basicFormatting + } + + filtered['advanced-formatting'] = { + name: 'Advanced Formatting', + label: 'Advanced Formatting', + commands: advancedFormatting + } + + // Remove original text-formatting + delete filtered['text-formatting'] + } + + return filtered +} + +// Get media commands, but filter out based on features +export function getMediaCommands(features: ComposerFeatures): any[] { + if (!commands.media) return [] + + let mediaCommands = [...commands.media.commands] + + // Filter based on features + if (!features.urlEmbed) { + mediaCommands = mediaCommands.filter((cmd) => cmd.name !== 'iframe-placeholder') + } + + return mediaCommands +} + +// Get color commands +export function getColorCommands(): any[] { + return commands.colors?.commands || [] +} + +// Commands to exclude from toolbar +export const excludedCommands = ['colors', 'fonts'] + +// Default placeholders by variant +export function getDefaultPlaceholder(variant: ComposerVariant): string { + return variant === 'inline' ? "What's on your mind?" : 'Type "/" for commands...' +} + +// Default min heights by variant +export function getDefaultMinHeight(variant: ComposerVariant): number { + return variant === 'inline' ? 80 : 400 +} + +// Whether to show toolbar by default +export function shouldShowToolbar(variant: ComposerVariant): boolean { + return variant === 'full' +} + +// Whether to show slash commands +export function shouldShowSlashCommands(variant: ComposerVariant): boolean { + return variant !== 'minimal' +} + +// Default features by variant +export function getDefaultFeatures(variant: ComposerVariant): ComposerFeatures { + if (variant === 'minimal') { + return { + imageUpload: false, + mediaLibrary: false, + urlEmbed: false, + tables: false, + codeBlocks: false + } + } + + if (variant === 'inline') { + return { + imageUpload: true, + mediaLibrary: true, + urlEmbed: false, + tables: false, + codeBlocks: false + } + } + + // Full variant + return { + imageUpload: true, + mediaLibrary: true, + urlEmbed: true, + tables: true, + codeBlocks: true + } +} \ No newline at end of file diff --git a/src/lib/components/admin/composer/index.ts b/src/lib/components/admin/composer/index.ts new file mode 100644 index 0000000..fb88f8e --- /dev/null +++ b/src/lib/components/admin/composer/index.ts @@ -0,0 +1,17 @@ +// Export ComposerCore as EnhancedComposer for backward compatibility +export { default as EnhancedComposer } from './ComposerCore.svelte' + +// Export types +export type { ComposerVariant, ComposerFeatures, ComposerProps } from './types' + +// Export individual components if needed elsewhere +export { default as ComposerToolbar } from './ComposerToolbar.svelte' +export { default as TextStyleDropdown } from './TextStyleDropdown.svelte' +export { default as MediaInsertDropdown } from './MediaInsertDropdown.svelte' +export { default as ComposerLinkManager } from './ComposerLinkManager.svelte' + +// Export utilities +export { ComposerMediaHandler } from './ComposerMediaHandler.svelte' +export { useComposerEvents } from './useComposerEvents.svelte' +export { useDropdown } from './useDropdown.svelte' +export * from './editorConfig' \ No newline at end of file diff --git a/src/lib/components/admin/composer/types.ts b/src/lib/components/admin/composer/types.ts new file mode 100644 index 0000000..67b637a --- /dev/null +++ b/src/lib/components/admin/composer/types.ts @@ -0,0 +1,40 @@ +import type { JSONContent } from '@tiptap/core' + +export type ComposerVariant = 'full' | 'inline' | 'minimal' + +export interface ComposerFeatures { + imageUpload?: boolean + mediaLibrary?: boolean + urlEmbed?: boolean + tables?: boolean + codeBlocks?: boolean +} + +export interface ComposerProps { + variant?: ComposerVariant + data?: JSONContent + onChange?: (content: JSONContent) => void + onCharacterCount?: (count: number) => void + placeholder?: string + minHeight?: number + autofocus?: boolean + editable?: boolean + class?: string + showToolbar?: boolean + showSlashCommands?: boolean + albumId?: number + features?: ComposerFeatures +} + +export interface DropdownPosition { + top: number + left: number +} + +export interface MediaSelectionOptions { + mode: 'single' | 'multiple' + fileType?: 'image' | 'video' | 'audio' | 'all' + albumId?: number + onSelect: (media: any) => void + onClose: () => void +} \ No newline at end of file diff --git a/src/lib/components/admin/composer/useComposerEvents.svelte.ts b/src/lib/components/admin/composer/useComposerEvents.svelte.ts new file mode 100644 index 0000000..bac6b3e --- /dev/null +++ b/src/lib/components/admin/composer/useComposerEvents.svelte.ts @@ -0,0 +1,138 @@ +import type { Editor } from '@tiptap/core' +import type { ComposerMediaHandler } from './ComposerMediaHandler.svelte' +import { focusEditor } from '$lib/components/edra/utils' + +export interface UseComposerEventsOptions { + editor: Editor | undefined + mediaHandler: ComposerMediaHandler | undefined + features: { + imageUpload?: boolean + } +} + +export function useComposerEvents(options: UseComposerEventsOptions) { + // Handle paste events + function handlePaste(view: any, event: ClipboardEvent): boolean { + const clipboardData = event.clipboardData + if (!clipboardData) return false + + // Let media handler check for images first + if (options.mediaHandler && options.features.imageUpload) { + const handled = options.mediaHandler.handlePasteImage(clipboardData) + if (handled) return true + } + + // Handle text paste - preserve links while stripping other formatting + const htmlData = clipboardData.getData('text/html') + const plainText = clipboardData.getData('text/plain') + + if (htmlData && plainText) { + event.preventDefault() + + // Use editor commands to insert HTML content + const editorInstance = (view as any).editor + if (editorInstance) { + editorInstance + .chain() + .focus() + .insertContent(htmlData, { parseOptions: { preserveWhitespace: false } }) + .run() + } else { + // Fallback to plain text + const { state, dispatch } = view + const { selection } = state + const transaction = state.tr.insertText(plainText, selection.from, selection.to) + dispatch(transaction) + } + + return true + } + + return false + } + + // Handle editor click + function handleEditorClick(event: MouseEvent) { + if (options.editor) { + focusEditor(options.editor, event) + } + } + + // Handle editor keyboard events + function handleEditorKeydown(event: KeyboardEvent) { + if (options.editor && (event.key === 'Enter' || event.key === ' ')) { + focusEditor(options.editor, event) + } + } + + // Handle drag and drop for images + function handleDrop(view: any, event: DragEvent): boolean { + if (!options.features.imageUpload || !options.mediaHandler) return false + + const files = event.dataTransfer?.files + if (!files || files.length === 0) return false + + // Check if any file is an image + const imageFile = Array.from(files).find((file) => file.type.startsWith('image/')) + if (!imageFile) return false + + event.preventDefault() + + // Get drop position + const coords = { left: event.clientX, top: event.clientY } + const pos = view.posAtCoords(coords) + if (!pos) return false + + // Set cursor position to drop location + const { state, dispatch } = view + const transaction = state.tr.setSelection(state.selection.constructor.near(state.doc.resolve(pos.pos))) + dispatch(transaction) + + // Upload the image + options.mediaHandler.uploadImage(imageFile) + return true + } + + // Handle file input for image selection + function handleFileSelect(event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + if (!file || !options.mediaHandler) return + + if (file.type.startsWith('image/')) { + options.mediaHandler.uploadImage(file) + } + + // Clear the input + input.value = '' + } + + // Create hidden file input for image selection + function createFileInput(): HTMLInputElement { + const input = document.createElement('input') + input.type = 'file' + input.accept = 'image/*' + input.style.display = 'none' + input.addEventListener('change', handleFileSelect) + document.body.appendChild(input) + return input + } + + // Trigger file selection dialog + function selectImageFile() { + const input = createFileInput() + input.click() + // Clean up after a delay + setTimeout(() => { + document.body.removeChild(input) + }, 1000) + } + + return { + handlePaste, + handleEditorClick, + handleEditorKeydown, + handleDrop, + selectImageFile + } +} \ No newline at end of file diff --git a/src/lib/components/admin/composer/useDropdown.svelte.ts b/src/lib/components/admin/composer/useDropdown.svelte.ts new file mode 100644 index 0000000..c4b8f5b --- /dev/null +++ b/src/lib/components/admin/composer/useDropdown.svelte.ts @@ -0,0 +1,65 @@ +import type { DropdownPosition } from './types' + +export interface UseDropdownOptions { + triggerRef: HTMLElement | undefined + isOpen: boolean + onClose: () => void + portalClass?: string +} + +export function useDropdown(options: UseDropdownOptions) { + let position = $state({ top: 0, left: 0 }) + + // Calculate dropdown position based on trigger element + function updatePosition() { + const { triggerRef } = options + if (triggerRef) { + const rect = triggerRef.getBoundingClientRect() + position = { + top: rect.bottom + 4, + left: rect.left + } + } + } + + // Toggle dropdown with position update + function toggle() { + if (!options.isOpen) { + updatePosition() + } + // The actual toggling is handled by the parent component + } + + // Handle click outside + function handleClickOutside(event: MouseEvent) { + const target = event.target as HTMLElement + const { triggerRef, portalClass, onClose } = options + + const isClickInsideTrigger = triggerRef?.contains(target) + const isClickInsidePortal = portalClass && target.closest(`.${portalClass}`) + + if (!isClickInsideTrigger && !isClickInsidePortal) { + onClose() + } + } + + // Effect to add/remove click listener + $effect(() => { + if (options.isOpen) { + // Small delay to avoid immediate closure + setTimeout(() => { + document.addEventListener('click', handleClickOutside) + }, 0) + + return () => { + document.removeEventListener('click', handleClickOutside) + } + } + }) + + return { + position: () => position, + updatePosition, + toggle + } +} \ No newline at end of file