feat: add Zero-Dependency AE_Comp_Editor_TipTap with auto-formatting support
This commit is contained in:
@@ -16,6 +16,7 @@ import { to_title_case } from './ae_utils__to_title_case';
|
|||||||
import { process_data_string } from './ae_utils__process_data_string';
|
import { process_data_string } from './ae_utils__process_data_string';
|
||||||
import { set_obj_prop_display_name } from './ae_utils__set_obj_prop_display_name';
|
import { set_obj_prop_display_name } from './ae_utils__set_obj_prop_display_name';
|
||||||
import { return_obj_type_path } from './ae_utils__return_obj_type_path';
|
import { return_obj_type_path } from './ae_utils__return_obj_type_path';
|
||||||
|
import { format_html } from './ae_utils__format_html';
|
||||||
import {
|
import {
|
||||||
combine_iv_and_base64,
|
combine_iv_and_base64,
|
||||||
encrypt_content,
|
encrypt_content,
|
||||||
@@ -336,6 +337,7 @@ export const ae_util = {
|
|||||||
shorten_string: shorten_string,
|
shorten_string: shorten_string,
|
||||||
shorten_filename: shorten_filename,
|
shorten_filename: shorten_filename,
|
||||||
file_extension_icon: file_extension_icon,
|
file_extension_icon: file_extension_icon,
|
||||||
|
format_html: format_html,
|
||||||
set_obj_prop_display_name: set_obj_prop_display_name,
|
set_obj_prop_display_name: set_obj_prop_display_name,
|
||||||
return_obj_type_path: return_obj_type_path,
|
return_obj_type_path: return_obj_type_path,
|
||||||
combine_iv_and_base64: combine_iv_and_base64,
|
combine_iv_and_base64: combine_iv_and_base64,
|
||||||
|
|||||||
21
src/lib/ae_utils/ae_utils__format_html.ts
Normal file
21
src/lib/ae_utils/ae_utils__format_html.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Simple Regex-based HTML Formatter for Aether Platform.
|
||||||
|
* Adds newlines and indentation to raw HTML strings.
|
||||||
|
*/
|
||||||
|
export function format_html(html: string): string {
|
||||||
|
if (!html) return '';
|
||||||
|
|
||||||
|
let indent = 0;
|
||||||
|
const tab = ' ';
|
||||||
|
return html
|
||||||
|
.replace(/>\s*</g, '>\n<') // Add newlines between tags
|
||||||
|
.split('\n')
|
||||||
|
.map(line => {
|
||||||
|
line = line.trim();
|
||||||
|
if (line.match(/<\//)) indent--; // Decrease indent for closing tags
|
||||||
|
const out = tab.repeat(Math.max(0, indent)) + line;
|
||||||
|
if (line.match(/<[^\/!]/) && !line.match(/\/>/) && !line.match(/<\//)) indent++; // Increase indent for opening tags
|
||||||
|
return out;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
157
src/lib/elements/AE_Comp_Editor_TipTap.svelte
Normal file
157
src/lib/elements/AE_Comp_Editor_TipTap.svelte
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* AE_Comp_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 {
|
||||||
|
Bold, Italic, List, ListOrdered,
|
||||||
|
RemoveFormatting, Type, Code, AlignLeft
|
||||||
|
} 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 flex flex-col border border-surface-500/20 rounded-container overflow-hidden bg-white dark:bg-black/10 {class_li}">
|
||||||
|
{#if !readonly}
|
||||||
|
<div class="toolbar flex flex-wrap gap-1 p-1 bg-surface-50 dark:bg-surface-900 border-b border-surface-500/20">
|
||||||
|
<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="w-px h-4 bg-surface-500/20 mx-1 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="w-px h-4 bg-surface-500/20 mx-1 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="text-[10px] ml-1">Format</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="ml-auto flex gap-1 items-center px-2">
|
||||||
|
<Type size="12" class="opacity-30" />
|
||||||
|
<span class="text-[10px] opacity-50 uppercase font-bold">Visual</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grow relative h-full min-h-[150px] overflow-auto p-4">
|
||||||
|
{#if !content && !is_focused}
|
||||||
|
<div class="absolute top-4 left-4 pointer-events-none opacity-30 italic text-sm">
|
||||||
|
{placeholder}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={editor_element}
|
||||||
|
contenteditable={!readonly}
|
||||||
|
class="tiptap outline-none h-full w-full prose dark:prose-invert max-w-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 './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>
|
||||||
51
src/routes/testing/editor_test/+page.svelte
Normal file
51
src/routes/testing/editor_test/+page.svelte
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AE_Comp_Editor_TipTap from '$lib/elements/AE_Comp_Editor_TipTap.svelte';
|
||||||
|
import AE_Comp_Editor_CodeMirror from '$lib/elements/AE_Comp_Editor_CodeMirror.svelte';
|
||||||
|
|
||||||
|
let test_content = $state('<h2>Welcome to the Visual Editor Test</h2><p>This editor uses <strong>native browser rich text</strong> capabilities.</p><ul><li>No library conflicts</li><li>Uses existing <em>.tiptap</em> styles</li><li>Instant performance</li></ul>');
|
||||||
|
let log_lvl = 1;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-8 space-y-8 max-w-5xl mx-auto h-full overflow-y-auto">
|
||||||
|
<header class="border-b border-surface-500/30 pb-4">
|
||||||
|
<h1 class="h1">Rich Text Editor Test</h1>
|
||||||
|
<p class="opacity-70">Testing the Zero-Dependency "TipTap" replacement.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<!-- Visual Editor -->
|
||||||
|
<section class="card p-4 space-y-4 variant-soft-primary">
|
||||||
|
<h2 class="h3 flex items-center gap-2">
|
||||||
|
<span class="fas fa-eye text-primary-500"></span>
|
||||||
|
Visual Editor (AE_Comp_Editor_TipTap)
|
||||||
|
</h2>
|
||||||
|
<div class="bg-surface-100-800-token rounded-lg">
|
||||||
|
<AE_Comp_Editor_TipTap
|
||||||
|
bind:content={test_content}
|
||||||
|
placeholder="Try writing something pretty..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Source Code View -->
|
||||||
|
<section class="card p-4 space-y-4 variant-soft-tertiary">
|
||||||
|
<h2 class="h3 flex items-center gap-2">
|
||||||
|
<span class="fas fa-code text-tertiary-500"></span>
|
||||||
|
Source View (AE_Comp_Editor_CodeMirror)
|
||||||
|
</h2>
|
||||||
|
<div class="bg-surface-100-800-token rounded-lg h-[250px]">
|
||||||
|
<AE_Comp_Editor_CodeMirror
|
||||||
|
bind:content={test_content}
|
||||||
|
language="html"
|
||||||
|
show_line_numbers={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HTML Preview -->
|
||||||
|
<section class="card p-4 space-y-4 bg-surface-900 text-green-400 font-mono text-xs overflow-auto max-h-96 shadow-2xl border border-white/10">
|
||||||
|
<h2 class="text-sm font-bold opacity-50 uppercase tracking-widest border-b border-white/10 pb-2">Raw Bound Content (Saved to DB)</h2>
|
||||||
|
<pre class="whitespace-pre">{test_content}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user