refactor: break down EnhancedComposer into focused components

- 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 <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-26 09:02:47 -04:00
parent 6ff2818e72
commit 6077fa126b
14 changed files with 3025 additions and 1341 deletions

View file

@ -94,22 +94,22 @@ Create a consistent design system by extracting hardcoded values.
### Phase 3: Component Refactoring (Weeks 3-4) ### Phase 3: Component Refactoring (Weeks 3-4)
Refactor components to reduce duplication and complexity. Refactor components to reduce duplication and complexity.
- [-] **Create base components** - [x] **Create base components**
- [x] Extract `BaseModal` component for shared modal logic - [x] Extract `BaseModal` component for shared modal logic
- [x] Create `BaseDropdown` for dropdown patterns - [x] Create `BaseDropdown` for dropdown patterns
- [x] Merge `FormField` and `FormFieldWrapper` - [x] Merge `FormField` and `FormFieldWrapper`
- [ ] Create `BaseSegmentedController` for shared logic - [x] Create `BaseSegmentedController` for shared logic
- [ ] **Refactor photo grids** - [x] **Refactor photo grids**
- [ ] Create unified `PhotoGrid` component with `columns` prop - [x] Create unified `PhotoGrid` component with `columns` prop
- [ ] Remove 3 duplicate grid components - [x] Remove 3 duplicate grid components
- [ ] Use composition for layout variations - [x] Use composition for layout variations
- [ ] **Componentize inline SVGs** - [x] **Componentize inline SVGs**
- [ ] Create `CloseButton` icon component - [x] Create `CloseButton` icon component
- [ ] Create `LoadingSpinner` component - [x] Create `LoadingSpinner` component (already existed)
- [ ] Create `NavigationArrow` components - [x] Create `NavigationArrow` components (using existing arrow SVGs)
- [ ] Extract other repeated inline SVGs - [x] Extract other repeated inline SVGs (FileIcon, CopyIcon)
### Phase 4: Complex Refactoring (Weeks 5-6) ### Phase 4: Complex Refactoring (Weeks 5-6)
Tackle the most complex components and patterns. Tackle the most complex components and patterns.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,377 @@
<script lang="ts">
import { type Editor } from '@tiptap/core'
import { onMount, setContext } from 'svelte'
import { initiateEditor } from '$lib/components/edra/editor.ts'
import { getEditorExtensions, EDITOR_PRESETS } from '$lib/components/edra/editor-extensions.js'
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
import LinkMenu from '$lib/components/edra/headless/menus/link-menu.svelte'
import TableRowMenu from '$lib/components/edra/headless/menus/table/table-row-menu.svelte'
import TableColMenu from '$lib/components/edra/headless/menus/table/table-col-menu.svelte'
import DragHandle from '$lib/components/edra/drag-handle.svelte'
import EnhancedImagePlaceholder from '$lib/components/edra/headless/components/EnhancedImagePlaceholder.svelte'
import UnifiedMediaModal from '../UnifiedMediaModal.svelte'
import { mediaSelectionStore } from '$lib/stores/media-selection'
import type { Media } from '@prisma/client'
// Import new components
import ComposerToolbar from './ComposerToolbar.svelte'
import TextStyleDropdown from './TextStyleDropdown.svelte'
import MediaInsertDropdown from './MediaInsertDropdown.svelte'
import ComposerLinkManager from './ComposerLinkManager.svelte'
import { ComposerMediaHandler } from './ComposerMediaHandler.svelte'
import { useComposerEvents } from './useComposerEvents.svelte'
import { useDropdown } from './useDropdown.svelte'
import type { ComposerProps } from './types'
import {
getCurrentTextStyle,
getFilteredCommands,
getColorCommands,
excludedCommands,
getDefaultPlaceholder,
getDefaultMinHeight,
shouldShowToolbar,
shouldShowSlashCommands,
getDefaultFeatures
} from './editorConfig'
// Import Edra styles
import '$lib/components/edra/headless/style.css'
import 'katex/dist/katex.min.css'
import '$lib/components/edra/editor.css'
import '$lib/components/edra/onedark.css'
let {
variant = 'full',
data = $bindable({
type: 'doc',
content: [{ type: 'paragraph' }]
}),
onChange,
onCharacterCount,
placeholder = getDefaultPlaceholder(variant),
minHeight = getDefaultMinHeight(variant),
autofocus = false,
editable = true,
class: className = '',
showToolbar = shouldShowToolbar(variant),
showSlashCommands = shouldShowSlashCommands(variant),
albumId,
features = getDefaultFeatures(variant)
}: ComposerProps = $props()
// Set editor context
setContext('editorContext', {
albumId,
contentType: albumId ? 'album' : 'default',
isAlbumEditor: !!albumId
})
// Core state
let editor = $state<Editor | undefined>()
let element = $state<HTMLElement>()
let isLoading = $state(true)
let initialized = false
const mediaSelectionState = $derived($mediaSelectionStore)
// Toolbar component ref
let toolbarRef = $state<ComposerToolbar>()
// Link manager ref
let linkManagerRef = $state<ComposerLinkManager>()
// Media handler
let mediaHandler = $state<ComposerMediaHandler>()
// Command configuration
const filteredCommands = getFilteredCommands(variant, features)
const colorCommands = getColorCommands()
const currentTextStyle = $derived(editor ? getCurrentTextStyle(editor) : 'Paragraph')
// Dropdown states
let showTextStyleDropdown = $state(false)
let showMediaDropdown = $state(false)
// Text style dropdown
const textStyleDropdown = useDropdown({
triggerRef: toolbarRef?.getDropdownRefs().textStyle,
isOpen: showTextStyleDropdown,
onClose: () => (showTextStyleDropdown = false),
portalClass: 'dropdown-menu-portal'
})
// Media dropdown
const mediaDropdown = useDropdown({
triggerRef: toolbarRef?.getDropdownRefs().media,
isOpen: showMediaDropdown,
onClose: () => (showMediaDropdown = false),
portalClass: 'media-dropdown-portal'
})
// Event handlers
const eventHandlers = useComposerEvents({
editor,
mediaHandler,
features
})
// Media selection handlers
function handleGlobalMediaSelect(media: Media) {
mediaHandler?.handleMediaSelect(media)
mediaSelectionStore.close()
}
function handleGlobalMediaClose() {
mediaHandler?.handleMediaClose()
mediaSelectionStore.close()
}
function handleOpenMediaLibrary() {
mediaSelectionStore.open({
mode: 'single',
fileType: 'image',
albumId,
onSelect: handleGlobalMediaSelect,
onClose: handleGlobalMediaClose
})
}
// Update content when editor changes
function handleUpdate({ editor: updatedEditor, transaction }: any) {
// Skip the first update to avoid circular updates
if (!initialized) {
initialized = true
return
}
// Dismiss link menus on typing
linkManagerRef?.dismissOnTyping(transaction)
const json = updatedEditor.getJSON()
data = json
onChange?.(json)
// Calculate character count if callback provided
if (onCharacterCount) {
const text = updatedEditor.getText()
onCharacterCount(text.length)
}
}
onMount(() => {
// Get extensions with custom options
const extensions = getEditorExtensions({
showSlashCommands,
onShowUrlConvertDropdown: features.urlEmbed ? linkManagerRef?.handleShowUrlConvertDropdown : undefined,
onShowLinkContextMenu: linkManagerRef?.handleShowLinkContextMenu,
imagePlaceholderComponent: EnhancedImagePlaceholder
})
// Initialize editor
const newEditor = initiateEditor(
element,
{
initialContent: data,
extensions,
onCreate: () => {
isLoading = false
},
onUpdate: handleUpdate,
editable,
autofocus,
editorProps: {
handlePaste: eventHandlers.handlePaste,
handleDrop: eventHandlers.handleDrop
}
},
placeholder
)
editor = newEditor
// Initialize media handler
mediaHandler = new ComposerMediaHandler({
editor: newEditor,
albumId,
features
})
// Initialize editor storage for image modal
newEditor.storage.imageModal = { placeholderPos: undefined }
return () => {
newEditor.destroy()
}
})
// Export public methods
export function focus() {
editor?.commands.focus()
}
export function blur() {
editor?.commands.blur()
}
export function clear() {
editor?.commands.clearContent()
}
export function isEmpty() {
return editor?.isEmpty || true
}
export function getContent() {
return editor?.getJSON()
}
export function getText() {
return editor?.getText() || ''
}
</script>
<div class={`composer composer--${variant} ${className}`}>
{#if showToolbar && editor && !isLoading}
<ComposerToolbar
bind:this={toolbarRef}
{editor}
{variant}
{currentTextStyle}
{filteredCommands}
{colorCommands}
{excludedCommands}
showMediaLibrary={!!features.mediaLibrary}
onTextStyleDropdownToggle={() => {
showTextStyleDropdown = !showTextStyleDropdown
textStyleDropdown.toggle()
}}
onMediaDropdownToggle={() => {
showMediaDropdown = !showMediaDropdown
mediaDropdown.toggle()
}}
/>
{/if}
{#if editor}
<LinkMenu {editor} />
{#if features.tables}
<TableRowMenu {editor} />
<TableColMenu {editor} />
{/if}
<ComposerLinkManager bind:this={linkManagerRef} {editor} {features} />
{/if}
{#if !editor}
<div class="edra-loading">
<LoaderCircle class="animate-spin" /> Loading...
</div>
{/if}
<div
bind:this={element}
role="button"
tabindex="0"
onclick={eventHandlers.handleEditorClick}
onkeydown={eventHandlers.handleEditorKeydown}
class="edra-editor"
class:with-toolbar={showToolbar}
style={`min-height: ${minHeight}px`}
></div>
{#if editor}
<DragHandle {editor} />
{/if}
</div>
<!-- Text Style Dropdown -->
{#if showTextStyleDropdown && editor}
<TextStyleDropdown
{editor}
position={textStyleDropdown.position()}
{features}
onDismiss={() => (showTextStyleDropdown = false)}
/>
{/if}
<!-- Media Insert Dropdown -->
{#if showMediaDropdown && editor && features.mediaLibrary}
<MediaInsertDropdown
{editor}
position={mediaDropdown.position()}
{features}
{albumId}
onDismiss={() => (showMediaDropdown = false)}
onOpenMediaLibrary={handleOpenMediaLibrary}
/>
{/if}
<!-- Global Media Selection Modal -->
{#if mediaSelectionState.isOpen}
<UnifiedMediaModal
bind:isOpen={mediaSelectionState.isOpen}
mode={mediaSelectionState.mode}
fileType={mediaSelectionState.fileType}
albumId={mediaSelectionState.albumId}
onSelect={mediaSelectionState.onSelect}
onClose={mediaSelectionState.onClose}
/>
{/if}
<style lang="scss">
@import '$styles/variables';
@import '$styles/mixins';
.composer {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
.edra-editor {
flex: 1;
width: 100%;
padding: $unit-2x;
min-height: 100px;
outline: none;
overflow-y: auto;
&.with-toolbar {
border-top: none;
}
}
.edra-loading {
display: flex;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-4x;
color: $gray-50;
font-size: 14px;
}
// Variant-specific styles
.composer--minimal {
.edra-editor {
padding: $unit;
min-height: 60px;
font-size: 14px;
}
}
.composer--inline {
.edra-editor {
padding: $unit-2x;
min-height: 80px;
}
}
.composer--full {
background: $white;
border: 1px solid $gray-85;
border-radius: $corner-radius;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,197 @@
<script lang="ts">
import type { Editor } from '@tiptap/core'
import UrlConvertDropdown from '$lib/components/edra/headless/components/UrlConvertDropdown.svelte'
import LinkContextMenuComponent from '$lib/components/edra/headless/components/LinkContextMenu.svelte'
import LinkEditDialog from '$lib/components/edra/headless/components/LinkEditDialog.svelte'
interface Props {
editor: Editor
features: {
urlEmbed?: boolean
}
}
let { editor, features }: Props = $props()
// URL convert dropdown state
let showUrlConvertDropdown = $state(false)
let urlConvertDropdownPosition = $state({ x: 0, y: 0 })
let urlConvertPos = $state<number | null>(null)
// Link context menu state
let showLinkContextMenu = $state(false)
let linkContextMenuPosition = $state({ x: 0, y: 0 })
let linkContextUrl = $state<string | null>(null)
let linkContextPos = $state<number | null>(null)
// Link edit dialog state
let showLinkEditDialog = $state(false)
let linkEditDialogPosition = $state({ x: 0, y: 0 })
let linkEditUrl = $state<string>('')
let linkEditPos = $state<number | null>(null)
// URL convert handlers
export function handleShowUrlConvertDropdown(pos: number, url: string) {
if (!editor) return
const coords = editor.view.coordsAtPos(pos)
urlConvertDropdownPosition = { x: coords.left, y: coords.bottom + 5 }
urlConvertPos = pos
showUrlConvertDropdown = true
}
function handleConvertToEmbed() {
if (!editor || urlConvertPos === null) return
editor.commands.convertLinkToEmbed(urlConvertPos)
showUrlConvertDropdown = false
urlConvertPos = null
}
// Link context menu handlers
export function handleShowLinkContextMenu(pos: number, url: string) {
if (!editor) return
const coords = editor.view.coordsAtPos(pos)
linkContextMenuPosition = { x: coords.left, y: coords.bottom + 5 }
linkContextUrl = url
linkContextPos = pos
showLinkContextMenu = true
}
function handleConvertLinkToEmbed() {
if (!editor || linkContextPos === null) return
editor.commands.convertLinkToEmbed(linkContextPos)
showLinkContextMenu = false
linkContextPos = null
linkContextUrl = null
}
function handleEditLink() {
if (!editor || linkContextPos === null || !linkContextUrl) return
const coords = editor.view.coordsAtPos(linkContextPos)
linkEditDialogPosition = { x: coords.left, y: coords.bottom + 5 }
linkEditUrl = linkContextUrl
linkEditPos = linkContextPos
showLinkEditDialog = true
showLinkContextMenu = false
}
function handleSaveLink(newUrl: string) {
if (!editor || linkEditPos === null) return
editor.commands.updateLinkUrl(linkEditPos, newUrl)
showLinkEditDialog = false
linkEditPos = null
linkEditUrl = ''
}
function handleCopyLink() {
if (!linkContextUrl) return
navigator.clipboard.writeText(linkContextUrl)
showLinkContextMenu = false
linkContextPos = null
linkContextUrl = null
}
function handleRemoveLink() {
if (!editor || linkContextPos === null) return
editor.commands.removeLink(linkContextPos)
showLinkContextMenu = false
linkContextPos = null
linkContextUrl = null
}
function handleOpenLink() {
if (!linkContextUrl) return
window.open(linkContextUrl, '_blank')
showLinkContextMenu = false
linkContextPos = null
linkContextUrl = null
}
// Handle click outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.url-convert-dropdown')) {
showUrlConvertDropdown = false
}
if (!target.closest('.link-context-menu')) {
showLinkContextMenu = false
}
if (!target.closest('.link-edit-dialog')) {
showLinkEditDialog = false
}
}
// Dismiss dropdowns on typing
export function dismissOnTyping(transaction: any) {
if (showUrlConvertDropdown && transaction.docChanged) {
const hasTextChange = transaction.steps.some(
(step: any) =>
step.toJSON().stepType === 'replace' || step.toJSON().stepType === 'replaceAround'
)
if (hasTextChange) {
showUrlConvertDropdown = false
urlConvertPos = null
}
}
}
$effect(() => {
if (showUrlConvertDropdown || showLinkContextMenu || showLinkEditDialog) {
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
}
}
})
// Export state for parent to check if any menus are open
export function hasOpenMenus() {
return showUrlConvertDropdown || showLinkContextMenu || showLinkEditDialog
}
</script>
<!-- URL Convert Dropdown -->
{#if showUrlConvertDropdown && features.urlEmbed}
<UrlConvertDropdown
x={urlConvertDropdownPosition.x}
y={urlConvertDropdownPosition.y}
onConvert={handleConvertToEmbed}
onDismiss={() => {
showUrlConvertDropdown = false
urlConvertPos = null
}}
/>
{/if}
<!-- Link Context Menu -->
{#if showLinkContextMenu && linkContextUrl}
<LinkContextMenuComponent
x={linkContextMenuPosition.x}
y={linkContextMenuPosition.y}
url={linkContextUrl}
onConvertToCard={features.urlEmbed ? handleConvertLinkToEmbed : undefined}
onEditLink={handleEditLink}
onCopyLink={handleCopyLink}
onRemoveLink={handleRemoveLink}
onOpenLink={handleOpenLink}
onDismiss={() => {
showLinkContextMenu = false
linkContextPos = null
linkContextUrl = null
}}
/>
{/if}
<!-- Link Edit Dialog -->
{#if showLinkEditDialog}
<LinkEditDialog
x={linkEditDialogPosition.x}
y={linkEditDialogPosition.y}
currentUrl={linkEditUrl}
onSave={handleSaveLink}
onCancel={() => {
showLinkEditDialog = false
linkEditPos = null
linkEditUrl = ''
}}
/>
{/if}

View file

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

View file

@ -0,0 +1,209 @@
<script lang="ts">
import type { Editor } from '@tiptap/core'
import EdraToolBarIcon from '$lib/components/edra/headless/components/EdraToolBarIcon.svelte'
import type { ComposerVariant } from './types'
interface Props {
editor: Editor
variant: ComposerVariant
currentTextStyle: string
filteredCommands: any
colorCommands: any[]
excludedCommands: string[]
showMediaLibrary: boolean
onTextStyleDropdownToggle: () => void
onMediaDropdownToggle: () => void
}
let {
editor,
variant,
currentTextStyle,
filteredCommands,
colorCommands,
excludedCommands,
showMediaLibrary,
onTextStyleDropdownToggle,
onMediaDropdownToggle
}: Props = $props()
let dropdownTriggerRef = $state<HTMLElement>()
let mediaDropdownTriggerRef = $state<HTMLElement>()
// Export refs for parent component positioning
export function getDropdownRefs() {
return {
textStyle: dropdownTriggerRef,
media: mediaDropdownTriggerRef
}
}
</script>
<div class="editor-toolbar">
<div class="edra-toolbar">
<!-- Text Style Dropdown -->
<div class="text-style-dropdown">
<button
bind:this={dropdownTriggerRef}
class="dropdown-trigger"
onclick={onTextStyleDropdownToggle}
>
<span>{currentTextStyle}</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<span class="separator"></span>
{#each Object.keys(filteredCommands).filter((key) => !excludedCommands.includes(key)) as keys}
{@const groups = filteredCommands[keys].commands}
{#each groups as command}
<EdraToolBarIcon {command} {editor} />
{/each}
<span class="separator"></span>
{/each}
{#if showMediaLibrary}
<!-- Media Dropdown -->
<div class="text-style-dropdown">
<button
bind:this={mediaDropdownTriggerRef}
class="dropdown-trigger"
onclick={onMediaDropdownToggle}
>
<span>Insert</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<span class="separator"></span>
{/if}
{#if colorCommands.length > 0}
<EdraToolBarIcon
command={colorCommands[0]}
{editor}
style={`color: ${editor.getAttributes('textStyle').color};`}
onclick={() => {
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()
}
}
}}
/>
<EdraToolBarIcon
command={colorCommands[1]}
{editor}
style={`background-color: ${editor.getAttributes('highlight').color};`}
onclick={() => {
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}
</div>
</div>
<style lang="scss">
@import '$styles/variables';
@import '$styles/mixins';
.editor-toolbar {
border-bottom: 1px solid $gray-90;
background: $white;
position: sticky;
top: 0;
z-index: 10;
padding: $unit 0;
}
.edra-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 0 $unit-2x;
flex-wrap: wrap;
}
.text-style-dropdown {
display: flex;
align-items: center;
}
.dropdown-trigger {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: transparent;
border: 1px solid transparent;
border-radius: $corner-radius;
font-size: 14px;
color: $gray-10;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: $gray-95;
border-color: $gray-85;
}
&:active {
background: $gray-90;
}
svg {
width: 12px;
height: 12px;
opacity: 0.5;
}
}
.separator {
width: 1px;
height: 20px;
background: $gray-85;
margin: 0 4px;
}
</style>

View file

@ -0,0 +1,118 @@
<script lang="ts">
import type { Editor } from '@tiptap/core'
import type { DropdownPosition, ComposerFeatures } from './types'
import { mediaSelectionStore } from '$lib/stores/media-selection'
interface Props {
editor: Editor
position: DropdownPosition
features: ComposerFeatures
albumId?: number
onDismiss: () => void
onOpenMediaLibrary: () => void
}
let { editor, position, features, albumId, onDismiss, onOpenMediaLibrary }: Props = $props()
function insertMedia(type: string) {
switch (type) {
case 'image':
handleImageInsert()
break
case 'gallery':
editor.chain().focus().insertGalleryPlaceholder().run()
break
case 'video':
editor.chain().focus().insertVideoPlaceholder().run()
break
case 'audio':
editor.chain().focus().insertAudioPlaceholder().run()
break
case 'location':
editor.chain().focus().insertGeolocationPlaceholder().run()
break
case 'link':
editor.chain().focus().insertUrlEmbedPlaceholder().run()
break
}
onDismiss()
}
function handleImageInsert() {
// Get current position before inserting placeholder
const pos = editor.state.selection.anchor
// Insert placeholder
editor.chain().focus().insertImagePlaceholder().run()
// Store the position for later deletion
editor.storage.imageModal = { placeholderPos: pos }
// Notify parent to open media library
onOpenMediaLibrary()
}
</script>
<div
class="media-dropdown-portal"
style="position: fixed; top: {position.top}px; left: {position.left}px; z-index: 10000;"
>
<div class="dropdown-menu">
<button class="dropdown-item" onclick={() => insertMedia('image')}> Image </button>
<button class="dropdown-item" onclick={() => insertMedia('gallery')}> Gallery </button>
<button class="dropdown-item" onclick={() => insertMedia('video')}> Video </button>
<button class="dropdown-item" onclick={() => insertMedia('audio')}> Audio </button>
<div class="dropdown-separator"></div>
<button class="dropdown-item" onclick={() => insertMedia('location')}> Location </button>
{#if features.urlEmbed}
<button class="dropdown-item" onclick={() => insertMedia('link')}> Link </button>
{/if}
</div>
</div>
<style lang="scss">
@import '$styles/variables';
@import '$styles/mixins';
.media-dropdown-portal {
font-family: inherit;
}
.dropdown-menu {
background: $white;
border: 1px solid $gray-85;
border-radius: $corner-radius;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
padding: 4px;
min-width: 160px;
}
.dropdown-item {
display: block;
width: 100%;
padding: 8px 12px;
text-align: left;
font-size: 14px;
color: $gray-10;
background: transparent;
border: none;
border-radius: $corner-radius-sm;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: $gray-95;
color: $gray-00;
}
&:active {
background: $gray-90;
}
}
.dropdown-separator {
height: 1px;
background: $gray-90;
margin: 4px 0;
}
</style>

View file

@ -0,0 +1,134 @@
<script lang="ts">
import type { Editor } from '@tiptap/core'
import type { DropdownPosition } from './types'
interface Props {
editor: Editor
position: DropdownPosition
features: {
codeBlocks?: boolean
}
onDismiss: () => void
}
let { editor, position, features, onDismiss }: Props = $props()
function selectStyle(action: () => void) {
action()
onDismiss()
}
</script>
<div
class="dropdown-menu-portal"
style="position: fixed; top: {position.top}px; left: {position.left}px; z-index: 10000;"
>
<div class="dropdown-menu">
<button
class="dropdown-item"
onclick={() => selectStyle(() => editor.chain().focus().setParagraph().run())}
>
Paragraph
</button>
<div class="dropdown-separator"></div>
<button
class="dropdown-item"
onclick={() => selectStyle(() => editor.chain().focus().toggleHeading({ level: 1 }).run())}
>
Heading 1
</button>
<button
class="dropdown-item"
onclick={() => selectStyle(() => editor.chain().focus().toggleHeading({ level: 2 }).run())}
>
Heading 2
</button>
<button
class="dropdown-item"
onclick={() => selectStyle(() => editor.chain().focus().toggleHeading({ level: 3 }).run())}
>
Heading 3
</button>
<div class="dropdown-separator"></div>
<button
class="dropdown-item"
onclick={() => selectStyle(() => editor.chain().focus().toggleBulletList().run())}
>
Unordered List
</button>
<button
class="dropdown-item"
onclick={() => selectStyle(() => editor.chain().focus().toggleOrderedList().run())}
>
Ordered List
</button>
<button
class="dropdown-item"
onclick={() => selectStyle(() => editor.chain().focus().toggleTaskList().run())}
>
Task List
</button>
{#if features.codeBlocks}
<div class="dropdown-separator"></div>
<button
class="dropdown-item"
onclick={() => selectStyle(() => editor.chain().focus().toggleCodeBlock().run())}
>
Code Block
</button>
{/if}
<button
class="dropdown-item"
onclick={() => selectStyle(() => editor.chain().focus().toggleBlockquote().run())}
>
Blockquote
</button>
</div>
</div>
<style lang="scss">
@import '$styles/variables';
@import '$styles/mixins';
.dropdown-menu-portal {
font-family: inherit;
}
.dropdown-menu {
background: $white;
border: 1px solid $gray-85;
border-radius: $corner-radius;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
padding: 4px;
min-width: 200px;
}
.dropdown-item {
display: block;
width: 100%;
padding: 8px 12px;
text-align: left;
font-size: 14px;
color: $gray-10;
background: transparent;
border: none;
border-radius: $corner-radius-sm;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: $gray-95;
color: $gray-00;
}
&:active {
background: $gray-90;
}
}
.dropdown-separator {
height: 1px;
background: $gray-90;
margin: 4px 0;
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

@ -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<DropdownPosition>({ 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
}
}