diff --git a/src/uix/eidos/components/words/words-drawer.svelte b/src/uix/eidos/components/words/words-drawer.svelte index 83323be44..329155ab0 100644 --- a/src/uix/eidos/components/words/words-drawer.svelte +++ b/src/uix/eidos/components/words/words-drawer.svelte @@ -1,80 +1,37 @@ - + + {/if} + {/snippet} + + + diff --git a/src/uix/morfo/components/words.ts b/src/uix/morfo/components/words.ts index ea75cc851..f0c18d1a0 100644 --- a/src/uix/morfo/components/words.ts +++ b/src/uix/morfo/components/words.ts @@ -15,7 +15,8 @@ export const wordsMorfo = { slashMenu: '#?components.words.slash-menu|Insert block', linkEditor: '#?components.words.link-editor|Edit link', findReplace: '#?components.words.find-replace|Find and replace', - status: '#?components.words.status|Editor status' + status: '#?components.words.status|Editor status', + drawer: '#?components.words.drawer|Editor companion drawer' }, events: [ { @@ -126,6 +127,23 @@ export const wordsMorfo = { intent: 'risk', sequence: 'post' } + }, + { + // Toggle of the companion drawer (DRAWER refactor). Fires + // when the user collapses or expands the side rail; sema + // can hook this for telemetry / soft haptic. Mode shifts + // (contextual panel changes) flow as pure data via the + // data-words-drawer-mode attr — no event for those because + // they are derived from caret movement (already an event + // elsewhere). + name: 'commit-toggle-drawer', + semantic: { + family: 'commit', + verb: 'toggle', + target: v.partRef('drawer'), + intent: 'neutral', + sequence: 'post' + } } ], parts: [ @@ -637,6 +655,40 @@ export const wordsMorfo = { severity: 'recommended' } ] + }, + { + // Companion drawer (right-rail). Hosts contextual panels + // that react to caret position — replaces N popovers + // (bubble menu + slash menu + link editor + table popover + // + image controls) with a single sticky surface. + // + // `data-words-drawer-mode` carries the active panel; eidos + // switches its body content per-mode. Open state is a + // boolean attr so a CSS-only collapse animation is possible. + name: 'Drawer', + kebab: 'drawer', + archetype: 'group', + kind: 'public', + defaultElement: 'aside', + role: 'complementary', + optional: true, + data: [ + { + attr: 'data-words-drawer-mode', + values: ['default', 'format', 'image', 'table', 'code', 'list'], + value: v.propRef('mode'), + severity: 'optional' + }, + { attr: 'data-open', value: v.propRef('open'), severity: 'optional' }, + { attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' } + ], + aria: [ + { + attr: 'aria-label', + value: v.translationRef('#?components.words.drawer|Editor companion drawer'), + severity: 'recommended' + } + ] } ] } as const satisfies Morfo; diff --git a/src/uix/soma/components/words/components/words-drawer.svelte b/src/uix/soma/components/words/components/words-drawer.svelte new file mode 100644 index 000000000..18fd111c5 --- /dev/null +++ b/src/uix/soma/components/words/components/words-drawer.svelte @@ -0,0 +1,47 @@ + + +{#if child} + {@render child({ props: mergedProps, ...state.snippetProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/words/exports.ts b/src/uix/soma/components/words/exports.ts index 50b677489..6698ceb4a 100644 --- a/src/uix/soma/components/words/exports.ts +++ b/src/uix/soma/components/words/exports.ts @@ -10,6 +10,7 @@ export { default as LinkEditor } from './components/words-link-editor.svelte'; export { default as HiddenInput } from './components/words-hidden-input.svelte'; export { default as Status } from './components/words-status.svelte'; export { default as FindReplace } from './components/words-find-replace.svelte'; +export { default as Drawer } from './components/words-drawer.svelte'; export type { WordsCommandName, @@ -33,6 +34,9 @@ export type { WordsHiddenInputProps as HiddenInputProps, WordsStatusProps as StatusProps, WordsFindReplaceProps as FindReplaceProps, + WordsDrawerProps as DrawerProps, + WordsDrawerMode, + WordsDrawerSnippetProps as DrawerSnippetProps, WordsProviderSnippetProps as ProviderSnippetProps, WordsContentSnippetProps as ContentSnippetProps, WordsBubbleMenuSnippetProps as BubbleMenuSnippetProps, diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts index 6e3eb945b..a3ffe9bae 100644 --- a/src/uix/soma/components/words/types.ts +++ b/src/uix/soma/components/words/types.ts @@ -351,6 +351,34 @@ export type WordsHiddenInputProps = WithChild<{ id?: string }> & export type WordsStatusProps = WithChild<{ id?: string }> & Without; +/** Active panel of the companion drawer (DRAWER refactor). Mirrors + * `WordsDrawerMode` exported from the provider; surfaced here so eidos + * doesn't import provider internals. */ +export type WordsDrawerMode = 'default' | 'format' | 'image' | 'table' | 'code' | 'list'; + +export type WordsDrawerSnippetProps = { + readonly mode: WordsDrawerMode; + readonly open: boolean; + readonly disabled: boolean; + readonly snippet: WordsProviderSnippetProps; + readonly toggle: (target?: HTMLElement) => boolean; + readonly setOpen: (open: boolean, target?: HTMLElement) => void; +}; + +export type WordsDrawerProps = WithChild< + { + id?: string; + open?: boolean; + defaultOpen?: boolean; + disabled?: boolean; + 'aria-label'?: string; + children?: Snippet<[WordsDrawerSnippetProps]>; + }, + WordsDrawerSnippetProps, + HTMLElement +> & + Without; + export type WordsFindReplaceSnippetProps = { readonly findQuery: string; readonly findCaseSensitive: boolean; diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index f335e5d93..20ef78d0b 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -122,6 +122,10 @@ interface WordsLinkEditorOpts interface WordsFindReplaceOpts extends WithRefOpts {} interface WordsHiddenInputOpts extends WithRefOpts {} interface WordsStatusOpts extends WithRefOpts {} +interface WordsDrawerOpts + extends WithRefOpts, + StateProps<{ open: boolean }>, + ActiveProps<{ disabled: boolean }> {} type WordsSelectionSyncSource = | 'selectionchange' | 'pointer' @@ -2401,6 +2405,131 @@ export class WordsStatusProvider { ); } +/** + * Drawer — companion right-rail provider. + * + * Owns the open/collapsed state of the drawer and the derived `mode` + * (the contextual panel that should be visible: default | format | + * image | table | code | list). Eidos consumes both via the snippet + * to render the shell + the per-mode body. + * + * Mode derivation lives here (not in eidos) so any other consumer + * that wants to react to the contextual context (telemetry, future + * mini-drawer overlays, etc.) reads the same source of truth. + */ +export type WordsDrawerMode = + | 'default' + | 'format' + | 'image' + | 'table' + | 'code' + | 'list'; + +export class WordsDrawerProvider { + readonly opts: WordsDrawerOpts; + readonly provider: WordsProvider; + readonly runtimePart: SomaRuntimePart; + + static create(opts: WordsDrawerOpts) { + return new WordsDrawerProvider(opts); + } + + private constructor(opts: WordsDrawerOpts) { + this.opts = opts; + this.provider = WordsProvider.require(); + this.runtimePart = this.provider.runtime.part('drawer', { + id: opts.id, + ref: opts.ref, + owner: this, + props: { + mode: () => this.mode, + open: () => this.opts.open.current, + disabled: () => this.opts.disabled.current || this.provider.isDisabled + }, + syncAttrs: true + }); + } + + readonly mode: WordsDrawerMode = $derived.by(() => { + const sel = this.provider.selection; + // Text-selection format takes precedence over block-type panels: + // a non-collapsed selection inside an inline-text block puts the + // drawer in 'format' mode so the user can act on the selection. + if ( + sel && + (sel.anchor.path[0] !== sel.focus.path[0] || + sel.anchor.offset !== sel.focus.offset) + ) { + const block = this.provider.currentBlock; + if ( + block === 'paragraph' || + block === 'heading' || + block === 'quote' || + block === 'list' + ) { + return 'format'; + } + } + const block = this.provider.currentBlock; + if (block === 'image') return 'image'; + if (block === 'table') return 'table'; + if (block === 'code') return 'code'; + if (block === 'list') return 'list'; + return 'default'; + }); + + /** + * Toggle the drawer open/closed and emit the `commit-toggle-drawer` + * event so sema / telemetry can react. Returns the new open state. + */ + toggle(target?: HTMLElement): boolean { + const next = !this.opts.open.current; + this.opts.open.current = next; + try { + void this.provider.runtime.trigger('commit-toggle-drawer', { + fallbackTarget: target ?? this.opts.ref.current ?? undefined + }); + } catch { + /* noop — runtime may not be wired (tests / SSR) */ + } + return next; + } + + setOpen(open: boolean, target?: HTMLElement): void { + if (this.opts.open.current === open) return; + this.opts.open.current = open; + try { + void this.provider.runtime.trigger('commit-toggle-drawer', { + fallbackTarget: target ?? this.opts.ref.current ?? undefined + }); + } catch { + /* noop */ + } + } + + readonly snippetProps = $derived.by(() => ({ + mode: this.mode, + open: this.opts.open.current, + disabled: this.opts.disabled.current || this.provider.isDisabled, + // Forward the parent snippet so eidos panels can call runCommand, + // read activeMarks, etc. without re-piping every action. + snippet: this.provider.snippetProps, + toggle: (target?: HTMLElement) => this.toggle(target), + setOpen: (open: boolean, target?: HTMLElement) => this.setOpen(open, target) + })); + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props, + role: 'complementary' as const, + 'data-words-drawer-mode': this.mode, + 'data-open': this.opts.open.current ? '' : undefined, + 'data-disabled': + this.opts.disabled.current || this.provider.isDisabled ? '' : undefined + } as const) + ); +} + function firstUsableClientRect(range: Range): DOMRect | null { const rects = range.getClientRects(); for (const rect of rects) { diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index fd961904c..d0c14e145 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -1074,7 +1074,7 @@ {/if} {#if showDrawer} - + {/if} {#if showStatus}