You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/components/words/words-bubble.svelte

378 lines
12 KiB

<script lang="ts">
/**
* Selection bubble toolbar.
*
* Floats above the current text selection (TipTap / Notion-style). A
* "Turn into" dropdown converts the current block (when convertible)
* plus a row of inline-mark toggles and a link control.
*
* Composition — every control is an ecosystem component, no bespoke
* widgets or hand-drawn glyphs:
* - soma `Words.BubbleMenu` owns open/close + viewport positioning +
* the single-focus-scope contract.
* - eidos `DropdownMenu` drives turn-into; its content carries
* `data-words-bubble-turn-into` so the focus scope recognises it.
* - marks are soma `Words.CommandButton`s rendered through their `child`
* snippet as eidos `Button`s (the canonical consumer pattern) — the
* CommandButton restores the DOM range before the command runs.
* - glyphs come from the eidos `Icon` set.
*
* Block transforms go through `api.applyCommand` (direct, no sema).
*/
import { tick } from 'svelte';
import * as Words from '$soma/components/words';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { Button } from '$uix/eidos/components/button';
import {
Bold,
Italic,
Underline,
Strikethrough,
Code,
Link,
Unlink,
ChevronDown,
Check,
Baseline,
Highlighter
} from '$uix/eidos/components/icon';
import type {
ProviderSnippetProps,
WordsBlock,
WordsHeadingLevel,
WordsListKind
} from '$soma/components/words';
let { api }: { api: ProviderSnippetProps } = $props();
let turnIntoOpen = $state(false);
let colorOpen = $state(false);
let linkOpen = $state(false);
let linkValue = $state('');
let linkInput = $state<HTMLInputElement | null>(null);
// Swatch palette for the bubble's text / background color marks — the
// SAME hues the inspector's color rows offer, minus pure white (invisible
// as a text color on a light surface; the "Default" chip clears instead).
// Each swatch applies via `Words.CommandButton` (command `color:#hex` /
// `bgcolor:#hex`) which the provider parses to a parametric `toggleMark`
// on the live selection — exactly how bold / italic apply, so a recolor
// rides the same range-restoring path with no bespoke wiring.
const COLOR_SWATCHES = [
'#e5484d',
'#f76b15',
'#ffc53d',
'#46a758',
'#0091ff',
'#8e4ec6',
'#e93d82',
'#646a73',
'#1a1a1a'
] as const;
// Blocks whose content the engine can reinterpret (setBlock / toggleList
// no-op on table / image / divider / callout), so turn-into is disabled
// elsewhere instead of offering inert options.
const CONVERTIBLE_BLOCKS = new Set(['paragraph', 'heading', 'quote', 'code', 'list']);
// When the caret sits inside a column the top-level `api.currentBlock`
// is `'columns'` — but the user is editing the NESTED block inside,
// which is the one Turn-into can convert. Resolve the effective
// block (and its heading level) by descending the selection path
// into the active column. The engine's `setBlock` already handles
// columns-recursive conversion (see block-format.ts).
const effectiveBlock = $derived.by<{
type: string;
level?: WordsHeadingLevel;
kind?: WordsListKind;
}>(() => {
const path = api.selection?.anchor.path;
if (!path || path.length === 0) {
return { type: api.currentBlock };
}
const top = api.document.children[path[0]] as WordsBlock | undefined;
if (top?.type === 'columns' && path.length >= 3) {
const col = top.columns[path[1] ?? 0];
const inner = col?.children[path[2] ?? 0];
if (inner) {
const result: { type: string; level?: WordsHeadingLevel; kind?: WordsListKind } = {
type: inner.type
};
if (inner.type === 'heading') result.level = inner.level;
if (inner.type === 'list') result.kind = inner.kind;
return result;
}
}
return {
type: api.currentBlock,
level: api.currentHeadingLevel,
kind: top?.type === 'list' ? top.kind : undefined
};
});
const canTurnInto = $derived(CONVERTIBLE_BLOCKS.has(effectiveBlock.type));
const currentListKind = $derived(effectiveBlock.kind);
const blockLabel = $derived.by(() => {
switch (effectiveBlock.type) {
case 'heading':
return `Heading ${effectiveBlock.level ?? 1}`;
case 'quote':
return 'Quote';
case 'code':
return 'Code';
case 'list':
return effectiveBlock.kind === 'ordered'
? 'Numbered list'
: effectiveBlock.kind === 'check'
? 'Check list'
: 'Bulleted list';
case 'paragraph':
return 'Text';
case 'table':
return 'Table';
case 'callout':
return 'Callout';
case 'image':
return 'Image';
case 'divider':
return 'Divider';
case 'columns':
return 'Columns';
default:
return 'Turn into';
}
});
const turnInto = $derived.by(() => [
{
id: 'paragraph',
label: 'Text',
active: effectiveBlock.type === 'paragraph',
run: () => api.applyCommand({ type: 'setBlock', block: 'paragraph' })
},
...([1, 2, 3] as const).map((level: WordsHeadingLevel) => ({
id: `heading-${level}`,
label: `Heading ${level}`,
active: effectiveBlock.type === 'heading' && (effectiveBlock.level ?? 1) === level,
run: () => api.applyCommand({ type: 'setBlock', block: 'heading', level })
})),
{
id: 'unordered-list',
label: 'Bulleted list',
active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'unordered',
run: () => api.applyCommand({ type: 'toggleList', kind: 'unordered' })
},
{
id: 'ordered-list',
label: 'Numbered list',
active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'ordered',
run: () => api.applyCommand({ type: 'toggleList', kind: 'ordered' })
},
{
id: 'check-list',
label: 'Check list',
active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'check',
run: () => api.applyCommand({ type: 'toggleList', kind: 'check' })
},
{
id: 'quote',
label: 'Quote',
active: effectiveBlock.type === 'quote',
run: () => api.applyCommand({ type: 'setBlock', block: 'quote' })
},
{
id: 'code',
label: 'Code',
active: effectiveBlock.type === 'code',
run: () => api.applyCommand({ type: 'setBlock', block: 'code' })
}
]);
const MARKS = [
{ command: 'bold', label: 'Bold', icon: Bold },
{ command: 'italic', label: 'Italic', icon: Italic },
{ command: 'underline', label: 'Underline', icon: Underline },
{ command: 'strike', label: 'Strikethrough', icon: Strikethrough },
{ command: 'code', label: 'Inline code', icon: Code }
] as const;
function openLinkEditor() {
linkValue = api.selectedLink?.href ?? '';
linkOpen = true;
void tick().then(() => {
linkInput?.focus();
linkInput?.select();
});
}
function applyLink() {
const href = linkValue.trim();
if (href) api.insertLink(href);
linkOpen = false;
}
function onLinkKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
applyLink();
} else if (event.key === 'Escape') {
event.preventDefault();
linkOpen = false;
}
}
</script>
<Words.BubbleMenu side="top">
{#snippet children(bubble)}
{#if bubble.open}
{#if linkOpen}
<div data-words-bubble-link>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={linkInput}
bind:value={linkValue}
type="url"
inputmode="url"
placeholder="Paste or type a link…"
aria-label="Link URL"
onkeydown={onLinkKeydown}
/>
<Button
variant="ghost"
size="xs"
iconOnly
aria-label="Apply link"
onpointerdown={(e: PointerEvent) => e.preventDefault()}
onclick={applyLink}
>
{#snippet icon()}<Check />{/snippet}
</Button>
</div>
{:else}
<DropdownMenu bind:open={turnIntoOpen}>
<DropdownMenu.Trigger
variant="ghost"
size="xs"
disabled={!canTurnInto}
data-words-bubble-trigger
>
{blockLabel}
{#snippet endIcon()}<ChevronDown />{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" data-words-bubble-turn-into>
{#each turnInto as item (item.id)}
<DropdownMenu.Item onSelect={item.run} data-words-bubble-into-item>
<span>{item.label}</span>
{#if item.active}<Check size="sm" />{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu>
<span data-words-bubble-divider></span>
{#each MARKS as mark (mark.command)}
{@const Glyph = mark.icon}
<Words.CommandButton command={mark.command} aria-label={mark.label}>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="xs" iconOnly>
{#snippet icon()}<Glyph />{/snippet}
</Button>
{/snippet}
</Words.CommandButton>
{/each}
<span data-words-bubble-divider></span>
<!-- Text + background colour. Each swatch is a `Words.CommandButton`
whose `color:#hex` / `bgcolor:#hex` command the provider parses
into a parametric `toggleMark` on the live selection — the same
range-restoring path bold / italic use. The "Default" chip clears
the mark (empty value). The dropdown carries `data-words-bubble-color`
so the bubble's focus scope keeps the selection alive. -->
<DropdownMenu bind:open={colorOpen}>
<DropdownMenu.Trigger
variant="ghost"
size="xs"
iconOnly
aria-label="Text and background colour"
data-words-bubble-trigger
>
{#snippet icon()}<Baseline />{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" data-words-bubble-color>
<div data-words-bubble-color-section>
<span data-words-bubble-color-label><Baseline size="sm" />Text</span>
<div data-words-bubble-swatches>
<Words.CommandButton command="color:" aria-label="Default text colour">
{#snippet child({ props })}
<button {...props} type="button" data-words-bubble-swatch data-clear></button>
{/snippet}
</Words.CommandButton>
{#each COLOR_SWATCHES as hex (hex)}
<Words.CommandButton command={`color:${hex}`} aria-label={`Text colour ${hex}`}>
{#snippet child({ props })}
<button
{...props}
type="button"
data-words-bubble-swatch
style="--words-swatch: {hex}"
></button>
{/snippet}
</Words.CommandButton>
{/each}
</div>
</div>
<div data-words-bubble-color-section>
<span data-words-bubble-color-label><Highlighter size="sm" />Background</span>
<div data-words-bubble-swatches>
<Words.CommandButton command="bgcolor:" aria-label="No background">
{#snippet child({ props })}
<button {...props} type="button" data-words-bubble-swatch data-clear></button>
{/snippet}
</Words.CommandButton>
{#each COLOR_SWATCHES as hex (hex)}
<Words.CommandButton command={`bgcolor:${hex}`} aria-label={`Background colour ${hex}`}>
{#snippet child({ props })}
<button
{...props}
type="button"
data-words-bubble-swatch
style="--words-swatch: {hex}"
></button>
{/snippet}
</Words.CommandButton>
{/each}
</div>
</div>
</DropdownMenu.Content>
</DropdownMenu>
<span data-words-bubble-divider></span>
<Button
variant="ghost"
size="xs"
iconOnly
aria-label="Link"
data-active={api.selectedLink ? '' : undefined}
onpointerdown={(e: PointerEvent) => e.preventDefault()}
onclick={openLinkEditor}
>
{#snippet icon()}<Link />{/snippet}
</Button>
{#if api.selectedLink}
<Words.CommandButton command="unlink" aria-label="Remove link">
{#snippet child({ props })}
<Button {...props} variant="ghost" size="xs" iconOnly>
{#snippet icon()}<Unlink />{/snippet}
</Button>
{/snippet}
</Words.CommandButton>
{/if}
{/if}
{/if}
{/snippet}
</Words.BubbleMenu>

Powered by TurnKey Linux.