Skip to content

Commit 837cdc4

Browse files
committed
feat(web): expand typography capabilities in manual edit panel
1 parent f52fda2 commit 837cdc4

7 files changed

Lines changed: 283 additions & 12 deletions

File tree

apps/web/src/components/FileViewer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ function manualEditPersistedValueMatchesSavedSnapshot(
516516
}
517517

518518
const MANUAL_EDIT_RECT_AFFECTING_STYLE_PROPS = new Set<keyof ManualEditStyles>([
519-
'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'letterSpacing',
519+
'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'textTransform', 'letterSpacing', 'wordSpacing',
520520
'display', 'position', 'left', 'top', 'right', 'bottom', 'width', 'height', 'minHeight',
521521
'gap', 'flexDirection', 'justifyContent', 'alignItems', 'transform',
522522
'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',

apps/web/src/components/ManualEditPanel.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,14 @@ const FONT_OPTS = [
513513
] as const;
514514
const WEIGHT_OPTS = ['', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
515515
const ALIGN_OPTS = ['', 'left', 'center', 'right', 'justify', 'start', 'end'];
516+
const TEXT_TRANSFORM_OPTS = ['', 'uppercase', 'lowercase', 'capitalize', 'none'];
517+
const TEXT_TRANSFORM_LABELS: Record<string, string> = {
518+
'': '-',
519+
uppercase: 'Uppercase',
520+
lowercase: 'Lowercase',
521+
capitalize: 'Capitalize',
522+
none: 'None',
523+
};
516524
const DIRECTION_OPTS = ['', 'row', 'column', 'row-reverse', 'column-reverse'];
517525
const JUSTIFY_OPTS = ['', 'flex-start', 'center', 'flex-end', 'space-between', 'space-around'];
518526
const ITEMS_OPTS = ['', 'stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
@@ -537,7 +545,7 @@ type NormalizeResult =
537545
| { ok: false; error: string };
538546

539547
const PX_STYLE_PROPS = new Set<keyof ManualEditStyles>([
540-
'fontSize', 'letterSpacing', 'width', 'height', 'minHeight', 'gap',
548+
'fontSize', 'letterSpacing', 'wordSpacing', 'width', 'height', 'minHeight', 'gap',
541549
'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
542550
'margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
543551
'border', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
@@ -548,6 +556,7 @@ const SELECT_STYLE_OPTIONS: Partial<Record<keyof ManualEditStyles, ReadonlyArray
548556
fontFamily: FONT_OPTS.map((option) => option.value),
549557
fontWeight: WEIGHT_OPTS,
550558
textAlign: ALIGN_OPTS,
559+
textTransform: TEXT_TRANSFORM_OPTS,
551560
flexDirection: DIRECTION_OPTS,
552561
justifyContent: JUSTIFY_OPTS,
553562
alignItems: ITEMS_OPTS,
@@ -662,8 +671,12 @@ function StyleInspector({
662671
<DropdownRow label="Align" value={styles.textAlign} onChange={(v) => u('textAlign', v)} options={ALIGN_OPTS} />
663672
</PairRow>
664673
<PairRow>
665-
<UnitRow label="Line" value={styles.lineHeight} onChange={(v) => u('lineHeight', v)} unit="" />
666-
<UnitRow label="Tracking" value={styles.letterSpacing} onChange={(v) => u('letterSpacing', v)} unit="px" autoUnit />
674+
<UnitRow label="Line height" value={styles.lineHeight} onChange={(v) => u('lineHeight', v)} unit="" />
675+
<DropdownRow label="Transform" value={styles.textTransform} onChange={(v) => u('textTransform', v)} options={TEXT_TRANSFORM_OPTS} optionLabels={TEXT_TRANSFORM_LABELS} />
676+
</PairRow>
677+
<PairRow>
678+
<UnitRow label="Letter spacing" value={styles.letterSpacing} onChange={(v) => u('letterSpacing', v)} unit="px" autoUnit />
679+
<UnitRow label="Word spacing" value={styles.wordSpacing} onChange={(v) => u('wordSpacing', v)} unit="px" autoUnit />
667680
</PairRow>
668681
</Section>
669682
) : null}
@@ -768,17 +781,18 @@ function UnitRow({ label, value, onChange, unit, autoUnit, disabled }: {
768781
);
769782
}
770783

771-
function DropdownRow({ label, value, onChange, options, placeholder, disabled }: {
784+
function DropdownRow({ label, value, onChange, options, placeholder, disabled, optionLabels }: {
772785
label: string; value: string; onChange: (v: string) => void;
773786
options: ReadonlyArray<string>; placeholder?: string; disabled?: boolean;
787+
optionLabels?: Readonly<Record<string, string>>;
774788
}) {
775789
return (
776790
<label className="cc-row">
777791
<span className="cc-label">{label}</span>
778792
<span className="cc-value cc-select">
779793
<select value={value} disabled={disabled} onChange={(e) => onChange(e.currentTarget.value)}>
780794
{!options.includes(value) && value ? <option value={value}>{value}</option> : null}
781-
{options.map((opt) => <option key={opt || '__'} value={opt}>{opt || (placeholder ?? '–')}</option>)}
795+
{options.map((opt) => <option key={opt || '__'} value={opt}>{optionLabels?.[opt] ?? (opt || (placeholder ?? '–'))}</option>)}
782796
</select>
783797
<em className="cc-chevron"></em>
784798
</span>

apps/web/src/edit-mode/bridge.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ export function buildManualEditBridge(enabled: boolean): string {
195195
var discoverySelector = ${JSON.stringify(MANUAL_EDIT_DISCOVERY_SELECTOR)};
196196
var hostNodeSelector = ${JSON.stringify(MANUAL_EDIT_HOST_NODE_SELECTOR)};
197197
var sourcePathAttr = ${JSON.stringify(MANUAL_EDIT_SOURCE_PATH_ATTR)};
198-
var styleProps = ['fontFamily','fontSize','fontWeight','fontStyle','textDecorationLine','color','textAlign','lineHeight','letterSpacing','whiteSpace','display','position','left','top','right','bottom','zIndex','width','height','minHeight','gap','flexDirection','justifyContent','alignItems','backgroundColor','opacity','transform','padding','paddingTop','paddingRight','paddingBottom','paddingLeft','margin','marginTop','marginRight','marginBottom','marginLeft','border','borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth','borderStyle','borderColor','borderRadius'];
198+
var styleProps = ['fontFamily','fontSize','fontWeight','fontStyle','textDecorationLine','color','textAlign','lineHeight','textTransform','letterSpacing','wordSpacing','whiteSpace','display','position','left','top','right','bottom','zIndex','width','height','minHeight','gap','flexDirection','justifyContent','alignItems','backgroundColor','opacity','transform','padding','paddingTop','paddingRight','paddingBottom','paddingLeft','margin','marginTop','marginRight','marginBottom','marginLeft','border','borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth','borderStyle','borderColor','borderRadius'];
199199
var inlineTextTags = ${JSON.stringify(MANUAL_EDIT_INLINE_TEXT_TAGS)};
200200
function isHostNode(el){
201201
return !!(el && el.matches && el.matches(hostNodeSelector));
@@ -360,6 +360,7 @@ export function buildManualEditBridge(enabled: boolean): string {
360360
var computed = window.getComputedStyle(el);
361361
var styles = {};
362362
styleProps.forEach(function(prop){ styles[prop] = el.style[prop] || computed[prop] || ''; });
363+
if (!el.style.textTransform && styles.textTransform === 'none') styles.textTransform = '';
363364
// Gesture math (move/resize) adds pointer deltas to these values, so they
364365
// must be RESOLVED px — an authored 'left: 43%' would otherwise be read as
365366
// 43px and teleport the element on the first drag. Computed left/top are

apps/web/src/edit-mode/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ export interface ManualEditStyles {
2323
color: string;
2424
textAlign: string;
2525
lineHeight: string;
26+
textTransform: string;
2627
letterSpacing: string;
28+
wordSpacing: string;
2729
whiteSpace: string;
2830
display: string;
2931
position: string;
@@ -302,7 +304,7 @@ export type ManualEditBridgeMessage =
302304

303305
export const MANUAL_EDIT_STYLE_PROPS: readonly (keyof ManualEditStyles)[] = [
304306
'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'textDecorationLine',
305-
'color', 'textAlign', 'lineHeight', 'letterSpacing', 'whiteSpace',
307+
'color', 'textAlign', 'lineHeight', 'textTransform', 'letterSpacing', 'wordSpacing', 'whiteSpace',
306308
'display', 'position', 'left', 'top', 'right', 'bottom', 'zIndex',
307309
'width', 'height', 'minHeight',
308310
'gap', 'flexDirection', 'justifyContent', 'alignItems',

apps/web/tests/components/FileViewer.manual-edit.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,73 @@ describe('FileViewer manual edit regressions', () => {
191191
expect(screen.queryByText('PAGE')).toBeNull();
192192
});
193193

194+
it('previews and saves manual-edit word spacing', async () => {
195+
const source = '<!doctype html><html><body><main data-od-id="hero">Two words</main></body></html>';
196+
const { fetchMock, savedBodies } = manualEditWriteMock(source);
197+
vi.stubGlobal('fetch', fetchMock);
198+
render(
199+
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
200+
liveHtml={source}
201+
/>,
202+
);
203+
204+
clickManualTool('manual-edit-mode-toggle');
205+
const frame = await previewFrame();
206+
await selectManualEditTarget();
207+
const postSpy = vi.spyOn(frame.contentWindow!, 'postMessage');
208+
fireEvent.change(await findStyleInput('Word spacing'), { target: { value: '-2' } });
209+
expect(postSpy).toHaveBeenCalledWith(expect.objectContaining({
210+
type: 'od-edit-preview-style', styles: expect.objectContaining({ wordSpacing: '-2px' }),
211+
}), '*');
212+
213+
fireEvent.click(within(document.querySelector('.manual-edit-modal') as HTMLElement)
214+
.getByRole('button', { name: /^Save$/ }));
215+
await waitFor(() => expect(savedBodies).toHaveLength(1));
216+
expect(savedBodies[0]!.content).toContain('word-spacing: -2px');
217+
});
218+
219+
it('previews, saves, and clears manual-edit text transform', async () => {
220+
const source = '<!doctype html><html><body><main data-od-id="hero">Two words</main></body></html>';
221+
const { fetchMock, savedBodies } = manualEditWriteMock(source);
222+
vi.stubGlobal('fetch', fetchMock);
223+
render(
224+
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
225+
liveHtml={source}
226+
/>,
227+
);
228+
229+
clickManualTool('manual-edit-mode-toggle');
230+
const frame = await previewFrame();
231+
await selectManualEditTarget();
232+
const postSpy = vi.spyOn(frame.contentWindow!, 'postMessage');
233+
const findTransformSelect = () => Array.from(document.querySelectorAll('.cc-row'))
234+
.find((row) => row.querySelector('.cc-label')?.textContent === 'Transform')
235+
?.querySelector('select') as HTMLSelectElement;
236+
let transformSelect = findTransformSelect();
237+
fireEvent.change(transformSelect, { target: { value: 'lowercase' } });
238+
expect(postSpy).toHaveBeenCalledWith(expect.objectContaining({
239+
type: 'od-edit-preview-style', styles: expect.objectContaining({ textTransform: 'lowercase' }),
240+
}), '*');
241+
242+
fireEvent.click(within(document.querySelector('.manual-edit-modal') as HTMLElement)
243+
.getByRole('button', { name: /^Save$/ }));
244+
await waitFor(() => expect(savedBodies).toHaveLength(1));
245+
expect(savedBodies[0]!.content).toContain('text-transform: lowercase');
246+
247+
await selectManualEditTarget();
248+
transformSelect = findTransformSelect();
249+
transformSelect.value = '';
250+
fireEvent.change(transformSelect);
251+
expect(postSpy).toHaveBeenCalledWith(expect.objectContaining({
252+
type: 'od-edit-preview-style', styles: expect.objectContaining({ textTransform: '' }),
253+
}), '*');
254+
255+
fireEvent.click(within(document.querySelector('.manual-edit-modal') as HTMLElement)
256+
.getByRole('button', { name: /^Save$/ }));
257+
await waitFor(() => expect(savedBodies).toHaveLength(2));
258+
expect(savedBodies[1]!.content).not.toMatch(/text-transform\s*:/);
259+
});
260+
194261
it('re-enters edit mode on the latest source after an external rewrite', async () => {
195262
const v1 = '<!doctype html><html><body><main data-od-id="hero">Version One</main></body></html>';
196263
const v2 = '<!doctype html><html><body><main data-od-id="hero">Version Two</main></body></html>';

apps/web/tests/components/ManualEditPanel.test.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,25 @@ describe('ManualEditPanel', () => {
203203
expect.objectContaining({ fontSize: '32px', color: '#111111', paddingTop: '8px' }),
204204
'Style: Hero Title',
205205
);
206+
207+
const transformRow = Array.from(host.querySelectorAll('.cc-row'))
208+
.find((row) => row.querySelector('.cc-label')?.textContent === 'Transform');
209+
const transformSelect = transformRow?.querySelector('select') as HTMLSelectElement | null;
210+
if (!transformSelect) throw new Error('Transform select not found');
211+
212+
expect(Array.from(transformSelect.options).map((option) => [option.text, option.value])).toEqual([
213+
['-', ''], ['Uppercase', 'uppercase'], ['Lowercase', 'lowercase'], ['Capitalize', 'capitalize'], ['None', 'none'],
214+
]);
215+
act(() => {
216+
transformSelect.value = 'uppercase';
217+
transformSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
218+
});
219+
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { textTransform: 'uppercase' }, 'Style: Hero Title');
220+
act(() => {
221+
transformSelect.value = '';
222+
transformSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
223+
});
224+
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { textTransform: '' }, 'Style: Hero Title');
206225
});
207226

208227
it('shows px-backed values without px in numeric inputs', () => {
@@ -230,23 +249,30 @@ describe('ManualEditPanel', () => {
230249
fontSize: '32px',
231250
lineHeight: '1.4',
232251
letterSpacing: '1px',
252+
wordSpacing: '1px',
233253
},
234254
});
235255

236256
const sizeIncrease = host.querySelector('button[aria-label="Size increase"]') as HTMLButtonElement | null;
237-
const lineIncrease = host.querySelector('button[aria-label="Line increase"]') as HTMLButtonElement | null;
238-
const trackingDecrease = host.querySelector('button[aria-label="Tracking decrease"]') as HTMLButtonElement | null;
239-
if (!sizeIncrease || !lineIncrease || !trackingDecrease) throw new Error('Stepper button not found');
257+
const lineIncrease = host.querySelector('button[aria-label="Line height increase"]') as HTMLButtonElement | null;
258+
const letterDecrease = host.querySelector('button[aria-label="Letter spacing decrease"]') as HTMLButtonElement | null;
259+
const wordsIncrease = host.querySelector('button[aria-label="Word spacing increase"]') as HTMLButtonElement | null;
260+
if (!sizeIncrease || !lineIncrease || !letterDecrease || !wordsIncrease) throw new Error('Stepper button not found');
240261

241262
act(() => {
242263
sizeIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
243264
lineIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
244-
trackingDecrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
265+
letterDecrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
245266
});
267+
act(() => wordsIncrease.click());
246268

247269
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { fontSize: '33px' }, 'Style: Hero Title');
248270
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { lineHeight: '1.5' }, 'Style: Hero Title');
249271
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { letterSpacing: '0px' }, 'Style: Hero Title');
272+
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { wordSpacing: '2px' }, 'Style: Hero Title');
273+
expect(host.textContent).toContain('Letter spacing');
274+
expect(host.textContent).toContain('Word spacing');
275+
expect(host.textContent).not.toContain('Tracking');
250276
expect(host.textContent).not.toContain('Opacity');
251277
expect(host.textContent).not.toContain('Padding');
252278
});
@@ -286,6 +312,10 @@ describe('ManualEditPanel', () => {
286312
ok: true,
287313
styles: { lineHeight: '49px' },
288314
});
315+
expect(normalizeManualEditStyles({ wordSpacing: '-1.5' }, { layoutEnabled: true })).toEqual({
316+
ok: true,
317+
styles: { wordSpacing: '-1.5px' },
318+
});
289319
});
290320

291321
it('rejects invalid style values before host preview/persistence', () => {
@@ -297,6 +327,12 @@ describe('ManualEditPanel', () => {
297327
ok: false,
298328
error: 'Line height must be a positive number or px value.',
299329
});
330+
expect(normalizeManualEditStyles({ wordSpacing: '1em' }, { layoutEnabled: true })).toEqual({
331+
ok: false,
332+
error: 'word spacing must be a number or px value.',
333+
});
334+
expect(normalizeManualEditStyles({ textTransform: 'full-width' }, { layoutEnabled: true }))
335+
.toEqual({ ok: false, error: 'text transform has an unsupported value.' });
300336
});
301337

302338
it('treats empty values as inline style clears', () => {

0 commit comments

Comments
 (0)