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:
parent
6ff2818e72
commit
6077fa126b
14 changed files with 3025 additions and 1341 deletions
|
|
@ -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.
|
||||
|
|
|
|||
1347
src/lib/components/admin/EnhancedComposer.old.svelte
Normal file
1347
src/lib/components/admin/EnhancedComposer.old.svelte
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
377
src/lib/components/admin/composer/ComposerCore.svelte
Normal file
377
src/lib/components/admin/composer/ComposerCore.svelte
Normal 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>
|
||||
197
src/lib/components/admin/composer/ComposerLinkManager.svelte
Normal file
197
src/lib/components/admin/composer/ComposerLinkManager.svelte
Normal 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}
|
||||
180
src/lib/components/admin/composer/ComposerMediaHandler.svelte.ts
Normal file
180
src/lib/components/admin/composer/ComposerMediaHandler.svelte.ts
Normal 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
|
||||
}
|
||||
}
|
||||
209
src/lib/components/admin/composer/ComposerToolbar.svelte
Normal file
209
src/lib/components/admin/composer/ComposerToolbar.svelte
Normal 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>
|
||||
118
src/lib/components/admin/composer/MediaInsertDropdown.svelte
Normal file
118
src/lib/components/admin/composer/MediaInsertDropdown.svelte
Normal 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>
|
||||
134
src/lib/components/admin/composer/TextStyleDropdown.svelte
Normal file
134
src/lib/components/admin/composer/TextStyleDropdown.svelte
Normal 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>
|
||||
168
src/lib/components/admin/composer/editorConfig.ts
Normal file
168
src/lib/components/admin/composer/editorConfig.ts
Normal 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
|
||||
}
|
||||
}
|
||||
17
src/lib/components/admin/composer/index.ts
Normal file
17
src/lib/components/admin/composer/index.ts
Normal 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'
|
||||
40
src/lib/components/admin/composer/types.ts
Normal file
40
src/lib/components/admin/composer/types.ts
Normal 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
|
||||
}
|
||||
138
src/lib/components/admin/composer/useComposerEvents.svelte.ts
Normal file
138
src/lib/components/admin/composer/useComposerEvents.svelte.ts
Normal 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
|
||||
}
|
||||
}
|
||||
65
src/lib/components/admin/composer/useDropdown.svelte.ts
Normal file
65
src/lib/components/admin/composer/useDropdown.svelte.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue