feat(words): drawer contextual panels — format/image/table/code/list (DRAWER-2)

The companion drawer now switches its body content based on where the
caret is. Five context modes wired to runCommand on the snippet
provider, all reading current state to show active highlights:

- format: B/I/U/S/code mark toggles (data-active reflects activeMarks),
  align L/C/R/J (data-active reflects currentTextAlign), Add link (URL
  prompt) + Remove (canUnlink-gated), Clear formatting.
- image: read-only view of src, alt (italic + "(missing)" placeholder
  when empty), caption, status (pending → accent tint, error → threat
  tint). Edit-image flow deferred to a follow-up (needs a new
  updateImage operation; for the spike, view-only is enough to
  validate the panel works).
- table: Rows +/- · Columns +/- · Row/Col header toggles · Striped /
  Compact toggles · Cell tone Plain/Muted/Accent. Every toggle reads
  current* state so the user sees which buttons are currently applied.
- code: Plain + TS/JS/CSS/HTML/JSON/Bash/Py language chips,
  data-active reflects currentCodeLanguage.
- list: Bullet/Numbered/Check kind chips · ⇤ Outdent / ⇥ Indent.

All buttons use onmousedown preventDefault so the editor selection
isn't dropped when clicking a drawer action (would otherwise lose the
target paragraph / table cell / list item).

CSS adds a generic .data-words-drawer-action chip (with hover, active,
ghost, full, risk tones) + readonly-text style with pending/error
tints. Reused across all panels, so adding new panels later means
markup-only, no CSS extension.

Verified in browser:
- Click table cell → drawer shows TABLE panel with Striped active
  (matches sample's striped=true) and Plain tone active (matches
  default cell tone).
