Skip to content

Commit ab508b8

Browse files
Fix Dual Output folder imports and canvas interactions
1 parent 4eebe85 commit ab508b8

8 files changed

Lines changed: 1102 additions & 114 deletions

File tree

app/components-react/root/StudioEditor.tsx

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ENotificationType } from 'services/notifications';
1515
import { Service } from 'services/core/service';
1616
import { AudioNotificationType } from 'services/audio/audio';
1717
import DualOutputToggle from 'components-react/shared/DualOutputToggle';
18+
import { createEditorMouseMoveDispatcher } from 'util/editor-mouse-move';
1819

1920
export default function StudioEditor() {
2021
const {
@@ -143,12 +144,8 @@ export default function StudioEditor() {
143144
};
144145
}, [v.studioMode]);
145146

146-
// This is a bit weird, but it's a performance optimization.
147-
// This component heavily re-renders, so trying to do as little
148-
// as possible on each re-render, including defining event handlers,
149-
// which in this case don't rely on the closure and therefore never
150-
// need to be redefined. It also ensures a single closure that never
151-
// changes for the moveInFlight piece of the mouseMove handler.
147+
// Keep one mouse-move dispatcher across renders so events from both canvases
148+
// share the same in-flight request and pending move.
152149
const eventHandlers = useMemo(() => {
153150
function getMouseEvent(event: React.MouseEvent, display: TDisplayType) {
154151
return {
@@ -166,28 +163,17 @@ export default function StudioEditor() {
166163
};
167164
}
168165

169-
let moveInFlight = false;
170-
let lastMoveEvent: React.MouseEvent | null = null;
171-
172-
function onMouseMove(event: React.MouseEvent, display: TDisplayType) {
173-
if (moveInFlight) {
174-
lastMoveEvent = event;
175-
return;
176-
}
177-
178-
moveInFlight = true;
179-
EditorService.actions.return.handleMouseMove(getMouseEvent(event, display)).then(stopMove => {
166+
const dispatchMouseMove = createEditorMouseMoveDispatcher(
167+
async event => {
168+
const stopMove = await EditorService.actions.return.handleMouseMove(event);
180169
if (stopMove && !messageActive) {
181170
showOutOfBoundsErrorMessage();
182171
}
183-
moveInFlight = false;
184-
185-
if (lastMoveEvent) {
186-
onMouseMove(lastMoveEvent, display);
187-
lastMoveEvent = null;
188-
}
189-
});
190-
}
172+
},
173+
(error, event) => {
174+
console.error('Failed to handle editor mouse move', error, { display: event.display });
175+
},
176+
);
191177

192178
return {
193179
onOutputResize(rect: IRectangle, display: TDisplayType) {
@@ -210,7 +196,9 @@ export default function StudioEditor() {
210196
EditorService.actions.handleMouseDblClick(getMouseEvent(event, display));
211197
},
212198

213-
onMouseMove,
199+
onMouseMove(event: React.MouseEvent, display: TDisplayType) {
200+
void dispatchMouseMove(getMouseEvent(event, display));
201+
},
214202

215203
enablePreview() {
216204
CustomizationService.actions.setSettings({ performanceMode: false });

app/services/dual-output/dual-output.ts

Lines changed: 120 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -681,56 +681,123 @@ export class DualOutputService extends PersistentStatefulService<IDualOutputServ
681681
*/
682682
validateSceneNodes(sceneId: string) {
683683
this.SET_IS_LOADING(true);
684-
const sceneNodes = this.scenesService.views.getSceneNodesBySceneId(sceneId);
685-
if (!sceneNodes) return;
686-
const corruptedNodeIds = new Set<string>();
687-
688-
const sceneNodeMap = this.views.sceneNodeMaps[sceneId];
689-
const invertedSceneNodeMap = invert(sceneNodeMap);
690-
691-
// The keys in the nodemap are the ids for the horizontal nodes. Initialize with all keys
692-
// and delete entries as nodes are visited; whatever remains are stale entries whose
693-
// horizontal node no longer exists in the scene.
694-
const horizontalNodeIds = new Set<string>(Object.keys(sceneNodeMap));
695-
696-
// Iterate over the scene nodes in reverse order to automatically handle correctly ordering
697-
// any nodes created as a part of the validation process. This optimizes validation by skipping
698-
// an extra loop over the nodes to reorder them.
699-
forEachRight(sceneNodes, (node: TSceneNode, index: number) => {
700-
// don't handle corrupted nodes
701-
if (corruptedNodeIds.has(node.id)) return;
702-
703-
// confirm partner node exists
704-
const nodeMap = node?.display === 'vertical' ? invertedSceneNodeMap : sceneNodeMap;
705-
const partnerNode = this.validatePartnerNode(node, nodeMap, sceneNodes);
706-
707-
// Remove from horizontal node ids because we have confirmed this entry.
708-
// Any nodes added as a horizontal partner node for a vertical node do not need
709-
// to be validated again in the node map
710-
if (node.display === 'horizontal') {
711-
horizontalNodeIds.delete(node.id);
712-
}
713-
714-
// confirm source and output for scene items
715-
if (node.isItem() && partnerNode.isItem()) {
716-
this.validateOutput(node, sceneId);
717-
const corruptedNode: SceneItem = this.validateSource(node, partnerNode);
718-
if (corruptedNode) {
719-
corruptedNodeIds.add(corruptedNode.id);
684+
try {
685+
const sceneNodes = this.scenesService.views.getSceneNodesBySceneId(sceneId);
686+
if (!sceneNodes) return;
687+
const corruptedNodeIds = new Set<string>();
688+
689+
const sceneNodeMap = this.views.sceneNodeMaps[sceneId];
690+
const invertedSceneNodeMap = invert(sceneNodeMap);
691+
692+
// The keys in the nodemap are the ids for the horizontal nodes. Initialize with all keys
693+
// and delete entries as nodes are visited; whatever remains are stale entries whose
694+
// horizontal node no longer exists in the scene.
695+
const horizontalNodeIds = new Set<string>(Object.keys(sceneNodeMap));
696+
697+
// Iterate over the scene nodes in reverse order to automatically handle correctly ordering
698+
// any nodes created as a part of the validation process. This optimizes validation by skipping
699+
// an extra loop over the nodes to reorder them.
700+
forEachRight(sceneNodes, (node: TSceneNode, index: number) => {
701+
// don't handle corrupted nodes
702+
if (corruptedNodeIds.has(node.id)) return;
703+
704+
// confirm partner node exists
705+
const nodeMap = node?.display === 'vertical' ? invertedSceneNodeMap : sceneNodeMap;
706+
const partnerNode = this.validatePartnerNode(node, nodeMap, sceneNodes);
707+
708+
// Either side confirms the horizontal entry. Source reconciliation may
709+
// replace the vertical item and skip its horizontal partner later in this
710+
// traversal, so mark the pair now to retain its valid map entry.
711+
const horizontalNode = node.display === 'horizontal' ? node : partnerNode;
712+
if (horizontalNode.display === 'horizontal') horizontalNodeIds.delete(horizontalNode.id);
713+
714+
// confirm source and output for scene items
715+
if (node.isItem() && partnerNode.isItem()) {
716+
this.validateOutput(node, sceneId);
717+
const corruptedNode: SceneItem = this.validateSource(node, partnerNode);
718+
if (corruptedNode) {
719+
corruptedNodeIds.add(corruptedNode.id);
720+
}
720721
}
721-
}
722722

723-
this.sceneNodeHandled.next(index);
723+
this.sceneNodeHandled.next(index);
724+
});
725+
726+
// After confirming all of the scene items, `horizontalNodeIds` should be empty.
727+
// If there are any remaining entries, these are stale entries in the scene node map.
728+
// To repair the scene node map, delete these incorrect entries.
729+
horizontalNodeIds.forEach((horizontalId: string) => {
730+
this.sceneCollectionsService.removeNodeMapEntry(sceneId, horizontalId);
731+
});
732+
733+
this.repairCrossDisplayItemParents(sceneId);
734+
} finally {
735+
this.SET_IS_LOADING(false);
736+
}
737+
}
738+
739+
/**
740+
* Older source repair placed recreated items next to their partner, inheriting
741+
* that partner's folder. Repair those saved cross-display parents without
742+
* changing valid per-display layouts or the items' transforms and visibility.
743+
*/
744+
private repairCrossDisplayItemParents(sceneId: string) {
745+
const scene = this.scenesService.views.getScene(sceneId);
746+
const nodes = scene.getNodes();
747+
const nodesById = new Map(nodes.map(node => [node.id, node]));
748+
const nodeMap = this.views.sceneNodeMaps[sceneId];
749+
const invertedNodeMap = invert(nodeMap);
750+
const verticalReferences = new Map<string, number>();
751+
Object.values(nodeMap).forEach(id => {
752+
verticalReferences.set(id, (verticalReferences.get(id) ?? 0) + 1);
753+
});
754+
const repairedParents = new Map<string, string>();
755+
756+
nodes.forEach(node => {
757+
if (!node.isItem() || !node.parentId) return;
758+
const parent = nodesById.get(node.parentId);
759+
if (!parent?.isFolder() || parent.display === node.display) return;
760+
761+
const parentMap = node.display === 'vertical' ? nodeMap : invertedNodeMap;
762+
const reverseParentMap = node.display === 'vertical' ? invertedNodeMap : nodeMap;
763+
const mappedParent = nodesById.get(parentMap[parent.id]);
764+
if (
765+
!mappedParent?.isFolder() ||
766+
mappedParent.display !== node.display ||
767+
reverseParentMap[mappedParent.id] !== parent.id ||
768+
verticalReferences.get(node.display === 'vertical' ? mappedParent.id : parent.id) !== 1
769+
) {
770+
throw new Error(`Cannot repair folder for scene item ${node.id}: invalid paired folder`);
771+
}
772+
repairedParents.set(node.id, mappedParent.id);
724773
});
725774

726-
// After confirming all of the scene items, `horizontalNodeIds` should be empty.
727-
// If there are any remaining entries, these are stale entries in the scene node map.
728-
// To repair the scene node map, delete these incorrect entries.
729-
horizontalNodeIds.forEach((horizontalId: string) => {
730-
this.sceneCollectionsService.removeNodeMapEntry(sceneId, horizontalId);
775+
if (!repairedParents.size) return;
776+
777+
// Plan the final preorder before mutating. Keep every root and sibling in
778+
// its existing relative order, including children already in the target folder.
779+
const children = new Map<string, TSceneNode[]>();
780+
nodes.forEach(node => {
781+
const parentId = repairedParents.get(node.id) ?? node.parentId ?? '';
782+
if (!children.has(parentId)) children.set(parentId, []);
783+
children.get(parentId)!.push(node);
731784
});
785+
const pending = [...(children.get('') ?? [])].reverse();
786+
const order: string[] = [];
787+
const visited = new Set<string>();
788+
while (pending.length) {
789+
const node = pending.pop()!;
790+
if (visited.has(node.id)) break;
791+
visited.add(node.id);
792+
order.push(node.id);
793+
pending.push(...[...(children.get(node.id) ?? [])].reverse());
794+
}
795+
if (order.length !== nodes.length) {
796+
throw new Error(`Cannot repair folders in scene ${sceneId}: invalid scene hierarchy`);
797+
}
732798

733-
this.SET_IS_LOADING(false);
799+
repairedParents.forEach((parentId, nodeId) => scene.getItem(nodeId)!.setParent(parentId));
800+
scene.setNodesOrder(order);
734801
}
735802

736803
/**
@@ -776,6 +843,9 @@ export class DualOutputService extends PersistentStatefulService<IDualOutputServ
776843
const matchVisibility = node.display === 'horizontal';
777844
const { visible, ...settings } = Object.assign(verticalNode.getSettings());
778845
const verticalNodeId = verticalNode.id;
846+
const scene = verticalNode.getScene();
847+
const parentId = verticalNode.parentId;
848+
const nodeOrder = scene.getNodesIds();
779849

780850
// remove old node
781851
this.sceneCollectionsService.removeNodeMapEntry(horizontalNode.sceneId, horizontalNode.id);
@@ -792,6 +862,12 @@ export class DualOutputService extends PersistentStatefulService<IDualOutputServ
792862
newPartner.setSettings({ ...settings, output: context });
793863
newPartner.setVisibility(visible);
794864

865+
// Source reconciliation replaces the OBS item, not its authored location in
866+
// the scene tree. createPartnerNode's placement inherits the other display's
867+
// parent, so restore both the original parent and the complete node order.
868+
newPartner.setParent(parentId);
869+
scene.setNodesOrder(nodeOrder);
870+
795871
return partnerNode;
796872
}
797873

app/services/editor.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,17 +152,28 @@ export class EditorService extends StatefulService<IEditorServiceState> {
152152
}
153153
}
154154

155-
startDragging(event: IMouseEvent) {
156-
const dragHandler = new DragHandler(event, {
157-
displaySize: {
158-
x: this.renderedWidths[event.display],
159-
y: this.renderedHeights[event.display],
160-
},
161-
displayOffset: {
162-
x: this.renderedOffsetXs[event.display],
163-
y: this.renderedOffsetYs[event.display],
155+
startDragging(event: IMouseEvent, source: SceneItem) {
156+
// Folder selection can end with an empty folder. Anchor the drag to the
157+
// hovered item, and only start while it remains selected on this display.
158+
const draggedSource = this.selectionService.views.globalSelection
159+
.getVisualItems(event.display)
160+
.find(item => item.id === source?.id);
161+
if (!draggedSource) return;
162+
163+
const dragHandler = new DragHandler(
164+
event,
165+
{
166+
displaySize: {
167+
x: this.renderedWidths[event.display],
168+
y: this.renderedHeights[event.display],
169+
},
170+
displayOffset: {
171+
x: this.renderedOffsetXs[event.display],
172+
y: this.renderedOffsetYs[event.display],
173+
},
164174
},
165-
});
175+
draggedSource,
176+
);
166177

167178
this.dragHandler = dragHandler;
168179
this.SET_CHANGING_POSITION_IN_PROGRESS(true);
@@ -323,7 +334,7 @@ export class EditorService extends StatefulService<IEditorServiceState> {
323334
}
324335

325336
// Start dragging it
326-
this.startDragging(event);
337+
this.startDragging(event, overSource);
327338
}
328339
}
329340

app/util/DragHandler.ts

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ export class DragHandler {
8080
snapDistance: number;
8181

8282
// Sources
83-
private draggedSource: SceneItem;
8483
private otherSources: SceneItem[];
8584

8685
// Mouse properties
@@ -91,9 +90,14 @@ export class DragHandler {
9190
/**
9291
* @param startEvent the mouse event for this drag
9392
* @param options drag handler options
93+
* @param draggedSource the selected visual item under the cursor on this display
9494
*/
9595

96-
constructor(startEvent: IMouseEvent, options: IDragHandlerOptions) {
96+
constructor(
97+
startEvent: IMouseEvent,
98+
options: IDragHandlerOptions,
99+
private draggedSource: SceneItem,
100+
) {
97101
// Load some settings we care about
98102
this.snapEnabled = this.settingsService.views.values.General.SnappingEnabled;
99103
this.renderedSnapDistance = this.settingsService.views.values.General.SnapDistance;
@@ -118,37 +122,6 @@ export class DragHandler {
118122
this.snapDistance =
119123
(this.renderedSnapDistance * this.scaleFactor * this.baseWidth) / this.displaySize.x;
120124

121-
// Load some attributes about sources
122-
const lastDragged = this.selectionService.views.globalSelection.getLastSelected();
123-
124-
if (lastDragged.isItem()) {
125-
/**
126-
* In dual output mode, the last selected node may be in a different display than the mouse event.
127-
* Dragging scene items in the display should only transform the nodes in the display with the mouse event.
128-
* So if the displays for the mouse event and last selected node don't match, use the node's partner
129-
* in the other display.
130-
*
131-
* If there are any issues finding the partner node, use the last dragged source as a default.
132-
* While it's not ideal, this will prevent errors from attempting to work with undefined values.
133-
*/
134-
if (startEvent.display !== lastDragged.display) {
135-
const dualOutputNodeId = this.dualOutputService.views.getDualOutputNodeId(lastDragged.id);
136-
// confirm the partner id was found
137-
if (dualOutputNodeId) {
138-
const dualOutputNode = this.selectionService.views.globalSelection
139-
.getItems()
140-
.find(item => item.id === dualOutputNodeId);
141-
142-
// confirm the partner node was found, or use the last selected node as a default
143-
this.draggedSource = dualOutputNode ?? lastDragged;
144-
} else {
145-
this.draggedSource = lastDragged;
146-
}
147-
} else {
148-
this.draggedSource = lastDragged;
149-
}
150-
}
151-
152125
this.otherSources = this.selectionService.views.globalSelection
153126
.clone()
154127
.invert()
@@ -179,6 +152,8 @@ export class DragHandler {
179152
*/
180153
//
181154
move(event: IMouseEvent) {
155+
if (event.display !== this.draggedSource.display) return false;
156+
182157
const rect = new ScalableRectangle(this.draggedSource.rectangle);
183158
const denormalize = rect.normalize();
184159

0 commit comments

Comments
 (0)