Skip to content

Commit 6d43d2a

Browse files
andresdeficlaude
andcommitted
Fix Text tab eyebrow/subtitle clearing + scope center guides per drag target
Text tab: - Eyebrow and subtitle inputs are now always rendered. Previously they vanished when the text was cleared, and the "Include eyebrow / Include subtitle" recovery checkboxes were no-ops on toggle-on (return; with no state change) so the inputs couldn't be brought back. Removed the broken checkboxes; advanced controls (colors, typography sub-sections, gradient) still hide when the corresponding text is empty. - previewBody.ts collapsed empty eyebrow to undefined, which the server re-hydrated from the on-disk YAML — so clearing the input left the old text in the preview. Send the empty string as-is, matching how headline and subtitle were already handled. Center guides: - useDragPosition now exposes a structured dragTarget (kind + cls) instead of a plain isDragging boolean. - PreviewArea recomputeGuides only checks the actively-dragged element. Previously it OR'd across device + all text elements, so the device's deviceOffsetX=0 default lit up the vertical guide the instant any text drag started. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8f44a23 commit 6d43d2a

7 files changed

Lines changed: 139 additions & 144 deletions

File tree

‎packages/web-preview/client-dist/assets/index-CiPENUh4.js‎

Lines changed: 58 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/web-preview/client-dist/assets/index-Dlsg1ewX.js‎

Lines changed: 0 additions & 58 deletions
This file was deleted.

‎packages/web-preview/client-dist/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="utf-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1" />
66
<title>appframe — Preview</title>
7-
<script type="module" crossorigin src="/assets/index-Dlsg1ewX.js"></script>
7+
<script type="module" crossorigin src="/assets/index-CiPENUh4.js"></script>
88
<link rel="stylesheet" crossorigin href="/assets/index-CJNmFTs8.css">
99
</head>
1010
<body>