- Click code block → drawer shows CODE panel with TS chip active
  (matches sample's language='ts').
- Click list item → drawer shows LIST panel with kind + indent
  actions.

Concept A+D validated visually: ONE surface replaces what would
otherwise be bubble menu + slash menu + heading picker + language
picker + table popover + image controls + link editor. The contextual
switch happens automatically; the user just moves the caret.

DRAWER-3 (polish): mobile bottom-sheet layout, A/B toggle in demo
controls, image-edit flow — deferred to next session if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 23f90a775c
commit 15dbf0f8fd

@ -174,6 +174,32 @@
function toggleOpen() { function toggleOpen() {
open = !open; open = !open;
} }
/**
* Return the image block currently containing the caret, or
* undefined if the caret is not on an image. The image panel uses
* this to display block attributes (src/alt/caption/status).
*/
function imageBlockAtCaret(
s: ProviderSnippetProps
): { readonly src: string; readonly alt?: string; readonly caption?: string; readonly status?: 'pending' | 'error' } | undefined {
const blockIndex = s.selection?.anchor.path[0];
if (blockIndex === undefined) return undefined;
const block = s.document.children[blockIndex];
if (!block || block.type !== 'image') return undefined;
return block;
}
const CODE_LANGUAGES = [
{ id: '', label: 'Plain' },
{ id: 'ts', label: 'TS' },
{ id: 'js', label: 'JS' },
{ id: 'css', label: 'CSS' },
{ id: 'html', label: 'HTML' },
{ id: 'json', label: 'JSON' },
{ id: 'bash', label: 'Bash' },
{ id: 'python', label: 'Py' }
] as const;
</script> </script>
<aside <aside
@ -251,14 +277,261 @@
</ul> </ul>
{/if} {/if}
</section> </section>
{:else} {:else if mode === 'format'}
<!-- Contextual panels land in DRAWER-2. --> <section data-words-drawer-section>
<h3 data-words-drawer-section-title>Marks</h3>
<div data-words-drawer-actions>
{#each [
{ id: 'bold', label: 'B', title: 'Bold' },
{ id: 'italic', label: 'I', title: 'Italic' },
{ id: 'underline', label: 'U', title: 'Underline' },
{ id: 'strike', label: 'S', title: 'Strikethrough' },
{ id: 'code', label: '<>', title: 'Inline code' }
] as mark (mark.id)}
<button
type="button"
data-words-drawer-action
data-active={snippet.activeMarks.includes(mark.id) ? '' : undefined}
title={mark.title}
aria-pressed={snippet.activeMarks.includes(mark.id)}
onmousedown={(e) => e.preventDefault()}
onclick={() => snippet.runCommand(mark.id)}
>
{mark.label}
</button>
{/each}
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Align</h3>
<div data-words-drawer-actions>
{#each [
{ id: 'align-left', label: '⫷', title: 'Left' },
{ id: 'align-center', label: '☰', title: 'Center' },
{ id: 'align-right', label: '⫸', title: 'Right' },
{ id: 'align-justify', label: '☷', title: 'Justify' }
] as a (a.id)}
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTextAlign === a.id.replace('align-', '') ? '' : undefined}
title={a.title}
onmousedown={(e) => e.preventDefault()}
onclick={() => snippet.runCommand(a.id)}
>
{a.label}
</button>
{/each}
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Link</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
data-tone="full"
onmousedown={(e) => e.preventDefault()}
onclick={() => {
const url = window.prompt('Link URL:');
if (url) snippet.insertLink(url);
}}
>
Add link
</button>
{#if snippet.canUnlink}
<button
type="button"
data-words-drawer-action
onmousedown={(e) => e.preventDefault()}
onclick={() => snippet.unlink()}
>
Remove
</button>
{/if}
</div>
</section>
<section data-words-drawer-section>
<button
type="button"
data-words-drawer-action
data-tone="ghost"
onmousedown={(e) => e.preventDefault()}
onclick={() => snippet.runCommand('clear-formatting')}
>
Clear formatting
</button>
</section>
{:else if mode === 'image'}
{@const block = imageBlockAtCaret(snippet)}
{#if block}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Source</h3>
<p data-words-drawer-readonly-text>{block.src}</p>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Alt text</h3>
<p data-words-drawer-readonly-text data-empty={!block.alt}>
{block.alt || '(missing)'}
</p>
</section>
{#if block.caption}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Caption</h3>
<p data-words-drawer-readonly-text>{block.caption}</p>
</section>
{/if}
{#if block.status}
<section data-words-drawer-section> <section data-words-drawer-section>
<p data-words-drawer-empty> <h3 data-words-drawer-section-title>Status</h3>
Panel for <strong>{modeLabel}</strong> coming soon. <p data-words-drawer-readonly-text data-tone={block.status}>
{block.status === 'pending' ? 'Uploading…' : 'Upload failed'}
</p> </p>
</section> </section>
{/if} {/if}
{:else}
<section data-words-drawer-section>
<p data-words-drawer-empty>Place caret on an image to edit it.</p>
</section>
{/if}
{:else if mode === 'table'}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Rows</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('insert-table-row')}
>+ Row</button>
<button
type="button"
data-words-drawer-action
data-tone="risk"
onclick={() => snippet.runCommand('delete-table-row')}
>− Row</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Columns</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('insert-table-column')}
>+ Col</button>
<button
type="button"
data-words-drawer-action
data-tone="risk"
onclick={() => snippet.runCommand('delete-table-column')}
>− Col</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Headers</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTableHeaderRowActive ? '' : undefined}
onclick={() => snippet.runCommand('toggle-table-header-row')}
>Row header</button>
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTableHeaderColumnActive ? '' : undefined}
onclick={() => snippet.runCommand('toggle-table-header-column')}
>Col header</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Appearance</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTableStriped ? '' : undefined}
onclick={() => snippet.runCommand('toggle-table-striped')}
>Striped</button>
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTableCompact ? '' : undefined}
onclick={() => snippet.runCommand('toggle-table-compact')}
>Compact</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Cell tone</h3>
<div data-words-drawer-actions>
{#each [
{ id: 'table-cell-tone-default', label: 'Plain' },
{ id: 'table-cell-tone-muted', label: 'Muted' },
{ id: 'table-cell-tone-accent', label: 'Accent' }
] as t (t.id)}
<button
type="button"
data-words-drawer-action
data-active={snippet.currentTableCellTone === t.id.replace('table-cell-tone-', '') ? '' : undefined}
onclick={() => snippet.runCommand(t.id)}
>{t.label}</button>
{/each}
</div>
</section>
{:else if mode === 'code'}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Language</h3>
<div data-words-drawer-actions>
{#each CODE_LANGUAGES as lang (lang.id)}
<button
type="button"
data-words-drawer-action
data-active={snippet.currentCodeLanguage === lang.id ? '' : undefined}
onclick={() => snippet.setCodeLanguage(lang.id || undefined)}
>{lang.label}</button>
{/each}
</div>
</section>
{:else if mode === 'list'}
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>List kind</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('unordered-list')}
>Bullet</button>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('ordered-list')}
>Numbered</button>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('check-list')}
>Check</button>
</div>
</section>
<section data-words-drawer-section>
<h3 data-words-drawer-section-title>Indent</h3>
<div data-words-drawer-actions>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('decrease-indent')}
>⇤ Outdent</button>
<button
type="button"
data-words-drawer-action
onclick={() => snippet.runCommand('increase-indent')}
>⇥ Indent</button>
</div>
</section>
{/if}
</div> </div>
{/if} {/if}
</aside> </aside>

