Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 14 additions & 26 deletions app/components-react/root/StudioEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ENotificationType } from 'services/notifications';
import { Service } from 'services/core/service';
import { AudioNotificationType } from 'services/audio/audio';
import DualOutputToggle from 'components-react/shared/DualOutputToggle';
import { createEditorMouseMoveDispatcher } from 'util/editor-mouse-move';

export default function StudioEditor() {
const {
Expand Down Expand Up @@ -143,12 +144,8 @@ export default function StudioEditor() {
};
}, [v.studioMode]);

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

let moveInFlight = false;
let lastMoveEvent: React.MouseEvent | null = null;

function onMouseMove(event: React.MouseEvent, display: TDisplayType) {
if (moveInFlight) {
lastMoveEvent = event;
return;
}

moveInFlight = true;
EditorService.actions.return.handleMouseMove(getMouseEvent(event, display)).then(stopMove => {
const dispatchMouseMove = createEditorMouseMoveDispatcher(
async event => {
const stopMove = await EditorService.actions.return.handleMouseMove(event);
if (stopMove && !messageActive) {
showOutOfBoundsErrorMessage();
}
moveInFlight = false;

if (lastMoveEvent) {
onMouseMove(lastMoveEvent, display);
lastMoveEvent = null;
}
});
}
},
(error, event) => {
console.error('Failed to handle editor mouse move', error, { display: event.display });
},
);

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

onMouseMove,
onMouseMove(event: React.MouseEvent, display: TDisplayType) {
void dispatchMouseMove(getMouseEvent(event, display));
},

