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:
parent
003e08836e
commit
6604032643
9 changed files with 902 additions and 1865 deletions
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,52 +1,15 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type Editor } from '@tiptap/core'
|
import { type Editor } from '@tiptap/core'
|
||||||
import { onMount } from 'svelte'
|
import { onMount, setContext } from 'svelte'
|
||||||
import { initiateEditor } from '$lib/components/edra/editor.js'
|
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 { EdraToolbar, EdraBubbleMenu } from '$lib/components/edra/headless/index.js'
|
||||||
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
|
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
|
||||||
import { focusEditor, type EdraProps } from '$lib/components/edra/utils.js'
|
import { focusEditor, type EdraProps } from '$lib/components/edra/utils.js'
|
||||||
import EdraToolBarIcon from '$lib/components/edra/headless/components/EdraToolBarIcon.svelte'
|
import EdraToolBarIcon from '$lib/components/edra/headless/components/EdraToolBarIcon.svelte'
|
||||||
import { commands } from '$lib/components/edra/commands/commands.js'
|
import { commands } from '$lib/components/edra/commands/commands.js'
|
||||||
|
import EnhancedImagePlaceholder from '$lib/components/edra/headless/components/EnhancedImagePlaceholder.svelte'
|
||||||
// Import all the same components as Edra
|
import type { JSONContent } from '@tiptap/core'
|
||||||
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
|
|
||||||
import { all, createLowlight } from 'lowlight'
|
|
||||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
|
||||||
import CodeExtended from '$lib/components/edra/headless/components/CodeExtended.svelte'
|
|
||||||
import { AudioPlaceholder } from '$lib/components/edra/extensions/audio/AudioPlaceholder.js'
|
|
||||||
import AudioPlaceholderComponent from '$lib/components/edra/headless/components/AudioPlaceholder.svelte'
|
|
||||||
import AudioExtendedComponent from '$lib/components/edra/headless/components/AudioExtended.svelte'
|
|
||||||
import { ImagePlaceholder } from '$lib/components/edra/extensions/image/ImagePlaceholder.js'
|
|
||||||
import ImageUploadPlaceholder from './ImageUploadPlaceholder.svelte' // Our custom component
|
|
||||||
import { VideoPlaceholder } from '$lib/components/edra/extensions/video/VideoPlaceholder.js'
|
|
||||||
import VideoPlaceholderComponent from '$lib/components/edra/headless/components/VideoPlaceholder.svelte'
|
|
||||||
import { ImageExtended } from '$lib/components/edra/extensions/image/ImageExtended.js'
|
|
||||||
import ImageExtendedComponent from '$lib/components/edra/headless/components/ImageExtended.svelte'
|
|
||||||
import VideoExtendedComponent from '$lib/components/edra/headless/components/VideoExtended.svelte'
|
|
||||||
import { VideoExtended } from '$lib/components/edra/extensions/video/VideoExtended.js'
|
|
||||||
import { AudioExtended } from '$lib/components/edra/extensions/audio/AudiExtended.js'
|
|
||||||
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 slashcommand from '$lib/components/edra/extensions/slash-command/slashcommand.js'
|
|
||||||
import SlashCommandList from '$lib/components/edra/headless/components/SlashCommandList.svelte'
|
|
||||||
import IFramePlaceholderComponent from '$lib/components/edra/headless/components/IFramePlaceholder.svelte'
|
|
||||||
import { IFramePlaceholder } from '$lib/components/edra/extensions/iframe/IFramePlaceholder.js'
|
|
||||||
import { IFrameExtended } from '$lib/components/edra/extensions/iframe/IFrameExtended.js'
|
|
||||||
import IFrameExtendedComponent from '$lib/components/edra/headless/components/IFrameExtended.svelte'
|
|
||||||
import { GalleryPlaceholder } from '$lib/components/edra/extensions/gallery/GalleryPlaceholder.js'
|
|
||||||
import GalleryPlaceholderComponent from '$lib/components/edra/headless/components/GalleryPlaceholder.svelte'
|
|
||||||
import { GalleryExtended } from '$lib/components/edra/extensions/gallery/GalleryExtended.js'
|
|
||||||
import GalleryExtendedComponent from '$lib/components/edra/headless/components/GalleryExtended.svelte'
|
|
||||||
import { UrlEmbed } from '$lib/components/edra/extensions/url-embed/UrlEmbed.js'
|
|
||||||
import { UrlEmbedPlaceholder } from '$lib/components/edra/extensions/url-embed/UrlEmbedPlaceholder.js'
|
|
||||||
import UrlEmbedPlaceholderComponent from '$lib/components/edra/headless/components/UrlEmbedPlaceholder.svelte'
|
|
||||||
import { UrlEmbedExtended } from '$lib/components/edra/extensions/url-embed/UrlEmbedExtended.js'
|
|
||||||
import UrlEmbedExtendedComponent from '$lib/components/edra/headless/components/UrlEmbedExtended.svelte'
|
|
||||||
import { LinkContextMenu } from '$lib/components/edra/extensions/link-context-menu/LinkContextMenu.js'
|
|
||||||
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 Edra styles
|
// Import Edra styles
|
||||||
import '$lib/components/edra/headless/style.css'
|
import '$lib/components/edra/headless/style.css'
|
||||||
|
|
@ -54,29 +17,82 @@
|
||||||
import '$lib/components/edra/editor.css'
|
import '$lib/components/edra/editor.css'
|
||||||
import '$lib/components/edra/onedark.css'
|
import '$lib/components/edra/onedark.css'
|
||||||
|
|
||||||
const lowlight = createLowlight(all)
|
// 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 {
|
let {
|
||||||
class: className = '',
|
variant = 'full',
|
||||||
content = undefined,
|
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,
|
editable = true,
|
||||||
limit = undefined,
|
class: className = '',
|
||||||
editor = $bindable<Editor | undefined>(),
|
showToolbar = variant === 'full',
|
||||||
showSlashCommands = true,
|
showSlashCommands = variant !== 'minimal',
|
||||||
showLinkBubbleMenu = true,
|
albumId,
|
||||||
showTableBubbleMenu = true,
|
features = {
|
||||||
onUpdate,
|
imageUpload: true,
|
||||||
showToolbar = true,
|
mediaLibrary: true,
|
||||||
placeholder = 'Type "/" for commands...',
|
urlEmbed: true,
|
||||||
onEditorReady
|
tables: true,
|
||||||
}: EdraProps & {
|
codeBlocks: true
|
||||||
showToolbar?: boolean
|
}
|
||||||
placeholder?: string
|
}: Props = $props()
|
||||||
onEditorReady?: (editor: Editor) => void
|
|
||||||
} = $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 element = $state<HTMLElement>()
|
||||||
let isLoading = $state(true)
|
let isLoading = $state(true)
|
||||||
|
let initialized = false
|
||||||
|
const mediaSelectionState = $derived($mediaSelectionStore)
|
||||||
|
|
||||||
|
// Dropdown states
|
||||||
let showTextStyleDropdown = $state(false)
|
let showTextStyleDropdown = $state(false)
|
||||||
let showMediaDropdown = $state(false)
|
let showMediaDropdown = $state(false)
|
||||||
let dropdownTriggerRef = $state<HTMLElement>()
|
let dropdownTriggerRef = $state<HTMLElement>()
|
||||||
|
|
@ -101,19 +117,37 @@
|
||||||
let linkEditUrl = $state<string>('')
|
let linkEditUrl = $state<string>('')
|
||||||
let linkEditPos = $state<number | null>(null)
|
let linkEditPos = $state<number | null>(null)
|
||||||
|
|
||||||
// Filter out unwanted commands
|
// Get filtered commands based on variant and features
|
||||||
const getFilteredCommands = () => {
|
const getFilteredCommands = () => {
|
||||||
const filtered = { ...commands }
|
const filtered = { ...commands }
|
||||||
|
|
||||||
// Remove these groups entirely
|
// 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['undo-redo']
|
||||||
delete filtered['headings'] // In text style dropdown
|
delete filtered['headings'] // In text style dropdown
|
||||||
delete filtered['lists'] // In text style dropdown
|
delete filtered['lists'] // In text style dropdown
|
||||||
delete filtered['alignment'] // Not needed
|
delete filtered['alignment']
|
||||||
delete filtered['table'] // Not needed
|
delete filtered['table']
|
||||||
delete filtered['media'] // Will be in media dropdown
|
delete filtered['media'] // In media dropdown
|
||||||
|
}
|
||||||
|
|
||||||
// Reorganize text-formatting commands
|
// Reorganize text formatting for toolbar
|
||||||
if (filtered['text-formatting']) {
|
if (filtered['text-formatting']) {
|
||||||
const allCommands = filtered['text-formatting'].commands
|
const allCommands = filtered['text-formatting'].commands
|
||||||
const basicFormatting = []
|
const basicFormatting = []
|
||||||
|
|
@ -153,17 +187,22 @@
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get media commands, but filter out iframe
|
// Get media commands, but filter out based on features
|
||||||
const getMediaCommands = () => {
|
const getMediaCommands = () => {
|
||||||
if (commands.media) {
|
if (!commands.media) return []
|
||||||
return commands.media.commands.filter((cmd) => cmd.name !== 'iframe-placeholder')
|
|
||||||
|
let mediaCommands = [...commands.media.commands]
|
||||||
|
|
||||||
|
// Filter based on features
|
||||||
|
if (!features.urlEmbed) {
|
||||||
|
mediaCommands = mediaCommands.filter((cmd) => cmd.name !== 'iframe-placeholder')
|
||||||
}
|
}
|
||||||
return []
|
|
||||||
|
return mediaCommands
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredCommands = getFilteredCommands()
|
const filteredCommands = getFilteredCommands()
|
||||||
const colorCommands = commands.colors.commands
|
const colorCommands = commands.colors?.commands || []
|
||||||
const fontCommands = commands.fonts.commands
|
|
||||||
const excludedCommands = ['colors', 'fonts']
|
const excludedCommands = ['colors', 'fonts']
|
||||||
|
|
||||||
// Get current text style for dropdown
|
// Get current text style for dropdown
|
||||||
|
|
@ -179,7 +218,7 @@
|
||||||
return 'Paragraph'
|
return 'Paragraph'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derived state for current text style to avoid reactive mutations
|
// Derived state for current text style
|
||||||
let currentTextStyle = $derived(editor ? getCurrentTextStyle(editor) : 'Paragraph')
|
let currentTextStyle = $derived(editor ? getCurrentTextStyle(editor) : 'Paragraph')
|
||||||
|
|
||||||
// Calculate dropdown position
|
// Calculate dropdown position
|
||||||
|
|
@ -240,42 +279,37 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle URL convert dropdown
|
// URL convert handlers
|
||||||
const handleShowUrlConvertDropdown = (pos: number, url: string) => {
|
const handleShowUrlConvertDropdown = (pos: number, url: string) => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
// Get the cursor coordinates
|
|
||||||
const coords = editor.view.coordsAtPos(pos)
|
const coords = editor.view.coordsAtPos(pos)
|
||||||
urlConvertDropdownPosition = { x: coords.left, y: coords.bottom + 5 }
|
urlConvertDropdownPosition = { x: coords.left, y: coords.bottom + 5 }
|
||||||
urlConvertPos = pos
|
urlConvertPos = pos
|
||||||
showUrlConvertDropdown = true
|
showUrlConvertDropdown = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle link context menu
|
const handleConvertToEmbed = () => {
|
||||||
|
if (!editor || urlConvertPos === null) return
|
||||||
|
editor.commands.convertLinkToEmbed(urlConvertPos)
|
||||||
|
showUrlConvertDropdown = false
|
||||||
|
urlConvertPos = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link context menu handlers
|
||||||
const handleShowLinkContextMenu = (
|
const handleShowLinkContextMenu = (
|
||||||
pos: number,
|
pos: number,
|
||||||
url: string,
|
url: string,
|
||||||
coords: { x: number; y: number }
|
coords: { x: number; y: number }
|
||||||
) => {
|
) => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
linkContextMenuPosition = { x: coords.x, y: coords.y + 5 }
|
linkContextMenuPosition = { x: coords.x, y: coords.y + 5 }
|
||||||
linkContextUrl = url
|
linkContextUrl = url
|
||||||
linkContextPos = pos
|
linkContextPos = pos
|
||||||
showLinkContextMenu = true
|
showLinkContextMenu = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConvertToEmbed = () => {
|
|
||||||
if (!editor || urlConvertPos === null) return
|
|
||||||
|
|
||||||
editor.commands.convertLinkToEmbed(urlConvertPos)
|
|
||||||
showUrlConvertDropdown = false
|
|
||||||
urlConvertPos = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConvertLinkToEmbed = () => {
|
const handleConvertLinkToEmbed = () => {
|
||||||
if (!editor || linkContextPos === null) return
|
if (!editor || linkContextPos === null) return
|
||||||
|
|
||||||
editor.commands.convertLinkToEmbed(linkContextPos)
|
editor.commands.convertLinkToEmbed(linkContextPos)
|
||||||
showLinkContextMenu = false
|
showLinkContextMenu = false
|
||||||
linkContextPos = null
|
linkContextPos = null
|
||||||
|
|
@ -284,7 +318,6 @@
|
||||||
|
|
||||||
const handleEditLink = () => {
|
const handleEditLink = () => {
|
||||||
if (!editor || !linkContextUrl) return
|
if (!editor || !linkContextUrl) return
|
||||||
|
|
||||||
linkEditUrl = linkContextUrl
|
linkEditUrl = linkContextUrl
|
||||||
linkEditPos = linkContextPos
|
linkEditPos = linkContextPos
|
||||||
linkEditDialogPosition = { ...linkContextMenuPosition }
|
linkEditDialogPosition = { ...linkContextMenuPosition }
|
||||||
|
|
@ -294,7 +327,6 @@
|
||||||
|
|
||||||
const handleSaveLink = (newUrl: string) => {
|
const handleSaveLink = (newUrl: string) => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
editor.chain().focus().extendMarkRange('link').setLink({ href: newUrl }).run()
|
editor.chain().focus().extendMarkRange('link').setLink({ href: newUrl }).run()
|
||||||
showLinkEditDialog = false
|
showLinkEditDialog = false
|
||||||
linkEditPos = null
|
linkEditPos = null
|
||||||
|
|
@ -303,7 +335,6 @@
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
if (!linkContextUrl) return
|
if (!linkContextUrl) return
|
||||||
|
|
||||||
navigator.clipboard.writeText(linkContextUrl)
|
navigator.clipboard.writeText(linkContextUrl)
|
||||||
showLinkContextMenu = false
|
showLinkContextMenu = false
|
||||||
linkContextPos = null
|
linkContextPos = null
|
||||||
|
|
@ -312,7 +343,6 @@
|
||||||
|
|
||||||
const handleRemoveLink = () => {
|
const handleRemoveLink = () => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
showLinkContextMenu = false
|
showLinkContextMenu = false
|
||||||
linkContextPos = null
|
linkContextPos = null
|
||||||
|
|
@ -321,29 +351,67 @@
|
||||||
|
|
||||||
const handleOpenLink = () => {
|
const handleOpenLink = () => {
|
||||||
if (!linkContextUrl) return
|
if (!linkContextUrl) return
|
||||||
|
|
||||||
window.open(linkContextUrl, '_blank', 'noopener,noreferrer')
|
window.open(linkContextUrl, '_blank', 'noopener,noreferrer')
|
||||||
showLinkContextMenu = false
|
showLinkContextMenu = false
|
||||||
linkContextPos = null
|
linkContextPos = null
|
||||||
linkContextUrl = null
|
linkContextUrl = null
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
// Handle media selection from the global store
|
||||||
if (
|
function handleGlobalMediaSelect(media: Media | Media[]) {
|
||||||
showTextStyleDropdown ||
|
if (!editor) return
|
||||||
showMediaDropdown ||
|
|
||||||
showUrlConvertDropdown ||
|
|
||||||
showLinkContextMenu ||
|
|
||||||
showLinkEditDialog
|
|
||||||
) {
|
|
||||||
document.addEventListener('click', handleClickOutside)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('click', handleClickOutside)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Custom paste handler for both images and text
|
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) {
|
function handlePaste(view: any, event: ClipboardEvent) {
|
||||||
const clipboardData = event.clipboardData
|
const clipboardData = event.clipboardData
|
||||||
if (!clipboardData) return false
|
if (!clipboardData) return false
|
||||||
|
|
@ -352,7 +420,7 @@
|
||||||
const imageItem = Array.from(clipboardData.items).find(
|
const imageItem = Array.from(clipboardData.items).find(
|
||||||
(item) => item.type.indexOf('image') === 0
|
(item) => item.type.indexOf('image') === 0
|
||||||
)
|
)
|
||||||
if (imageItem) {
|
if (imageItem && features.imageUpload) {
|
||||||
const file = imageItem.getAsFile()
|
const file = imageItem.getAsFile()
|
||||||
if (!file) return false
|
if (!file) return false
|
||||||
|
|
||||||
|
|
@ -375,33 +443,30 @@
|
||||||
if (htmlData && plainText) {
|
if (htmlData && plainText) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
// Use editor commands to insert HTML content, but let Tiptap handle the parsing
|
// Use editor commands to insert HTML content
|
||||||
// This will preserve links while stripping unwanted formatting based on editor config
|
|
||||||
const editorInstance = (view as any).editor
|
const editorInstance = (view as any).editor
|
||||||
if (editorInstance) {
|
if (editorInstance) {
|
||||||
// Use pasteHTML to let Tiptap process the HTML and apply configured extensions
|
|
||||||
editorInstance
|
editorInstance
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.insertContent(htmlData, { parseOptions: { preserveWhitespace: false } })
|
.insertContent(htmlData, { parseOptions: { preserveWhitespace: false } })
|
||||||
.run()
|
.run()
|
||||||
} else {
|
} else {
|
||||||
// Fallback to plain text if editor instance not available
|
// Fallback to plain text
|
||||||
const { state, dispatch } = view
|
const { state, dispatch } = view
|
||||||
const { selection } = state
|
const { selection } = state
|
||||||
const transaction = state.tr.insertText(plainText, selection.from, selection.to)
|
const transaction = state.tr.insertText(plainText, selection.from, selection.to)
|
||||||
dispatch(transaction)
|
dispatch(transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true // Prevent default paste behavior
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let default handling take care of plain text only
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadImage(file: File) {
|
async function uploadImage(file: File) {
|
||||||
if (!editor) return
|
if (!editor || !features.imageUpload) return
|
||||||
|
|
||||||
// Create a placeholder while uploading
|
// Create a placeholder while uploading
|
||||||
const placeholderSrc = URL.createObjectURL(file)
|
const placeholderSrc = URL.createObjectURL(file)
|
||||||
|
|
@ -416,6 +481,11 @@
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|
||||||
|
// Add albumId if available
|
||||||
|
if (albumId) {
|
||||||
|
formData.append('albumId', albumId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/media/upload', {
|
const response = await fetch('/api/media/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -431,7 +501,6 @@
|
||||||
const media = await response.json()
|
const media = await response.json()
|
||||||
|
|
||||||
// Replace placeholder with actual URL
|
// Replace placeholder with actual URL
|
||||||
// Set a reasonable default width (max 600px)
|
|
||||||
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
||||||
|
|
||||||
editor.commands.insertContent({
|
editor.commands.insertContent({
|
||||||
|
|
@ -455,47 +524,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
// Update content when editor changes
|
||||||
const newEditor = initiateEditor(
|
function handleUpdate({ editor: updatedEditor, transaction }: any) {
|
||||||
element,
|
// Skip the first update to avoid circular updates
|
||||||
content,
|
if (!initialized) {
|
||||||
limit,
|
initialized = true
|
||||||
[
|
return
|
||||||
CodeBlockLowlight.configure({
|
|
||||||
lowlight
|
|
||||||
}).extend({
|
|
||||||
addNodeView() {
|
|
||||||
return SvelteNodeViewRenderer(CodeExtended)
|
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
AudioPlaceholder(AudioPlaceholderComponent),
|
|
||||||
ImagePlaceholder(ImageUploadPlaceholder), // Use our custom component
|
|
||||||
GalleryPlaceholder(GalleryPlaceholderComponent),
|
|
||||||
IFramePlaceholder(IFramePlaceholderComponent),
|
|
||||||
IFrameExtended(IFrameExtendedComponent),
|
|
||||||
VideoPlaceholder(VideoPlaceholderComponent),
|
|
||||||
AudioExtended(AudioExtendedComponent),
|
|
||||||
ImageExtended(ImageExtendedComponent),
|
|
||||||
GalleryExtended(GalleryExtendedComponent),
|
|
||||||
VideoExtended(VideoExtendedComponent),
|
|
||||||
UrlEmbed.configure({
|
|
||||||
onShowDropdown: handleShowUrlConvertDropdown
|
|
||||||
}),
|
|
||||||
UrlEmbedPlaceholder(UrlEmbedPlaceholderComponent),
|
|
||||||
UrlEmbedExtended(UrlEmbedExtendedComponent),
|
|
||||||
LinkContextMenu.configure({
|
|
||||||
onShowContextMenu: handleShowLinkContextMenu
|
|
||||||
}),
|
|
||||||
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
|
|
||||||
],
|
|
||||||
{
|
|
||||||
editable,
|
|
||||||
onUpdate: ({ editor: updatedEditor, transaction }) => {
|
|
||||||
// Dismiss URL convert dropdown if user types
|
// Dismiss URL convert dropdown if user types
|
||||||
if (showUrlConvertDropdown && transaction.docChanged) {
|
if (showUrlConvertDropdown && transaction.docChanged) {
|
||||||
// Check if the change is actual typing (not just cursor movement)
|
|
||||||
const hasTextChange = transaction.steps.some(
|
const hasTextChange = transaction.steps.some(
|
||||||
(step) =>
|
(step: any) =>
|
||||||
step.toJSON().stepType === 'replace' || step.toJSON().stepType === 'replaceAround'
|
step.toJSON().stepType === 'replace' || step.toJSON().stepType === 'replaceAround'
|
||||||
)
|
)
|
||||||
if (hasTextChange) {
|
if (hasTextChange) {
|
||||||
|
|
@ -504,43 +544,105 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the original onUpdate if provided
|
const json = updatedEditor.getJSON()
|
||||||
if (onUpdate) {
|
data = json
|
||||||
onUpdate({ editor: updatedEditor, transaction })
|
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: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class: 'prose prose-sm max-w-none focus:outline-none'
|
class: 'prose prose-sm max-w-none focus:outline-none'
|
||||||
},
|
},
|
||||||
handlePaste: handlePaste
|
handlePaste: features.imageUpload ? handlePaste : undefined
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
placeholder
|
placeholder
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Initialize storage for image modal
|
||||||
|
newEditor.storage.imageModal = { autoOpen: false }
|
||||||
|
|
||||||
editor = newEditor
|
editor = newEditor
|
||||||
|
|
||||||
// Notify parent component that editor is ready
|
// Auto-focus if requested
|
||||||
if (onEditorReady) {
|
if (autofocus) {
|
||||||
onEditorReady(newEditor)
|
setTimeout(() => {
|
||||||
}
|
newEditor.commands.focus()
|
||||||
|
}, 100)
|
||||||
// Add placeholder
|
|
||||||
if (placeholder && editor) {
|
|
||||||
editor.extensionManager.extensions
|
|
||||||
.find((ext) => ext.name === 'placeholder')
|
|
||||||
?.configure({
|
|
||||||
placeholder
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|
||||||
return () => editor?.destroy()
|
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>
|
</script>
|
||||||
|
|
||||||
<div class={`edra ${className}`}>
|
<div class={`composer composer--${variant} ${className}`}>
|
||||||
{#if showToolbar && editor && !isLoading}
|
{#if showToolbar && editor && !isLoading}
|
||||||
<div class="editor-toolbar">
|
<div class="editor-toolbar">
|
||||||
<div class="edra-toolbar">
|
<div class="edra-toolbar">
|
||||||
|
|
@ -576,6 +678,7 @@
|
||||||
<span class="separator"></span>
|
<span class="separator"></span>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if features.mediaLibrary}
|
||||||
<!-- Media Dropdown -->
|
<!-- Media Dropdown -->
|
||||||
<div class="text-style-dropdown">
|
<div class="text-style-dropdown">
|
||||||
<button
|
<button
|
||||||
|
|
@ -603,7 +706,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="separator"></span>
|
<span class="separator"></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if colorCommands.length > 0}
|
||||||
<EdraToolBarIcon
|
<EdraToolBarIcon
|
||||||
command={colorCommands[0]}
|
command={colorCommands[0]}
|
||||||
{editor}
|
{editor}
|
||||||
|
|
@ -637,23 +742,25 @@
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if editor}
|
{#if editor}
|
||||||
{#if false && showLinkBubbleMenu}
|
|
||||||
<LinkMenu {editor} />
|
<LinkMenu {editor} />
|
||||||
{/if}
|
{#if features.tables}
|
||||||
{#if showTableBubbleMenu}
|
|
||||||
<TableRowMenu {editor} />
|
<TableRowMenu {editor} />
|
||||||
<TableColMenu {editor} />
|
<TableColMenu {editor} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !editor}
|
{#if !editor}
|
||||||
<div class="edra-loading">
|
<div class="edra-loading">
|
||||||
<LoaderCircle class="animate-spin" /> Loading...
|
<LoaderCircle class="animate-spin" /> Loading...
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
role="button"
|
role="button"
|
||||||
|
|
@ -666,11 +773,12 @@
|
||||||
}}
|
}}
|
||||||
class="edra-editor"
|
class="edra-editor"
|
||||||
class:with-toolbar={showToolbar}
|
class:with-toolbar={showToolbar}
|
||||||
|
style={`min-height: ${minHeight}px`}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Media Dropdown Portal -->
|
<!-- Media Dropdown Portal -->
|
||||||
{#if showMediaDropdown}
|
{#if showMediaDropdown && features.mediaLibrary}
|
||||||
<div
|
<div
|
||||||
class="media-dropdown-portal"
|
class="media-dropdown-portal"
|
||||||
style="position: fixed; top: {mediaDropdownPosition.top}px; left: {mediaDropdownPosition.left}px; z-index: 10000;"
|
style="position: fixed; top: {mediaDropdownPosition.top}px; left: {mediaDropdownPosition.left}px; z-index: 10000;"
|
||||||
|
|
@ -679,7 +787,25 @@
|
||||||
<button
|
<button
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
editor?.chain().focus().insertImagePlaceholder().run()
|
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
|
showMediaDropdown = false
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -713,6 +839,16 @@
|
||||||
Audio
|
Audio
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-separator"></div>
|
<div class="dropdown-separator"></div>
|
||||||
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
|
editor?.chain().focus().insertGeolocationPlaceholder().run()
|
||||||
|
showMediaDropdown = false
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Location
|
||||||
|
</button>
|
||||||
|
{#if features.urlEmbed}
|
||||||
<button
|
<button
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
|
@ -722,11 +858,12 @@
|
||||||
>
|
>
|
||||||
Link
|
Link
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Dropdown Menu Portal -->
|
<!-- Text Style Dropdown Menu Portal -->
|
||||||
{#if showTextStyleDropdown}
|
{#if showTextStyleDropdown}
|
||||||
<div
|
<div
|
||||||
class="dropdown-menu-portal"
|
class="dropdown-menu-portal"
|
||||||
|
|
@ -798,6 +935,7 @@
|
||||||
>
|
>
|
||||||
Task List
|
Task List
|
||||||
</button>
|
</button>
|
||||||
|
{#if features.codeBlocks}
|
||||||
<div class="dropdown-separator"></div>
|
<div class="dropdown-separator"></div>
|
||||||
<button
|
<button
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
|
|
@ -808,6 +946,7 @@
|
||||||
>
|
>
|
||||||
Code Block
|
Code Block
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
|
@ -822,7 +961,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- URL Convert Dropdown -->
|
<!-- URL Convert Dropdown -->
|
||||||
{#if showUrlConvertDropdown}
|
{#if showUrlConvertDropdown && features.urlEmbed}
|
||||||
<UrlConvertDropdown
|
<UrlConvertDropdown
|
||||||
x={urlConvertDropdownPosition.x}
|
x={urlConvertDropdownPosition.x}
|
||||||
y={urlConvertDropdownPosition.y}
|
y={urlConvertDropdownPosition.y}
|
||||||
|
|
@ -840,7 +979,7 @@
|
||||||
x={linkContextMenuPosition.x}
|
x={linkContextMenuPosition.x}
|
||||||
y={linkContextMenuPosition.y}
|
y={linkContextMenuPosition.y}
|
||||||
url={linkContextUrl}
|
url={linkContextUrl}
|
||||||
onConvertToCard={handleConvertLinkToEmbed}
|
onConvertToCard={features.urlEmbed ? handleConvertLinkToEmbed : undefined}
|
||||||
onEditLink={handleEditLink}
|
onEditLink={handleEditLink}
|
||||||
onCopyLink={handleCopyLink}
|
onCopyLink={handleCopyLink}
|
||||||
onRemoveLink={handleRemoveLink}
|
onRemoveLink={handleRemoveLink}
|
||||||
|
|
@ -868,8 +1007,22 @@
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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">
|
<style lang="scss">
|
||||||
.edra {
|
@import '$styles/variables.scss';
|
||||||
|
|
||||||
|
.composer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -895,17 +1048,37 @@
|
||||||
background: rgba(255, 255, 255, 0.9);
|
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 {
|
.edra-editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
padding: 0 $unit-4x;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// .edra-editor.with-toolbar {
|
.composer--full .edra-editor :global(.ProseMirror) {
|
||||||
// padding-top: 52px; /* Account for sticky toolbar height */
|
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) {
|
:global(.ProseMirror) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -1017,4 +1190,26 @@
|
||||||
:global(.edra-toolbar svg) {
|
:global(.edra-toolbar svg) {
|
||||||
stroke-width: 2;
|
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>
|
</style>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -255,6 +255,8 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
name: 'image-placeholder',
|
name: 'image-placeholder',
|
||||||
label: 'Image',
|
label: 'Image',
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
|
// Set flag to auto-open modal and insert placeholder
|
||||||
|
editor.storage.imageModal = { autoOpen: true }
|
||||||
editor.chain().focus().insertImagePlaceholder().run()
|
editor.chain().focus().insertImagePlaceholder().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -281,6 +283,14 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().insertIFramePlaceholder().run()
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
src/lib/components/edra/editor-extensions.ts
Normal file
127
src/lib/components/edra/editor-extensions.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,14 @@ export const GROUPS: Group[] = [
|
||||||
commands: [
|
commands: [
|
||||||
...commands.media.commands,
|
...commands.media.commands,
|
||||||
...commands.table.commands,
|
...commands.table.commands,
|
||||||
|
{
|
||||||
|
iconName: 'MapPin',
|
||||||
|
name: 'geolocation-placeholder',
|
||||||
|
label: 'Location',
|
||||||
|
action: (editor: Editor) => {
|
||||||
|
editor.chain().focus().insertGeolocationPlaceholder().run()
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
iconName: 'Minus',
|
iconName: 'Minus',
|
||||||
name: 'horizontalRule',
|
name: 'horizontalRule',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -3,45 +3,16 @@
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
import { initiateEditor } from '../editor.js'
|
import { initiateEditor } from '../editor.js'
|
||||||
|
import { getEditorExtensions } from '../editor-extensions.js'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import 'katex/dist/katex.min.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 '../editor.css'
|
||||||
import '../onedark.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 LinkMenu from './menus/link-menu.svelte'
|
||||||
import TableRowMenu from './menus/table/table-row-menu.svelte'
|
import TableRowMenu from './menus/table/table-row-menu.svelte'
|
||||||
import TableColMenu from './menus/table/table-col-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 LoaderCircle from 'lucide-svelte/icons/loader-circle'
|
||||||
import { focusEditor, type EdraProps } from '../utils.js'
|
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 {
|
let {
|
||||||
class: className = '',
|
class: className = '',
|
||||||
|
|
@ -60,30 +31,13 @@
|
||||||
let element = $state<HTMLElement>()
|
let element = $state<HTMLElement>()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const extensions = getEditorExtensions({ showSlashCommands })
|
||||||
|
|
||||||
editor = initiateEditor(
|
editor = initiateEditor(
|
||||||
element,
|
element,
|
||||||
content,
|
content,
|
||||||
limit,
|
limit,
|
||||||
[
|
extensions,
|
||||||
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)] : [])
|
|
||||||
],
|
|
||||||
{
|
{
|
||||||
editable,
|
editable,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue