fix(web): expose hidden edit targets in layers (#2067)

* fix(web): expose hidden edit targets in layers

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): respect visibility overrides in edit layers

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): keep hidden layout targets editable

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): address hidden layer review followups

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
kami 2026-05-24 22:22:29 +08:00 committed by GitHub
parent 1b9caf50a0
commit 94421f9676
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 491 additions and 105 deletions

View file

@ -22,10 +22,12 @@ export function emptyManualEditDraft(source = ''): ManualEditDraft {
}
export function ManualEditPanel({
targets,
selectedTarget,
draft,
error,
canUndo,
onSelectTarget,
onDraftChange,
onStyleChange,
onInvalidStyle,
@ -114,107 +116,132 @@ export function ManualEditPanel({
};
return (
<aside className="manual-edit-right">
<section className="manual-edit-modal cc-panel">
<div className="manual-edit-tabs" role="tablist" aria-label="Manual edit tabs">
{(targetForInspector ? ELEMENT_TABS : PAGE_TABS).map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={tab === item.id}
className={tab === item.id ? 'selected' : ''}
onClick={() => setActiveTab(item.id)}
>
{item.label}
</button>
))}
<>
<section className="manual-edit-layers">
<div className="manual-edit-panel-head">
<h3>{t('manualEdit.layers')}</h3>
<span>{targets.length}</span>
</div>
{targetForInspector ? (
<>
{tab === 'content' ? (
<ContentEditor
target={targetForInspector}
draft={draft}
busy={busy}
onDraftChange={onDraftChange}
onApply={applyContent}
/>
) : null}
{tab === 'style' ? (
<StyleInspector
styles={draft.styles}
layoutEnabled={targetForInspector.isLayoutContainer}
onClearSelection={onClearSelection}
onChange={changeTargetStyle}
/>
) : null}
{tab === 'attributes' ? (
<div className="manual-edit-tab-body">
<label className="manual-edit-field">
<span>Attributes JSON</span>
<textarea
className="manual-edit-code"
value={draft.attributesText}
onChange={(event) => onDraftChange({ ...draft, attributesText: event.currentTarget.value })}
/>
</label>
<button type="button" className="btn btn-primary" disabled={busy} onClick={applyAttributes}>Apply Attributes</button>
</div>
) : null}
{tab === 'html' ? (
<div className="manual-edit-tab-body">
<label className="manual-edit-field">
<span>Selected element HTML</span>
<textarea
className="manual-edit-code tall"
value={draft.outerHtml}
onChange={(event) => onDraftChange({ ...draft, outerHtml: event.currentTarget.value })}
/>
</label>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => onApplyPatch({ id: targetForInspector.id, kind: 'set-outer-html', html: draft.outerHtml }, `HTML: ${targetForInspector.label}`)}
>
Apply HTML
</button>
</div>
) : null}
{tab === 'source' ? (
<div className="manual-edit-layer-list">
{targets.length > 0 ? targets.map((target) => (
<button
key={target.id}
type="button"
className={`manual-edit-layer-row${selectedTarget?.id === target.id ? ' selected' : ''}`}
onClick={() => onSelectTarget(target)}
>
<strong>{target.label}</strong>
<span>
{target.tagName}
{target.isHidden ? ` - ${t('manualEdit.hiddenBadge')}` : ''}
</span>
</button>
)) : (
<p className="manual-edit-empty">{t('manualEdit.noEditableLayers')}</p>
)}
</div>
</section>
<aside className="manual-edit-right">
<section className="manual-edit-modal cc-panel">
<div className="manual-edit-tabs" role="tablist" aria-label="Manual edit tabs">
{(targetForInspector ? ELEMENT_TABS : PAGE_TABS).map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={tab === item.id}
className={tab === item.id ? 'selected' : ''}
onClick={() => setActiveTab(item.id)}
>
{item.label}
</button>
))}
</div>
{targetForInspector ? (
<>
{tab === 'content' ? (
<ContentEditor
target={targetForInspector}
draft={draft}
busy={busy}
onDraftChange={onDraftChange}
onApply={applyContent}
/>
) : null}
{tab === 'style' ? (
<StyleInspector
styles={draft.styles}
layoutEnabled={targetForInspector.isLayoutContainer}
onClearSelection={onClearSelection}
onChange={changeTargetStyle}
/>
) : null}
{tab === 'attributes' ? (
<div className="manual-edit-tab-body">
<label className="manual-edit-field">
<span>Attributes JSON</span>
<textarea
className="manual-edit-code"
value={draft.attributesText}
onChange={(event) => onDraftChange({ ...draft, attributesText: event.currentTarget.value })}
/>
</label>
<button type="button" className="btn btn-primary" disabled={busy} onClick={applyAttributes}>Apply Attributes</button>
</div>
) : null}
{tab === 'html' ? (
<div className="manual-edit-tab-body">
<label className="manual-edit-field">
<span>Selected element HTML</span>
<textarea
className="manual-edit-code tall"
value={draft.outerHtml}
onChange={(event) => onDraftChange({ ...draft, outerHtml: event.currentTarget.value })}
/>
</label>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => onApplyPatch({ id: targetForInspector.id, kind: 'set-outer-html', html: draft.outerHtml }, `HTML: ${targetForInspector.label}`)}
>
Apply HTML
</button>
</div>
) : null}
{tab === 'source' ? (
<SourceEditor
draft={draft}
busy={busy}
onDraftChange={onDraftChange}
onApply={() => onApplyPatch({ kind: 'set-full-source', source: draft.fullSource }, 'Full source')}
/>
) : null}
</>
) : !targetForInspector ? (
tab === 'source' ? (
<SourceEditor
draft={draft}
busy={busy}
onDraftChange={onDraftChange}
onApply={() => onApplyPatch({ kind: 'set-full-source', source: draft.fullSource }, 'Full source')}
/>
) : null}
</>
) : !targetForInspector ? (
tab === 'source' ? (
<SourceEditor
draft={draft}
busy={busy}
onDraftChange={onDraftChange}
onApply={() => onApplyPatch({ kind: 'set-full-source', source: draft.fullSource }, 'Full source')}
/>
) : (
<PageInspector
enabled={pageStylesEnabled}
onStyleChange={(styles) => {
const normalized = normalizeManualEditStyles(styles, { layoutEnabled: true });
if (!normalized.ok) {
onError(normalized.error);
onInvalidStyle?.('__body__', Object.keys(styles) as Array<keyof ManualEditStyles>);
return;
}
onError('');
onStyleChange?.('__body__', normalized.styles, 'Page styles');
}}
/>
)
) : null}
) : (
<PageInspector
enabled={pageStylesEnabled}
onStyleChange={(styles) => {
const normalized = normalizeManualEditStyles(styles, { layoutEnabled: true });
if (!normalized.ok) {
onError(normalized.error);
onInvalidStyle?.('__body__', Object.keys(styles) as Array<keyof ManualEditStyles>);
return;
}
onError('');
onStyleChange?.('__body__', normalized.styles, 'Page styles');
}}
/>
)
) : null}
{targetForInspector?.kind === 'image' && onPickImage ? (
<div className="cc-section">
@ -299,8 +326,9 @@ export function ManualEditPanel({
) : null}
{error ? <div className="manual-edit-error">{error}</div> : null}
</section>
</aside>
</section>
</aside>
</>
);
}

