refactor(editor): consolidate editors into unified EnhancedComposer

- 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>
This commit is contained in:
Justin Edmund 2025-06-24 01:11:57 +01:00
parent 003e08836e
commit 6604032643
9 changed files with 902 additions and 1865 deletions

View file

@ -1,166 +0,0 @@
<script lang="ts">
import Editor from './Editor.svelte'
import type { JSONContent } from '@tiptap/core'
interface Props {
data?: JSONContent
onChange?: (content: JSONContent) => void
placeholder?: string
minHeight?: number
autofocus?: boolean
mode?: 'default' | 'inline'
showToolbar?: boolean
class?: string
}
let {
data = $bindable(),
onChange = () => {},
placeholder = 'Write your content here...',
minHeight = 400,
autofocus = false,
mode = 'default',
showToolbar = true,
class: className = ''
}: Props = $props()
let editorRef: Editor | undefined = $state()
// Forward editor methods if needed
export function focus() {
editorRef?.focus()
}
export function blur() {
editorRef?.blur()
}
export function getContent() {
return editorRef?.getContent()
}
export function clear() {
editorRef?.clear()
}
</script>
<div class={`case-study-editor-wrapper ${mode} ${className}`}>
<Editor
bind:this={editorRef}
bind:data
{onChange}
{placeholder}
{minHeight}
{autofocus}
{showToolbar}
class="case-study-editor"
/>
</div>
<style lang="scss">
@import '$styles/variables.scss';
.case-study-editor-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
}
/* Default mode - used in ProjectForm */
.case-study-editor-wrapper.default {
:global(.case-study-editor) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.case-study-editor .edra) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.case-study-editor .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;
}
:global(.case-study-editor .edra-editor) {
padding: 0 $unit-4x;
overflow-y: auto;
box-sizing: border-box;
}
:global(.case-study-editor .ProseMirror) {
min-height: calc(100% - 80px);
}
:global(.case-study-editor .ProseMirror:focus) {
outline: none;
}
:global(.case-study-editor .ProseMirror > * + *) {
margin-top: 0.75em;
}
:global(.case-study-editor .ProseMirror p.is-editor-empty:first-child::before) {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
}
/* Inline mode - used in UniverseComposer */
.case-study-editor-wrapper.inline {
:global(.case-study-editor) {
border: none !important;
box-shadow: none !important;
}
:global(.case-study-editor .edra-editor) {
padding: $unit-2x 0;
}
:global(.case-study-editor .editor-container) {
padding: 0 $unit-3x;
}
:global(.case-study-editor .editor-content) {
padding: 0;
min-height: 80px;
font-size: 15px;
line-height: 1.5;
}
:global(.case-study-editor .ProseMirror) {
padding: 0;
min-height: 80px;
}
:global(.case-study-editor .ProseMirror:focus) {
outline: none;
}
:global(.case-study-editor .ProseMirror p) {
margin: 0;
}
:global(
.case-study-editor .ProseMirror.ProseMirror-focused .is-editor-empty:first-child::before
) {
color: $grey-40;
content: attr(data-placeholder);
float: left;
pointer-events: none;
height: 0;
}
}
</style>

View file

