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.
378 lines
12 KiB
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>
|