@ -368,6 +368,89 @@
color: var(--words-risk-text); color: var(--words-risk-text);
} }
/* Contextual panel actions (DRAWER-2) ---------------------------------- */
[data-words-drawer-actions] {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
[data-words-drawer-action] {
display: inline-flex;
align-items: center;
justify-content: center;
min-inline-size: 2rem;
padding-block: 0.3rem;
padding-inline: 0.55rem;
border: var(--words-border-width) solid
color-mix(in srgb, var(--words-toolbar-border) 80%, transparent);
border-radius: var(--words-command-radius);
background: var(--words-bg);
color: var(--words-command-color);
font-size: 0.75rem;
font-weight: var(--words-strong-font-weight);
cursor: pointer;
transition:
background-color 100ms ease,
border-color 100ms ease;
}
[data-words-drawer-action]:hover {
background: color-mix(in srgb, var(--_words-accent-solid) 8%, var(--words-bg));
border-color: var(--_words-accent-border);
}
[data-words-drawer-action][data-active] {
background: color-mix(in srgb, var(--_words-accent-solid) 18%, var(--words-bg));
border-color: var(--_words-accent-border);
color: var(--_words-accent-text);
}
[data-words-drawer-action][data-tone='full'] {
background: var(--_words-accent-solid);
border-color: var(--_words-accent-solid);
color: var(--_words-accent-on);
}
[data-words-drawer-action][data-tone='ghost'] {
background: transparent;
border-style: dashed;
}
[data-words-drawer-action][data-tone='risk']:hover {
background: color-mix(in srgb, var(--words-risk-text) 15%, var(--words-bg));
border-color: var(--words-risk-text);
color: var(--words-risk-text);
}
[data-words-drawer-readonly-text] {
margin: 0;
padding-block: 0.3rem;
padding-inline: 0.5rem;
border-radius: var(--words-command-radius);
background: color-mix(in srgb, var(--words-toolbar-border) 30%, transparent);
color: var(--words-command-color);
font-size: 0.75rem;
line-height: 1.35;
word-break: break-all;
}
[data-words-drawer-readonly-text][data-empty='true'] {
color: var(--words-status-color);
font-style: italic;
}
[data-words-drawer-readonly-text][data-tone='pending'] {
background: color-mix(in srgb, var(--_words-accent-solid) 10%, transparent);
color: var(--_words-accent-text);
}
[data-words-drawer-readonly-text][data-tone='error'] {
background: color-mix(in srgb, var(--words-threat-text) 12%, transparent);
color: var(--words-threat-text);
}
[data-words]:has([data-words-toolbar][data-orientation='vertical']):has([data-words-find-replace]) { [data-words]:has([data-words-toolbar][data-orientation='vertical']):has([data-words-find-replace]) {
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto;
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.