Fix some visual glitches in the Events Sheet when opening and scrolling (#8571)

This commit is contained in:
Clément Pasteau
2026-05-11 10:52:30 +02:00
committed by GitHub
parent d52d42d3b4
commit 0eb6014a93
24 changed files with 567 additions and 92 deletions
+12
View File
@@ -18,6 +18,18 @@ ElseEvent::ElseEvent()
ElseEvent::~ElseEvent(){};
gd::InstructionsList* ElseEvent::GetInstructionList(const gd::String& label) {
if (label == BaseEvent::conditionsLabel) return &conditions;
if (label == BaseEvent::actionsLabel) return &actions;
return nullptr;
}
const gd::InstructionsList* ElseEvent::GetInstructionList(
const gd::String& label) const {
if (label == BaseEvent::conditionsLabel) return &conditions;
if (label == BaseEvent::actionsLabel) return &actions;
return nullptr;
}
vector<const gd::InstructionsList*> ElseEvent::GetAllConditionsVectors() const {
vector<const gd::InstructionsList*> allConditions;
allConditions.push_back(&conditions);
+3
View File
@@ -39,6 +39,9 @@ class GD_CORE_API ElseEvent : public gd::BaseEvent {
const gd::InstructionsList& GetActions() const { return actions; };
gd::InstructionsList& GetActions() { return actions; };
virtual gd::InstructionsList* GetInstructionList(const gd::String& label) override;
virtual const gd::InstructionsList* GetInstructionList(const gd::String& label) const override;
virtual std::vector<const gd::InstructionsList*> GetAllConditionsVectors() const;
virtual std::vector<const gd::InstructionsList*> GetAllActionsVectors() const;
virtual std::vector<gd::InstructionsList*> GetAllConditionsVectors();
@@ -19,6 +19,19 @@ ForEachChildVariableEvent::ForEachChildVariableEvent()
iterableVariableName(""),
variables(gd::VariablesContainer::SourceType::Local) {}
gd::InstructionsList* ForEachChildVariableEvent::GetInstructionList(
const gd::String& label) {
if (label == BaseEvent::conditionsLabel) return &conditions;
if (label == BaseEvent::actionsLabel) return &actions;
return nullptr;
}
const gd::InstructionsList* ForEachChildVariableEvent::GetInstructionList(
const gd::String& label) const {
if (label == BaseEvent::conditionsLabel) return &conditions;
if (label == BaseEvent::actionsLabel) return &actions;
return nullptr;
}
vector<gd::InstructionsList*> ForEachChildVariableEvent::GetAllConditionsVectors() {
vector<gd::InstructionsList*> allConditions;
allConditions.push_back(&conditions);
@@ -47,6 +47,9 @@ class GD_CORE_API ForEachChildVariableEvent : public gd::BaseEvent {
const gd::InstructionsList& GetActions() const { return actions; };
gd::InstructionsList& GetActions() { return actions; };
virtual gd::InstructionsList* GetInstructionList(const gd::String& label) override;
virtual const gd::InstructionsList* GetInstructionList(const gd::String& label) const override;
/**
* \brief Get the iterable variable name attached to the event.
*
@@ -21,6 +21,19 @@ ForEachEvent::ForEachEvent()
limit(""),
variables(gd::VariablesContainer::SourceType::Local) {}
gd::InstructionsList* ForEachEvent::GetInstructionList(
const gd::String& label) {
if (label == BaseEvent::conditionsLabel) return &conditions;
if (label == BaseEvent::actionsLabel) return &actions;
return nullptr;
}
const gd::InstructionsList* ForEachEvent::GetInstructionList(
const gd::String& label) const {
if (label == BaseEvent::conditionsLabel) return &conditions;
if (label == BaseEvent::actionsLabel) return &actions;
return nullptr;
}
vector<gd::InstructionsList*> ForEachEvent::GetAllConditionsVectors() {
vector<gd::InstructionsList*> allConditions;
allConditions.push_back(&conditions);
@@ -49,6 +49,9 @@ class GD_CORE_API ForEachEvent : public gd::BaseEvent {
const gd::InstructionsList& GetActions() const { return actions; };
gd::InstructionsList& GetActions() { return actions; };
virtual gd::InstructionsList* GetInstructionList(const gd::String& label) override;
virtual const gd::InstructionsList* GetInstructionList(const gd::String& label) const override;
const gd::String& GetObjectToPick() const {
return objectsToPick.GetPlainString();
};
@@ -18,6 +18,18 @@ RepeatEvent::RepeatEvent()
variables(gd::VariablesContainer::SourceType::Local),
repeatNumberExpressionSelected(false) {}
gd::InstructionsList* RepeatEvent::GetInstructionList(const gd::String& label) {
if (label == BaseEvent::conditionsLabel) return &conditions;
if (label == BaseEvent::actionsLabel) return &actions;
return nullptr;
}
const gd::InstructionsList* RepeatEvent::GetInstructionList(
const gd::String& label) const {
if (label == BaseEvent::conditionsLabel) return &conditions;
if (label == BaseEvent::actionsLabel) return &actions;
return nullptr;
}
vector<gd::InstructionsList*> RepeatEvent::GetAllConditionsVectors() {
vector<gd::InstructionsList*> allConditions;
allConditions.push_back(&conditions);
+3
View File
@@ -43,6 +43,9 @@ class GD_CORE_API RepeatEvent : public gd::BaseEvent {
const gd::InstructionsList& GetActions() const { return actions; };
gd::InstructionsList& GetActions() { return actions; };
virtual gd::InstructionsList* GetInstructionList(const gd::String& label) override;
virtual const gd::InstructionsList* GetInstructionList(const gd::String& label) const override;
const gd::Expression& GetRepeatExpression() const {
return repeatNumberExpression;
};
@@ -20,6 +20,19 @@ StandardEvent::StandardEvent()
StandardEvent::~StandardEvent(){};
gd::InstructionsList* StandardEvent::GetInstructionList(
const gd::String& label) {
if (label == BaseEvent::conditionsLabel) return &conditions;
if (label == BaseEvent::actionsLabel) return &actions;
return nullptr;
}
const gd::InstructionsList* StandardEvent::GetInstructionList(
const gd::String& label) const {
if (label == BaseEvent::conditionsLabel) return &conditions;
if (label == BaseEvent::actionsLabel) return &actions;
return nullptr;
}
vector<const gd::InstructionsList*> StandardEvent::GetAllConditionsVectors()
const {
vector<const gd::InstructionsList*> allConditions;
@@ -43,6 +43,9 @@ class GD_CORE_API StandardEvent : public gd::BaseEvent {
const gd::InstructionsList& GetActions() const { return actions; };
gd::InstructionsList& GetActions() { return actions; };
virtual gd::InstructionsList* GetInstructionList(const gd::String& label) override;
virtual const gd::InstructionsList* GetInstructionList(const gd::String& label) const override;
virtual std::vector<const gd::InstructionsList*> GetAllConditionsVectors()
const;
virtual std::vector<const gd::InstructionsList*> GetAllActionsVectors() const;
+14
View File
@@ -13,6 +13,20 @@ using namespace std;
namespace gd {
gd::InstructionsList* WhileEvent::GetInstructionList(const gd::String& label) {
if (label == BaseEvent::conditionsLabel) return &conditions;
if (label == BaseEvent::actionsLabel) return &actions;
if (label == BaseEvent::whileConditionsLabel) return &whileConditions;
return nullptr;
}
const gd::InstructionsList* WhileEvent::GetInstructionList(
const gd::String& label) const {
if (label == BaseEvent::conditionsLabel) return &conditions;
if (label == BaseEvent::actionsLabel) return &actions;
if (label == BaseEvent::whileConditionsLabel) return &whileConditions;
return nullptr;
}
vector<gd::InstructionsList*> WhileEvent::GetAllConditionsVectors() {
vector<gd::InstructionsList*> allConditions;
allConditions.push_back(&whileConditions);
+3
View File
@@ -51,6 +51,9 @@ class GD_CORE_API WhileEvent : public gd::BaseEvent {
const gd::InstructionsList& GetActions() const { return actions; };
gd::InstructionsList& GetActions() { return actions; };
virtual gd::InstructionsList* GetInstructionList(const gd::String& label) override;
virtual const gd::InstructionsList* GetInstructionList(const gd::String& label) const override;
const gd::InstructionsList& GetWhileConditions() const {
return whileConditions;
};
+3
View File
@@ -18,6 +18,9 @@ namespace gd {
EventsList BaseEvent::badSubEvents;
VariablesContainer BaseEvent::badLocalVariables;
const gd::String BaseEvent::conditionsLabel = "conditions";
const gd::String BaseEvent::actionsLabel = "actions";
const gd::String BaseEvent::whileConditionsLabel = "whileConditions";
BaseEvent::BaseEvent()
: totalTimeDuringLastSession(0),
+20
View File
@@ -117,6 +117,26 @@ class GD_CORE_API BaseEvent {
*/
bool HasVariables() const;
/**
* \brief Return the instruction list identified by \a label, or nullptr if
* the event has no such list.
*
* Supported labels are "conditions", "actions", and "whileConditions" (only
* for WhileEvent). Derived classes should override this; the base
* implementation always returns nullptr.
*/
virtual gd::InstructionsList* GetInstructionList(const gd::String& label) {
return nullptr;
};
virtual const gd::InstructionsList* GetInstructionList(
const gd::String& label) const {
return nullptr;
};
static const gd::String conditionsLabel;
static const gd::String actionsLabel;
static const gd::String whileConditionsLabel;
/**
* \brief Return a list of all conditions of the event.
* \note Used to preprocess or search in the conditions.
+2
View File
@@ -2499,6 +2499,8 @@ interface BaseEvent {
boolean IsFolded();
void SetFolded(boolean folded);
InstructionsList GetInstructionList([Const] DOMString label);
void SerializeTo([Ref] SerializerElement element);
void UnserializeFrom([Ref] Project project, [Const, Ref] SerializerElement element);
+1
View File
@@ -1925,6 +1925,7 @@ export class BaseEvent extends EmscriptenObject {
setDisabled(disable: boolean): void;
isFolded(): boolean;
setFolded(folded: boolean): void;
getInstructionList(label: string): InstructionsList;
serializeTo(element: SerializerElement): void;
unserializeFrom(project: Project, element: SerializerElement): void;
getAiGeneratedEventId(): string;
+1
View File
@@ -15,6 +15,7 @@ declare class gdBaseEvent extends gdBaseEvent {
setDisabled(disable: boolean): void;
isFolded(): boolean;
setFolded(folded: boolean): void;
getInstructionList(label: string): gdInstructionsList;
serializeTo(element: gdSerializerElement): void;
unserializeFrom(project: gdProject, element: gdSerializerElement): void;
getAiGeneratedEventId(): string;
@@ -6,15 +6,22 @@
* the size of each event - which we only know after the event has been rendered.
*/
export default class EventHeightsCache {
// $FlowFixMe[missing-local-annot]
eventHeights = {};
updateTimeoutId: ?TimeoutID = null;
eventHeights: { [number]: number } = {};
_onHeightsChanged: ?() => void = null;
setEventHeight(event: gdBaseEvent, height: number) {
if (this.eventHeights[event.ptr] === height) return;
this.eventHeights[event.ptr] = height;
if (this._onHeightsChanged) {
this._onHeightsChanged();
}
}
getEventHeight(event: gdBaseEvent): number {
return this.eventHeights[event.ptr] || 0;
}
setOnHeightsChanged(callback: ?() => void) {
this._onHeightsChanged = callback;
}
}
@@ -94,7 +94,7 @@ export default class WhileEvent extends React.Component<
projectScopedContainersAccessor={
this.props.projectScopedContainersAccessor
}
idPrefix={this.props.idPrefix}
idPrefix={`${this.props.idPrefix}-while`}
highlightedSearchText={this.props.highlightedSearchText}
highlightedSearchMatchCase={this.props.highlightedSearchMatchCase}
/>
@@ -64,6 +64,7 @@ type Props = {|
searchFocusOffset?: ?number,
searchFocusedEvent?: ?gdBaseEvent,
className?: string,
eventsSheetWidth?: number,
reactVirtualizedListProps?: {
ref?: (list: {
scrollToRow: (row: number) => void,
@@ -365,6 +366,7 @@ const SortableEventsTree = ({
searchFocusOffset,
searchFocusedEvent,
className,
eventsSheetWidth,
reactVirtualizedListProps,
}: Props): React.MixedElement => {
// $FlowFixMe[value-as-type]
@@ -390,7 +392,7 @@ const SortableEventsTree = ({
[flatData, searchMethod, searchQuery]
);
React.useEffect(
React.useLayoutEffect(
() => {
if (!reactVirtualizedListProps || !reactVirtualizedListProps.ref) return;
reactVirtualizedListProps.ref({
@@ -464,7 +466,7 @@ const SortableEventsTree = ({
return (
<div className={className}>
<AutoSizer>
<AutoSizer defaultWidth={eventsSheetWidth || 0}>
{({ width, height }) => (
<VariableSizeList
ref={listRef}
@@ -477,6 +479,7 @@ const SortableEventsTree = ({
itemData={itemData}
itemKey={itemKey}
onScroll={handleScroll}
overscanCount={10}
>
{TreeRow}
</VariableSizeList>
+168 -10
View File
@@ -74,6 +74,10 @@ const getThumbnail = ObjectsRenderingService.getThumbnail.bind(
const defaultIndentWidth = 22;
const smallIndentWidth = 11;
// Placeholder height for events whose real height is not yet measured.
// Prevents all events from collapsing to 1px at startup, which would cause
// the virtualized list to render all of them at once.
const defaultEventHeight = 100;
const styles = {
container: { flex: 1, position: 'relative' },
@@ -180,8 +184,10 @@ const EventContainer = (props: EventsContainerProps) => {
React.useLayoutEffect(() => {
const container = containerRef.current;
const height = container ? container.offsetHeight : 0;
if (height === 0) {
// An empty height means that the event is hidden, when navigating outside of the events sheet tab for example.
const width = container ? container.offsetWidth : 0;
if (height === 0 || width === 0) {
// An empty height or width means the event is hidden (e.g. navigating outside
// the events sheet tab), or the container hasn't been sized yet.
// Don't store the height in this case.
return;
}
@@ -204,7 +210,10 @@ const EventContainer = (props: EventsContainerProps) => {
[onEventContextMenu]
);
const { isPressingRef, contextMenuProps } = useLongTouch(
const {
isPressingRef,
contextMenuProps: longTouchForContextMenuProps,
} = useLongTouch(
React.useCallback(
(domEvent: any) => {
onEventContextMenu(domEvent.clientX, domEvent.clientY);
@@ -263,7 +272,7 @@ const EventContainer = (props: EventsContainerProps) => {
ref={containerRef}
onClick={props.onEventClick}
onContextMenu={_onEventContextMenu}
{...contextMenuProps}
{...longTouchForContextMenuProps}
>
{!!EventComponent && (
<div style={styles.eventComponentContainer}>
@@ -449,6 +458,11 @@ export type EventsTreeInterface = {|
rowIndexes: Array<number>
) => Array<EventContext>,
scrollToRow: (row: number) => void,
scrollToInstruction: (
rowIndex: number,
listLabel: string,
instructionIndex: number
) => void,
getEventRow: (event: gdBaseEvent) => number,
unfoldForEvent: (event: gdBaseEvent) => void,
|};
@@ -471,6 +485,13 @@ const EventsTree: React.ComponentType<{
const _list = React.useRef<?any>(null);
const eventsHeightsCache = React.useMemo(() => new EventHeightsCache(), []);
React.useLayoutEffect(
() => {
eventsHeightsCache.setOnHeightsChanged(forceUpdate);
return () => eventsHeightsCache.setOnHeightsChanged(null);
},
[eventsHeightsCache, forceUpdate]
);
const temporaryUnfoldedNodes = React.useRef<Array<SortableTreeNode>>([]);
const _hoverTimerId = React.useRef<?TimeoutID>(null);
@@ -514,8 +535,28 @@ const EventsTree: React.ComponentType<{
const scrollToRow = React.useCallback((row: number) => {
if (row === -1) return;
if (!_list.current) return;
_list.current.scrollToRow(row);
const list = _list.current;
if (!list) return;
// If the target row is already fully visible in the DOM, skip the scroll.
// React-window's scrollToItem uses summed item heights which can be
// inaccurate for non-visible rows, causing unnecessary jumps.
const container = list.container;
if (container) {
const element = container.querySelector(`[data-row-index="${row}"]`);
if (element) {
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
if (
elementRect.top >= containerRect.top &&
elementRect.bottom <= containerRect.bottom
) {
return;
}
}
}
list.scrollToRow(row);
}, []);
/**
@@ -578,6 +619,13 @@ const EventsTree: React.ComponentType<{
[draggedNode, _restoreFoldedNodes, forceUpdate]
);
// Position-based height snapshot. Used as a fallback in _getRowHeight when
// the ptr-based cache has no entry (e.g. right after undo/redo, which
// deserializes all events with fresh pointers). Merged rather than replaced
// so off-screen events keep their last known height instead of collapsing to
// defaultEventHeight.
const heightsByRowIndex = React.useRef<{ [number]: number }>({});
const eventPtrToRowIndex = React.useRef<{ [key: string]: number }>({});
const getEventRow = React.useCallback(
(searchedEvent: gdBaseEvent) => {
@@ -586,6 +634,89 @@ const EventsTree: React.ComponentType<{
[eventPtrToRowIndex]
);
const scrollToInstruction = React.useCallback(
(rowIndex: number, listLabel: string, instructionIndex: number) => {
// Do NOT call scrollToRow here unconditionally. For tall event rows (many
// actions), scrollToRow snaps the row's top edge to the viewport top even
// when the target instruction is already visible. We only fall back to it
// inside tryScrollToElement if the DOM element isn't found (row not yet
// rendered by the virtual list).
const tryScrollToElement = ({
retriesLeft,
didScrollToRow,
}: {
retriesLeft: number,
didScrollToRow: boolean,
}) => {
requestAnimationFrame(() => {
const list = _list.current;
if (!list || !list.container) return;
// Search by rowIndex (position-based, stable across undo/redo) rather
// than by event.ptr which changes after every deserialization.
let relativeNodePath = null;
const findNode = (nodes: Array<SortableTreeNode>): boolean => {
for (const node of nodes) {
if (node.rowIndex === rowIndex) {
relativeNodePath = node.relativeNodePath;
return true;
}
if (node.children.length > 0 && findNode(node.children))
return true;
}
return false;
};
findNode(treeDataRoot.current);
if (!relativeNodePath) return;
// For WhileEvent's loop-guard list the idPrefix gets a '-while' suffix
// (set in WhileEvent.js) to avoid colliding with the body conditions.
const listIdPrefix =
listLabel === 'whileConditions'
? `event-${relativeNodePath.join('-')}-while`
: `event-${relativeNodePath.join('-')}`;
const type = listLabel === 'actions' ? 'action' : 'condition';
const doc = list.container.ownerDocument;
// Try the exact index first, then one below (covers undo of add where
// the instruction at instructionIndex was removed).
const element =
doc.getElementById(`${listIdPrefix}-${type}-${instructionIndex}`) ||
(instructionIndex > 0
? doc.getElementById(
`${listIdPrefix}-${type}-${instructionIndex - 1}`
)
: null);
if (element) {
const containerRect = list.container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const alreadyVisible =
elementRect.top >= containerRect.top &&
elementRect.bottom <= containerRect.bottom;
if (!alreadyVisible) {
element.scrollIntoView({ block: 'nearest' });
}
} else if (!didScrollToRow) {
// Element not in DOM — the row may not be rendered by the virtual
// list yet. Scroll to the row first, then retry.
scrollToRow(rowIndex);
tryScrollToElement({ retriesLeft, didScrollToRow: true });
} else if (retriesLeft > 0) {
tryScrollToElement({
retriesLeft: retriesLeft - 1,
didScrollToRow: true,
});
}
});
};
tryScrollToElement({ retriesLeft: 2, didScrollToRow: false });
},
[scrollToRow]
);
const temporaryUnfoldNode = React.useCallback(
(isOverLazy: boolean, node: SortableTreeNode) => {
const { event } = node;
@@ -622,7 +753,11 @@ const EventsTree: React.ComponentType<{
}
const height = eventsHeightsCache.getEventHeight(node.event);
return height;
if (height > 0) return height;
// Fall back to the last known height for this row position. This avoids a
// visible collapse to defaultEventHeight after undo/redo, which replaces all
// event pointers via deserialization and empties the ptr-based cache.
return heightsByRowIndex.current[node.rowIndex] || defaultEventHeight;
};
const {
@@ -1038,6 +1173,7 @@ const EventsTree: React.ComponentType<{
unfoldToLevel,
getEventContextAtRowIndexes,
scrollToRow,
scrollToInstruction,
getEventRow,
unfoldForEvent,
}));
@@ -1094,11 +1230,28 @@ const EventsTree: React.ComponentType<{
);
React.useLayoutEffect(() => {
// Recompute the row heights of the tree at each render, because there
// is no guarantee that events heights have not changed (resizing, change in event...).
// Recompute row heights on every render. This is needed for any change
// that affects which event is at which row (deletion, fold/unfold, move)
// — not just changes to individual event heights. Scroll-triggered
// re-renders (isScrolledTop / isScrolledBottom state flips) are infrequent
// and recalculate the same heights, so the extra work is negligible.
if (_list.current) {
_list.current.recomputeRowHeights();
}
// Merge newly measured heights into the position-based snapshot so that the
// next render after an undo/redo (which replaces all ptrs) can use them as
// fallback heights instead of collapsing to defaultEventHeight.
const flatData = getFlatDataFromTree({
treeData: treeDataRoot.current,
getNodeKey,
ignoreCollapsed: true,
});
flatData.forEach(entry => {
if (entry.node.event) {
const h = eventsHeightsCache.getEventHeight(entry.node.event);
if (h > 0) heightsByRowIndex.current[entry.node.rowIndex] = h;
}
});
});
return (
@@ -1151,6 +1304,7 @@ const EventsTree: React.ComponentType<{
: null
}
className={props.searchResults ? eventsTreeWithSearchResults : ''}
eventsSheetWidth={props.eventsSheetWidth}
reactVirtualizedListProps={{
ref: list => {
_list.current = list;
@@ -1166,7 +1320,11 @@ const EventsTree: React.ComponentType<{
event.clientHeight + event.scrollTop >= event.scrollHeight
);
},
scrollToAlignment: 'center',
// 'smart': no-op if the row is already visible; centers it only when
// it is more than one viewport away. This prevents undo of an in-view
// edit from jumping the viewport due to accumulated height-estimate
// errors in never-scrolled-to rows.
scrollToAlignment: 'smart',
}}
/>
</div>
+2 -10
View File
@@ -48,11 +48,7 @@ export type SelectionState = {|
selectedEvents: Array<EventContext>,
|};
export const getInitialSelection = (): {
selectedEvents: Array<empty>,
selectedInstructions: Array<empty>,
selectedInstructionsLists: Array<empty>,
} => {
export const getInitialSelection = (): SelectionState => {
return {
selectedInstructions: [],
selectedEvents: [],
@@ -342,11 +338,7 @@ export const selectInstructionsList = (
export const selectEventsAfterHistoryChange = (
eventContexts: Array<EventContext>
): {
selectedEvents: Array<empty>,
selectedInstructions: Array<empty>,
selectedInstructionsLists: Array<empty>,
} => {
): SelectionState => {
let newSelection = getInitialSelection();
eventContexts.forEach(eventContext => {
+258 -65
View File
@@ -59,6 +59,7 @@ import {
getLastSelectedEventContext,
getLastSelectedEventContextWhichCanHaveSubEvents,
getLastSelectedInstructionContext,
getLastSelectedInstructionsListsContext,
getLastSelectedInstructionEventContextWhichCanHaveSubEvents,
getLastSelectedEventContextWhichCanHaveVariables,
getLastSelectedInstructionEventContextWhichCanHaveVariables,
@@ -131,9 +132,26 @@ import { useHighlightedAiGeneratedEvent } from './UseHighlightedAiGeneratedEvent
import { findEventByPath } from '../Utils/EventsValidationScanner';
import type { SearchFilterParams } from '../Utils/Search';
import type { InitialSearchFilterParams } from './SearchPanel';
import { isNullPtr } from '../Utils/IsNullPtr';
const gd: libGDevelop = global.gd;
// Derives the stable list label stored in history for a given live instruction
// list reference. 'whileConditions' identifies the loop-guard list of a WhileEvent,
// which shares isCondition=true with the body conditions but is a distinct list.
const getInstructionListLabel = (
event: gdBaseEvent,
instrsList: gdInstructionsList,
isCondition: boolean
): string => {
if (!isCondition) return 'actions';
const whileList = event.getInstructionList('whileConditions');
// $FlowFixMe[incompatible-exact]
if (!isNullPtr(gd, whileList) && whileList.ptr === instrsList.ptr)
return 'whileConditions';
return 'conditions';
};
const zoomLevel = { min: 1, max: 50 };
const loopEventTypes = [
'BuiltinCommonInstructions::While',
@@ -888,7 +906,22 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
};
closeInstructionEditor(saveChanges: boolean = false) {
const { instruction, eventContext } = this.state.editedInstruction;
const {
instruction,
eventContext,
instrsList,
indexInList,
isCondition,
} = this.state.editedInstruction;
// Capture instruction index before state is reset:
// - for edit: use the existing indexInList
// - for add: instruction was just inserted at the end of the list
const instructionIndex =
indexInList !== undefined && indexInList !== null
? indexInList
: instrsList
? instrsList.size() - 1
: undefined;
this.setState(
{
@@ -912,6 +945,14 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
this._saveChangesToHistory('EDIT', {
positionsBeforeAction: positions,
positionAfterAction: positions,
instructionIndex,
instructionListLabel: instrsList
? getInstructionListLabel(
eventContext.event,
instrsList,
isCondition
)
: undefined,
});
}
}
@@ -1585,6 +1626,22 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
selectedEvents.forEach(event => eventsRemover.addEventToRemove(event));
eventsWithDeletion = eventsWithDeletion.concat(selectedEvents);
}
// Capture instruction index before deletion for precise undo scroll.
const firstDeletedInstrCtx =
deleteInstructions && !deleteEvents
? getSelectedInstructionsContexts(this.state.selection)[0]
: undefined;
const deleteInstructionIndex = firstDeletedInstrCtx
? firstDeletedInstrCtx.indexInList
: undefined;
const deleteInstructionListLabel = firstDeletedInstrCtx
? getInstructionListLabel(
firstDeletedInstrCtx.eventContext.event,
firstDeletedInstrCtx.instrsList,
firstDeletedInstrCtx.isCondition
)
: undefined;
if (deleteInstructions) {
getSelectedInstructions(this.state.selection).forEach(instruction =>
eventsRemover.addInstructionToRemove(instruction)
@@ -1615,6 +1672,8 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
this._saveChangesToHistory('DELETE', {
positionsBeforeAction: positions,
positionAfterAction: positions,
instructionIndex: deleteInstructionIndex,
instructionListLabel: deleteInstructionListLabel,
});
// Deletion of an event/instruction will remove it from the DOM,
// potentially losing the focus on the associated DOM elements. Ensure
@@ -1658,6 +1717,32 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
};
pasteInstructions = () => {
const lastInstrCtx = getLastSelectedInstructionContext(
this.state.selection
);
const lastListCtx = getLastSelectedInstructionsListsContext(
this.state.selection
);
const pasteInstructionListLabel = lastInstrCtx
? getInstructionListLabel(
lastInstrCtx.eventContext.event,
lastInstrCtx.instrsList,
lastInstrCtx.isCondition
)
: lastListCtx
? lastListCtx.isCondition
? 'conditions'
: 'actions'
: undefined;
// Capture the selected instruction's ptr and list before paste so we can
// find where it ended up after (paste may insert before it, shifting it).
const selectedInstructionPtr = lastInstrCtx
? lastInstrCtx.instruction.ptr
: null;
const listSizeBeforePaste = lastListCtx
? lastListCtx.instrsList.size()
: null;
if (
!pasteInstructionsFromClipboardInSelection(
this.props.project,
@@ -1666,6 +1751,27 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
) {
return;
}
// Compute instructionIndex AFTER paste by finding where the originally
// selected instruction ended up (paste may have inserted before it).
// This ensures undo/redo always highlights the original instruction, not
// whatever happens to occupy the paste position.
let pasteInstructionIndex: ?number = undefined;
if (lastInstrCtx && selectedInstructionPtr !== null) {
const instrsList = lastInstrCtx.instrsList;
for (let i = instrsList.size() - 1; i >= 0; i--) {
if (instrsList.get(i).ptr === selectedInstructionPtr) {
pasteInstructionIndex = i;
break;
}
}
if (pasteInstructionIndex === undefined) {
pasteInstructionIndex = instrsList.size() - 1;
}
} else if (lastListCtx && listSizeBeforePaste !== null) {
pasteInstructionIndex = listSizeBeforePaste;
}
const locatingEvents = getSelectedInstructionsLocatingEvents(
this.state.selection
);
@@ -1675,6 +1781,8 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
{
positionsBeforeAction: positions,
positionAfterAction: positions,
instructionIndex: pasteInstructionIndex,
instructionListLabel: pasteInstructionListLabel,
},
() => {
if (this._eventsTree) this._eventsTree.forceEventsUpdate();
@@ -1694,6 +1802,14 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
eventContext: EventContext,
instructionsListContext: InstructionsListContext
) => {
// Capture index before paste (paste always appends at the end of the list).
const instructionIndex = instructionsListContext.instrsList.size();
const instructionListLabel = getInstructionListLabel(
eventContext.event,
instructionsListContext.instrsList,
instructionsListContext.isCondition
);
if (
!pasteInstructionsFromClipboardInInstructionsList(
this.props.project,
@@ -1708,6 +1824,8 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
{
positionsBeforeAction: positions,
positionAfterAction: positions,
instructionIndex,
instructionListLabel,
},
() => {
if (this._eventsTree) this._eventsTree.forceEventsUpdate();
@@ -1818,6 +1936,8 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
positions: {
positionsBeforeAction: Array<number>,
positionAfterAction: Array<number>,
instructionIndex?: ?number,
instructionListLabel?: ?string,
},
cb: ?Function
) => {
@@ -1851,6 +1971,16 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
const { _eventsTree: eventsTree } = this;
if (!eventsTree) return;
// Clear selection immediately alongside the history update so that stale
// C++ ptrs from the old selection don't accidentally match newly allocated
// objects (WASM ptr reuse) and cause a spurious highlight flash during
// the re-render triggered by forceEventsUpdate below.
this.setState({
selection: clearSelection(),
eventsHistory: newEventsHistory,
});
eventsTree.forceEventsUpdate(() => {
const {
changeContext: { positions },
@@ -1859,44 +1989,70 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
newEventsHistory.futureActions.length - 1
];
// $FlowFixMe[incompatible-type]
let newSelection: SelectionState = getInitialSelection();
// If it is a DELETE or EDIT, then the element will be present, so we can select them.
// If it is an ADD, then it will not be present, so we can't select them.
if (type === 'ADD') {
newSelection = clearSelection();
} else {
const eventContexts = eventsTree.getEventContextAtRowIndexes(
positions.positionsBeforeAction
);
// $FlowFixMe[incompatible-type]
newSelection = selectEventsAfterHistoryChange(eventContexts);
const eventContexts = eventsTree.getEventContextAtRowIndexes(
positions.positionsBeforeAction
);
let newSelection: SelectionState = clearSelection();
if (type !== 'ADD') {
if (
positions.instructionIndex !== undefined &&
positions.instructionListLabel !== undefined &&
eventContexts[0]
) {
const maybeInstrsList = eventContexts[0].event.getInstructionList(
positions.instructionListLabel
);
// $FlowFixMe[incompatible-exact]
const instrsList = !isNullPtr(gd, maybeInstrsList)
? maybeInstrsList
: null;
// Select the instruction at instructionIndex if it exists, otherwise
// select the closest one before it (e.g. after undoing a paste at end).
const clampedIdx = instrsList
? Math.min(positions.instructionIndex, instrsList.size() - 1)
: -1;
if (instrsList && clampedIdx >= 0) {
newSelection = selectInstruction(
eventContexts[0],
clearSelection(),
{
isCondition: positions.instructionListLabel !== 'actions',
instrsList,
instruction: instrsList.get(clampedIdx),
indexInList: clampedIdx,
},
false
);
} else {
newSelection = selectEventsAfterHistoryChange(eventContexts);
}
} else {
newSelection = selectEventsAfterHistoryChange(eventContexts);
}
}
this.setState(
// $FlowFixMe[incompatible-type]
{
selection: newSelection,
eventsHistory: newEventsHistory,
},
() => {
const row = positions.positionsBeforeAction[0];
this.setState({ selection: newSelection }, () => {
const row = positions.positionsBeforeAction[0];
if (row !== undefined) {
// Whether it is an ADD, EDIT or DELETE, scroll to the place where it was done.
if (row !== undefined) {
if (
positions.instructionIndex !== undefined &&
positions.instructionListLabel !== undefined &&
eventContexts[0]
) {
eventsTree.scrollToInstruction(
row,
positions.instructionListLabel,
positions.instructionIndex
);
} else {
eventsTree.scrollToRow(row);
// Hack: because of the virtualization and the undo/redo, we lose the heights of events
// (at least some, because they are different objects in memory).
// While they are recomputed when rendered, scroll again to be sure we don't end
// up at the very beginning (if everything was recomputed from 0) or at
// an offset too large.
setTimeout(() => {
eventsTree.scrollToRow(row);
}, 70);
}
this.updateToolbar();
}
);
this._ensureFocused();
this.updateToolbar();
});
});
};
@@ -1913,6 +2069,13 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
const { _eventsTree: eventsTree } = this;
if (!eventsTree) return;
// Clear selection immediately to prevent stale ptr flashes (see undo above).
this.setState({
selection: clearSelection(),
eventsHistory: newEventsHistory,
});
eventsTree.forceEventsUpdate(() => {
const {
changeContext: { positions },
@@ -1921,44 +2084,74 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
newEventsHistory.previousActions.length - 1
];
// If it is a ADD or EDIT, then the element will be present, so we can select them.
// If it is a DELETE, then they will not be present, so we can't select them.
// $FlowFixMe[incompatible-type]
let newSelection: SelectionState = getInitialSelection();
if (type === 'DELETE') {
newSelection = clearSelection();
} else {
const eventContexts = eventsTree.getEventContextAtRowIndexes(
positions.positionAfterAction
);
// $FlowFixMe[incompatible-type]
newSelection = selectEventsAfterHistoryChange(eventContexts);
const eventContextsForScroll = eventsTree.getEventContextAtRowIndexes(
positions.positionsBeforeAction
);
let newSelection: SelectionState = clearSelection();
if (type !== 'DELETE') {
if (
positions.instructionIndex !== undefined &&
positions.instructionListLabel !== undefined &&
eventContextsForScroll[0]
) {
const maybeInstrsList = eventContextsForScroll[0].event.getInstructionList(
positions.instructionListLabel
);
// $FlowFixMe[incompatible-exact]
const instrsList = !isNullPtr(gd, maybeInstrsList)
? maybeInstrsList
: null;
const clampedIdx = instrsList
? Math.min(positions.instructionIndex, instrsList.size() - 1)
: -1;
if (instrsList && clampedIdx >= 0) {
newSelection = selectInstruction(
eventContextsForScroll[0],
clearSelection(),
{
isCondition: positions.instructionListLabel !== 'actions',
instrsList,
instruction: instrsList.get(clampedIdx),
indexInList: clampedIdx,
},
false
);
} else {
const eventContexts = eventsTree.getEventContextAtRowIndexes(
positions.positionAfterAction
);
newSelection = selectEventsAfterHistoryChange(eventContexts);
}
} else {
const eventContexts = eventsTree.getEventContextAtRowIndexes(
positions.positionAfterAction
);
newSelection = selectEventsAfterHistoryChange(eventContexts);
}
}
this.setState(
// $FlowFixMe[incompatible-type]
{
selection: newSelection,
eventsHistory: newEventsHistory,
},
() => {
const row = positions.positionsBeforeAction[0];
this.setState({ selection: newSelection }, () => {
const row = positions.positionsBeforeAction[0];
if (row !== undefined) {
// Whether it was an ADD, EDIT or DELETE, scroll to the place where it will happen.
if (row !== undefined) {
if (
positions.instructionIndex !== undefined &&
positions.instructionListLabel !== undefined &&
eventContextsForScroll[0]
) {
eventsTree.scrollToInstruction(
row,
positions.instructionListLabel,
positions.instructionIndex
);
} else {
eventsTree.scrollToRow(row);
// Hack: because of the virtualization and the undo/redo, we lose the heights of events
// (at least some, because they are different objects in memory).
// While they are recomputed when rendered, scroll again to be sure we don't end
// up at the very beginning (if everything was recomputed from 0) or at
// an offset too large.
setTimeout(() => {
eventsTree.scrollToRow(row);
}, 70);
}
this.updateToolbar();
}
);
this._ensureFocused();
this.updateToolbar();
});
});
};
@@ -49,7 +49,7 @@ const ImageThumbnail = (props: Props): React.MixedElement => {
const [error, setError] = React.useState(false);
// Allow a long press to show the context menu
const longTouchForContextMenuProps = useLongTouch(
const { contextMenuProps: longTouchForContextMenuProps } = useLongTouch(
React.useCallback(
event => {
if (onContextMenu) onContextMenu(event.clientX, event.clientY);