- Create EnhancedComposer as the single unified editor component - Remove redundant editor components (Editor, EditorWithUpload, CaseStudyEditor, UniverseComposer) - Add editor-extensions.ts for centralized extension configuration - Enhance image placeholder with better UI and selection support - Update editor commands and slash command groups - Improve editor state management and content handling Simplifies the codebase by having one powerful editor component instead of multiple specialized ones. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1215 lines
30 KiB
Svelte
1215 lines
30 KiB
Svelte
<script lang="ts">
|
|
import { type Editor } from '@tiptap/core'
|
|
import { onMount, setContext } from 'svelte'
|
|
import { initiateEditor } from '$lib/components/edra/editor.js'
|
|
import { getEditorExtensions, EDITOR_PRESETS } from '$lib/components/edra/editor-extensions.js'
|
|
import { EdraToolbar, EdraBubbleMenu } from '$lib/components/edra/headless/index.js'
|
|
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
|
|
import { focusEditor, type EdraProps } from '$lib/components/edra/utils.js'
|
|
import EdraToolBarIcon from '$lib/components/edra/headless/components/EdraToolBarIcon.svelte'
|
|
import { commands } from '$lib/components/edra/commands/commands.js'
|
|
import EnhancedImagePlaceholder from '$lib/components/edra/headless/components/EnhancedImagePlaceholder.svelte'
|
|
import type { JSONContent } from '@tiptap/core'
|
|
|
|
// 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'
|
|
|
|
// Import menus
|
|
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 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'
|
|
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
|
import { mediaSelectionStore } from '$lib/stores/media-selection'
|
|
import type { Media } from '@prisma/client'
|
|
|
|
// Component types
|
|
type ComposerVariant = 'full' | 'inline' | 'minimal'
|
|
|
|
interface Props {
|
|
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?: {
|
|
imageUpload?: boolean
|
|
mediaLibrary?: boolean
|
|
urlEmbed?: boolean
|
|
tables?: boolean
|
|
codeBlocks?: boolean
|
|
}
|
|
}
|
|
|
|
let {
|
|
variant = 'full',
|
|
data = $bindable({
|
|
type: 'doc',
|
|
content: [{ type: 'paragraph' }]
|
|
}),
|
|
onChange,
|
|
onCharacterCount,
|
|
placeholder = variant === 'inline' ? "What's on your mind?" : 'Type "/" for commands...',
|
|
minHeight = variant === 'inline' ? 80 : 400,
|
|
autofocus = false,
|
|
editable = true,
|
|
class: className = '',
|
|
showToolbar = variant === 'full',
|
|
showSlashCommands = variant !== 'minimal',
|
|
albumId,
|
|
features = {
|
|
imageUpload: true,
|
|
mediaLibrary: true,
|
|
urlEmbed: true,
|
|
tables: true,
|
|
codeBlocks: true
|
|
}
|
|
}: Props = $props()
|
|
|
|
// Set editor context for child components
|
|
setContext('editorContext', {
|
|
albumId,
|
|
contentType: albumId ? 'album' : 'default',
|
|
isAlbumEditor: !!albumId
|
|
})
|
|
|
|
// State
|
|
let editor = $state<Editor | undefined>()
|
|
let element = $state<HTMLElement>()
|
|
let isLoading = $state(true)
|
|
let initialized = false
|
|
const mediaSelectionState = $derived($mediaSelectionStore)
|
|
|
|
// Dropdown states
|
|
let showTextStyleDropdown = $state(false)
|
|
let showMediaDropdown = $state(false)
|
|
let dropdownTriggerRef = $state<HTMLElement>()
|
|
let mediaDropdownTriggerRef = $state<HTMLElement>()
|
|
let dropdownPosition = $state({ top: 0, left: 0 })
|
|
let mediaDropdownPosition = $state({ top: 0, left: 0 })
|
|
|
|
// 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)
|
|
|
|
// Get filtered commands based on variant and features
|
|
const getFilteredCommands = () => {
|
|
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 = []
|
|
const advancedFormatting = []
|
|
|
|
// Group basic formatting first
|
|
const basicOrder = ['bold', 'italic', 'underline', 'strike']
|
|
basicOrder.forEach((name) => {
|
|
const cmd = allCommands.find((c) => c.name === name)
|
|
if (cmd) basicFormatting.push(cmd)
|
|
})
|
|
|
|
// Then link and code
|
|
const advancedOrder = ['link', 'code']
|
|
advancedOrder.forEach((name) => {
|
|
const cmd = allCommands.find((c) => 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
|
|
const getMediaCommands = () => {
|
|
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
|
|
}
|
|
|
|
const filteredCommands = getFilteredCommands()
|
|
const colorCommands = commands.colors?.commands || []
|
|
const excludedCommands = ['colors', 'fonts']
|
|
|
|
// Get current text style for dropdown
|
|
const getCurrentTextStyle = (editor: Editor) => {
|
|
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'
|
|
}
|
|
|
|
// Derived state for current text style
|
|
let currentTextStyle = $derived(editor ? getCurrentTextStyle(editor) : 'Paragraph')
|
|
|
|
// Calculate dropdown position
|
|
const updateDropdownPosition = () => {
|
|
if (dropdownTriggerRef) {
|
|
const rect = dropdownTriggerRef.getBoundingClientRect()
|
|
dropdownPosition = {
|
|
top: rect.bottom + 4,
|
|
left: rect.left
|
|
}
|
|
}
|
|
}
|
|
|
|
// Toggle dropdown with position update
|
|
const toggleDropdown = () => {
|
|
if (!showTextStyleDropdown) {
|
|
updateDropdownPosition()
|
|
}
|
|
showTextStyleDropdown = !showTextStyleDropdown
|
|
}
|
|
|
|
// Update media dropdown position
|
|
const updateMediaDropdownPosition = () => {
|
|
if (mediaDropdownTriggerRef) {
|
|
const rect = mediaDropdownTriggerRef.getBoundingClientRect()
|
|
mediaDropdownPosition = {
|
|
top: rect.bottom + 4,
|
|
left: rect.left
|
|
}
|
|
}
|
|
}
|
|
|
|
// Toggle media dropdown
|
|
const toggleMediaDropdown = () => {
|
|
if (!showMediaDropdown) {
|
|
updateMediaDropdownPosition()
|
|
}
|
|
showMediaDropdown = !showMediaDropdown
|
|
}
|
|
|
|
// Close dropdown when clicking outside
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement
|
|
if (!dropdownTriggerRef?.contains(target) && !target.closest('.dropdown-menu-portal')) {
|
|
showTextStyleDropdown = false
|
|
}
|
|
if (!mediaDropdownTriggerRef?.contains(target) && !target.closest('.media-dropdown-portal')) {
|
|
showMediaDropdown = false
|
|
}
|
|
if (!target.closest('.url-convert-dropdown')) {
|
|
showUrlConvertDropdown = false
|
|
}
|
|
if (!target.closest('.link-context-menu')) {
|
|
showLinkContextMenu = false
|
|
}
|
|
if (!target.closest('.link-edit-dialog')) {
|
|
showLinkEditDialog = false
|
|
}
|
|
}
|
|
|
|
// URL convert handlers
|
|
const 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
|
|
}
|
|
|
|
const handleConvertToEmbed = () => {
|
|
if (!editor || urlConvertPos === null) return
|
|
editor.commands.convertLinkToEmbed(urlConvertPos)
|
|
showUrlConvertDropdown = false
|
|
urlConvertPos = null
|
|
}
|
|
|
|
// Link context menu handlers
|
|
const handleShowLinkContextMenu = (
|
|
pos: number,
|
|
url: string,
|
|
coords: { x: number; y: number }
|
|
) => {
|
|
if (!editor) return
|
|
linkContextMenuPosition = { x: coords.x, y: coords.y + 5 }
|
|
linkContextUrl = url
|
|
linkContextPos = pos
|
|
showLinkContextMenu = true
|
|
}
|
|
|
|
const handleConvertLinkToEmbed = () => {
|
|
if (!editor || linkContextPos === null) return
|
|
editor.commands.convertLinkToEmbed(linkContextPos)
|
|
showLinkContextMenu = false
|
|
linkContextPos = null
|
|
linkContextUrl = null
|
|
}
|
|
|
|
const handleEditLink = () => {
|
|
if (!editor || !linkContextUrl) return
|
|
linkEditUrl = linkContextUrl
|
|
linkEditPos = linkContextPos
|
|
linkEditDialogPosition = { ...linkContextMenuPosition }
|
|
showLinkEditDialog = true
|
|
showLinkContextMenu = false
|
|
}
|
|
|
|
const handleSaveLink = (newUrl: string) => {
|
|
if (!editor) return
|
|
editor.chain().focus().extendMarkRange('link').setLink({ href: newUrl }).run()
|
|
showLinkEditDialog = false
|
|
linkEditPos = null
|
|
linkEditUrl = ''
|
|
}
|
|
|
|
const handleCopyLink = () => {
|
|
if (!linkContextUrl) return
|
|
navigator.clipboard.writeText(linkContextUrl)
|
|
showLinkContextMenu = false
|
|
linkContextPos = null
|
|
linkContextUrl = null
|
|
}
|
|
|
|
const handleRemoveLink = () => {
|
|
if (!editor) return
|
|
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
|
showLinkContextMenu = false
|
|
linkContextPos = null
|
|
linkContextUrl = null
|
|
}
|
|
|
|
const handleOpenLink = () => {
|
|
if (!linkContextUrl) return
|
|
window.open(linkContextUrl, '_blank', 'noopener,noreferrer')
|
|
showLinkContextMenu = false
|
|
linkContextPos = null
|
|
linkContextUrl = null
|
|
}
|
|
|
|
// Handle media selection from the global store
|
|
function handleGlobalMediaSelect(media: Media | Media[]) {
|
|
if (!editor) return
|
|
|
|
const selectedMedia = Array.isArray(media) ? media[0] : media
|
|
if (selectedMedia) {
|
|
// Set a reasonable default width (max 600px)
|
|
const displayWidth =
|
|
selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width
|
|
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.setImage({
|
|
src: selectedMedia.url,
|
|
alt: selectedMedia.altText || '',
|
|
title: selectedMedia.description || '',
|
|
width: displayWidth,
|
|
height: selectedMedia.height,
|
|
align: 'center'
|
|
})
|
|
.run()
|
|
}
|
|
|
|
// Close the modal
|
|
mediaSelectionStore.close()
|
|
|
|
// Remove the placeholder if it exists
|
|
if (editor.storage.imageModal?.placeholderPos !== undefined) {
|
|
const pos = editor.storage.imageModal.placeholderPos
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.deleteRange({ from: pos, to: pos + 1 })
|
|
.run()
|
|
editor.storage.imageModal.placeholderPos = undefined
|
|
}
|
|
}
|
|
|
|
function handleGlobalMediaClose() {
|
|
mediaSelectionStore.close()
|
|
|
|
// Remove the placeholder if user cancelled
|
|
if (editor && editor.storage.imageModal?.placeholderPos !== undefined) {
|
|
const pos = editor.storage.imageModal.placeholderPos
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.deleteRange({ from: pos, to: pos + 1 })
|
|
.run()
|
|
editor.storage.imageModal.placeholderPos = undefined
|
|
}
|
|
}
|
|
|
|
// Handle paste for images
|
|
function handlePaste(view: any, event: ClipboardEvent) {
|
|
const clipboardData = event.clipboardData
|
|
if (!clipboardData) return false
|
|
|
|
// Check for images first
|
|
const imageItem = Array.from(clipboardData.items).find(
|
|
(item) => item.type.indexOf('image') === 0
|
|
)
|
|
if (imageItem && features.imageUpload) {
|
|
const file = imageItem.getAsFile()
|
|
if (!file) return false
|
|
|
|
// Check 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 true
|
|
}
|
|
|
|
// Upload to our media API
|
|
uploadImage(file)
|
|
return true // Prevent default paste behavior
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
async function uploadImage(file: File) {
|
|
if (!editor || !features.imageUpload) return
|
|
|
|
// Create a placeholder while uploading
|
|
const placeholderSrc = URL.createObjectURL(file)
|
|
editor.commands.setImage({ src: placeholderSrc })
|
|
|
|
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 (albumId) {
|
|
formData.append('albumId', 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
|
|
|
|
editor.commands.insertContent({
|
|
type: 'image',
|
|
attrs: {
|
|
src: media.url,
|
|
alt: media.filename || '',
|
|
width: displayWidth,
|
|
height: media.height,
|
|
align: 'center'
|
|
}
|
|
})
|
|
|
|
// 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
|
|
editor.commands.undo()
|
|
}
|
|
}
|
|
|
|
// 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 URL convert dropdown if user types
|
|
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
|
|
}
|
|
}
|
|
|
|
const json = updatedEditor.getJSON()
|
|
data = json
|
|
onChange?.(json)
|
|
|
|
// Calculate character count if callback provided
|
|
if (onCharacterCount) {
|
|
const text = updatedEditor.getText()
|
|
onCharacterCount(text.length)
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (
|
|
showTextStyleDropdown ||
|
|
showMediaDropdown ||
|
|
showUrlConvertDropdown ||
|
|
showLinkContextMenu ||
|
|
showLinkEditDialog
|
|
) {
|
|
document.addEventListener('click', handleClickOutside)
|
|
return () => {
|
|
document.removeEventListener('click', handleClickOutside)
|
|
}
|
|
}
|
|
})
|
|
|
|
onMount(() => {
|
|
// Get extensions with custom options
|
|
const extensions = getEditorExtensions({
|
|
showSlashCommands,
|
|
onShowUrlConvertDropdown: features.urlEmbed ? handleShowUrlConvertDropdown : undefined,
|
|
onShowLinkContextMenu: handleShowLinkContextMenu,
|
|
imagePlaceholderComponent: EnhancedImagePlaceholder
|
|
})
|
|
|
|
// Initialize editor storage for image modal
|
|
const newEditor = initiateEditor(
|
|
element,
|
|
data,
|
|
undefined, // no character limit by default
|
|
extensions,
|
|
{
|
|
editable,
|
|
onUpdate: handleUpdate,
|
|
editorProps: {
|
|
attributes: {
|
|
class: 'prose prose-sm max-w-none focus:outline-none'
|
|
},
|
|
handlePaste: features.imageUpload ? handlePaste : undefined
|
|
}
|
|
},
|
|
placeholder
|
|
)
|
|
|
|
// Initialize storage for image modal
|
|
newEditor.storage.imageModal = { autoOpen: false }
|
|
|
|
editor = newEditor
|
|
|
|
// Auto-focus if requested
|
|
if (autofocus) {
|
|
setTimeout(() => {
|
|
newEditor.commands.focus()
|
|
}, 100)
|
|
}
|
|
|
|
isLoading = false
|
|
|
|
return () => editor?.destroy()
|
|
})
|
|
|
|
|
|
// Public API
|
|
export function save(): JSONContent | null {
|
|
return editor?.getJSON() || null
|
|
}
|
|
|
|
export function clear() {
|
|
editor?.commands.clearContent()
|
|
}
|
|
|
|
export function focus() {
|
|
editor?.commands.focus()
|
|
}
|
|
|
|
export function blur() {
|
|
editor?.commands.blur()
|
|
}
|
|
|
|
export function getContent() {
|
|
return editor?.getJSON()
|
|
}
|
|
|
|
export function getText() {
|
|
return editor?.getText() || ''
|
|
}
|
|
</script>
|
|
|
|
<div class={`composer composer--${variant} ${className}`}>
|
|
{#if showToolbar && editor && !isLoading}
|
|
<div class="editor-toolbar">
|
|
<div class="edra-toolbar">
|
|
<!-- Text Style Dropdown -->
|
|
<div class="text-style-dropdown">
|
|
<button bind:this={dropdownTriggerRef} class="dropdown-trigger" onclick={toggleDropdown}>
|
|
<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 features.mediaLibrary}
|
|
<!-- Media Dropdown -->
|
|
<div class="text-style-dropdown">
|
|
<button
|
|
bind:this={mediaDropdownTriggerRef}
|
|
class="dropdown-trigger"
|
|
onclick={toggleMediaDropdown}
|
|
>
|
|
<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>
|
|
{/if}
|
|
|
|
{#if editor}
|
|
<LinkMenu {editor} />
|
|
{#if features.tables}
|
|
<TableRowMenu {editor} />
|
|
<TableColMenu {editor} />
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if !editor}
|
|
<div class="edra-loading">
|
|
<LoaderCircle class="animate-spin" /> Loading...
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
bind:this={element}
|
|
role="button"
|
|
tabindex="0"
|
|
onclick={(event) => focusEditor(editor, event)}
|
|
onkeydown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
focusEditor(editor, event)
|
|
}
|
|
}}
|
|
class="edra-editor"
|
|
class:with-toolbar={showToolbar}
|
|
style={`min-height: ${minHeight}px`}
|
|
></div>
|
|
</div>
|
|
|
|
<!-- Media Dropdown Portal -->
|
|
{#if showMediaDropdown && features.mediaLibrary}
|
|
<div
|
|
class="media-dropdown-portal"
|
|
style="position: fixed; top: {mediaDropdownPosition.top}px; left: {mediaDropdownPosition.left}px; z-index: 10000;"
|
|
>
|
|
<div class="dropdown-menu">
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
if (editor) {
|
|
// 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 }
|
|
|
|
// Open the modal through the store
|
|
mediaSelectionStore.open({
|
|
mode: 'single',
|
|
fileType: 'image',
|
|
albumId,
|
|
onSelect: handleGlobalMediaSelect,
|
|
onClose: handleGlobalMediaClose
|
|
})
|
|
}
|
|
showMediaDropdown = false
|
|
}}
|
|
>
|
|
Image
|
|
</button>
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().insertGalleryPlaceholder().run()
|
|
showMediaDropdown = false
|
|
}}
|
|
>
|
|
Gallery
|
|
</button>
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().insertVideoPlaceholder().run()
|
|
showMediaDropdown = false
|
|
}}
|
|
>
|
|
Video
|
|
</button>
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().insertAudioPlaceholder().run()
|
|
showMediaDropdown = false
|
|
}}
|
|
>
|
|
Audio
|
|
</button>
|
|
<div class="dropdown-separator"></div>
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().insertGeolocationPlaceholder().run()
|
|
showMediaDropdown = false
|
|
}}
|
|
>
|
|
Location
|
|
</button>
|
|
{#if features.urlEmbed}
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().insertUrlEmbedPlaceholder().run()
|
|
showMediaDropdown = false
|
|
}}
|
|
>
|
|
Link
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Text Style Dropdown Menu Portal -->
|
|
{#if showTextStyleDropdown}
|
|
<div
|
|
class="dropdown-menu-portal"
|
|
style="position: fixed; top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; z-index: 10000;"
|
|
>
|
|
<div class="dropdown-menu">
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().setParagraph().run()
|
|
showTextStyleDropdown = false
|
|
}}
|
|
>
|
|
Paragraph
|
|
</button>
|
|
<div class="dropdown-separator"></div>
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().toggleHeading({ level: 1 }).run()
|
|
showTextStyleDropdown = false
|
|
}}
|
|
>
|
|
Heading 1
|
|
</button>
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().toggleHeading({ level: 2 }).run()
|
|
showTextStyleDropdown = false
|
|
}}
|
|
>
|
|
Heading 2
|
|
</button>
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().toggleHeading({ level: 3 }).run()
|
|
showTextStyleDropdown = false
|
|
}}
|
|
>
|
|
Heading 3
|
|
</button>
|
|
<div class="dropdown-separator"></div>
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().toggleBulletList().run()
|
|
showTextStyleDropdown = false
|
|
}}
|
|
>
|
|
Unordered List
|
|
</button>
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().toggleOrderedList().run()
|
|
showTextStyleDropdown = false
|
|
}}
|
|
>
|
|
Ordered List
|
|
</button>
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().toggleTaskList().run()
|
|
showTextStyleDropdown = false
|
|
}}
|
|
>
|
|
Task List
|
|
</button>
|
|
{#if features.codeBlocks}
|
|
<div class="dropdown-separator"></div>
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().toggleCodeBlock().run()
|
|
showTextStyleDropdown = false
|
|
}}
|
|
>
|
|
Code Block
|
|
</button>
|
|
{/if}
|
|
<button
|
|
class="dropdown-item"
|
|
onclick={() => {
|
|
editor?.chain().focus().toggleBlockquote().run()
|
|
showTextStyleDropdown = false
|
|
}}
|
|
>
|
|
Blockquote
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- 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}
|
|
|
|
<!-- 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.scss';
|
|
|
|
.composer {
|
|
width: 100%;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.editor-toolbar {
|
|
background: var(--edra-button-bg-color);
|
|
box-sizing: border-box;
|
|
padding: $unit ($unit-2x + $unit);
|
|
position: sticky;
|
|
box-sizing: border-box;
|
|
top: 75px;
|
|
z-index: 10;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
white-space: nowrap;
|
|
-webkit-overflow-scrolling: touch;
|
|
width: 100%;
|
|
flex-shrink: 0;
|
|
backdrop-filter: blur(10px);
|
|
background: rgba(255, 255, 255, 0.9);
|
|
}
|
|
|
|
.composer--full .editor-toolbar {
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
box-shadow: 0 0 16px rgba(0, 0, 0, 0.12);
|
|
background: $grey-95;
|
|
}
|
|
|
|
.edra-editor {
|
|
width: 100%;
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: visible;
|
|
box-sizing: border-box;
|
|
padding: 0 $unit-4x;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.composer--full .edra-editor :global(.ProseMirror) {
|
|
min-height: 400px;
|
|
padding: $unit-4x 0;
|
|
}
|
|
|
|
.composer--inline .edra-editor :global(.ProseMirror) {
|
|
min-height: 80px;
|
|
padding: $unit-2x 0;
|
|
}
|
|
|
|
.composer--minimal .edra-editor :global(.ProseMirror) {
|
|
min-height: 60px;
|
|
padding: $unit-2x 0;
|
|
}
|
|
|
|
:global(.ProseMirror) {
|
|
width: 100%;
|
|
min-height: 100%;
|
|
position: relative;
|
|
word-wrap: break-word;
|
|
white-space: pre-wrap;
|
|
cursor: auto;
|
|
-webkit-font-variant-ligatures: none;
|
|
font-variant-ligatures: none;
|
|
&:focus {
|
|
outline: none;
|
|
}
|
|
}
|
|
|
|
/* Text Style Dropdown Styles */
|
|
.text-style-dropdown {
|
|
position: relative;
|
|
display: inline-block;
|
|
}
|
|
|
|
.dropdown-trigger {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 6px 12px;
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-family: inherit;
|
|
color: var(--edra-text-color);
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
min-width: 120px;
|
|
justify-content: space-between;
|
|
height: 36px;
|
|
}
|
|
|
|
.dropdown-trigger:hover {
|
|
background: rgba(0, 0, 0, 0.06);
|
|
border-color: transparent;
|
|
}
|
|
|
|
.dropdown-menu {
|
|
background: white;
|
|
border: 1px solid #e0e0e0;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
min-width: 160px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.dropdown-item {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 8px 16px;
|
|
text-align: left;
|
|
background: none;
|
|
border: none;
|
|
font-size: 14px;
|
|
font-family: inherit;
|
|
color: var(--edra-text-color);
|
|
cursor: pointer;
|
|
transition: background-color 0.2s ease;
|
|
}
|
|
|
|
.dropdown-item:hover {
|
|
background-color: #f5f5f5;
|
|
}
|
|
|
|
.dropdown-separator {
|
|
height: 1px;
|
|
background-color: #e0e0e0;
|
|
margin: 4px 0;
|
|
}
|
|
|
|
/* Separator in toolbar */
|
|
:global(.edra-toolbar .separator) {
|
|
display: inline-block;
|
|
width: 2px;
|
|
height: 24px;
|
|
background-color: #e0e0e0;
|
|
border-radius: 1px;
|
|
margin: 0 4px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
/* Remove default button backgrounds */
|
|
:global(.edra-toolbar button) {
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
border-radius: 6px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
:global(.edra-toolbar button:hover) {
|
|
background: rgba(0, 0, 0, 0.06);
|
|
border-color: transparent;
|
|
}
|
|
|
|
:global(.edra-toolbar button.active),
|
|
:global(.edra-toolbar button[data-active='true']) {
|
|
background: rgba(0, 0, 0, 0.1);
|
|
border-color: transparent;
|
|
}
|
|
|
|
/* Thicker strokes for icons */
|
|
:global(.edra-toolbar svg) {
|
|
stroke-width: 2;
|
|
}
|
|
|
|
.edra-loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: $unit-4x;
|
|
gap: $unit;
|
|
color: $grey-30;
|
|
}
|
|
|
|
:global(.animate-spin) {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
</style>
|