‎packages/web-preview/src/client/components/Preview/PreviewArea.tsx‎

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ function ScreenCard({
202202
[index, screen?.textPositions, updateScreen],
203203
);
204204

205-
const { onOverlayMouseDown, getCursorForPosition, isDragging } = useDragPosition(
205+
const { onOverlayMouseDown, getCursorForPosition, isDragging, dragTarget } = useDragPosition(
206206
iframeRef,
207207
containerRef,
208208
screen,
@@ -219,8 +219,22 @@ function ScreenCard({
219219
vertical: false,
220220
});
221221
const guideObserverRef = useRef<MutationObserver | null>(null);
222+
// Ref-mirror of dragTarget so recomputeGuides can stay deps-free; the
223+
// MutationObserver and iframe-rewrite effect that depend on it would
224+
// otherwise cascade-rerun on every drag start/end.
225+
const dragTargetRef = useRef(dragTarget);
226+
useEffect(() => {
227+
dragTargetRef.current = dragTarget;
228+
}, [dragTarget]);
222229

223230
const recomputeGuides = useCallback(() => {
231+
const target = dragTargetRef.current;
232+
// No active drag → nothing to align, clear guides. Render-side already
233+
// gates on isDragging, but clearing here keeps internal state honest.
234+
if (!target) {
235+
setGuides({ horizontal: false, vertical: false });
236+
return;
237+
}
224238
const iframe = iframeRef.current;
225239
const doc = iframe?.contentDocument;
226240
if (!doc) {
@@ -238,41 +252,44 @@ function ScreenCard({
238252
// Drag offsets are stored as integer percentages of canvas width/height,
239253
// so each step moves the element by cRect.{w,h}/100 screen px. The guide
240254
// should fire only on the integer step closest to true center — half a
241-
// step in either direction. Wider tolerance (e.g. 4px) lit up multiple
242-
// adjacent steps; tighter tolerance missed Y entirely because the device
243-
// is top-anchored (no translateY(-50%)) so Y-center lives at a non-zero
244-
// deviceTop that depends on device height.
255+
// step in either direction.
245256
const tolerancePxX = cRect.width / 200;
246257
const tolerancePxY = cRect.height / 200;
247258
const view = doc.defaultView;
248259

249-
// Device always has a meaningful bounding box (always absolutely positioned).
250-
// Text elements only produce a meaningful center check once they've been
251-
// dragged to a custom position (position: fixed via injectTextPositionCSS);
252-
// in normal flow their block-level div spans the full .text-area and would
253-
// permanently read as X-centered.
254-
const targets: Array<{ el: HTMLElement; requiresFixed: boolean }> = [];
255-
const device = doc.querySelector('.device-wrapper') as HTMLElement | null;
256-
if (device) targets.push({ el: device, requiresFixed: false });
257-
for (const selector of ['.eyebrow', '.headline', '.subtitle']) {
258-
const el = doc.querySelector(selector) as HTMLElement | null;
259-
if (el) targets.push({ el, requiresFixed: true });
260+
// Pick the single element being dragged. Anything else has no bearing
261+
// on alignment feedback right now — checking all draggables would light
262+
// up the device's vertical guide (deviceOffsetX=0 by default) whenever
263+
// text is being dragged.
264+
const selector = target.kind === 'device' ? '.device-wrapper' : `.${target.cls}`;
265+
const el = doc.querySelector(selector) as HTMLElement | null;
266+
if (!el) {
267+
setGuides({ horizontal: false, vertical: false });
268+
return;
260269
}
261-
262-
let vertical = false;
263-
let horizontal = false;
264-
for (const { el, requiresFixed } of targets) {
265-
if (requiresFixed && view?.getComputedStyle(el).position !== 'fixed') continue;
266-
const rect = el.getBoundingClientRect();
267-
const centerX = rect.left + rect.width / 2;
268-
const centerY = rect.top + rect.height / 2;
269-
if (Math.abs(centerX - cCenterX) <= tolerancePxX) vertical = true;
270-
if (Math.abs(centerY - cCenterY) <= tolerancePxY) horizontal = true;
271-
if (vertical && horizontal) break;
270+
// Text elements only produce a meaningful center check once
271+
// position:fixed is applied (set by useDragPosition on drag start). In
272+
// normal flow the block spans the entire text-area and would always
273+
// read as X-centered.
274+
if (target.kind === 'text' && view?.getComputedStyle(el).position !== 'fixed') {
275+
setGuides({ horizontal: false, vertical: false });
276+
return;
272277
}
278+
const rect = el.getBoundingClientRect();
279+
const centerX = rect.left + rect.width / 2;
280+
const centerY = rect.top + rect.height / 2;
281+
const vertical = Math.abs(centerX - cCenterX) <= tolerancePxX;
282+
const horizontal = Math.abs(centerY - cCenterY) <= tolerancePxY;
273283
setGuides({ vertical, horizontal });
274284
}, []);
275285

286+
// MutationObserver fires only on style mutations, so drag-start (which
287+
// changes dragTarget but may not yet have moved the element) wouldn't
288+
// re-run the guide check. Recompute explicitly on every drag transition.
289+
useEffect(() => {
290+
recomputeGuides();
291+
}, [dragTarget, recomputeGuides]);
292+
276293
// Attach a MutationObserver to every draggable element in the iframe (device
277294
// wrapper + eyebrow + headline + subtitle). Both slider instant patches and
278295
// mouse drags mutate inline style attributes, so watching each element's

‎packages/web-preview/src/client/components/Sidebar/TextTab.tsx‎

Lines changed: 24 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -98,21 +98,19 @@ export function TextTab() {
9898
<>
9999
{/* Text inputs */}
100100
<Section title="Text" tooltip="Edit the eyebrow, headline, and subtitle text that appears above or below the device frame." defaultCollapsed={false}>
101-
{hasEyebrow && (
102-
<div className="mb-2.5">
103-
<label htmlFor={eyebrowId} className="block text-xs text-text-dim mb-1">
104-
Eyebrow <span className="text-text-dim opacity-60">(small label above the headline)</span>
105-
</label>
106-
<input
107-
id={eyebrowId}
108-
type="text"
109-
value={screen.eyebrow}
110-
onChange={(e) => update({ eyebrow: e.target.value })}
111-
placeholder='e.g. "Split View"'
112-
className="w-full px-2.5 py-2 bg-surface-2 border border-border rounded-md text-text text-[13px] font-inherit outline-none focus:border-accent"
113-
/>
114-
</div>
115-
)}
101+
<div className="mb-2.5">
102+
<label htmlFor={eyebrowId} className="block text-xs text-text-dim mb-1">
103+
Eyebrow <span className="text-text-dim opacity-60">(small label above the headline)</span>
104+
</label>
105+
<input
106+
id={eyebrowId}
107+
type="text"
108+
value={screen.eyebrow}
109+
onChange={(e) => update({ eyebrow: e.target.value })}
110+
placeholder='e.g. "Split View"'
111+
className="w-full px-2.5 py-2 bg-surface-2 border border-border rounded-md text-text text-[13px] font-inherit outline-none focus:border-accent"
112+
/>
113+
</div>
116114
<div className="mb-2.5">
117115
<label htmlFor={headlineId} className="block text-xs text-text-dim mb-1">Headline</label>
118116
<textarea
@@ -123,19 +121,17 @@ export function TextTab() {
123121
className="w-full px-2.5 py-2 bg-surface-2 border border-border rounded-md text-text text-[13px] font-inherit outline-none focus:border-accent resize-y min-h-[60px]"
124122
/>
125123
</div>
126-
{hasSubtitle && (
127-
<div className="mb-2.5">
128-
<label htmlFor={subtitleId} className="block text-xs text-text-dim mb-1">Subtitle</label>
129-
<input
130-
id={subtitleId}
131-
type="text"
132-
value={screen.subtitle}
133-
onChange={(e) => update({ subtitle: e.target.value })}
134-
placeholder="Optional subtitle"
135-
className="w-full px-2.5 py-2 bg-surface-2 border border-border rounded-md text-text text-[13px] font-inherit outline-none focus:border-accent"
136-
/>
137-
</div>
138-
)}
124+
<div className="mb-2.5">
125+
<label htmlFor={subtitleId} className="block text-xs text-text-dim mb-1">Subtitle</label>
126+
<input
127+
id={subtitleId}
128+
type="text"
129+
value={screen.subtitle}
130+
onChange={(e) => update({ subtitle: e.target.value })}
131+
placeholder="Optional subtitle"
132+
className="w-full px-2.5 py-2 bg-surface-2 border border-border rounded-md text-text text-[13px] font-inherit outline-none focus:border-accent"
133+
/>
134+
</div>
139135
<ColorPicker
140136
label="Headline Color"
141137
value={screen.colors.text}
@@ -157,29 +153,6 @@ export function TextTab() {
157153

158154
{/* Typography */}
159155
<Section title="Typography" tooltip="Control font family, weight, size, rotation, spacing, and text transformations.">
160-
<Checkbox
161-
label="Include eyebrow"
162-
checked={hasEyebrow}
163-
onChange={(checked) => {
164-
if (checked) {
165-
// Reveal the input; leave text empty so the user can type.
166-
return;
167-
}
168-
update({ eyebrow: '' });
169-
}}
170-
/>
171-
<Checkbox
172-
label="Include subtitle"
173-
checked={hasSubtitle}
174-
onChange={(checked) => {
175-
if (checked) {
176-
// Reveal the input; leave text empty so the user can type.
177-
return;
178-
}
179-
update({ subtitle: '', subtitleGradient: null });
180-
}}
181-
/>
182-
183156
<div className="mb-3 px-1">
184157
<div className="text-[11px] font-medium text-text-dim uppercase tracking-wider mb-2">
185158
Defaults (used when an element has no override)

‎packages/web-preview/src/client/hooks/useDragPosition.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,12 @@ export function useDragPosition(
6262
onTextDrop: (cls: 'eyebrow' | 'headline' | 'subtitle', pos: TextPosition) => void,
6363
) {
6464
const dragRef = useRef<DragState | null>(null);
65-
const [isDragging, setIsDragging] = useState(false);
65+
// Exposes what's actively being dragged so consumers (e.g. center guides)
66+
// can scope their feedback to that element rather than all draggables.
67+
const [dragTarget, setDragTarget] = useState<
68+
{ kind: 'device' } | { kind: 'text'; cls: 'eyebrow' | 'headline' | 'subtitle' } | null
69+
>(null);
70+
const isDragging = dragTarget !== null;
6671

6772
const hitTest = useCallback(
6873
(ix: number, iy: number): { cls: string; el: HTMLElement; kind: DragKind } | null => {
@@ -154,7 +159,7 @@ export function useDragPosition(
154159
scale,
155160
};
156161
hit.el.style.outline = '2px solid rgba(99,102,241,0.5)';
157-
setIsDragging(true);
162+
setDragTarget({ kind: 'device' });
158163

159164
const onMove = (ev: MouseEvent) => {
160165
const drag = dragRef.current;
@@ -182,7 +187,7 @@ export function useDragPosition(
182187
dragRef.current = null;
183188
document.removeEventListener('mousemove', onMove);
184189
document.removeEventListener('mouseup', onUp);
185-
setIsDragging(false);
190+
setDragTarget(null);
186191
onDeviceDrop({ deviceTop: newTop, deviceOffsetX: newOffsetX });
187192
};
188193

@@ -230,7 +235,7 @@ export function useDragPosition(
230235
scale,
231236
};
232237
el.style.outline = '2px dashed rgba(99,102,241,0.5)';
233-
setIsDragging(true);
238+
setDragTarget({ kind: 'text', cls });
234239

235240
const onMove = (ev: MouseEvent) => {
236241
const drag = dragRef.current;
@@ -251,7 +256,7 @@ export function useDragPosition(
251256
dragRef.current = null;
252257
document.removeEventListener('mousemove', onMove);
253258
document.removeEventListener('mouseup', onUp);
254-
setIsDragging(false);
259+
setDragTarget(null);
255260
onTextDrop(drag.cls as 'eyebrow' | 'headline' | 'subtitle', { x: leftPct, y: topPct, width: widthPct });
256261
};
257262

@@ -272,5 +277,5 @@ export function useDragPosition(
272277
[toIframe, hitTest],
273278
);
274279

275-
return { onOverlayMouseDown, getCursorForPosition, isDragging };
280+
return { onOverlayMouseDown, getCursorForPosition, isDragging, dragTarget };
276281
}

‎packages/web-preview/src/client/utils/previewBody.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function buildScreenRenderBody(
1717
localeConfig: preferLocaleText ? localeConfig : undefined,
1818
style: screen.style,
1919
layout: screen.layout,
20-
eyebrow: screen.eyebrow || undefined,
20+
eyebrow: screen.eyebrow,
2121
headline: screen.headline,
2222
subtitle: screen.subtitle,
2323
accentColor: screen.accentColor || undefined,

0 commit comments

Comments
 (0)