jedmund-svelte/src/lib/components/admin/composer/ComposerCore.svelte
Justin Edmund e64788962e refactor: remove EnhancedComposer backward compatibility
- Remove EnhancedComposer shim and old backup file
- Update all imports to use new Composer from ./composer
- Fix editor command implementations for link operations
- Fix dropdown hook usage with proper reactive patterns
- All 5 components now directly import the modular implementation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-26 09:12:08 -04:00

381 lines
No EOL
9.1 KiB
Svelte

<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 = $derived.by(() => {
return useDropdown({
triggerRef: toolbarRef?.getDropdownRefs()?.textStyle,
isOpen: showTextStyleDropdown,
onClose: () => (showTextStyleDropdown = false),
portalClass: 'dropdown-menu-portal'
})
})
// Media dropdown
const mediaDropdown = $derived.by(() => {
return 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() || { top: 0, left: 0 }}
{features}
onDismiss={() => (showTextStyleDropdown = false)}
/>
{/if}
<!-- Media Insert Dropdown -->
{#if showMediaDropdown && editor && features.mediaLibrary}
<MediaInsertDropdown
{editor}
position={mediaDropdown?.position() || { top: 0, left: 0 }}
{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>