View file

@ -119,12 +119,31 @@ export function buildManualEditBridge(enabled: boolean): string {
}
function isLayoutContainer(el){
var display = window.getComputedStyle(el).display || '';
return display.indexOf('flex') >= 0 || display.indexOf('grid') >= 0;
if (display.indexOf('flex') >= 0 || display.indexOf('grid') >= 0) return true;
return hasOwnDisplayHiddenState(el) && inferKind(el) === 'container';
}
function hasOwnDisplayHiddenState(el){
var computed = window.getComputedStyle(el);
return computed.display === 'none' || el.hasAttribute('hidden');
}
function hasHiddenAncestorDisplayState(el){
var node = el;
while (node && node !== document.documentElement) {
if (hasOwnDisplayHiddenState(node)) return true;
node = node.parentElement;
}
return false;
}
function isHiddenTarget(el, rect){
var targetVisibility = window.getComputedStyle(el).visibility;
if (targetVisibility === 'hidden' || targetVisibility === 'collapse') return true;
return hasHiddenAncestorDisplayState(el);
}
function targetFrom(el, includeOuterHtml){
var rect = el.getBoundingClientRect();
var kind = inferKind(el);
var id = stableId(el);
var hidden = isHiddenTarget(el, rect);
var fields = {};
if (kind === 'link') {
fields.text = (el.textContent || '').trim();
@ -147,6 +166,7 @@ export function buildManualEditBridge(enabled: boolean): string {
attributes: attrsFor(el),
styles: stylesFor(el),
isLayoutContainer: isLayoutContainer(el),
isHidden: hidden,
outerHtml: includeOuterHtml ? (el.outerHTML || '').replace(/\\sdata-od-runtime-id="[^"]*"/g, '').replace(/\\sdata-od-source-path="[^"]*"/g, '').replace(/\\sdata-od-edit-selected="[^"]*"/g, '') : ''
};
}
@ -155,8 +175,8 @@ export function buildManualEditBridge(enabled: boolean): string {
var targets = [];
for (var i = 0; i < nodes.length; i++) {
var rect = nodes[i].getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) continue;
if (!isSourceMappable(nodes[i])) continue;
if (!isHiddenTarget(nodes[i], rect) && (rect.width < 4 || rect.height < 4)) continue;
targets.push(targetFrom(nodes[i], false));
}
return targets;

View file

@ -63,6 +63,7 @@ export interface ManualEditTarget {
attributes: Record<string, string>;
styles: ManualEditStyles;
isLayoutContainer: boolean;
isHidden?: boolean;
outerHtml: string;
}

View file

@ -976,9 +976,11 @@ export const ar: Dict = {
'fileViewer.draw': 'رسم',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -864,9 +864,11 @@ export const de: Dict = {
'fileViewer.draw': 'Zeichnen',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -1567,9 +1567,11 @@ export const en: Dict = {
'fileViewer.draw': 'Draw',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -865,9 +865,11 @@ export const esES: Dict = {
'fileViewer.draw': 'Dibujar',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -1000,9 +1000,11 @@ export const fa: Dict = {
'fileViewer.draw': 'رسم',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -992,9 +992,11 @@ export const fr: Dict = {
'fileViewer.draw': 'Dessiner',
'manualEdit.layers': "Calques",
'manualEdit.editableCount': "{count} modifiable(s)",
'manualEdit.hiddenBadge': "Masqué",
'manualEdit.title': "Éditeur manuel",
'manualEdit.selectLayer': "Sélectionnez un calque",
'manualEdit.empty': "Cliquez sur un élément dans laperçu ou choisissez un calque.",
'manualEdit.noEditableLayers': "Aucun calque modifiable trouvé.",
'manualEdit.noClass': "aucune classe",
'manualEdit.tabsAria': "Onglets dédition manuelle",
'manualEdit.tabContent': "Contenu",

View file

@ -976,9 +976,11 @@ export const hu: Dict = {
'fileViewer.draw': 'Rajz',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -1093,9 +1093,11 @@ export const id: Dict = {
'manualEdit.layers': 'Lapisan',
'manualEdit.editableCount': '{count} dapat diedit',
'manualEdit.hiddenBadge': 'Tersembunyi',
'manualEdit.title': 'Editor manual',
'manualEdit.selectLayer': 'Pilih lapisan',
'manualEdit.empty': 'Klik elemen di pratinjau atau pilih lapisan.',
'manualEdit.noEditableLayers': 'Tidak ada lapisan yang dapat diedit.',
'manualEdit.noClass': 'tanpa class',
'manualEdit.tabsAria': 'Tab edit manual',
'manualEdit.tabContent': 'Konten',

View file

@ -891,9 +891,11 @@ export const it: Dict = {
'fileViewer.draw': 'Disegna',
'manualEdit.layers': 'Livelli',
'manualEdit.editableCount': '{count} modificabile',
'manualEdit.hiddenBadge': 'Nascosto',
'manualEdit.title': 'Editor manuale',
'manualEdit.selectLayer': 'Seleziona un livello',
'manualEdit.empty': 'Clicca un elemento nell\'anteprima o scegli un livello.',
'manualEdit.noEditableLayers': 'Nessun livello modificabile trovato.',
'manualEdit.noClass': 'nessuna classe',
'manualEdit.tabsAria': 'Schede di modifica manuale',
'manualEdit.tabContent': 'Contenuto',

View file

@ -863,9 +863,11 @@ export const ja: Dict = {
'fileViewer.draw': '描画',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -976,9 +976,11 @@ export const ko: Dict = {
'fileViewer.draw': '그리기',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -976,9 +976,11 @@ export const pl: Dict = {
'fileViewer.draw': 'Rysuj',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -999,9 +999,11 @@ export const ptBR: Dict = {
'fileViewer.draw': 'Desenhar',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -999,9 +999,11 @@ export const ru: Dict = {
'fileViewer.draw': 'Рисовать',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -916,9 +916,11 @@ export const th: Dict = {
'fileViewer.draw': 'วาดรูป',
'manualEdit.layers': "เลเยอร์",
'manualEdit.editableCount': "ใช้แก้ได้ {count} รูปแบบ",
'manualEdit.hiddenBadge': "ซ่อน",
'manualEdit.title': "กล่องควบคุม",
'manualEdit.selectLayer': "เลือกเลเยอร์ขึ้นมา",
'manualEdit.empty': "เลือกกล่องด้านบนเพื่อเปิดดู",
'manualEdit.noEditableLayers': "ไม่พบเลเยอร์ที่แก้ไขได้",
'manualEdit.noClass': "ไร้คลาสสไตล์",
'manualEdit.tabsAria': "แท็บปรับโครง",
'manualEdit.tabContent': "ตัวหนังสือ",

View file

@ -963,9 +963,11 @@ export const tr: Dict = {
'fileViewer.draw': 'Çiz',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -1018,9 +1018,11 @@ export const uk: Dict = {
'fileViewer.draw': 'Малювати',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -1556,9 +1556,11 @@ export const zhCN: Dict = {
'fileViewer.draw': '绘制',
'manualEdit.layers': '图层',
'manualEdit.editableCount': '{count} 个可编辑元素',
'manualEdit.hiddenBadge': '隐藏',
'manualEdit.title': '手动编辑器',
'manualEdit.selectLayer': '选择图层',
'manualEdit.empty': '在预览中点击元素,或选择一个图层。',
'manualEdit.noEditableLayers': '未找到可编辑图层。',
'manualEdit.noClass': '无类名',
'manualEdit.tabsAria': '手动编辑选项卡',
'manualEdit.tabContent': '内容',

View file

@ -1167,9 +1167,11 @@ export const zhTW: Dict = {
'fileViewer.draw': '繪製',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "隱藏",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "未找到可編輯圖層。",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",

View file

@ -1882,9 +1882,11 @@ export interface Dict {
'fileViewer.draw': string;
'manualEdit.layers': string;
'manualEdit.editableCount': string;
'manualEdit.hiddenBadge': string;
'manualEdit.title': string;
'manualEdit.selectLayer': string;
'manualEdit.empty': string;
'manualEdit.noEditableLayers': string;
'manualEdit.noClass': string;
'manualEdit.tabsAria': string;
'manualEdit.tabContent': string;

View file

@ -22053,16 +22053,17 @@ body.desktop-pet-shell .pet-task-item {
/* Manual edit mode */
.manual-edit-workspace {
display: grid;
grid-template-columns: minmax(420px, 1fr) 280px;
grid-template-columns: 240px minmax(420px, 1fr) 280px;
gap: 10px;
height: 100%;
min-height: 0;
padding: 10px;
background: var(--bg);
}
.manual-edit-workspace > .manual-edit-canvas { grid-column: 1; grid-row: 1; }
.manual-edit-workspace > .manual-edit-canvas { grid-column: 2; grid-row: 1; }
.manual-edit-workspace > .manual-edit-layers { grid-column: 1; grid-row: 1; }
.manual-edit-workspace > .manual-edit-right {
grid-column: 2;
grid-column: 3;
grid-row: 1;
height: 100%;
min-height: 0;

View file

@ -3,6 +3,7 @@ import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Simulate } from 'react-dom/test-utils';
import { JSDOM } from 'jsdom';
import { I18nProvider } from '../../src/i18n';
import { ManualEditPanel, emptyManualEditDraft, manualEditPatchSummary, normalizeManualEditStyles, type ManualEditDraft } from '../../src/components/ManualEditPanel';
import { emptyManualEditStyles, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../../src/edit-mode/types';
@ -119,6 +120,55 @@ describe('ManualEditPanel', () => {
expect(onClearSelection).toHaveBeenCalledTimes(1);
});
it('lists hidden targets so they can be selected outside the canvas', () => {
const onSelectTarget = vi.fn();
const hiddenTarget: ManualEditTarget = {
...target,
id: 'authors',
label: 'Authors',
tagName: 'section',
kind: 'container',
rect: { x: 0, y: 0, width: 0, height: 0 },
isHidden: true,
};
renderPanel({ targets: [target, hiddenTarget], selectedTarget: null, onSelectTarget });
const hiddenRow = Array.from(host.querySelectorAll('.manual-edit-layer-row'))
.find((row) => row.textContent?.includes('Authors')) as HTMLButtonElement | undefined;
if (!hiddenRow) throw new Error('Hidden target row not found');
expect(hiddenRow.textContent).toContain('Hidden');
act(() => {
hiddenRow.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
expect(onSelectTarget).toHaveBeenCalledWith(hiddenTarget);
});
it('renders layer panel labels from the active locale', () => {
const hiddenTarget: ManualEditTarget = {
...target,
id: 'authors',
label: 'Authors',
tagName: 'section',
kind: 'container',
isHidden: true,
};
renderPanel({ targets: [hiddenTarget], selectedTarget: null, locale: 'fr' });
expect(host.textContent).toContain('Calques');
expect(host.textContent).toContain('Masqué');
expect(host.textContent).not.toContain('Layers');
expect(host.textContent).not.toContain('Hidden');
});
it('renders the empty layers message from the active locale', () => {
renderPanel({ targets: [], selectedTarget: null, locale: 'fr' });
expect(host.textContent).toContain('Aucun calque modifiable trouvé.');
expect(host.textContent).not.toContain('No editable layers found.');
});
it('normalizes font stacks and writes a usable font-family value', () => {
const onDraftChange = vi.fn();
const onStyleChange = vi.fn();
@ -464,6 +514,48 @@ describe('ManualEditPanel', () => {
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { flexDirection: 'column' }, 'Style: Hero Title');
});
it('keeps layout controls enabled for hidden layout containers', () => {
const onStyleChange = vi.fn();
const hiddenLayoutTarget: ManualEditTarget = {
...target,
id: 'hidden-section',
label: 'Hidden Section',
tagName: 'section',
kind: 'container',
rect: { x: 0, y: 0, width: 0, height: 0 },
isHidden: true,
isLayoutContainer: true,
};
renderPanel({
onStyleChange,
targets: [hiddenLayoutTarget],
selectedTarget: hiddenLayoutTarget,
styles: {
...emptyManualEditStyles(),
gap: '12px',
flexDirection: 'row',
},
});
const layoutSection = sectionByTitle('LAYOUT');
expect(layoutSection.classList.contains('cc-section-inactive')).toBe(false);
expect(layoutSection.textContent).not.toContain('Select a container or group to edit layout.');
const gapIncrease = layoutSection.querySelector('button[aria-label="Gap increase"]') as HTMLButtonElement | null;
const directionSelect = layoutSection.querySelector('select') as HTMLSelectElement | null;
if (!gapIncrease || !directionSelect) throw new Error('Layout controls not found');
expect(gapIncrease.disabled).toBe(false);
expect(directionSelect.disabled).toBe(false);
act(() => {
gapIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
directionSelect.value = 'column';
directionSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('hidden-section', { gap: '13px' }, 'Style: Hidden Section');
expect(onStyleChange).toHaveBeenCalledWith('hidden-section', { flexDirection: 'column' }, 'Style: Hidden Section');
});
it('summarizes full-source history entries without rendering the full file', () => {
const source = '<html><body>' + 'x'.repeat(10_000) + '</body></html>';
@ -501,12 +593,15 @@ describe('ManualEditPanel', () => {
onStyleChange = vi.fn<OnStyleChange>(),
onInvalidStyle = vi.fn<OnInvalidStyle>(),
onClearSelection = vi.fn<OnClearSelection>(),
onSelectTarget = vi.fn<(target: ManualEditTarget) => void>(),
attributesText = '{}',
targets = [target],
selectedTarget = target,
styles = emptyManualEditStyles(),
pageStylesEnabled = true,
outerHtml = target.outerHtml,
fullSource = '<html></html>',
locale,
}: {
onDraftChange?: OnDraftChange;
onApplyPatch?: OnApplyPatch;
@ -514,12 +609,15 @@ describe('ManualEditPanel', () => {
onStyleChange?: OnStyleChange;
onInvalidStyle?: OnInvalidStyle;
onClearSelection?: OnClearSelection;
onSelectTarget?: (target: ManualEditTarget) => void;
attributesText?: string;
targets?: ManualEditTarget[];
selectedTarget?: ManualEditTarget | null;
styles?: ReturnType<typeof emptyManualEditStyles>;
pageStylesEnabled?: boolean;
outerHtml?: string;
fullSource?: string;
locale?: 'en' | 'fr';
} = {}) {
const draft = {
...emptyManualEditDraft(fullSource),
@ -529,9 +627,9 @@ describe('ManualEditPanel', () => {
outerHtml,
};
act(() => {
root.render(
const panel = (
<ManualEditPanel
targets={[target]}
targets={targets}
selectedTarget={selectedTarget}
draft={draft}
history={[]}
@ -539,7 +637,7 @@ describe('ManualEditPanel', () => {
canUndo={false}
canRedo={false}
pageStylesEnabled={pageStylesEnabled}
onSelectTarget={vi.fn<(target: ManualEditTarget) => void>()}
onSelectTarget={onSelectTarget}
onDraftChange={onDraftChange}
onStyleChange={onStyleChange}
onInvalidStyle={onInvalidStyle}
@ -549,7 +647,10 @@ describe('ManualEditPanel', () => {
onCancelDraft={vi.fn<() => void>()}
onUndo={vi.fn<() => void>()}
onRedo={vi.fn<() => void>()}
/>,
/>
);
root.render(
locale ? <I18nProvider initial={locale}>{panel}</I18nProvider> : panel,
);
});
}

View file

@ -48,6 +48,201 @@ describe('manual edit bridge target normalization', () => {
expect(isMeaningfulManualEditElement(script, { width: 80, height: 24 })).toBe(false);
});
it('keeps source-mappable display:none targets available for the layers panel', async () => {
const posts: Array<{ type?: string; targets?: Array<{ id: string; isHidden?: boolean }> }> = [];
const dom = new JSDOM(
`<main>
<h1 data-od-source-path="path-0-0">Visible title</h1>
<section data-od-source-path="path-0-1" style="display:none">
<p data-od-source-path="path-0-1-0">Hidden author notes</p>
</section>
</main>${buildManualEditBridge(true)}`,
{ runScripts: 'dangerously', url: 'http://localhost' },
);
const visible = dom.window.document.querySelector('h1')!;
const hiddenSection = dom.window.document.querySelector('section')!;
const hiddenParagraph = dom.window.document.querySelector('p')!;
visible.getBoundingClientRect = () => ({
x: 0, y: 0, width: 160, height: 32,
top: 0, right: 160, bottom: 32, left: 0,
toJSON: () => ({}),
} as DOMRect);
hiddenSection.getBoundingClientRect = () => ({
x: 0, y: 0, width: 0, height: 0,
top: 0, right: 0, bottom: 0, left: 0,
toJSON: () => ({}),
} as DOMRect);
hiddenParagraph.getBoundingClientRect = hiddenSection.getBoundingClientRect;
dom.window.parent.postMessage = ((message: unknown) => {
posts.push(message as { type?: string; targets?: Array<{ id: string; isHidden?: boolean }> });
}) as typeof dom.window.parent.postMessage;
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
data: { type: 'od-edit-mode', enabled: true },
}));
await new Promise((resolve) => dom.window.setTimeout(resolve, 0));
const targetsMessage = posts.find((message) => message.type === 'od-edit-targets');
expect(targetsMessage?.targets?.map((target) => target.id)).toEqual([
'path-0-0',
'path-0-1',
'path-0-1-0',
]);
expect(targetsMessage?.targets?.find((target) => target.id === 'path-0-1')?.isHidden).toBe(true);
expect(targetsMessage?.targets?.find((target) => target.id === 'path-0-1-0')?.isHidden).toBe(true);
dom.window.close();
});
it('treats hidden containers as layout editable targets', async () => {
const posts: Array<{ type?: string; targets?: Array<{ id: string; isHidden?: boolean; isLayoutContainer?: boolean }> }> = [];
const dom = new JSDOM(
`<main>
<section data-od-source-path="path-0-0" style="display:none">
<p data-od-source-path="path-0-0-0">Hidden layout copy</p>
</section>
</main>${buildManualEditBridge(true)}`,
{ runScripts: 'dangerously', url: 'http://localhost' },
);
const section = dom.window.document.querySelector('section')!;
const paragraph = dom.window.document.querySelector('p')!;
section.getBoundingClientRect = () => ({
x: 0, y: 0, width: 0, height: 0,
top: 0, right: 0, bottom: 0, left: 0,
toJSON: () => ({}),
} as DOMRect);
paragraph.getBoundingClientRect = section.getBoundingClientRect;
dom.window.parent.postMessage = ((message: unknown) => {
posts.push(message as { type?: string; targets?: Array<{ id: string; isHidden?: boolean; isLayoutContainer?: boolean }> });
}) as typeof dom.window.parent.postMessage;
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
data: { type: 'od-edit-mode', enabled: true },
}));
await new Promise((resolve) => dom.window.setTimeout(resolve, 0));
const targetsMessage = posts.find((message) => message.type === 'od-edit-targets');
const hiddenSection = targetsMessage?.targets?.find((target) => target.id === 'path-0-0');
const hiddenParagraph = targetsMessage?.targets?.find((target) => target.id === 'path-0-0-0');
expect(hiddenSection?.isHidden).toBe(true);
expect(hiddenSection?.isLayoutContainer).toBe(true);
expect(hiddenParagraph?.isLayoutContainer).toBe(false);
dom.window.close();
});
it('does not treat visibility-hidden block containers as layout editable targets', async () => {
const posts: Array<{ type?: string; targets?: Array<{ id: string; isHidden?: boolean; isLayoutContainer?: boolean }> }> = [];
const dom = new JSDOM(
`<main>
<section data-od-source-path="path-0-0" style="visibility:hidden">
<p data-od-source-path="path-0-0-0">Hidden block copy</p>
</section>
</main>${buildManualEditBridge(true)}`,
{ runScripts: 'dangerously', url: 'http://localhost' },
);
const section = dom.window.document.querySelector('section')!;
const paragraph = dom.window.document.querySelector('p')!;
section.getBoundingClientRect = () => ({
x: 0, y: 0, width: 160, height: 32,
top: 0, right: 160, bottom: 32, left: 0,
toJSON: () => ({}),
} as DOMRect);
paragraph.getBoundingClientRect = () => ({
x: 8, y: 8, width: 140, height: 20,
top: 8, right: 148, bottom: 28, left: 8,
toJSON: () => ({}),
} as DOMRect);
dom.window.parent.postMessage = ((message: unknown) => {
posts.push(message as { type?: string; targets?: Array<{ id: string; isHidden?: boolean; isLayoutContainer?: boolean }> });
}) as typeof dom.window.parent.postMessage;
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
data: { type: 'od-edit-mode', enabled: true },
}));
await new Promise((resolve) => dom.window.setTimeout(resolve, 0));
const targetsMessage = posts.find((message) => message.type === 'od-edit-targets');
const hiddenSection = targetsMessage?.targets?.find((target) => target.id === 'path-0-0');
expect(hiddenSection?.isHidden).toBe(true);
expect(hiddenSection?.isLayoutContainer).toBe(false);
dom.window.close();
});
it('does not treat block containers hidden only by an ancestor as layout editable targets', async () => {
const posts: Array<{ type?: string; targets?: Array<{ id: string; isHidden?: boolean; isLayoutContainer?: boolean }> }> = [];
const dom = new JSDOM(
`<main>
<div data-od-source-path="path-0-0" style="display:none">
<section data-od-source-path="path-0-0-0">Nested hidden section</section>
</div>
</main>${buildManualEditBridge(true)}`,
{ runScripts: 'dangerously', url: 'http://localhost' },
);
const wrapper = dom.window.document.querySelector('div')!;
const section = dom.window.document.querySelector('section')!;
wrapper.getBoundingClientRect = () => ({
x: 0, y: 0, width: 0, height: 0,
top: 0, right: 0, bottom: 0, left: 0,
toJSON: () => ({}),
} as DOMRect);
section.getBoundingClientRect = wrapper.getBoundingClientRect;
dom.window.parent.postMessage = ((message: unknown) => {
posts.push(message as { type?: string; targets?: Array<{ id: string; isHidden?: boolean; isLayoutContainer?: boolean }> });
}) as typeof dom.window.parent.postMessage;
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
data: { type: 'od-edit-mode', enabled: true },
}));
await new Promise((resolve) => dom.window.setTimeout(resolve, 0));
const targetsMessage = posts.find((message) => message.type === 'od-edit-targets');
const hiddenSection = targetsMessage?.targets?.find((target) => target.id === 'path-0-0-0');
expect(hiddenSection?.isHidden).toBe(true);
expect(hiddenSection?.isLayoutContainer).toBe(false);
dom.window.close();
});
it('does not mark visibility:visible descendants as hidden', async () => {
const posts: Array<{ type?: string; targets?: Array<{ id: string; isHidden?: boolean }> }> = [];
const dom = new JSDOM(
`<main>
<section data-od-source-path="path-0-0" style="visibility:hidden">
<p data-od-source-path="path-0-0-0" style="visibility:visible">Visible child copy</p>
</section>
</main>${buildManualEditBridge(true)}`,
{ runScripts: 'dangerously', url: 'http://localhost' },
);
const section = dom.window.document.querySelector('section')!;
const visibleChild = dom.window.document.querySelector('p')!;
section.getBoundingClientRect = () => ({
x: 0, y: 0, width: 160, height: 32,
top: 0, right: 160, bottom: 32, left: 0,
toJSON: () => ({}),
} as DOMRect);
visibleChild.getBoundingClientRect = () => ({
x: 8, y: 8, width: 140, height: 20,
top: 8, right: 148, bottom: 28, left: 8,
toJSON: () => ({}),
} as DOMRect);
dom.window.parent.postMessage = ((message: unknown) => {
posts.push(message as { type?: string; targets?: Array<{ id: string; isHidden?: boolean }> });
}) as typeof dom.window.parent.postMessage;
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
data: { type: 'od-edit-mode', enabled: true },
}));
await new Promise((resolve) => dom.window.setTimeout(resolve, 0));
const targetsMessage = posts.find((message) => message.type === 'od-edit-targets');
expect(targetsMessage?.targets?.find((target) => target.id === 'path-0-0')?.isHidden).toBe(true);
expect(targetsMessage?.targets?.find((target) => target.id === 'path-0-0-0')?.isHidden).toBe(false);
dom.window.close();
});
it('does not expose path targets unless they carry a source path marker', () => {
const dom = new JSDOM('<main><h1>Runtime title</h1><p data-od-source-path="path-0-1">Source text</p></main>');
const runtimeTitle = dom.window.document.querySelector('h1')!;