Skip to content

Commit 40213f6

Browse files
staging-devin-ai-integration[bot]streamkit-devinstreamer45
authored
fix(compositor-ui): address 7 UX issues in compositor node (#72)
* fix(compositor-ui): address 7 UX issues in compositor node Issue #1: Click outside text layer commits inline edit - Add document.activeElement.blur() in handlePaneClick before deselecting - Add useEffect on TextOverlayLayer watching isSelected to commit on deselect Issue #2: Preview panel resizable from all four edges - Add ResizeEdgeRight and ResizeEdgeBottom styled components - Extend handleResizeStart edge type to support right/bottom - Update resizeRef type to match Issue #3: Monitor view preview extracts MoQ peer settings from pipeline - Find transport::moq::peer node in pipeline and extract gateway_path/output_broadcast - Set correct serverUrl and outputBroadcast before connecting - Import updateUrlPath utility Issue #4: Deep-compare layer state to prevent position jumps on selection change - Skip setLayers/setTextOverlays/setImageOverlays when merged state is structurally equal - Prevents stale server-echoed values from causing visual glitches Issue #5: Rotate mouse delta for rotated layer resize handles - Transform (dx, dy) by -rotationDegrees in computeUpdatedLayer - Makes resize handles behave naturally regardless of layer rotation Issue #6: Visual separator between layer list and per-layer controls - Add borderTop and paddingTop to LayerInfoRow for both video and text controls Issue #7: Text layers support opacity and rotation sliders - Add rotationDegrees field to TextOverlayState, parse/serialize rotation_degrees - Add rotation transform to TextOverlayLayer canvas rendering - Replace numeric opacity input with slider matching video layer controls - Add rotation slider for text layers Co-Authored-By: Claudio Costa <cstcld91@gmail.com> * fix(compositor-ui): fix preview drag, text state flicker, overlay throttling, multiline text - OutputPreviewPanel: make panel body draggable (not just header) with cursor: grab styling so preview behaves like other canvas nodes - useCompositorLayers: add throttledOverlayCommit for text/image overlay updates (sliders, etc.) to prevent flooding the server on every tick; increase overlay commit guard from 1.5s to 3s to prevent stale params from overwriting local state; arm guard immediately in updateTextOverlay and updateImageOverlay - CompositorCanvas: change InlineTextInput from <input> to <textarea> for multiline text editing; Enter inserts newline, Ctrl/Cmd+Enter commits; add white-space: pre-wrap and word-break to text content rendering; add ResizeHandles to TextOverlayLayer when selected - CompositorNode: change OverlayTextInput to <textarea> with vertical resize support for multiline text in node controls panel Co-Authored-By: Claudio Costa <cstcld91@gmail.com> --------- Co-authored-by: StreamKit Devin <devin@streamkit.dev> Co-authored-by: Claudio Costa <cstcld91@gmail.com>
1 parent 2e355f7 commit 40213f6

File tree

5 files changed

+397
-138
lines changed

5 files changed

+397
-138
lines changed

ui/src/components/CompositorCanvas.tsx

Lines changed: 146 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ const TextContent = styled.div`
8787
z-index: 1;
8888
`;
8989

90-
/** Inline text editing input shown on double-click */
91-
const InlineTextInput = styled.input`
90+
/** Inline text editing textarea shown on double-click (supports multiline) */
91+
const InlineTextInput = styled.textarea`
9292
position: absolute;
9393
inset: 0;
9494
width: 100%;
@@ -102,6 +102,13 @@ const InlineTextInput = styled.input`
102102
outline: none;
103103
z-index: 3;
104104
box-sizing: border-box;
105+
resize: none;
106+
overflow: hidden;
107+
white-space: pre-wrap;
108+
word-break: break-word;
109+
line-height: 1.2;
110+
padding: 4px;
111+
font-family: inherit;
105112
`;
106113

107114
/** Icon badge for image overlay layers */
@@ -271,116 +278,146 @@ const TextOverlayLayer: React.FC<{
271278
isSelected: boolean;
272279
scale: number;
273280
onPointerDown: (layerId: string, e: React.PointerEvent) => void;
281+
onResizeStart: (layerId: string, handle: ResizeHandle, e: React.PointerEvent) => void;
274282
onTextEdit?: (id: string, updates: Partial<Omit<TextOverlayState, 'id'>>) => void;
275283
layerRef: (el: HTMLDivElement | null) => void;
276-
}> = React.memo(({ overlay, index, isSelected, scale, onPointerDown, onTextEdit, layerRef }) => {
277-
const [editing, setEditing] = useState(false);
278-
const [editText, setEditText] = useState(overlay.text);
279-
const inputRef = useRef<HTMLInputElement>(null);
280-
const cancelledRef = useRef(false);
281-
const committedRef = useRef(false);
284+
}> = React.memo(
285+
({ overlay, index, isSelected, scale, onPointerDown, onResizeStart, onTextEdit, layerRef }) => {
286+
const [editing, setEditing] = useState(false);
287+
const [editText, setEditText] = useState(overlay.text);
288+
const inputRef = useRef<HTMLTextAreaElement>(null);
289+
const cancelledRef = useRef(false);
290+
const committedRef = useRef(false);
291+
292+
// Issue #1 fix: when the layer is deselected while editing, commit the edit.
293+
const prevSelectedRef = useRef(isSelected);
294+
useEffect(() => {
295+
if (prevSelectedRef.current && !isSelected && editing) {
296+
// Layer was deselected while editing – commit
297+
if (!cancelledRef.current && !committedRef.current) {
298+
committedRef.current = true;
299+
if (editText.trim() && editText !== overlay.text && onTextEdit) {
300+
onTextEdit(overlay.id, { text: editText.trim() });
301+
}
302+
}
303+
setEditing(false);
304+
}
305+
prevSelectedRef.current = isSelected;
306+
}, [isSelected, editing, editText, overlay.id, overlay.text, onTextEdit]);
282307

283-
const handlePointerDown = useCallback(
284-
(e: React.PointerEvent) => {
285-
if (editing) return; // don't start drag while editing
286-
onPointerDown(overlay.id, e);
287-
},
288-
[overlay.id, onPointerDown, editing]
289-
);
308+
const handlePointerDown = useCallback(
309+
(e: React.PointerEvent) => {
310+
if (editing) return; // don't start drag while editing
311+
onPointerDown(overlay.id, e);
312+
},
313+
[overlay.id, onPointerDown, editing]
314+
);
290315

291-
const handleDoubleClick = useCallback(
292-
(e: React.MouseEvent) => {
293-
e.stopPropagation();
294-
e.preventDefault();
295-
if (!onTextEdit) return;
296-
setEditText(overlay.text);
297-
cancelledRef.current = false;
298-
committedRef.current = false;
299-
setEditing(true);
300-
// Focus the input after React renders it
301-
requestAnimationFrame(() => inputRef.current?.focus());
302-
},
303-
[onTextEdit, overlay.text]
304-
);
316+
const handleDoubleClick = useCallback(
317+
(e: React.MouseEvent) => {
318+
e.stopPropagation();
319+
e.preventDefault();
320+
if (!onTextEdit) return;
321+
setEditText(overlay.text);
322+
cancelledRef.current = false;
323+
committedRef.current = false;
324+
setEditing(true);
325+
// Focus the textarea after React renders it
326+
requestAnimationFrame(() => inputRef.current?.focus());
327+
},
328+
[onTextEdit, overlay.text]
329+
);
305330

306-
const commitEdit = useCallback(() => {
307-
if (cancelledRef.current) return;
308-
if (committedRef.current) return; // guard against double-fire (Enter + blur)
309-
committedRef.current = true;
310-
setEditing(false);
311-
if (editText.trim() && editText !== overlay.text && onTextEdit) {
312-
onTextEdit(overlay.id, { text: editText.trim() });
313-
}
314-
}, [editText, overlay.id, overlay.text, onTextEdit]);
315-
316-
const handleKeyDown = useCallback(
317-
(e: React.KeyboardEvent) => {
318-
e.stopPropagation();
319-
if (e.key === 'Enter') commitEdit();
320-
if (e.key === 'Escape') {
321-
cancelledRef.current = true;
322-
setEditing(false);
331+
const commitEdit = useCallback(() => {
332+
if (cancelledRef.current) return;
333+
if (committedRef.current) return; // guard against double-fire
334+
committedRef.current = true;
335+
setEditing(false);
336+
if (editText.trim() && editText !== overlay.text && onTextEdit) {
337+
onTextEdit(overlay.id, { text: editText.trim() });
323338
}
324-
},
325-
[commitEdit]
326-
);
339+
}, [editText, overlay.id, overlay.text, onTextEdit]);
340+
341+
const handleKeyDown = useCallback(
342+
(e: React.KeyboardEvent) => {
343+
e.stopPropagation();
344+
// Ctrl/Cmd+Enter commits; plain Enter inserts newline (textarea default)
345+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
346+
e.preventDefault();
347+
commitEdit();
348+
}
349+
if (e.key === 'Escape') {
350+
cancelledRef.current = true;
351+
setEditing(false);
352+
}
353+
},
354+
[commitEdit]
355+
);
327356

328-
const hue = layerHue(index + 100); // offset from video layers
329-
const borderColor = isSelected ? 'var(--sk-primary)' : `hsla(${hue}, 70%, 65%, 0.8)`;
330-
const bgColor = isSelected ? `hsla(${hue}, 60%, 50%, 0.25)` : `hsla(${hue}, 60%, 50%, 0.12)`;
357+
const hue = layerHue(index + 100); // offset from video layers
358+
const borderColor = isSelected ? 'var(--sk-primary)' : `hsla(${hue}, 70%, 65%, 0.8)`;
359+
const bgColor = isSelected ? `hsla(${hue}, 60%, 50%, 0.25)` : `hsla(${hue}, 60%, 50%, 0.12)`;
331360

332-
const [r, g, b, a] = overlay.color;
333-
const textColor = `rgba(${r}, ${g}, ${b}, ${(a ?? 255) / 255})`;
361+
const [r, g, b, a] = overlay.color;
362+
const textColor = `rgba(${r}, ${g}, ${b}, ${(a ?? 255) / 255})`;
334363

335-
return (
336-
<LayerBox
337-
ref={layerRef}
338-
className="nodrag nopan"
339-
style={{
340-
left: overlay.x,
341-
top: overlay.y,
342-
width: overlay.width,
343-
height: overlay.height,
344-
opacity: overlay.visible ? overlay.opacity : 0.2,
345-
zIndex: 100 + index,
346-
border: `2px dashed ${borderColor}`,
347-
background: bgColor,
348-
filter: overlay.visible ? undefined : 'grayscale(0.6)',
349-
}}
350-
onPointerDown={handlePointerDown}
351-
onDoubleClick={handleDoubleClick}
352-
>
353-
<LayerLabel>text_{index}</LayerLabel>
354-
{editing ? (
355-
<InlineTextInput
356-
ref={inputRef}
357-
className="nodrag nopan"
358-
value={editText}
359-
onChange={(e) => setEditText(e.target.value)}
360-
onBlur={commitEdit}
361-
onKeyDown={handleKeyDown}
362-
style={{ fontSize: Math.max(10, overlay.fontSize * scale * 0.6) }}
363-
/>
364-
) : (
365-
<TextContent>
366-
<span
367-
style={{
368-
fontSize: Math.max(8, overlay.fontSize * scale),
369-
color: textColor,
370-
fontWeight: 600,
371-
textShadow: '0 1px 3px rgba(0,0,0,0.7)',
372-
lineHeight: 1.1,
373-
textAlign: 'center',
374-
wordBreak: 'break-word',
375-
}}
376-
>
377-
{overlay.text}
378-
</span>
379-
</TextContent>
380-
)}
381-
</LayerBox>
382-
);
383-
});
364+
return (
365+
<LayerBox
366+
ref={layerRef}
367+
className="nodrag nopan"
368+
style={{
369+
left: overlay.x,
370+
top: overlay.y,
371+
width: overlay.width,
372+
height: overlay.height,
373+
opacity: overlay.visible ? overlay.opacity : 0.2,
374+
zIndex: 100 + index,
375+
border: `2px dashed ${borderColor}`,
376+
background: bgColor,
377+
filter: overlay.visible ? undefined : 'grayscale(0.6)',
378+
transform:
379+
overlay.rotationDegrees !== 0 ? `rotate(${overlay.rotationDegrees}deg)` : undefined,
380+
}}
381+
onPointerDown={handlePointerDown}
382+
onDoubleClick={handleDoubleClick}
383+
>
384+
<LayerLabel>text_{index}</LayerLabel>
385+
{editing ? (
386+
<InlineTextInput
387+
ref={inputRef}
388+
className="nodrag nopan"
389+
value={editText}
390+
onChange={(e) => setEditText(e.target.value)}
391+
onBlur={commitEdit}
392+
onKeyDown={handleKeyDown}
393+
style={{ fontSize: Math.max(10, overlay.fontSize * scale * 0.6) }}
394+
/>
395+
) : (
396+
<TextContent>
397+
<span
398+
style={{
399+
fontSize: Math.max(8, overlay.fontSize * scale),
400+
color: textColor,
401+
fontWeight: 600,
402+
textShadow: '0 1px 3px rgba(0,0,0,0.7)',
403+
lineHeight: 1.2,
404+
textAlign: 'center',
405+
wordBreak: 'break-word',
406+
whiteSpace: 'pre-wrap',
407+
maxWidth: '100%',
408+
padding: '2px 4px',
409+
boxSizing: 'border-box',
410+
}}
411+
>
412+
{overlay.text}
413+
</span>
414+
</TextContent>
415+
)}
416+
{isSelected && <ResizeHandles layerId={overlay.id} onResizeStart={onResizeStart} />}
417+
</LayerBox>
418+
);
419+
}
420+
);
384421
TextOverlayLayer.displayName = 'TextOverlayLayer';
385422

386423
// ── Image overlay layer ─────────────────────────────────────────────────────
@@ -486,7 +523,12 @@ export const CompositorCanvas: React.FC<CompositorCanvasProps> = React.memo(
486523
return () => observer.disconnect();
487524
}, [canvasWidth]);
488525

526+
// Issue #1 fix: blur any active element (e.g. inline text input) before
527+
// deselecting so that the input's onBlur → commitEdit fires reliably.
489528
const handlePaneClick = useCallback(() => {
529+
if (document.activeElement instanceof HTMLElement) {
530+
document.activeElement.blur();
531+
}
490532
onSelectLayer(null);
491533
}, [onSelectLayer]);
492534

@@ -546,6 +588,7 @@ export const CompositorCanvas: React.FC<CompositorCanvasProps> = React.memo(
546588
isSelected={selectedLayerId === overlay.id}
547589
scale={scale}
548590
onPointerDown={disabled ? noopPointerDown : onLayerPointerDown}
591+
onResizeStart={disabled ? noopResizeStart : onResizePointerDown}
549592
onTextEdit={disabled ? undefined : onTextEdit}
550593
layerRef={setLayerRef(overlay.id)}
551594
/>

0 commit comments

Comments
 (0)