enablePreview() {
CustomizationService.actions.setSettings({ performanceMode: false });
Expand Down
164 changes: 120 additions & 44 deletions app/services/dual-output/dual-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,56 +681,123 @@ export class DualOutputService extends PersistentStatefulService<IDualOutputServ
*/
validateSceneNodes(sceneId: string) {
this.SET_IS_LOADING(true);
const sceneNodes = this.scenesService.views.getSceneNodesBySceneId(sceneId);
if (!sceneNodes) return;
const corruptedNodeIds = new Set<string>();

const sceneNodeMap = this.views.sceneNodeMaps[sceneId];
const invertedSceneNodeMap = invert(sceneNodeMap);

// The keys in the nodemap are the ids for the horizontal nodes. Initialize with all keys
// and delete entries as nodes are visited; whatever remains are stale entries whose
// horizontal node no longer exists in the scene.
const horizontalNodeIds = new Set<string>(Object.keys(sceneNodeMap));

// Iterate over the scene nodes in reverse order to automatically handle correctly ordering
// any nodes created as a part of the validation process. This optimizes validation by skipping
// an extra loop over the nodes to reorder them.
forEachRight(sceneNodes, (node: TSceneNode, index: number) => {
// don't handle corrupted nodes
if (corruptedNodeIds.has(node.id)) return;

// confirm partner node exists
const nodeMap = node?.display === 'vertical' ? invertedSceneNodeMap : sceneNodeMap;
const partnerNode = this.validatePartnerNode(node, nodeMap, sceneNodes);

// Remove from horizontal node ids because we have confirmed this entry.
// Any nodes added as a horizontal partner node for a vertical node do not need
// to be validated again in the node map
if (node.display === 'horizontal') {
horizontalNodeIds.delete(node.id);
}

// confirm source and output for scene items
if (node.isItem() && partnerNode.isItem()) {
this.validateOutput(node, sceneId);
const corruptedNode: SceneItem = this.validateSource(node, partnerNode);
if (corruptedNode) {
corruptedNodeIds.add(corruptedNode.id);
try {
const sceneNodes = this.scenesService.views.getSceneNodesBySceneId(sceneId);
if (!sceneNodes) return;
const corruptedNodeIds = new Set<string>();

const sceneNodeMap = this.views.sceneNodeMaps[sceneId];
const invertedSceneNodeMap = invert(sceneNodeMap);

// The keys in the nodemap are the ids for the horizontal nodes. Initialize with all keys
// and delete entries as nodes are visited; whatever remains are stale entries whose
// horizontal node no longer exists in the scene.
const horizontalNodeIds = new Set<string>(Object.keys(sceneNodeMap));

// Iterate over the scene nodes in reverse order to automatically handle correctly ordering
// any nodes created as a part of the validation process. This optimizes validation by skipping
// an extra loop over the nodes to reorder them.
forEachRight(sceneNodes, (node: TSceneNode, index: number) => {
// don't handle corrupted nodes
if (corruptedNodeIds.has(node.id)) return;

// confirm partner node exists
const nodeMap = node?.display === 'vertical' ? invertedSceneNodeMap : sceneNodeMap;
const partnerNode = this.validatePartnerNode(node, nodeMap, sceneNodes);

// Either side confirms the horizontal entry. Source reconciliation may
// replace the vertical item and skip its horizontal partner later in this
// traversal, so mark the pair now to retain its valid map entry.
const horizontalNode = node.display === 'horizontal' ? node : partnerNode;
if (horizontalNode.display === 'horizontal') horizontalNodeIds.delete(horizontalNode.id);

// confirm source and output for scene items
if (node.isItem() && partnerNode.isItem()) {
this.validateOutput(node, sceneId);
const corruptedNode: SceneItem = this.validateSource(node, partnerNode);
if (corruptedNode) {
corruptedNodeIds.add(corruptedNode.id);
}
}
}

this.sceneNodeHandled.next(index);
this.sceneNodeHandled.next(index);
});

// After confirming all of the scene items, `horizontalNodeIds` should be empty.
// If there are any remaining entries, these are stale entries in the scene node map.
// To repair the scene node map, delete these incorrect entries.
horizontalNodeIds.forEach((horizontalId: string) => {
this.sceneCollectionsService.removeNodeMapEntry(sceneId, horizontalId);
});

this.repairCrossDisplayItemParents(sceneId);
} finally {
this.SET_IS_LOADING(false);
}
}

/**
* Older source repair placed recreated items next to their partner, inheriting
* that partner's folder. Repair those saved cross-display parents without
* changing valid per-display layouts or the items' transforms and visibility.
*/
private repairCrossDisplayItemParents(sceneId: string) {
const scene = this.scenesService.views.getScene(sceneId);
const nodes = scene.getNodes();
const nodesById = new Map(nodes.map(node => [node.id, node]));
const nodeMap = this.views.sceneNodeMaps[sceneId];
const invertedNodeMap = invert(nodeMap);
const verticalReferences = new Map<string, number>();
Object.values(nodeMap).forEach(id => {
verticalReferences.set(id, (verticalReferences.get(id) ?? 0) + 1);
});
const repairedParents = new Map<string, string>();

nodes.forEach(node => {
if (!node.isItem() || !node.parentId) return;
const parent = nodesById.get(node.parentId);
if (!parent?.isFolder() || parent.display === node.display) return;

const parentMap = node.display === 'vertical' ? nodeMap : invertedNodeMap;
const reverseParentMap = node.display === 'vertical' ? invertedNodeMap : nodeMap;
const mappedParent = nodesById.get(parentMap[parent.id]);
if (
!mappedParent?.isFolder() ||
mappedParent.display !== node.display ||
reverseParentMap[mappedParent.id] !== parent.id ||
verticalReferences.get(node.display === 'vertical' ? mappedParent.id : parent.id) !== 1
) {
throw new Error(`Cannot repair folder for scene item ${node.id}: invalid paired folder`);
}
repairedParents.set(node.id, mappedParent.id);
});

// After confirming all of the scene items, `horizontalNodeIds` should be empty.
// If there are any remaining entries, these are stale entries in the scene node map.
// To repair the scene node map, delete these incorrect entries.
horizontalNodeIds.forEach((horizontalId: string) => {
this.sceneCollectionsService.removeNodeMapEntry(sceneId, horizontalId);
if (!repairedParents.size) return;

// Plan the final preorder before mutating. Keep every root and sibling in
// its existing relative order, including children already in the target folder.
const children = new Map<string, TSceneNode[]>();
nodes.forEach(node => {
const parentId = repairedParents.get(node.id) ?? node.parentId ?? '';
if (!children.has(parentId)) children.set(parentId, []);
children.get(parentId)!.push(node);
});
const pending = [...(children.get('') ?? [])].reverse();
const order: string[] = [];
const visited = new Set<string>();
while (pending.length) {
const node = pending.pop()!;
if (visited.has(node.id)) break;
visited.add(node.id);
order.push(node.id);
pending.push(...[...(children.get(node.id) ?? [])].reverse());
}
if (order.length !== nodes.length) {
throw new Error(`Cannot repair folders in scene ${sceneId}: invalid scene hierarchy`);
}

this.SET_IS_LOADING(false);
repairedParents.forEach((parentId, nodeId) => scene.getItem(nodeId)!.setParent(parentId));
scene.setNodesOrder(order);
}

/**
Expand Down Expand Up @@ -776,6 +843,9 @@ export class DualOutputService extends PersistentStatefulService<IDualOutputServ
const matchVisibility = node.display === 'horizontal';
const { visible, ...settings } = Object.assign(verticalNode.getSettings());
const verticalNodeId = verticalNode.id;
const scene = verticalNode.getScene();
const parentId = verticalNode.parentId;
const nodeOrder = scene.getNodesIds();

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

// Source reconciliation replaces the OBS item, not its authored location in
// the scene tree. createPartnerNode's placement inherits the other display's
// parent, so restore both the original parent and the complete node order.
newPartner.setParent(parentId);
scene.setNodesOrder(nodeOrder);

return partnerNode;
}

Expand Down
33 changes: 22 additions & 11 deletions app/services/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,17 +152,28 @@ export class EditorService extends StatefulService<IEditorServiceState> {
}
}

startDragging(event: IMouseEvent) {
const dragHandler = new DragHandler(event, {
displaySize: {
x: this.renderedWidths[event.display],
y: this.renderedHeights[event.display],
},
displayOffset: {
x: this.renderedOffsetXs[event.display],
y: this.renderedOffsetYs[event.display],
startDragging(event: IMouseEvent, source: SceneItem) {
// Folder selection can end with an empty folder. Anchor the drag to the
// hovered item, and only start while it remains selected on this display.
const draggedSource = this.selectionService.views.globalSelection
.getVisualItems(event.display)
.find(item => item.id === source?.id);
if (!draggedSource) return;

const dragHandler = new DragHandler(
event,
{
displaySize: {
x: this.renderedWidths[event.display],
y: this.renderedHeights[event.display],
},
displayOffset: {
x: this.renderedOffsetXs[event.display],
y: this.renderedOffsetYs[event.display],
},
},
});
draggedSource,
);

this.dragHandler = dragHandler;
this.SET_CHANGING_POSITION_IN_PROGRESS(true);
Expand Down Expand Up @@ -323,7 +334,7 @@ export class EditorService extends StatefulService<IEditorServiceState> {
}

// Start dragging it
this.startDragging(event);
this.startDragging(event, overSource);
}
}

Expand Down
41 changes: 8 additions & 33 deletions app/util/DragHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export class DragHandler {
snapDistance: number;

// Sources
private draggedSource: SceneItem;
private otherSources: SceneItem[];

// Mouse properties
Expand All @@ -91,9 +90,14 @@ export class DragHandler {
/**
* @param startEvent the mouse event for this drag
* @param options drag handler options
* @param draggedSource the selected visual item under the cursor on this display
*/

constructor(startEvent: IMouseEvent, options: IDragHandlerOptions) {
constructor(
startEvent: IMouseEvent,
options: IDragHandlerOptions,
private draggedSource: SceneItem,
) {
// Load some settings we care about
this.snapEnabled = this.settingsService.views.values.General.SnappingEnabled;
this.renderedSnapDistance = this.settingsService.views.values.General.SnapDistance;
Expand All @@ -118,37 +122,6 @@ export class DragHandler {
this.snapDistance =
(this.renderedSnapDistance * this.scaleFactor * this.baseWidth) / this.displaySize.x;

// Load some attributes about sources
const lastDragged = this.selectionService.views.globalSelection.getLastSelected();

if (lastDragged.isItem()) {
/**
* In dual output mode, the last selected node may be in a different display than the mouse event.
* Dragging scene items in the display should only transform the nodes in the display with the mouse event.
* So if the displays for the mouse event and last selected node don't match, use the node's partner
* in the other display.
*
* If there are any issues finding the partner node, use the last dragged source as a default.
* While it's not ideal, this will prevent errors from attempting to work with undefined values.
*/
if (startEvent.display !== lastDragged.display) {
const dualOutputNodeId = this.dualOutputService.views.getDualOutputNodeId(lastDragged.id);
// confirm the partner id was found
if (dualOutputNodeId) {
const dualOutputNode = this.selectionService.views.globalSelection
.getItems()
.find(item => item.id === dualOutputNodeId);

// confirm the partner node was found, or use the last selected node as a default
this.draggedSource = dualOutputNode ?? lastDragged;
} else {
this.draggedSource = lastDragged;
}
} else {
this.draggedSource = lastDragged;
}
}

this.otherSources = this.selectionService.views.globalSelection
.clone()
.invert()
Expand Down Expand Up @@ -179,6 +152,8 @@ export class DragHandler {
*/
//
move(event: IMouseEvent) {
if (event.display !== this.draggedSource.display) return false;

const rect = new ScalableRectangle(this.draggedSource.rectangle);
const denormalize = rect.normalize();

Expand Down
Loading
Loading