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

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