Appearance
Lexical — live demo
This is the real @rozie-ui/lexical-vue package running on this page (VitePress is itself a Vue app). Type in the editor, select some text and hit the toolbar buttons — Bold, Italic, • List, and Link — and watch each button light up as the caret moves through matching formatting. Everything below is driven by the same .rozie sources that compile to all five frameworks.
The editor is composed the way you'd compose it in your own app: a <LexicalEditor> shell wrapping a <Toolbar /> plus the HistoryPlugin / ListPlugin / LinkPlugin children. Each child $injects the same shared editor instance the shell provides — no editor handle is prop-drilled. The shell registers the RichText baseline itself, so formatting and undo/redo work out of the box; the list and link buttons take effect because their plugins are nested. See the full API for the composition model, the plugin list, and the $inject contract for custom children.
One source, five outputs
You author the editor shell once as a .rozie file:
html
<!--
LexicalEditor.rozie — the editor SHELL for @rozie-ui/lexical (D-04). The
substrate every later wave builds on: it creates a Lexical editor in $onMount,
binds it to a contenteditable host via $refs + setRootElement, registers the
RichText baseline, and $provides the live instance so plugin/toolbar components
(waves 2–4) $inject the SAME identity and drive it imperatively (spike 010).
D-05 / REQ-37 (HARD, spike 013): ALL Lexical `$`-API is authored in the
NAMESPACE-import form — `import * as lexical from 'lexical'; lexical.$getRoot()`.
Named `$`-imports (`import { $getRoot } from 'lexical'`) break the Svelte
compiler (`dollar_prefix_invalid`); the namespace form makes every `$`-call a
PROPERTY access, which Svelte does not reserve. This is the ONE cross-target-safe
form and is gated by scripts/compile-lexical-check.mjs.
The shell registers the full v1.0 node CLASS set up front (Lexical requires all
node classes declared at editor creation); the plugin components register only
BEHAVIOR. MentionNode is added to `nodes` by plan 76-04.
-->
<rozie name="LexicalEditor">
<props>
{
nodes: {
type: Array,
default: () => [],
docs: {
description:
'Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).',
},
},
namespace: {
type: String,
default: '',
docs: {
description:
"The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.",
},
},
ariaLabel: {
type: String,
default: null,
docs: {
description:
'Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.',
},
},
theme: {
type: Object,
default: () => ({}),
docs: {
description:
'Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.',
},
},
}
</props>
<script>
// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical'
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text'
import { ListNode, ListItemNode } from '@lexical/list'
import { LinkNode, AutoLinkNode } from '@lexical/link'
import { mergeRegister } from '@lexical/utils'
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode'
import { mountDecorators } from './mountDecorators'
// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).
let editor = null
// $provide the editor at INIT (top-level setup), NOT inside $onMount. Context
// tokens must be established during component init on Svelte (setContext is
// init-only — REQ-32) and Vue; providing inside $onMount would land setContext
// after init and fail. The value is a GETTER object so the identity is fixed at
// init while the live `editor` late-binds once $onMount assigns it — a plugin that
// mounts after the shell reads the current instance through the getter (the exact
// spike 010 `{ get color() {…} }` late-binding pattern). The token string is the
// stable cross-file identity the plugin/toolbar `$inject('rozie-lexical-editor')`
// reads (spike 010 cross-file token contract); plugins read the live editor via
// `.instance`.
//
// The getter key is `instance`, NOT `editor`: naming it `editor` collides with the
// top-level `let editor`, and the emitter's reactive-identifier rewrite pass then
// tries to rewrite the ObjectMethod KEY `editor` into a member expression and
// crashes (@babel/types ObjectMethod-key invariant). Renaming the key sidesteps
// that compile-path gap while the getter BODY `return editor` still late-binds to
// the live instance. (SCOPE FENCE: source workaround, no emitter edit.)
$provide('rozie-lexical-editor', {
get instance() {
return editor
},
})
$onMount(() => {
editor = lexical.createEditor({
namespace: $props.namespace || 'rozie-lexical',
// The full v1.0 node CLASS set is declared here (Lexical requires all node
// classes up front); consumer `nodes` are composed LAST so they win.
nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ...$props.nodes],
// Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
onError: (e) => {
throw e
},
theme: $props.theme,
})
// Bind the editor to the authored contenteditable host.
editor.setRootElement($refs.rootEl)
// Register the RichText baseline AND wire the per-target @mention decorator bridge
// (D-06): mountDecorators returns an unregister fn, folded into the same
// mergeRegister so the decorator listener tears down with everything else. Plugin
// components (wave 2) add History/List/Link BEHAVIOR against the same $injected
// editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
const cleanup = mergeRegister(registerRichText(editor), mountDecorators(editor))
// Seed an empty paragraph so the caret has a block to land in when the document
// is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
editor.update(() => {
const root = lexical.$getRoot()
if (root.getFirstChild() === null) {
const paragraph = lexical.$createParagraphNode()
root.append(paragraph)
}
})
// Teardown colocated in the $onMount return (D-04): unregister everything, then
// null the instance so a late teardown read sees a defined value.
return () => {
cleanup()
editor = null
}
})
</script>
<template>
<div class="rozie-lexical">
<!-- The single contenteditable host Lexical owns via setRootElement($refs.rootEl).
:aria-label drops from the DOM when ariaLabel is null (rozieAttr nullish-drop). -->
<div ref="rootEl" class="rozie-lexical-content" :contenteditable="true" :aria-label="$props.ariaLabel"></div>
<!-- Compositional model: plugin/toolbar children mount here and $inject the
$provided editor. Non-visual plugins render nothing; the toolbar (D-03)
renders its own controls. Toolbar is a SEPARATE component — never inlined. -->
<slot />
</div>
</template>
<style>
.rozie-lexical {
display: block;
}
.rozie-lexical-content {
min-height: 6rem;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
outline: none;
font: inherit;
}
.rozie-lexical-content:focus {
border-color: #4f46e5;
}
/* The formatted nodes Lexical renders inside the contenteditable host (strong / em
/ ul / ol / headings) are ENGINE-rendered — they carry NO Rozie [data-rozie-s-*]
scope attribute, so a plain scoped descendant rule would silently fail to match
on the scoped-CSS targets (React/Solid/Lit). The nested :root { } escape hatch
emits its children UNSCOPED/global on all targets, reaching the engine nodes.
(Not :global() — that is a ROZ128 hard error; :root { nested } is canonical —
the TipTap placeholder-ghost lesson, D-12.) */
:root {
.rozie-lexical-content strong {
font-weight: 700;
}
.rozie-lexical-content em {
font-style: italic;
}
.rozie-lexical-content ul {
margin: 0.25rem 0;
padding-left: 1.5rem;
}
.rozie-lexical-content ol {
margin: 0.25rem 0;
padding-left: 1.5rem;
}
.rozie-lexical-content h1 {
font-size: 1.5rem;
margin: 0.5rem 0 0.375rem;
}
.rozie-lexical-content h2 {
font-size: 1.25rem;
margin: 0.5rem 0 0.375rem;
}
/* Default @mention chip styling (D-06 bridge output). Shipped BY THE COMPONENT
(not left to the consumer) so the chip is styled on the Lit target too: the
bridge renders the pill into a host span INSIDE this component's shadow root,
which document/consumer CSS cannot cross — so the shadow-injected copy here is
the only way a Lit consumer gets a styled chip. On the five light-DOM targets
this is an identical-valued default under any consumer override (same four
properties, same values), so it is a no-op there — the existing render is
byte-identical. Themeable via `--rozie-lexical-mention-*`. */
.rozie-lexical-content .rozie-mention {
background: var(--rozie-lexical-mention-bg, #e0e7ff);
border-radius: var(--rozie-lexical-mention-radius, 6px);
padding: var(--rozie-lexical-mention-padding, 1px 6px);
font-size: var(--rozie-lexical-mention-font-size, 0.875rem);
}
}
</style>
</rozie>…and Rozie compiles it to five idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/lexical-{react,vue,svelte,angular,solid}):
tsx
import { useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, rozieAttr, rozieContext } from '@rozie/runtime-react';
import './LexicalEditor.css';
import './LexicalEditor.global.css';
// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical';
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { mergeRegister } from '@lexical/utils';
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode';
import { mountDecorators } from './mountDecorators';
// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).
interface LexicalEditorProps {
/**
* Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).
*/
nodes?: any[];
/**
* The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.
*/
namespace?: string;
/**
* Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.
*/
ariaLabel?: (string) | null;
/**
* Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.
*/
theme?: Record<string, any>;
children?: ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export default function LexicalEditor(_props: LexicalEditorProps): JSX.Element {
const __ctx_rozie_lexical_editor = rozieContext("rozie-lexical-editor");
const __defaultNodes = useState(() => (() => [])())[0];
const __defaultTheme = useState(() => (() => ({}))())[0];
const props: Omit<LexicalEditorProps, 'nodes' | 'namespace' | 'ariaLabel' | 'theme'> & { nodes: any[]; namespace: string; ariaLabel: (string) | null; theme: Record<string, any> } = {
..._props,
nodes: _props.nodes ?? __defaultNodes,
namespace: _props.namespace ?? '',
ariaLabel: _props.ariaLabel ?? null,
theme: _props.theme ?? __defaultTheme,
};
const attrs: Record<string, unknown> = (() => {
const { nodes, namespace, ariaLabel, theme, ...rest } = _props as LexicalEditorProps & Record<string, unknown>;
void nodes; void namespace; void ariaLabel; void theme;
return rest;
})();
const editor = useRef<any>(null);
const _namespaceRef = useRef(props.namespace);
_namespaceRef.current = props.namespace;
const _nodesRef = useRef(props.nodes);
_nodesRef.current = props.nodes;
const _themeRef = useRef(props.theme);
_themeRef.current = props.theme;
const rootEl = useRef<HTMLDivElement | null>(null);
useEffect(() => {
editor.current = lexical.createEditor({
namespace: _namespaceRef.current || 'rozie-lexical',
// The full v1.0 node CLASS set is declared here (Lexical requires all node
// classes up front); consumer `nodes` are composed LAST so they win.
nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ..._nodesRef.current],
// Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
onError: (e: any) => {
throw e;
},
theme: _themeRef.current
});
// Bind the editor to the authored contenteditable host.
editor.current.setRootElement(rootEl.current!);
// Register the RichText baseline AND wire the per-target @mention decorator bridge
// (D-06): mountDecorators returns an unregister fn, folded into the same
// mergeRegister so the decorator listener tears down with everything else. Plugin
// components (wave 2) add History/List/Link BEHAVIOR against the same $injected
// editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
const cleanup = mergeRegister(registerRichText(editor.current), mountDecorators(editor.current));
// Seed an empty paragraph so the caret has a block to land in when the document
// is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
editor.current.update(() => {
const root = lexical.$getRoot();
if (root.getFirstChild() === null) {
const paragraph = lexical.$createParagraphNode();
root.append(paragraph);
}
});
// Teardown colocated in the $onMount return (D-04): unregister everything, then
// null the instance so a late teardown read sees a defined value.
return () => {
cleanup();
editor.current = null;
};
}, []);
return (
<__ctx_rozie_lexical_editor.Provider value={{
get instance() {
return editor.current;
}
}}>
<>
<div {...attrs} className={clsx("rozie-lexical", (attrs.className as string | undefined))} data-rozie-s-f679124a="">
<div ref={rootEl} className={"rozie-lexical-content"} contentEditable={true} aria-label={rozieAttr(props.ariaLabel)} data-rozie-s-f679124a="" />
{(typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)() : (props.children ?? props.slots?.['']))}
</div>
</>
</__ctx_rozie_lexical_editor.Provider>
);
}vue
<template>
<div class="rozie-lexical" v-bind="$attrs">
<div ref="rootElRef" class="rozie-lexical-content" :contenteditable="true" :aria-label="props.ariaLabel"></div>
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, provide, ref } from 'vue';
const props = withDefaults(
defineProps<{
/**
* Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).
*/
nodes?: any[];
/**
* The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.
*/
namespace?: string;
/**
* Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.
*/
ariaLabel?: string | null;
/**
* Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.
*/
theme?: Record<string, any>;
}>(),
{ nodes: () => [], namespace: '', ariaLabel: null, theme: () => ({}) }
);
defineSlots<{
default(props: { }): any;
}>();
const rootElRef = ref<HTMLElement>();
// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical';
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { mergeRegister } from '@lexical/utils';
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode';
import { mountDecorators } from './mountDecorators';
// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).
let editor: any = null;
// $provide the editor at INIT (top-level setup), NOT inside $onMount. Context
// tokens must be established during component init on Svelte (setContext is
// init-only — REQ-32) and Vue; providing inside $onMount would land setContext
// after init and fail. The value is a GETTER object so the identity is fixed at
// init while the live `editor` late-binds once $onMount assigns it — a plugin that
// mounts after the shell reads the current instance through the getter (the exact
// spike 010 `{ get color() {…} }` late-binding pattern). The token string is the
// stable cross-file identity the plugin/toolbar `$inject('rozie-lexical-editor')`
// reads (spike 010 cross-file token contract); plugins read the live editor via
// `.instance`.
//
// The getter key is `instance`, NOT `editor`: naming it `editor` collides with the
// top-level `let editor`, and the emitter's reactive-identifier rewrite pass then
// tries to rewrite the ObjectMethod KEY `editor` into a member expression and
// crashes (@babel/types ObjectMethod-key invariant). Renaming the key sidesteps
// that compile-path gap while the getter BODY `return editor` still late-binds to
// the live instance. (SCOPE FENCE: source workaround, no emitter edit.)
provide('rozie-lexical-editor', {
get instance() {
return editor;
}
});
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
editor = lexical.createEditor({
namespace: props.namespace || 'rozie-lexical',
// The full v1.0 node CLASS set is declared here (Lexical requires all node
// classes up front); consumer `nodes` are composed LAST so they win.
nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ...props.nodes],
// Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
onError: (e: any) => {
throw e;
},
theme: props.theme
});
// Bind the editor to the authored contenteditable host.
editor.setRootElement(rootElRef.value!);
// Register the RichText baseline AND wire the per-target @mention decorator bridge
// (D-06): mountDecorators returns an unregister fn, folded into the same
// mergeRegister so the decorator listener tears down with everything else. Plugin
// components (wave 2) add History/List/Link BEHAVIOR against the same $injected
// editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
const cleanup = mergeRegister(registerRichText(editor), mountDecorators(editor));
// Seed an empty paragraph so the caret has a block to land in when the document
// is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
editor.update(() => {
const root = lexical.$getRoot();
if (root.getFirstChild() === null) {
const paragraph = lexical.$createParagraphNode();
root.append(paragraph);
}
});
// Teardown colocated in the $onMount return (D-04): unregister everything, then
// null the instance so a late teardown read sees a defined value.
_cleanup_0 = () => {
cleanup();
editor = null;
};
});
onBeforeUnmount(() => { _cleanup_0?.(); });
</script>
<style scoped>
.rozie-lexical {
display: block;
}
.rozie-lexical-content {
min-height: 6rem;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
outline: none;
font: inherit;
}
.rozie-lexical-content:focus {
border-color: #4f46e5;
}
</style>
<style>
.rozie-lexical-content strong {
font-weight: 700;
}
.rozie-lexical-content em {
font-style: italic;
}
.rozie-lexical-content ul {
margin: 0.25rem 0;
padding-left: 1.5rem;
}
.rozie-lexical-content ol {
margin: 0.25rem 0;
padding-left: 1.5rem;
}
.rozie-lexical-content h1 {
font-size: 1.5rem;
margin: 0.5rem 0 0.375rem;
}
.rozie-lexical-content h2 {
font-size: 1.25rem;
margin: 0.5rem 0 0.375rem;
}
.rozie-lexical-content .rozie-mention {
background: var(--rozie-lexical-mention-bg, #e0e7ff);
border-radius: var(--rozie-lexical-mention-radius, 6px);
padding: var(--rozie-lexical-mention-padding, 1px 6px);
font-size: var(--rozie-lexical-mention-font-size, 0.875rem);
}
</style>svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
import { onMount, setContext } from 'svelte';
interface Props {
/**
* Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).
*/
nodes?: any[];
/**
* The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.
*/
namespace?: string;
/**
* Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.
*/
ariaLabel?: (string) | null;
/**
* Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.
*/
theme?: any;
children?: Snippet;
snippets?: Record<string, any>;
[key: string]: unknown;
}
let __defaultNodes = (() => [])();
let __defaultTheme = (() => ({}))();
let {
nodes = __defaultNodes,
namespace = '',
ariaLabel = null,
theme = __defaultTheme,
children: __childrenProp,
snippets,
...__rozieAttrs
}: Props = $props();
const children = $derived(__childrenProp ?? snippets?.children);
let rootEl = $state<HTMLElement | undefined>(undefined);
// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical';
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { mergeRegister } from '@lexical/utils';
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode';
import { mountDecorators } from './mountDecorators';
// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).
let editor: any = null;
// $provide the editor at INIT (top-level setup), NOT inside $onMount. Context
// tokens must be established during component init on Svelte (setContext is
// init-only — REQ-32) and Vue; providing inside $onMount would land setContext
// after init and fail. The value is a GETTER object so the identity is fixed at
// init while the live `editor` late-binds once $onMount assigns it — a plugin that
// mounts after the shell reads the current instance through the getter (the exact
// spike 010 `{ get color() {…} }` late-binding pattern). The token string is the
// stable cross-file identity the plugin/toolbar `$inject('rozie-lexical-editor')`
// reads (spike 010 cross-file token contract); plugins read the live editor via
// `.instance`.
//
// The getter key is `instance`, NOT `editor`: naming it `editor` collides with the
// top-level `let editor`, and the emitter's reactive-identifier rewrite pass then
// tries to rewrite the ObjectMethod KEY `editor` into a member expression and
// crashes (@babel/types ObjectMethod-key invariant). Renaming the key sidesteps
// that compile-path gap while the getter BODY `return editor` still late-binds to
// the live instance. (SCOPE FENCE: source workaround, no emitter edit.)
setContext('rozie-lexical-editor', {
get instance() {
return editor;
}
});
onMount(() => {
editor = lexical.createEditor({
namespace: namespace || 'rozie-lexical',
// The full v1.0 node CLASS set is declared here (Lexical requires all node
// classes up front); consumer `nodes` are composed LAST so they win.
nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ...nodes],
// Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
onError: (e: any) => {
throw e;
},
theme: theme
});
// Bind the editor to the authored contenteditable host.
editor.setRootElement(rootEl!);
// Register the RichText baseline AND wire the per-target @mention decorator bridge
// (D-06): mountDecorators returns an unregister fn, folded into the same
// mergeRegister so the decorator listener tears down with everything else. Plugin
// components (wave 2) add History/List/Link BEHAVIOR against the same $injected
// editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
const cleanup = mergeRegister(registerRichText(editor), mountDecorators(editor));
// Seed an empty paragraph so the caret has a block to land in when the document
// is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
editor.update(() => {
const root = lexical.$getRoot();
if (root.getFirstChild() === null) {
const paragraph = lexical.$createParagraphNode();
root.append(paragraph);
}
});
// Teardown colocated in the $onMount return (D-04): unregister everything, then
// null the instance so a late teardown read sees a defined value.
return () => {
cleanup();
editor = null;
};
});
</script>
<div {...__rozieAttrs} class={["rozie-lexical", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-f679124a><div bind:this={rootEl} class="rozie-lexical-content" contenteditable={true} aria-label={ariaLabel} data-rozie-s-f679124a></div>{@render children?.()}</div>
<style>
:global {
.rozie-lexical[data-rozie-s-f679124a] {
display: block;
}
.rozie-lexical-content[data-rozie-s-f679124a] {
min-height: 6rem;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
outline: none;
font: inherit;
}
.rozie-lexical-content[data-rozie-s-f679124a]:focus {
border-color: #4f46e5;
}
}
:global {
.rozie-lexical-content strong {
font-weight: 700;
}
.rozie-lexical-content em {
font-style: italic;
}
.rozie-lexical-content ul {
margin: 0.25rem 0;
padding-left: 1.5rem;
}
.rozie-lexical-content ol {
margin: 0.25rem 0;
padding-left: 1.5rem;
}
.rozie-lexical-content h1 {
font-size: 1.5rem;
margin: 0.5rem 0 0.375rem;
}
.rozie-lexical-content h2 {
font-size: 1.25rem;
margin: 0.5rem 0 0.375rem;
}
.rozie-lexical-content .rozie-mention {
background: var(--rozie-lexical-mention-bg, #e0e7ff);
border-radius: var(--rozie-lexical-mention-radius, 6px);
padding: var(--rozie-lexical-mention-padding, 1px 6px);
font-size: var(--rozie-lexical-mention-font-size, 0.875rem);
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, InjectionToken, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical';
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { mergeRegister } from '@lexical/utils';
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode';
import { mountDecorators } from './mountDecorators';
// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).
interface DefaultCtx {}
function __rozieDisplay(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'object') {
try {
return JSON.stringify(v, null, 2);
} catch {
// Circular structure or a non-serialisable value (BigInt nested in an
// object). Degrade to a non-throwing form so the wrap never crashes the
// render — that is the entire point of "safe" interpolation (SPEC-1).
return String(v);
}
}
return String(v);
}
function __rozieAttr(v: unknown): string | null {
return v == null ? null : __rozieDisplay(v);
}
const __rozieTokenRegistry: Map<string, InjectionToken<unknown>> =
((globalThis as Record<string, unknown>).__rozieCtx ??= new Map()) as Map<
string,
InjectionToken<unknown>
>;
function rozieToken(key: string): InjectionToken<unknown> {
let token = __rozieTokenRegistry.get(key);
if (!token) {
token = new InjectionToken<unknown>('rozie:' + key);
__rozieTokenRegistry.set(key, token);
}
return token;
}
@Component({
selector: 'rozie-lexical-editor',
standalone: true,
imports: [NgTemplateOutlet],
template: `
<div class="rozie-lexical" #rozieSpread_0 #rozieListenersTarget_1>
<div #rootEl class="rozie-lexical-content" [contentEditable]="true" [attr.aria-label]="rozieAttr(ariaLabel())"></div>
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot'])" />
</div>
`,
styles: [`
:host(rozie-lexical-editor) { display: contents; }
.rozie-lexical {
display: block;
}
.rozie-lexical-content {
min-height: 6rem;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
outline: none;
font: inherit;
}
.rozie-lexical-content:focus {
border-color: #4f46e5;
}
::ng-deep .rozie-lexical-content strong {
font-weight: 700;
}
::ng-deep .rozie-lexical-content em {
font-style: italic;
}
::ng-deep .rozie-lexical-content ul {
margin: 0.25rem 0;
padding-left: 1.5rem;
}
::ng-deep .rozie-lexical-content ol {
margin: 0.25rem 0;
padding-left: 1.5rem;
}
::ng-deep .rozie-lexical-content h1 {
font-size: 1.5rem;
margin: 0.5rem 0 0.375rem;
}
::ng-deep .rozie-lexical-content h2 {
font-size: 1.25rem;
margin: 0.5rem 0 0.375rem;
}
::ng-deep .rozie-lexical-content .rozie-mention {
background: var(--rozie-lexical-mention-bg, #e0e7ff);
border-radius: var(--rozie-lexical-mention-radius, 6px);
padding: var(--rozie-lexical-mention-padding, 1px 6px);
font-size: var(--rozie-lexical-mention-font-size, 0.875rem);
}
`],
providers: [
{
provide: rozieToken('rozie-lexical-editor'),
useFactory: () => { const __rozieCtxHost = inject(forwardRef(() => LexicalEditor)); return ({
get instance() {
return __rozieCtxHost.editor;
}
}); },
},
],
})
export class LexicalEditor {
/**
* Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).
*/
nodes = input<any[]>((() => [])());
/**
* The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.
*/
namespace = input<string>('');
/**
* Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.
*/
ariaLabel = input<(string) | null>(null);
/**
* Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.
*/
theme = input<Record<string, any>>((() => ({}))());
rootEl = viewChild<ElementRef<HTMLDivElement>>('rootEl');
@ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
private __rozieDestroyRef = inject(DestroyRef);
ngAfterViewInit() {
this.editor = lexical.createEditor({
namespace: this.namespace() || 'rozie-lexical',
// The full v1.0 node CLASS set is declared here (Lexical requires all node
// classes up front); consumer `nodes` are composed LAST so they win.
nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ...this.nodes()],
// Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
onError: (e: any) => {
throw e;
},
theme: this.theme()
});
// Bind the editor to the authored contenteditable host.
// Bind the editor to the authored contenteditable host.
this.editor.setRootElement(this.rootEl()!.nativeElement);
// Register the RichText baseline AND wire the per-target @mention decorator bridge
// (D-06): mountDecorators returns an unregister fn, folded into the same
// mergeRegister so the decorator listener tears down with everything else. Plugin
// components (wave 2) add History/List/Link BEHAVIOR against the same $injected
// editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
// Register the RichText baseline AND wire the per-target @mention decorator bridge
// (D-06): mountDecorators returns an unregister fn, folded into the same
// mergeRegister so the decorator listener tears down with everything else. Plugin
// components (wave 2) add History/List/Link BEHAVIOR against the same $injected
// editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
const cleanup = mergeRegister(registerRichText(this.editor), mountDecorators(this.editor));
// Seed an empty paragraph so the caret has a block to land in when the document
// is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
// Seed an empty paragraph so the caret has a block to land in when the document
// is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
this.editor.update(() => {
const root = lexical.$getRoot();
if (root.getFirstChild() === null) {
const paragraph = lexical.$createParagraphNode();
root.append(paragraph);
}
});
// Teardown colocated in the $onMount return (D-04): unregister everything, then
// null the instance so a late teardown read sees a defined value.
this.__rozieDestroyRef.onDestroy(() => {
cleanup();
this.editor = null;
});
}
editor: any = null;
static ngTemplateContextGuard(
_dir: LexicalEditor,
_ctx: unknown,
): _ctx is DefaultCtx {
return true;
}
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default LexicalEditor;tsx
import type { JSX } from 'solid-js';
import { mergeProps, onCleanup, onMount, splitProps } from 'solid-js';
import { __rozieInjectStyle, rozieAttr, rozieContext } from '@rozie/runtime-solid';
// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical';
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { mergeRegister } from '@lexical/utils';
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode';
import { mountDecorators } from './mountDecorators';
// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).
__rozieInjectStyle('LexicalEditor-f679124a', `.rozie-lexical[data-rozie-s-f679124a] {
display: block;
}
.rozie-lexical-content[data-rozie-s-f679124a] {
min-height: 6rem;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
outline: none;
font: inherit;
}
.rozie-lexical-content[data-rozie-s-f679124a]:focus {
border-color: #4f46e5;
}
.rozie-lexical-content strong {
font-weight: 700;
}
.rozie-lexical-content em {
font-style: italic;
}
.rozie-lexical-content ul {
margin: 0.25rem 0;
padding-left: 1.5rem;
}
.rozie-lexical-content ol {
margin: 0.25rem 0;
padding-left: 1.5rem;
}
.rozie-lexical-content h1 {
font-size: 1.5rem;
margin: 0.5rem 0 0.375rem;
}
.rozie-lexical-content h2 {
font-size: 1.25rem;
margin: 0.5rem 0 0.375rem;
}
.rozie-lexical-content .rozie-mention {
background: var(--rozie-lexical-mention-bg, #e0e7ff);
border-radius: var(--rozie-lexical-mention-radius, 6px);
padding: var(--rozie-lexical-mention-padding, 1px 6px);
font-size: var(--rozie-lexical-mention-font-size, 0.875rem);
}`);
interface LexicalEditorProps {
/**
* Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).
*/
nodes?: any[];
/**
* The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.
*/
namespace?: string;
/**
* Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.
*/
ariaLabel?: (string) | null;
/**
* Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.
*/
theme?: Record<string, any>;
// D-131: default slot resolved via children() at body top
children?: JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
}
export default function LexicalEditor(_props: LexicalEditorProps): JSX.Element {
const _merged = mergeProps({ nodes: (() => [])() as any[], namespace: '', ariaLabel: null, theme: (() => ({}))() as Record<string, any> }, _props);
const [local, attrs] = splitProps(_merged, ['nodes', 'namespace', 'ariaLabel', 'theme', 'children']);
const resolved = () => local.children;
const __ctx_rozie_lexical_editor = rozieContext("rozie-lexical-editor");
onMount(() => {
editor = lexical.createEditor({
namespace: local.namespace || 'rozie-lexical',
// The full v1.0 node CLASS set is declared here (Lexical requires all node
// classes up front); consumer `nodes` are composed LAST so they win.
nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ...local.nodes],
// Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
onError: (e: any) => {
throw e;
},
theme: local.theme
});
// Bind the editor to the authored contenteditable host.
editor.setRootElement(rootElRef!);
// Register the RichText baseline AND wire the per-target @mention decorator bridge
// (D-06): mountDecorators returns an unregister fn, folded into the same
// mergeRegister so the decorator listener tears down with everything else. Plugin
// components (wave 2) add History/List/Link BEHAVIOR against the same $injected
// editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
const cleanup = mergeRegister(registerRichText(editor), mountDecorators(editor));
// Seed an empty paragraph so the caret has a block to land in when the document
// is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
editor.update(() => {
const root = lexical.$getRoot();
if (root.getFirstChild() === null) {
const paragraph = lexical.$createParagraphNode();
root.append(paragraph);
}
});
// Teardown colocated in the $onMount return (D-04): unregister everything, then
// null the instance so a late teardown read sees a defined value.
onCleanup(() => {
cleanup();
editor = null;
});
});
let rootElRef: HTMLElement | null = null;
// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).
let editor: any = null;
// $provide the editor at INIT (top-level setup), NOT inside $onMount. Context
// tokens must be established during component init on Svelte (setContext is
// init-only — REQ-32) and Vue; providing inside $onMount would land setContext
// after init and fail. The value is a GETTER object so the identity is fixed at
// init while the live `editor` late-binds once $onMount assigns it — a plugin that
// mounts after the shell reads the current instance through the getter (the exact
// spike 010 `{ get color() {…} }` late-binding pattern). The token string is the
// stable cross-file identity the plugin/toolbar `$inject('rozie-lexical-editor')`
// reads (spike 010 cross-file token contract); plugins read the live editor via
// `.instance`.
//
// The getter key is `instance`, NOT `editor`: naming it `editor` collides with the
// top-level `let editor`, and the emitter's reactive-identifier rewrite pass then
// tries to rewrite the ObjectMethod KEY `editor` into a member expression and
// crashes (@babel/types ObjectMethod-key invariant). Renaming the key sidesteps
// that compile-path gap while the getter BODY `return editor` still late-binds to
// the live instance. (SCOPE FENCE: source workaround, no emitter edit.)
return (
<__ctx_rozie_lexical_editor.Provider value={{
get instance() {
return editor;
}
}}>
<>
<div {...attrs} class={"rozie-lexical" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-f679124a="">
<div ref={(el) => { rootElRef = el as HTMLElement; }} class={"rozie-lexical-content"} contentEditable={true} aria-label={rozieAttr(local.ariaLabel)} data-rozie-s-f679124a="" />
{resolved()}
</div>
</>
</__ctx_rozie_lexical_editor.Provider>
);
}Each is a real, idiomatic component for its framework — React hooks, Vue <script setup>, Svelte 5 runes, an Angular standalone component, and a Solid component. Same props, same $provide/$inject editor-sharing contract, same plugins, all from the one source above. (Lit is v1.1 — see the roadmap.)
See also
- Lexical — showcase & API — install, composition, the plugin list, the toolbar, and the decorator node.
- Lexical libraries comparison — how
@rozie-ui/lexicalstacks up against the per-framework wrappers. - Decorator node authoring recipe — author a custom node + its per-target mount bridge.