@ -1,446 +0,0 @@
<script lang="ts">
import EditorWithUpload from './EditorWithUpload.svelte'
import type { Editor } from '@tiptap/core'
import type { JSONContent } from '@tiptap/core'
interface Props {
data?: JSONContent
onChange?: (data: JSONContent) => void
placeholder?: string
readOnly?: boolean
minHeight?: number
autofocus?: boolean
class?: string
showToolbar?: boolean
simpleMode?: boolean
}
let {
data = $bindable({
type: 'doc',
content: [{ type: 'paragraph' }]
}),
onChange,
placeholder = 'Type "/" for commands...',
readOnly = false,
minHeight = 400,
autofocus = false,
class: className = '',
showToolbar = true,
simpleMode = false
}: Props = $props()
let editor = $state<Editor | undefined>()
let initialized = false
// Update content when editor changes
function onUpdate(props: { editor: Editor }) {
// Skip the first update to avoid circular updates
if (!initialized) {
initialized = true
return
}
const json = props.editor.getJSON()
data = json
onChange?.(json)
}
// 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 getIsDirty(): boolean {
// This would need to track changes since last save
return false
}
// Focus on mount if requested
$effect(() => {
if (editor && autofocus) {
// Only focus once on initial mount
const timer = setTimeout(() => {
editor.commands.focus()
}, 100)
return () => clearTimeout(timer)
}
})
</script>
<div class="editor-wrapper {className}" style="--min-height: {minHeight}px">
<div class="editor-container">
<EditorWithUpload
bind:editor
content={data}
{onUpdate}
editable={!readOnly}
showToolbar={!simpleMode && showToolbar}
{placeholder}
showSlashCommands={!simpleMode}
showLinkBubbleMenu={!simpleMode}
showTableBubbleMenu={false}
class="editor-content"
onEditorReady={(e) => {
editor = e
}}
/>
</div>
</div>
<style lang="scss">
@import '$styles/variables.scss';
@import '$styles/mixins.scss';
.editor-wrapper {
width: 100%;
min-height: var(--min-height);
background: white;
display: flex;
flex-direction: column;
}
.editor-container {
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
}
:global(.editor-content) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.editor-content .edra) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.editor-content .editor-toolbar) {
border-radius: $corner-radius-full;
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 0 16px rgba(0, 0, 0, 0.12);
box-sizing: border-box;
background: $grey-95;
padding: $unit $unit-2x;
position: sticky;
z-index: 10;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
// Hide scrollbar but keep functionality
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
// Override Edra toolbar styles
:global(.edra-toolbar) {
overflow: visible;
width: auto;
padding: 0;
display: flex;
align-items: center;
gap: $unit;
}
}
// Override Edra styles to match our design
:global(.edra-editor) {
flex: 1;
min-height: 0;
height: 100%;
overflow-y: auto;
padding: 0 $unit-4x;
@include breakpoint('phone') {
padding: $unit-3x;
}
}
:global(.edra .ProseMirror) {
font-size: 16px;
line-height: 1.6;
color: $grey-10;
min-height: 100%;
padding-bottom: 30vh; // Give space for scrolling
}
:global(.edra .ProseMirror h1) {
font-size: 2rem;
font-weight: 700;
margin: $unit-3x 0 $unit-2x;
line-height: 1.2;
}
:global(.edra .ProseMirror h2) {
font-size: 1.5rem;
font-weight: 600;
margin: $unit-3x 0 $unit-2x;
line-height: 1.3;
}
:global(.edra .ProseMirror h3) {
font-size: 1.25rem;
font-weight: 600;
margin: $unit-3x 0 $unit-2x;
line-height: 1.4;
}
:global(.edra .ProseMirror p) {
margin: $unit-2x 0;
}
:global(.edra .ProseMirror ul),
:global(.edra .ProseMirror ol) {
padding-left: $unit-4x;
margin: $unit-2x 0;
}
:global(.edra .ProseMirror li) {
margin: $unit 0;
}
:global(.edra .ProseMirror blockquote) {
border-left: 3px solid $grey-80;
margin: $unit-3x 0;
padding-left: $unit-3x;
font-style: italic;
color: $grey-30;
}
:global(.edra .ProseMirror pre) {
background: $grey-95;
border: 1px solid $grey-80;
border-radius: 4px;
color: $grey-10;
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
margin: $unit-2x 0;
padding: $unit-2x;
overflow-x: auto;
}
:global(.edra .ProseMirror code) {
background: $grey-90;
border-radius: 0.25rem;
color: $grey-10;
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
font-size: 0.875em;
padding: 0.125rem 0.25rem;
}
:global(.edra .ProseMirror hr) {
border: none;
border-top: 1px solid $grey-80;
margin: $unit-4x 0;
}
:global(.edra .ProseMirror a) {
color: #3b82f6;
text-decoration: underline;
cursor: pointer;
}
:global(.edra .ProseMirror a:hover) {
color: #2563eb;
}
:global(.edra .ProseMirror ::selection) {
background: rgba(59, 130, 246, 0.15);
}
// Placeholder
:global(.edra .ProseMirror p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
color: #999;
pointer-events: none;
height: 0;
}
// Focus styles
:global(.edra .ProseMirror.ProseMirror-focused) {
outline: none;
}
// Loading state
:global(.edra-loading) {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--min-height);
color: $grey-50;
gap: $unit;
}
// Image styles
:global(.edra .ProseMirror img) {
max-width: 100%;
width: auto;
max-height: 400px;
height: auto;
border-radius: 4px;
margin: $unit-2x auto;
display: block;
object-fit: contain;
}
:global(.edra .ProseMirror .edra-url-embed-image img) {
width: 100%;
height: 100%;
max-width: auto;
max-height: auto;
margin: 0;
object-fit: cover;
border-radius: 0;
}
:global(.edra-media-placeholder-wrapper) {
margin: $unit-2x 0;
}
:global(.edra-media-placeholder-content) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-4x;
border: 2px dashed $grey-80;
border-radius: 8px;
background: $grey-95;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: $grey-60;
background: $grey-90;
}
}
:global(.edra-media-placeholder-icon) {
width: 48px;
height: 48px;
color: $grey-50;
}
:global(.edra-media-placeholder-text) {
font-size: 1rem;
color: $grey-30;
}
// Image container styles
:global(.edra-media-container) {
margin: $unit-3x auto;
position: relative;
&.align-left {
margin-left: 0;
}
&.align-right {
margin-right: 0;
margin-left: auto;
}
&.align-center {
margin-left: auto;
margin-right: auto;
}
}
// URL Embed styles - ensure proper isolation
:global(.edra .edra-url-embed-wrapper) {
margin: $unit-3x 0;
width: 100%;
max-width: none;
}
:global(.edra .edra-url-embed-card) {
max-width: 100%;
margin: 0 auto;
}
:global(.edra .edra-url-embed-content) {
background: $grey-95;
border: 1px solid $grey-85;
&:hover {
border-color: $grey-60;
}
}
:global(.edra .edra-url-embed-title) {
color: $grey-10;
font-family: inherit;
margin: 0 !important; // Override ProseMirror h3 margins
font-size: 1rem !important;
font-weight: 600 !important;
line-height: 1.3 !important;
}
:global(.edra .edra-url-embed-description) {
color: $grey-30;
font-family: inherit;
margin: 0 !important; // Override any inherited margins
}
:global(.edra .edra-url-embed-meta) {
color: $grey-40;
font-family: inherit;
}
// Override ProseMirror img styles for favicons only
:global(.edra .ProseMirror .edra-url-embed-favicon) {
width: 16px !important;
height: 16px !important;
margin: 0 !important; // Remove auto margins
display: inline-block !important;
max-width: 16px !important;
max-height: 16px !important;
border-radius: 0 !important;
}
:global(.edra-media-content) {
width: 100%;
height: auto;
display: block;
}
:global(.edra-media-caption) {
width: 100%;
margin-top: $unit;
padding: $unit $unit-2x;
border: 1px solid $grey-80;
border-radius: 4px;
font-size: 0.875rem;
color: $grey-30;
background: $grey-95;
&:focus {
outline: none;
border-color: $grey-60;
background: white;
}
}
</style>

