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>