feat(words): drawer follows emptiness — Insert when empty, Inspect when filled

User-clarified rule for the right drawer: when the current block is
empty, the drawer shows ONLY the constructors (Insert panel) — no
point in showing format/block-scope options on a blank line. When
the block has any content, the inspector for that content type
appears (paragraph + text-align, heading, image, code, etc.).

Implementation:

- New helper `isEmptyEditableTextBlock(block)` next to the slash
  command catalog. It returns true for paragraph / heading / quote /
  code-without-language whose single text child is the empty string.
  Atomic blocks (image / table / list) never qualify — they always
  carry structure.

- `WordsDrawerProvider.modes` derivation gates the scope-stack
  computation behind `!isEmptyTextBlock(currentNode)`. Empty text
  blocks fall through directly to `['default']` (just Insert);
  blocks with content build the scope stack as before (format /
  block / cell / row / table / list-item / list) and Insert
  collapses at the bottom of the stack.

- Atomic blocks (image / code with language) still push their
  scope unconditionally because their "content" lives in
  attributes, not children.

Before: clicking "+" → empty paragraph inserted → caret on it →
drawer showed `['block', 'default']` with Block expanded
(text-align controls for an empty line — useless), Insert
collapsed below.

After: clicking "+" → empty paragraph inserted → caret on it →
drawer shows `['default']` only (Insert expanded). As soon as the
user types a character, Block panel appears above with Insert
collapsed underneath.

152/152 soma words tests still pass; `npm run check` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 2bf6aef035
commit 377168550c

@ -2645,12 +2645,24 @@ export class WordsDrawerProvider {
const scopeStack: WordsDrawerMode[] = [];
// Emptiness gate — if the current block is a text-style block
// with no content, skip the scope entirely so the drawer only
// shows Insert (no point in formatting an empty line). Atomic
// blocks (image/table/code-with-language) always have content
// and pass through. Per user spec: "cuando el bloque está vacío
// solo se muestran los constructores; cuando ya tiene contenido,
// las opciones del objeto".
const currentIdx = sel?.anchor.path[0] ?? this.provider.selectedBlockIndex ?? -1;
const currentNode =
currentIdx >= 0 ? this.provider.document.children[currentIdx] : undefined;
const isEmptyTextBlock = isEmptyEditableTextBlock(currentNode);
// Atomic blocks (no inline format, no parent scopes)
if (block === 'image') {
scopeStack.push('image');
} else if (block === 'code') {
scopeStack.push('code');
} else if (sel) {
} else if (sel && !isEmptyTextBlock) {
// Inline-text format is the most specific scope. Only meaningful
// when there's a non-collapsed selection AND the current block
// can host inline marks (excludes 'code' which is plain text).
@ -2941,6 +2953,39 @@ const DEFAULT_SLASH_COMMANDS = [
}
] as const satisfies readonly WordsSlashCommandItem[];
/**
* "Empty text block" check used by the drawer's modes derivation
* (Insert ↔ Inspect rule): if the current block is a text-style
* block with no content, the drawer shows ONLY the Insert panel
* instead of the scope inspector. Paragraph / heading / quote /
* code-without-language qualify when their single text child is
* the empty string. Atomic blocks (image / table / list) never
* "look empty" — they always carry structure or content.
*/
function isEmptyEditableTextBlock(block: unknown): boolean {
if (!block || typeof block !== 'object') return false;
const b = block as {
type?: string;
language?: string;
children?: readonly { type?: string; text?: string }[];
};
if (
b.type !== 'paragraph' &&
b.type !== 'heading' &&
b.type !== 'quote' &&
b.type !== 'code'
) {
return false;
}
// A code block with a language already carries intent — even an
// empty body deserves the Code inspector so the user can change it.
if (b.type === 'code' && b.language) return false;
const kids = b.children ?? [];
if (kids.length !== 1) return false;
const only = kids[0];
return only?.type === 'text' && (only.text ?? '') === '';
}
function filterSlashCommandItems(query: string): readonly WordsSlashCommandItem[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return DEFAULT_SLASH_COMMANDS;

Loading…
Cancel
Save

Powered by TurnKey Linux.