198 lines
5.9 KiB
Svelte
198 lines
5.9 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* element_editor_tiptap.svelte
|
|
* Zero-Dependency Rich Text Editor for Aether Platform.
|
|
* Uses native contenteditable to avoid TipTap/ProseMirror library conflicts.
|
|
* Styles aligned with existing .tiptap SCSS definitions.
|
|
*/
|
|
import { onMount, untrack } from 'svelte';
|
|
import { browser } from '$app/environment';
|
|
import {
|
|
AlignLeft,
|
|
Bold,
|
|
Code,
|
|
Italic,
|
|
List,
|
|
ListOrdered,
|
|
RemoveFormatting,
|
|
Type
|
|
} from '@lucide/svelte';
|
|
import { ae_util } from '$lib/ae_utils/ae_utils';
|
|
|
|
interface Props {
|
|
content?: string;
|
|
new_content?: string;
|
|
placeholder?: string;
|
|
readonly?: boolean;
|
|
auto_format?: boolean;
|
|
class_li?: string;
|
|
}
|
|
|
|
let {
|
|
content = $bindable(''),
|
|
new_content = $bindable(),
|
|
placeholder = 'Start writing...',
|
|
readonly = false,
|
|
auto_format = true,
|
|
class_li = ''
|
|
}: Props = $props();
|
|
|
|
let editor_element: HTMLDivElement | undefined = $state();
|
|
let is_focused = $state(false);
|
|
|
|
// Sync external content changes into the editor
|
|
$effect(() => {
|
|
if (editor_element && content !== editor_element.innerHTML) {
|
|
untrack(() => {
|
|
editor_element!.innerHTML = content || '';
|
|
});
|
|
}
|
|
});
|
|
|
|
function handle_input(e: Event) {
|
|
const html = (e.target as HTMLDivElement).innerHTML;
|
|
// Clean up empty state (browsers sometimes leave <br> or <p></p>)
|
|
const cleaned = html === '<br>' || html === '<p></p>' ? '' : html;
|
|
content = cleaned;
|
|
new_content = cleaned;
|
|
}
|
|
|
|
// Toolbar Actions using native execCommand
|
|
// (While deprecated, it remains the standard for zero-dep simple rich text)
|
|
function exec(command: string, value: string | undefined = undefined) {
|
|
if (!browser || readonly) return;
|
|
document.execCommand(command, false, value);
|
|
editor_element?.focus();
|
|
}
|
|
|
|
function handle_keydown(e: KeyboardEvent) {
|
|
// Basic shortcuts: Cmd/Ctrl + B, I
|
|
if (e.metaKey || e.ctrlKey) {
|
|
if (e.key === 'b') {
|
|
e.preventDefault();
|
|
exec('bold');
|
|
}
|
|
if (e.key === 'i') {
|
|
e.preventDefault();
|
|
exec('italic');
|
|
}
|
|
}
|
|
}
|
|
|
|
function handle_format() {
|
|
if (!content) return;
|
|
const formatted = ae_util.format_html(content);
|
|
content = formatted;
|
|
new_content = formatted;
|
|
}
|
|
|
|
function handle_blur() {
|
|
is_focused = false;
|
|
if (auto_format) {
|
|
handle_format();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="ae__comp__editor_tiptap border-surface-500/20 rounded-container flex flex-col overflow-hidden border bg-white dark:bg-black/10 {class_li}">
|
|
{#if !readonly}
|
|
<div
|
|
class="toolbar bg-surface-50 dark:bg-surface-900 border-surface-500/20 flex flex-wrap gap-1 border-b p-1">
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm variant-soft hover:variant-filled-primary"
|
|
onclick={() => exec('bold')}
|
|
title="Bold">
|
|
<Bold size="14" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm variant-soft hover:variant-filled-primary"
|
|
onclick={() => exec('italic')}
|
|
title="Italic">
|
|
<Italic size="14" />
|
|
</button>
|
|
<div class="bg-surface-500/20 mx-1 h-4 w-px self-center"></div>
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm variant-soft hover:variant-filled-primary"
|
|
onclick={() => exec('insertUnorderedList')}
|
|
title="Bullet List">
|
|
<List size="14" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm variant-soft hover:variant-filled-primary"
|
|
onclick={() => exec('insertOrderedList')}
|
|
title="Numbered List">
|
|
<ListOrdered size="14" />
|
|
</button>
|
|
<div class="bg-surface-500/20 mx-1 h-4 w-px self-center"></div>
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm variant-soft hover:variant-filled-error"
|
|
onclick={() => exec('removeFormat')}
|
|
title="Clear Formatting">
|
|
<RemoveFormatting size="14" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm variant-soft hover:variant-filled-success"
|
|
onclick={handle_format}
|
|
title="Format HTML Source">
|
|
<AlignLeft size="14" />
|
|
<span class="ml-1 text-[10px]">Format</span>
|
|
</button>
|
|
|
|
<div class="ml-auto flex items-center gap-1 px-2">
|
|
<Type size="12" class="opacity-30" />
|
|
<span class="text-[10px] font-bold uppercase opacity-50"
|
|
>Visual</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="relative h-full min-h-[150px] grow overflow-auto p-4">
|
|
{#if !content && !is_focused}
|
|
<div
|
|
class="pointer-events-none absolute top-4 left-4 text-sm italic opacity-30">
|
|
{placeholder}
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
bind:this={editor_element}
|
|
contenteditable={!readonly}
|
|
class="tiptap prose dark:prose-invert h-full w-full max-w-none outline-none"
|
|
oninput={handle_input}
|
|
onfocus={() => (is_focused = true)}
|
|
onblur={handle_blur}
|
|
onkeydown={handle_keydown}
|
|
role="textbox"
|
|
tabindex="0"
|
|
aria-multiline="true">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style lang="postcss">
|
|
/* Import your existing TipTap styles */
|
|
@import './styles/element_tiptap_editor.scss';
|
|
|
|
.ae__comp__editor_tiptap :global(.tiptap) {
|
|
min-height: 120px;
|
|
}
|
|
|
|
/* Ensure lists look correct inside the editor */
|
|
.ae__comp__editor_tiptap :global(.tiptap ul) {
|
|
list-style-type: disc;
|
|
padding-left: 1.5rem;
|
|
}
|
|
.ae__comp__editor_tiptap :global(.tiptap ol) {
|
|
list-style-type: decimal;
|
|
padding-left: 1.5rem;
|
|
}
|
|
</style>
|