fix(palabras): panel/inspector — tab válida, alt sin base64, spacing sin fuga, draft sin doble commit

Cuatro defectos confirmados por el barrido:

- Panel (#5): tras clickear una tab (tabAuto=false), cambiar a un bloque sin
  Contenido dejaba la tab en 'contenido' (trigger no renderiza) → panel en blanco
  sin tab activa. El efecto de auto-landing ahora corrige una tab INVÁLIDA aunque
  tabAuto=false (cae a la 1a disponible). Verificado Playwright: párrafo → Diseño.

- Inspector (#7): el draft de `src` derivaba `alt` de split('/')/split('.') →
  basura base64 para data: URLs. Ahora se salta la derivación para data:.

- Spacing (#10): el override de enlace (localUnlink) se reseteaba por
  JSON.stringify(value); dos bloques sin spacing serializan igual ('null') → el
  override se filtraba entre ellos. Ahora resetea por identidad de bloque
  (resetKey=api.activeBlockId), cableado en panel + inspector.

- Draft-field (#12): Enter llamaba confirm() y luego blur() que re-dispara
  onblur=confirm antes de que `value` se propague → doble onCommit → 2 entradas
  de historial. Guard de reentrada por el flag `editing`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent 51d365a6b5
commit a85c21eb39

@ -46,8 +46,12 @@
const dirty = $derived(editing && draft !== value);
function confirm() {
if (draft !== value) onCommit(draft);
// Guard re-entry: Enter calls confirm() and then blur(), and blur re-fires
// onblur=confirm SYNCHRONOUSLY before the external `value` flushes — without
// this guard the same draft commits twice (two history entries for one edit).
if (!editing) return;
editing = false;
if (draft !== value) onCommit(draft);
}
function revert() {
draft = value;

@ -311,7 +311,10 @@
const safe = sanitizeImageSrc(draft);
if (safe === undefined) return;
write(field.key, safe);
if (field.key === 'src' && safe && valueOf('alt') == null) {
// Derive an alt guess from the filename — but ONLY for a real URL. A data:
// URL carries no filename (split('/')/split('.') would yield a base64 chunk),
// so skip it and leave alt empty for the author to fill.
if (field.key === 'src' && safe && !/^data:/i.test(safe) && valueOf('alt') == null) {
const guess = safe.split('/').pop()?.split('.')[0];
if (guess) write('alt', guess);
}
@ -477,6 +480,7 @@
label={ts(field.label)}
value={effective(field) as SpacingValue | undefined}
{disabled}
resetKey={api.activeBlockId}
onValueChange={(s) => commit(field, s)}
/>
</div>

@ -335,9 +335,22 @@
// the user picks a tab, `tabAuto` turns off and their choice persists.
let tabAuto = $state(true);
$effect(() => {
if (!tabAuto) return;
const has = (t: string) =>
t === 'contenido'
? contenidoFields.length > 0
: t === 'diseño'
? disenoSections.length > 0
: avanzadoFields.length > 0;
const next = contenidoFields.length ? 'contenido' : disenoSections.length ? 'diseño' : 'avanzado';
if (next !== tab) tab = next;
if (tabAuto) {
// Auto mode: keep landing on the first non-empty tab.
if (next !== tab) tab = next;
} else if (!has(tab)) {
// User picked a tab, then switched to a block that lacks it (only the
// Contenido trigger is conditional) — the tab would be lit-less and the
// panel blank. Fall to the first available tab even in manual mode.
tab = next;
}
});
// ── Reset to defaults — per Diseño accordion section + per whole block. Clears
@ -1025,6 +1038,7 @@
label={ts(field.label)}
value={effective(field) as SpacingValue | undefined}
{disabled}
resetKey={api.activeBlockId}
onValueChange={(s) => commit(field, s)}
/>
</div>

@ -29,6 +29,7 @@
label,
value,
disabled = false,
resetKey,
onValueChange
}: {
/** Field label — the control OWNS its header row (label + link toggle). */
@ -36,6 +37,10 @@
/** Current spacing (raw px numbers, per axis and/or per side). */
value: PalabrasSpacing | undefined;
disabled?: boolean;
/** Block identity — resets the empty-mode link override on a block switch.
* Two empty-spacing blocks serialize identically, so the override can't be
* reset from `value` alone; the caller passes the active block id. */
resetKey?: string | number | null;
/** Fired with the FULL next spacing, or `undefined` when every side is cleared. */
onValueChange: (value: PalabrasSpacing | undefined) => void;
} = $props();
@ -65,14 +70,14 @@
);
const linked = $derived(isEmpty ? !localUnlink : dataLinked);
// Drop the empty-mode override whenever the bound value changes, so a new block
// starts linked and real data drives the state. `lastKey` is non-reactive (no loop:
// the effect reads `value`, not `localUnlink`).
let lastKey = '';
// Drop the empty-mode override on a BLOCK SWITCH (identity change), so a new block
// starts linked. Keying on `value` (as before) can't distinguish two empty-spacing
// blocks — both serialize to "null" — so the override would leak across them.
// `lastKey` is non-reactive (no loop: the effect reads `resetKey`, not `localUnlink`).
let lastKey: string | number | null | undefined;
$effect(() => {
const key = JSON.stringify(value ?? null);
if (key !== lastKey) {
lastKey = key;
if (resetKey !== lastKey) {
lastKey = resetKey;
localUnlink = false;
}
});

Loading…
Cancel
Save

Powered by TurnKey Linux.