View file

@ -1,852 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { goto } from '$app/navigation'
import Modal from './Modal.svelte'
import CaseStudyEditor from './CaseStudyEditor.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import FormFieldWrapper from './FormFieldWrapper.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import MediaDetailsModal from './MediaDetailsModal.svelte'
import SmartImage from '../SmartImage.svelte'
import type { JSONContent } from '@tiptap/core'
import type { Media } from '@prisma/client'
export let isOpen = false
export let initialMode: 'modal' | 'page' = 'modal'
export let initialPostType: 'post' | 'essay' = 'post'
export let initialContent: JSONContent | undefined = undefined
export let closeOnSave = true
type PostType = 'post' | 'essay'
type ComposerMode = 'modal' | 'page'
let postType: PostType = initialPostType
let mode: ComposerMode = initialMode
let content: JSONContent = initialContent || {
type: 'doc',
content: [{ type: 'paragraph' }]
}
let characterCount = 0
let editorInstance: CaseStudyEditor
// Essay metadata
let essayTitle = ''
let essaySlug = ''
let essayExcerpt = ''
let essayTags = ''
let essayTab = 0
// Photo attachment state
let attachedPhotos: Media[] = []
let isMediaLibraryOpen = false
let fileInput: HTMLInputElement
// Media details modal state
let selectedMedia: Media | null = null
let isMediaDetailsOpen = false
const CHARACTER_LIMIT = 600
const dispatch = createEventDispatcher()
function handleClose() {
if (hasContent() && !confirm('Are you sure you want to close? Your changes will be lost.')) {
return
}
resetComposer()
isOpen = false
dispatch('close')
}
function hasContent(): boolean {
return characterCount > 0 || attachedPhotos.length > 0
}
function resetComposer() {
postType = initialPostType
content = {
type: 'doc',
content: [{ type: 'paragraph' }]
}
characterCount = 0
attachedPhotos = []
if (editorInstance) {
editorInstance.clear()
}
}
function switchToEssay() {
// Store content in sessionStorage to avoid messy URLs
if (content && content.content && content.content.length > 0) {
sessionStorage.setItem('draft_content', JSON.stringify(content))
}
goto('/admin/posts/new?type=essay')
}
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
$: if (essayTitle && !essaySlug) {
essaySlug = generateSlug(essayTitle)
}
function handlePhotoUpload() {
fileInput.click()
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
for (const file of files) {
if (!file.type.startsWith('image/')) continue
const formData = new FormData()
formData.append('file', file)
formData.append('type', 'image')
// Add auth header if needed
const auth = localStorage.getItem('admin_auth')
const headers: Record<string, string> = {}
if (auth) {
headers.Authorization = `Basic ${auth}`
}
try {
const response = await fetch('/api/media/upload', {
method: 'POST',
headers,
body: formData
})
if (response.ok) {
const media = await response.json()
attachedPhotos = [...attachedPhotos, media]
} else {
console.error('Failed to upload image:', response.status)
}
} catch (error) {
console.error('Error uploading image:', error)
}
}
// Clear the input
input.value = ''
}
function handleMediaSelect(media: Media | Media[]) {
const mediaArray = Array.isArray(media) ? media : [media]
const currentIds = attachedPhotos.map((p) => p.id)
const newMedia = mediaArray.filter((m) => !currentIds.includes(m.id))
attachedPhotos = [...attachedPhotos, ...newMedia]
}
function handleMediaLibraryClose() {
isMediaLibraryOpen = false
}
function removePhoto(photoId: number) {
attachedPhotos = attachedPhotos.filter((p) => p.id !== photoId)
}
function handlePhotoClick(photo: Media) {
selectedMedia = photo
isMediaDetailsOpen = true
}
function handleMediaDetailsClose() {
isMediaDetailsOpen = false
selectedMedia = null
}
function handleMediaUpdate(updatedMedia: Media) {
// Update the photo in the attachedPhotos array
attachedPhotos = attachedPhotos.map((photo) =>
photo.id === updatedMedia.id ? updatedMedia : photo
)
}
function getTextFromContent(json: JSONContent): number {
if (!json || !json.content) return 0
let text = ''
function extractText(node: any) {
if (node.text) {
text += node.text
}
if (node.content && Array.isArray(node.content)) {
node.content.forEach(extractText)
}
}
extractText(json)
return text.length
}
async function handleSave() {
if (!hasContent() && postType !== 'essay') return
if (postType === 'essay' && !essayTitle) return
let postData: any = {
content,
status: 'published',
attachedPhotos: attachedPhotos.map((photo) => photo.id)
}
if (postType === 'essay') {
postData = {
...postData,
type: 'essay', // No mapping needed anymore
title: essayTitle,
slug: essaySlug,
excerpt: essayExcerpt,
tags: essayTags ? essayTags.split(',').map((tag) => tag.trim()) : []
}
} else {
// All other content is just a "post" with attachments
postData = {
...postData,
type: 'post' // No mapping needed anymore
}
}
try {
const auth = localStorage.getItem('admin_auth')
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (auth) {
headers.Authorization = `Basic ${auth}`
}
const response = await fetch('/api/posts', {
method: 'POST',
headers,
body: JSON.stringify(postData)
})
if (response.ok) {
resetComposer()
if (closeOnSave) {
isOpen = false
}
dispatch('saved')
if (postType === 'essay') {
goto('/admin/posts')
}
} else {
console.error('Failed to save post')
}
} catch (error) {
console.error('Error saving post:', error)
}
}
$: isOverLimit = characterCount > CHARACTER_LIMIT
$: canSave =
(postType === 'post' && (characterCount > 0 || attachedPhotos.length > 0) && !isOverLimit) ||
(postType === 'essay' && essayTitle.length > 0 && content)
</script>
{#if mode === 'modal'}
<Modal bind:isOpen size="medium" on:close={handleClose} showCloseButton={false}>
<div class="composer">
<div class="composer-header">
<Button variant="ghost" onclick={handleClose}>Cancel</Button>
<div class="header-right">
<Button
variant="ghost"
iconOnly
onclick={switchToEssay}
title="Expand to essay"
class="expand-button"
>
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M10 6L14 2M14 2H10M14 2V6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M6 10L2 14M2 14H6M2 14V10"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
<Button variant="primary" onclick={handleSave} disabled={!canSave}>Post</Button>
</div>
</div>
<div class="composer-body">
<CaseStudyEditor
bind:this={editorInstance}
bind:data={content}
onChange={(newContent) => {
content = newContent
characterCount = getTextFromContent(newContent)
}}
placeholder="What's on your mind?"
minHeight={80}
autofocus={true}
mode="inline"
showToolbar={false}
/>
{#if attachedPhotos.length > 0}
<div class="attached-photos">
{#each attachedPhotos as photo}
<div class="photo-item">
<button
class="photo-button"
onclick={() => handlePhotoClick(photo)}
title="View media details"
>
<img src={photo.url} alt={photo.altText || ''} class="photo-preview" />
</button>
<button
class="remove-photo"
onclick={() => removePhoto(photo.id)}
title="Remove photo"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M4 4L12 12M4 12L12 4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
{/each}
</div>
{/if}
<div class="composer-footer">
<div class="footer-left">
<Button
variant="ghost"
iconOnly
buttonSize="icon"
onclick={handlePhotoUpload}
title="Add image"
class="tool-button"
>
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect
x="2"
y="2"
width="14"
height="14"
rx="2"
stroke="currentColor"
stroke-width="1.5"
/>
<circle cx="5.5" cy="5.5" r="1.5" fill="currentColor" />
<path
d="M2 12l4-4 3 3 5-5 2 2"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
<Button
variant="ghost"
iconOnly
buttonSize="icon"
onclick={() => (isMediaLibraryOpen = true)}
title="Browse library"
class="tool-button"
>
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path
d="M2 5L9 12L16 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
</div>
<div class="footer-right">
{#if postType === 'post'}
<span
class="character-count"
class:warning={characterCount > CHARACTER_LIMIT * 0.9}
class:error={isOverLimit}
>
{CHARACTER_LIMIT - characterCount}
</span>
{/if}
</div>
</div>
</div>
</div>
</Modal>
{:else if mode === 'page'}
{#if postType === 'essay'}
<div class="essay-composer">
<div class="essay-header">
<h1>New Essay</h1>
<div class="essay-actions">
<Button variant="secondary" onclick={() => goto('/admin/posts')}>Cancel</Button>
<Button variant="primary" onclick={handleSave} disabled={!canSave}>Publish</Button>
</div>
</div>
<AdminSegmentedControl bind:selectedIndex={essayTab}>
<button slot="0">Metadata</button>
<button slot="1">Content</button>
</AdminSegmentedControl>
<div class="essay-content">
{#if essayTab === 0}
<div class="metadata-section">
<Input label="Title" bind:value={essayTitle} placeholder="Essay title" required />
<Input label="Slug" bind:value={essaySlug} placeholder="essay-slug" />
<Input
type="textarea"
label="Excerpt"
bind:value={essayExcerpt}
placeholder="Brief description of your essay"
rows={3}
/>
<Input
label="Tags"
bind:value={essayTags}
placeholder="design, development, thoughts"
helpText="Comma-separated list of tags"
/>
</div>
{:else}
<div class="content-section">
<CaseStudyEditor
bind:this={editorInstance}
bind:data={content}
onChange={(newContent) => {
content = newContent
characterCount = getTextFromContent(newContent)
}}
placeholder="Start writing your essay..."
minHeight={500}
autofocus={true}
mode="default"
/>
</div>
{/if}
</div>
</div>
{:else}
<div class="inline-composer">
{#if hasContent()}
<Button
variant="ghost"
iconOnly
buttonSize="icon"
onclick={switchToEssay}
title="Switch to essay mode"
class="floating-expand-button"
>
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M10 6L14 2M14 2H10M14 2V6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M6 10L2 14M2 14H6M2 14V10"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
{/if}
<div class="composer-body">
<CaseStudyEditor
bind:this={editorInstance}
bind:data={content}
onChange={(newContent) => {
content = newContent
characterCount = getTextFromContent(newContent)
}}
placeholder="What's on your mind?"
minHeight={120}
autofocus={true}
mode="inline"
showToolbar={false}
/>
{#if attachedPhotos.length > 0}
<div class="attached-photos">
{#each attachedPhotos as photo}
<div class="photo-item">
<button
class="photo-button"
onclick={() => handlePhotoClick(photo)}
title="View media details"
>
<img src={photo.url} alt={photo.altText || ''} class="photo-preview" />
</button>
<button
class="remove-photo"
onclick={() => removePhoto(photo.id)}
title="Remove photo"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M4 4L12 12M4 12L12 4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
{/each}
</div>
{/if}
<div class="composer-footer">
<div class="footer-left">
<Button
variant="ghost"
iconOnly
buttonSize="icon"
onclick={handlePhotoUpload}
title="Add image"
class="tool-button"
>
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect
x="2"
y="2"
width="14"
height="14"
rx="2"
stroke="currentColor"
stroke-width="1.5"
/>
<circle cx="5.5" cy="5.5" r="1.5" fill="currentColor" />
<path
d="M2 12l4-4 3 3 5-5 2 2"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
<Button
variant="ghost"
iconOnly
buttonSize="icon"
onclick={() => (isMediaLibraryOpen = true)}
title="Browse library"
class="tool-button"
>
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path
d="M2 5L9 12L16 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
</div>
<div class="footer-right">
<span
class="character-count"
class:warning={characterCount > CHARACTER_LIMIT * 0.9}
class:error={isOverLimit}
>
{CHARACTER_LIMIT - characterCount}
</span>
<Button variant="primary" onclick={handleSave} disabled={!canSave}>Post</Button>
</div>
</div>
</div>
</div>
{/if}
{/if}
<!-- Hidden file input for photo upload -->
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
onchange={handleFileUpload}
style="display: none;"
/>
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={isMediaLibraryOpen}
mode="multiple"
fileType="image"
onSelect={handleMediaSelect}
onClose={handleMediaLibraryClose}
/>
<!-- Media Details Modal -->
{#if selectedMedia}
<MediaDetailsModal
bind:isOpen={isMediaDetailsOpen}
media={selectedMedia}
onClose={handleMediaDetailsClose}
onUpdate={handleMediaUpdate}
/>
{/if}
<style lang="scss">
@import '$styles/variables.scss';
.composer {
padding: 0;
max-width: 600px;
margin: 0 auto;
}
.composer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: $unit-2x;
}
.header-right {
display: flex;
align-items: center;
gap: $unit;
}
.composer-body {
display: flex;
flex-direction: column;
}
.link-fields {
padding: 0 $unit-2x $unit-2x;
display: flex;
flex-direction: column;
gap: $unit;
}
.composer-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: calc($unit * 1.5) $unit-2x;
border-top: 1px solid $grey-80;
background-color: $grey-5;
}
.footer-left,
.footer-right {
display: flex;
align-items: center;
gap: $unit-half;
}
.character-count {
font-size: 13px;
color: $grey-50;
font-weight: 400;
padding: 0 $unit;
min-width: 30px;
text-align: right;
font-variant-numeric: tabular-nums;
&.warning {
color: $universe-color;
}
&.error {
color: $red-50;
font-weight: 500;
}
}
// Essay composer styles
.essay-composer {
max-width: 1200px;
margin: 0 auto;
padding: $unit-3x;
}
.essay-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $unit-3x;
h1 {
font-size: 28px;
font-weight: 600;
margin: 0;
}
}
.essay-actions {
display: flex;
gap: $unit;
}
.essay-content {
margin-top: $unit-3x;
}
.metadata-section {
max-width: 600px;
display: flex;
flex-direction: column;
gap: $unit-3x;
}
.content-section {
:global(.editor) {
min-height: 500px;
}
}
// Inline composer styles
.inline-composer {
position: relative;
background: white;
border-radius: $unit-2x;
border: 1px solid $grey-80;
overflow: hidden;
width: 100%;
.composer-body {
display: flex;
flex-direction: column;
}
}
:global(.floating-expand-button) {
position: absolute !important;
top: $unit-2x;
right: $unit-2x;
z-index: 10;
background-color: rgba(255, 255, 255, 0.9) !important;
backdrop-filter: blur(8px);
border: 1px solid $grey-80 !important;
&:hover {
background-color: rgba(255, 255, 255, 0.95) !important;
}
}
.inline-composer .link-fields {
padding: 0 $unit-3x;
display: flex;
flex-direction: column;
gap: $unit-2x;
margin-top: $unit-2x;
}
.inline-composer .composer-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: $unit-2x $unit-3x;
border-top: 1px solid $grey-80;
background-color: $grey-90;
}
.attached-photos {
padding: 0 $unit-3x $unit-2x;
display: flex;
flex-wrap: wrap;
gap: $unit;
}
.photo-item {
position: relative;
.photo-button {
border: none;
background: none;
padding: 0;
cursor: pointer;
display: block;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
}
:global(.photo-preview) {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 12px;
display: block;
}
.remove-photo {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.8);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
opacity: 0;
&:hover {
background: rgba(0, 0, 0, 0.9);
}
svg {
width: 10px;
height: 10px;
}
}
&:hover .remove-photo {
opacity: 1;
}
}
.inline-composer .attached-photos {
padding: 0 $unit-3x $unit-2x;
}
</style>

View file

@ -255,6 +255,8 @@ export const commands: Record<string, EdraCommandGroup> = {
name: 'image-placeholder',
label: 'Image',
action: (editor) => {
// Set flag to auto-open modal and insert placeholder
editor.storage.imageModal = { autoOpen: true }
editor.chain().focus().insertImagePlaceholder().run()
}
},
@ -281,6 +283,14 @@ export const commands: Record<string, EdraCommandGroup> = {
action: (editor) => {
editor.chain().focus().insertIFramePlaceholder().run()
}
},
{
iconName: 'MapPin',
name: 'geolocation-placeholder',
label: 'Location',
action: (editor) => {
editor.chain().focus().insertGeolocationPlaceholder().run()
}
}
]
},
@ -349,95 +359,5 @@ export const commands: Record<string, EdraCommandGroup> = {
}
}
]
},
lists: {
name: 'Lists',
label: 'Lists',
commands: [
{
iconName: 'List',
name: 'bulletList',
label: 'Bullet List',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+8`],
action: (editor) => {
editor.chain().focus().toggleBulletList().run()
},
isActive: (editor) => editor.isActive('bulletList')
},
{
iconName: 'ListOrdered',
name: 'orderedList',
label: 'Ordered List',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+7`],
action: (editor) => {
editor.chain().focus().toggleOrderedList().run()
},
isActive: (editor) => editor.isActive('orderedList')
},
{
iconName: 'ListTodo',
name: 'taskList',
label: 'Task List',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+9`],
action: (editor) => {
editor.chain().focus().toggleTaskList().run()
},
isActive: (editor) => editor.isActive('taskList')
}
]
},
media: {
name: 'Media',
label: 'Media',
commands: [
{
iconName: 'Image',
name: 'image-placeholder',
label: 'Image',
action: (editor) => {
editor.chain().focus().insertImagePlaceholder().run()
}
},
{
iconName: 'Images',
name: 'gallery-placeholder',
label: 'Gallery',
action: (editor) => {
editor.chain().focus().insertGalleryPlaceholder().run()
}
},
{
iconName: 'Video',
name: 'video-placeholder',
label: 'Video',
action: (editor) => {
editor.chain().focus().insertVideoPlaceholder().run()
}
},
{
iconName: 'Mic',
name: 'audio-placeholder',
label: 'Audio',
action: (editor) => {
editor.chain().focus().insertAudioPlaceholder().run()
}
},
{
iconName: 'Code',
name: 'iframe-placeholder',
label: 'Iframe',
action: (editor) => {
editor.chain().focus().insertIframePlaceholder().run()
}
},
{
iconName: 'Link',
name: 'url-embed-placeholder',
label: 'URL Embed',
action: (editor) => {
editor.chain().focus().insertUrlEmbedPlaceholder().run()
}
}
]
}
}

View file

@ -0,0 +1,127 @@
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
import { all, createLowlight } from 'lowlight'
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
import type { Extensions } from '@tiptap/core'
// Extension classes
import { AudioPlaceholder } from './extensions/audio/AudioPlaceholder.js'
import { ImagePlaceholder } from './extensions/image/ImagePlaceholder.js'
import { VideoPlaceholder } from './extensions/video/VideoPlaceholder.js'
import { AudioExtended } from './extensions/audio/AudiExtended.js'
import { ImageExtended } from './extensions/image/ImageExtended.js'
import { VideoExtended } from './extensions/video/VideoExtended.js'
import { GalleryPlaceholder } from './extensions/gallery/GalleryPlaceholder.js'
import { GalleryExtended } from './extensions/gallery/GalleryExtended.js'
import { IFramePlaceholder } from './extensions/iframe/IFramePlaceholder.js'
import { IFrameExtended } from './extensions/iframe/IFrameExtended.js'
import { UrlEmbed } from './extensions/url-embed/UrlEmbed.js'
import { UrlEmbedPlaceholder } from './extensions/url-embed/UrlEmbedPlaceholder.js'
import { UrlEmbedExtended } from './extensions/url-embed/UrlEmbedExtended.js'
import { LinkContextMenu } from './extensions/link-context-menu/LinkContextMenu.js'
import { GeolocationPlaceholder } from './extensions/geolocation/GeolocationPlaceholder.js'
import { GeolocationExtended } from './extensions/geolocation/GeolocationExtended.js'
import slashcommand from './extensions/slash-command/slashcommand.js'
// Component imports
import CodeExtended from './headless/components/CodeExtended.svelte'
import AudioPlaceholderComponent from './headless/components/AudioPlaceholder.svelte'
import AudioExtendedComponent from './headless/components/AudioExtended.svelte'
import ImagePlaceholderComponent from './headless/components/ImagePlaceholder.svelte'
import ImageExtendedComponent from './headless/components/ImageExtended.svelte'
import VideoPlaceholderComponent from './headless/components/VideoPlaceholder.svelte'
import VideoExtendedComponent from './headless/components/VideoExtended.svelte'
import GalleryPlaceholderComponent from './headless/components/GalleryPlaceholder.svelte'
import GalleryExtendedComponent from './headless/components/GalleryExtended.svelte'
import IFramePlaceholderComponent from './headless/components/IFramePlaceholder.svelte'
import IFrameExtendedComponent from './headless/components/IFrameExtended.svelte'
import UrlEmbedPlaceholderComponent from './headless/components/UrlEmbedPlaceholder.svelte'
import UrlEmbedExtendedComponent from './headless/components/UrlEmbedExtended.svelte'
import GeolocationPlaceholderComponent from './headless/components/GeolocationPlaceholder.svelte'
import GeolocationExtendedComponent from './headless/components/GeolocationExtended.svelte'
import SlashCommandList from './headless/components/SlashCommandList.svelte'
// Create lowlight instance
const lowlight = createLowlight(all)
export interface EditorExtensionOptions {
showSlashCommands?: boolean
onShowUrlConvertDropdown?: (pos: number, url: string) => void
onShowLinkContextMenu?: (pos: number, url: string, coords: { x: number; y: number }) => void
imagePlaceholderComponent?: any // Allow custom image placeholder component
}
export function getEditorExtensions(options: EditorExtensionOptions = {}): Extensions {
const {
showSlashCommands = true,
onShowUrlConvertDropdown,
onShowLinkContextMenu,
imagePlaceholderComponent = ImagePlaceholderComponent
} = options
const extensions: Extensions = [
CodeBlockLowlight.configure({
lowlight
}).extend({
addNodeView() {
return SvelteNodeViewRenderer(CodeExtended)
}
}),
AudioPlaceholder(AudioPlaceholderComponent),
ImagePlaceholder(imagePlaceholderComponent),
VideoPlaceholder(VideoPlaceholderComponent),
AudioExtended(AudioExtendedComponent),
ImageExtended(ImageExtendedComponent),
VideoExtended(VideoExtendedComponent),
GalleryPlaceholder(GalleryPlaceholderComponent),
GalleryExtended(GalleryExtendedComponent),
IFramePlaceholder(IFramePlaceholderComponent),
IFrameExtended(IFrameExtendedComponent),
GeolocationPlaceholder(GeolocationPlaceholderComponent),
GeolocationExtended(GeolocationExtendedComponent)
]
// Add URL embed extensions with callbacks if provided
if (onShowUrlConvertDropdown) {
extensions.push(
UrlEmbed.configure({ onShowDropdown: onShowUrlConvertDropdown }),
UrlEmbedPlaceholder(UrlEmbedPlaceholderComponent),
UrlEmbedExtended(UrlEmbedExtendedComponent)
)
} else {
extensions.push(
UrlEmbed,
UrlEmbedPlaceholder(UrlEmbedPlaceholderComponent),
UrlEmbedExtended(UrlEmbedExtendedComponent)
)
}
// Add link context menu if callback provided
if (onShowLinkContextMenu) {
extensions.push(LinkContextMenu.configure({ onShowContextMenu: onShowLinkContextMenu }))
} else {
extensions.push(LinkContextMenu)
}
// Add slash commands if enabled
if (showSlashCommands) {
extensions.push(slashcommand(SlashCommandList))
}
return extensions
}
// Extension presets for different editor variants
export const EDITOR_PRESETS = {
full: {
showSlashCommands: true,
includeAllExtensions: true
},
inline: {
showSlashCommands: true,
includeAllExtensions: true
},
minimal: {
showSlashCommands: false,
includeAllExtensions: false
}
}

View file

@ -40,6 +40,14 @@ export const GROUPS: Group[] = [
commands: [
...commands.media.commands,
...commands.table.commands,
{
iconName: 'MapPin',
name: 'geolocation-placeholder',
label: 'Location',
action: (editor: Editor) => {
editor.chain().focus().insertGeolocationPlaceholder().run()
}
},
{
iconName: 'Minus',
name: 'horizontalRule',

View file

@ -0,0 +1,297 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core'
import type { Media } from '@prisma/client'
import Image from 'lucide-svelte/icons/image'
import Upload from 'lucide-svelte/icons/upload'
import { NodeViewWrapper } from 'svelte-tiptap'
import { getContext, onMount } from 'svelte'
import { mediaSelectionStore } from '$lib/stores/media-selection'
const { editor, deleteNode }: NodeViewProps = $props()
// Get album context if available
const editorContext = getContext<any>('editorContext') || {}
const albumId = $derived(editorContext.albumId)
let fileInput: HTMLInputElement
let isUploading = $state(false)
let autoOpenModal = $state(false)
// If configured to auto-open modal, do it on mount
onMount(() => {
// Check if we should auto-open from editor storage
if (editor.storage.imageModal?.placeholderPos !== undefined) {
autoOpenModal = true
// Modal is already open from the composer level
}
})
function handleBrowseLibrary(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
// Open modal through the store
mediaSelectionStore.open({
mode: 'single',
fileType: 'image',
albumId,
onSelect: handleMediaSelect,
onClose: handleMediaLibraryClose
})
}
function handleDirectUpload(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
fileInput.click()
}
function handleMediaSelect(media: Media | Media[]) {
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 store
mediaSelectionStore.close()
// Delete the placeholder node
deleteNode()
}
function handleMediaLibraryClose() {
// Close the store
mediaSelectionStore.close()
// Delete placeholder if user cancelled
deleteNode()
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
const file = files[0]
if (!file.type.startsWith('image/')) {
alert('Please select an image file.')
return
}
// 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
}
isUploading = true
try {
const formData = new FormData()
formData.append('file', file)
formData.append('type', 'image')
// If we have an albumId, add it to the upload
if (albumId) {
formData.append('albumId', albumId.toString())
}
// Add auth header if needed
const auth = localStorage.getItem('admin_auth')
const headers: Record<string, string> = {}
if (auth) {
headers.Authorization = `Basic ${auth}`
}
const response = await fetch('/api/media/upload', {
method: 'POST',
headers,
body: formData
})
if (response.ok) {
const media = await response.json()
// Set a reasonable default width (max 600px)
const displayWidth = media.width && media.width > 600 ? 600 : media.width
editor
.chain()
.focus()
.setImage({
src: media.url,
alt: media.altText || '',
title: media.description || '',
width: displayWidth,
height: media.height,
align: 'center'
})
.run()
} else {
console.error('Failed to upload image:', response.status)
alert('Failed to upload image. Please try again.')
}
} catch (error) {
console.error('Error uploading image:', error)
alert('Failed to upload image. Please try again.')
} finally {
isUploading = false
// Clear the input
input.value = ''
}
}
// Handle keyboard navigation
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleBrowseLibrary(e as any)
} else if (e.key === 'Escape') {
deleteNode()
}
}
</script>
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
<div class="edra-media-placeholder-container">
{#if isUploading}
<div class="edra-media-placeholder-uploading">
<div class="spinner"></div>
<span>Uploading...</span>
</div>
{:else if !autoOpenModal}
<button
class="edra-media-placeholder-option"
onclick={handleDirectUpload}
onkeydown={handleKeyDown}
tabindex="0"
aria-label="Upload Image"
title="Upload from device"
>
<Upload class="edra-media-placeholder-icon" />
<span class="edra-media-placeholder-text">Upload Image</span>
</button>
<button
class="edra-media-placeholder-option"
onclick={handleBrowseLibrary}
onkeydown={handleKeyDown}
tabindex="0"
aria-label="Browse Media Library"
title="Choose from library"
>
<Image class="edra-media-placeholder-icon" />
<span class="edra-media-placeholder-text">Browse Library</span>
</button>
{/if}
</div>
<!-- Hidden file input -->
<input
bind:this={fileInput}
type="file"
accept="image/*"
onchange={handleFileUpload}
style="display: none;"
/>
</NodeViewWrapper>
<style>
.edra-media-placeholder-container {
display: flex;
gap: 12px;
padding: 24px;
border: 2px dashed #e5e7eb;
border-radius: 8px;
background: #f9fafb;
transition: all 0.2s ease;
justify-content: center;
align-items: center;
min-height: 80px;
}
.edra-media-placeholder-container:hover {
border-color: #d1d5db;
background: #f3f4f6;
}
.edra-media-placeholder-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 20px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
}
.edra-media-placeholder-option:hover {
border-color: #d1d5db;
background: #f9fafb;
transform: translateY(-1px);
}
.edra-media-placeholder-option:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.edra-media-placeholder-uploading {
display: flex;
align-items: center;
gap: 8px;
padding: 20px;
color: #6b7280;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #f3f4f6;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
:global(.edra-media-placeholder-icon) {
width: 28px;
height: 28px;
color: #6b7280;
}
.edra-media-placeholder-text {
font-size: 14px;
color: #6b7280;
font-weight: 500;
}
</style>

View file

@ -3,45 +3,16 @@
import { onMount } from 'svelte'
import { initiateEditor } from '../editor.js'
import { getEditorExtensions } from '../editor-extensions.js'
import './style.css'
import 'katex/dist/katex.min.css'
// Lowlight
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
import { all, createLowlight } from 'lowlight'
import '../editor.css'
import '../onedark.css'
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
import CodeExtended from './components/CodeExtended.svelte'
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder.js'
import AudioPlaceholderComponent from './components/AudioPlaceholder.svelte'
import AudioExtendedComponent from './components/AudioExtended.svelte'
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder.js'
import ImagePlaceholderComponent from './components/ImagePlaceholder.svelte'
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder.js'
import VideoPlaceholderComponent from './components/VideoPlaceholder.svelte'
import { ImageExtended } from '../extensions/image/ImageExtended.js'
import ImageExtendedComponent from './components/ImageExtended.svelte'
import VideoExtendedComponent from './components/VideoExtended.svelte'
import { VideoExtended } from '../extensions/video/VideoExtended.js'
import { AudioExtended } from '../extensions/audio/AudiExtended.js'
import { GalleryPlaceholder } from '../extensions/gallery/GalleryPlaceholder.js'
import GalleryPlaceholderComponent from './components/GalleryPlaceholder.svelte'
import { GalleryExtended } from '../extensions/gallery/GalleryExtended.js'
import GalleryExtendedComponent from './components/GalleryExtended.svelte'
import LinkMenu from './menus/link-menu.svelte'
import TableRowMenu from './menus/table/table-row-menu.svelte'
import TableColMenu from './menus/table/table-col-menu.svelte'
import slashcommand from '../extensions/slash-command/slashcommand.js'
import SlashCommandList from './components/SlashCommandList.svelte'
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
import { focusEditor, type EdraProps } from '../utils.js'
import IFramePlaceholderComponent from './components/IFramePlaceholder.svelte'
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder.js'
import { IFrameExtended } from '../extensions/iframe/IFrameExtended.js'
import IFrameExtendedComponent from './components/IFrameExtended.svelte'
const lowlight = createLowlight(all)
let {
class: className = '',
@ -60,30 +31,13 @@
let element = $state<HTMLElement>()
onMount(() => {
const extensions = getEditorExtensions({ showSlashCommands })
editor = initiateEditor(
element,
content,
limit,
[
CodeBlockLowlight.configure({
lowlight
}).extend({
addNodeView() {
return SvelteNodeViewRenderer(CodeExtended)
}
}),
AudioPlaceholder(AudioPlaceholderComponent),
ImagePlaceholder(ImagePlaceholderComponent),
GalleryPlaceholder(GalleryPlaceholderComponent),
IFramePlaceholder(IFramePlaceholderComponent),
IFrameExtended(IFrameExtendedComponent),
VideoPlaceholder(VideoPlaceholderComponent),
AudioExtended(AudioExtendedComponent),
ImageExtended(ImageExtendedComponent),
GalleryExtended(GalleryExtendedComponent),
VideoExtended(VideoExtendedComponent),
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
],
extensions,
{
editable,
onUpdate,