feat: add Zero-Dependency AE_Comp_Editor_TipTap with auto-formatting support

This commit is contained in:
Scott Idem
2026-01-29 14:30:28 -05:00
parent 363d94a36b
commit 7ec3bae343
5 changed files with 231 additions and 0 deletions

View File

@@ -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 { 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 { format_html } from './ae_utils__format_html';
import {
combine_iv_and_base64,
encrypt_content,
@@ -336,6 +337,7 @@ export const ae_util = {
shorten_string: shorten_string,
shorten_filename: shorten_filename,
file_extension_icon: file_extension_icon,
format_html: format_html,
set_obj_prop_display_name: set_obj_prop_display_name,
return_obj_type_path: return_obj_type_path,
combine_iv_and_base64: combine_iv_and_base64,

View 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');
}

View 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>

View 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>