diff --git a/Core/GDCore/Events/Event.h b/Core/GDCore/Events/Event.h index 46917f36aca2..6be9d2615a8d 100644 --- a/Core/GDCore/Events/Event.h +++ b/Core/GDCore/Events/Event.h @@ -300,6 +300,20 @@ class GD_CORE_API BaseEvent { const gd::String& GetAiGeneratedEventId() const { return aiGeneratedEventId; } + + /** + * \brief Set the event bookmark ID. + */ + void SetEventBookmarkId(const gd::String& eventBookmarkId_) { + eventBookmarkId = eventBookmarkId_; + } + + /** + * \brief Get the event bookmark ID. + */ + const gd::String& GetEventBookmarkId() const { + return eventBookmarkId; + } ///@} std::weak_ptr @@ -319,6 +333,7 @@ class GD_CORE_API BaseEvent { gd::String type; ///< Type of the event. Must be assigned at the creation. ///< Used for saving the event for instance. gd::String aiGeneratedEventId; ///< When generated by an AI/external tool. + gd::String eventBookmarkId; ///< Bookmark identifier for the event. static gd::EventsList badSubEvents; static gd::VariablesContainer badLocalVariables; diff --git a/Core/GDCore/Events/Serialization.cpp b/Core/GDCore/Events/Serialization.cpp index ec1a8bc2c6b8..c8c6d176f48d 100644 --- a/Core/GDCore/Events/Serialization.cpp +++ b/Core/GDCore/Events/Serialization.cpp @@ -223,6 +223,8 @@ void EventsListSerialization::UnserializeEventsFrom( event->SetFolded(eventElem.GetBoolAttribute("folded", false)); event->SetAiGeneratedEventId( eventElem.GetStringAttribute("aiGeneratedEventId", "")); + event->SetEventBookmarkId( + eventElem.GetStringAttribute("eventBookmarkId", "")); list.InsertEvent(event, list.GetEventsCount()); } @@ -240,6 +242,8 @@ void EventsListSerialization::SerializeEventsTo(const EventsList& list, if (event.IsFolded()) eventElem.SetAttribute("folded", event.IsFolded()); if (!event.GetAiGeneratedEventId().empty()) eventElem.SetAttribute("aiGeneratedEventId", event.GetAiGeneratedEventId()); + if (!event.GetEventBookmarkId().empty()) + eventElem.SetAttribute("eventBookmarkId", event.GetEventBookmarkId()); eventElem.AddChild("type").SetValue(event.GetType()); event.SerializeTo(eventElem); diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index 3803d134ed0c..632b01b4f338 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -2462,6 +2462,9 @@ interface BaseEvent { [Const, Ref] DOMString GetAiGeneratedEventId(); void SetAiGeneratedEventId([Const] DOMString aiGeneratedEventId); + + [Const, Ref] DOMString GetEventBookmarkId(); + void SetEventBookmarkId([Const] DOMString eventBookmarkId); }; interface StandardEvent { diff --git a/GDevelop.js/types.d.ts b/GDevelop.js/types.d.ts index 61e2e2bdb60d..1d5a9345e9a7 100644 --- a/GDevelop.js/types.d.ts +++ b/GDevelop.js/types.d.ts @@ -1893,6 +1893,8 @@ export class BaseEvent extends EmscriptenObject { unserializeFrom(project: Project, element: SerializerElement): void; getAiGeneratedEventId(): string; setAiGeneratedEventId(aiGeneratedEventId: string): void; + getEventBookmarkId(): string; + setEventBookmarkId(eventBookmarkId: string): void; } export class StandardEvent extends BaseEvent { diff --git a/GDevelop.js/types/gdbaseevent.js b/GDevelop.js/types/gdbaseevent.js index 161ef1dbae38..d9be90ddf21e 100644 --- a/GDevelop.js/types/gdbaseevent.js +++ b/GDevelop.js/types/gdbaseevent.js @@ -19,6 +19,8 @@ declare class gdBaseEvent extends gdBaseEvent { unserializeFrom(project: gdProject, element: gdSerializerElement): void; getAiGeneratedEventId(): string; setAiGeneratedEventId(aiGeneratedEventId: string): void; + getEventBookmarkId(): string; + setEventBookmarkId(eventBookmarkId: string): void; delete(): void; ptr: number; }; \ No newline at end of file diff --git a/newIDE/app/src/EventsSheet/Bookmarks/BookmarksPanel.css b/newIDE/app/src/EventsSheet/Bookmarks/BookmarksPanel.css new file mode 100644 index 000000000000..ec7b92cea4cb --- /dev/null +++ b/newIDE/app/src/EventsSheet/Bookmarks/BookmarksPanel.css @@ -0,0 +1,72 @@ +.bookmarksPanelContainer { + display: flex; + flex-direction: column; + z-index: 10; + pointer-events: auto; +} + +.bookmarksPanelContainer.mobile { + position: fixed; + bottom: 0; + left: 0; + right: 0; + width: 100%; + height: min(300px, 60vh); + max-height: 80vh; + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.2); + transform: translateY(100%); + transition: transform 0.3s ease-in-out; +} + +.bookmarksPanelContainer.mobile.open { + transform: translateY(0); +} + +.bookmarksPanelContainer.desktop { + position: absolute; + top: 0; + right: 0; + height: 100%; + width: 280px; + box-shadow: -4px 0 12px rgba(0, 0, 0, 0.2); + transform: translateX(100%); + transition: transform 0.3s ease-in-out; +} + +.bookmarksPanelContainer.desktop.open { + transform: translateX(0%); +} + +.emptyContainer { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + padding: 20px; +} + +.bookmarkItem { + display: flex; + align-items: center; + padding: 8px 4px 8px 5px; + gap: 4px; + border-radius: 4px; + cursor: pointer; + border-bottom: 1px solid; + transition: background-color 0.2s ease; +} + +.bookmarkItem:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.bookmarkItemName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.starIcon { + margin-right: 8px; +} diff --git a/newIDE/app/src/EventsSheet/Bookmarks/BookmarksUtils.js b/newIDE/app/src/EventsSheet/Bookmarks/BookmarksUtils.js new file mode 100644 index 000000000000..752e3f6a33cd --- /dev/null +++ b/newIDE/app/src/EventsSheet/Bookmarks/BookmarksUtils.js @@ -0,0 +1,230 @@ +// @flow +import { rgbToHex } from '../../Utils/ColorTransformer'; + +const gd: libGDevelop = global.gd; + +export type Bookmark = {| + eventPtr: number, + name: string, + eventType: string, + id: string, + timestamp: number, + borderLeftColor?: ?string, +|}; + +/** + * Scan all events recursively and collect those that are bookmarked + */ +export const scanEventsForBookmarks = ( + events: gdEventsList +): Array => { + if (!events) return []; + + const bookmarks: Array = []; + + const scanEventsList = (eventsList: gdEventsList) => { + try { + // Safety check: ensure eventsList is valid and has the getEventsCount method + if (!eventsList || typeof eventsList.getEventsCount !== 'function') { + return; + } + + for (let i = 0; i < eventsList.getEventsCount(); i++) { + const event = eventsList.getEventAt(i); + if (!event) continue; + + try { + // Check if event has a bookmark ID + const bookmarkId = + event.getEventBookmarkId && event.getEventBookmarkId(); + if (bookmarkId && bookmarkId.length > 0) { + const bookmark: Bookmark = { + eventPtr: event.ptr, + name: generateBookmarkName(event), + eventType: event.getType(), + id: bookmarkId, + timestamp: Date.now(), + borderLeftColor: getEventTypeColor(event), + }; + bookmarks.push(bookmark); + } + + // Recursively scan sub-events + if (event.canHaveSubEvents && event.canHaveSubEvents()) { + const subEvents = event.getSubEvents(); + if (subEvents) { + scanEventsList(subEvents); + } + } + } catch (err) { + console.error('Error processing event for bookmarks:', err); + } + } + } catch (err) { + console.error('Error scanning event list for bookmarks:', err); + } + }; + + scanEventsList(events); + + return bookmarks; +}; + +/** + * Get the border color for a bookmark based on the event type + */ +const getEventTypeColor = (event: gdBaseEvent): ?string => { + const eventType = event.getType(); + + if (eventType === 'BuiltinCommonInstructions::Comment') { + const commentEvent = gd.asCommentEvent(event); + return `#${rgbToHex( + commentEvent.getBackgroundColorRed(), + commentEvent.getBackgroundColorGreen(), + commentEvent.getBackgroundColorBlue() + )}`; + } + + if (eventType === 'BuiltinCommonInstructions::Group') { + const groupEvent = gd.asGroupEvent(event); + return `#${rgbToHex( + groupEvent.getBackgroundColorR(), + groupEvent.getBackgroundColorG(), + groupEvent.getBackgroundColorB() + )}`; + } + + return null; +}; + +/** + * Generate a default name for a bookmark based on the event + */ +export const generateBookmarkName = (event: gdBaseEvent): string => { + const eventType = event.getType(); + + // For comment events, use the comment text + if (eventType === 'BuiltinCommonInstructions::Comment') { + const commentEvent = gd.asCommentEvent(event); + const comment = commentEvent.getComment(); + if (comment.length > 0) { + return comment.length > 50 ? comment.substring(0, 50) + '...' : comment; + } + return 'Comment'; + } + + // For group events, use the group name + if (eventType === 'BuiltinCommonInstructions::Group') { + const groupEvent = gd.asGroupEvent(event); + const name = groupEvent.getName(); + if (name.length > 0) { + return name.length > 50 ? name.substring(0, 50) + '...' : name; + } + return 'Group'; + } + + // For standard events, try to extract first condition or action text + if (eventType === 'BuiltinCommonInstructions::Standard') { + const standardEvent = gd.asStandardEvent(event); + + // Try to get first condition + const conditions = standardEvent.getConditions(); + if (conditions.size() > 0) { + const firstCondition = conditions.get(0); + const type = firstCondition.getType(); + if (type.length > 0) { + const conditionText = type.replace(/:/g, ' '); + return conditionText.length > 50 + ? conditionText.substring(0, 50) + '...' + : conditionText; + } + } + + // Try to get first action + const actions = standardEvent.getActions(); + if (actions.size() > 0) { + const firstAction = actions.get(0); + const type = firstAction.getType(); + if (type.length > 0) { + const actionText = type.replace(/:/g, ' '); + return actionText.length > 50 + ? actionText.substring(0, 50) + '...' + : actionText; + } + } + + return 'Standard Event'; + } + + // For other event types, use a generic name based on the type + const readableType = eventType + .replace('BuiltinCommonInstructions::', '') + .replace(/([A-Z])/g, ' $1') + .trim(); + + return readableType || 'Event'; +}; + +/** + * Recursively search for an event by its pointer in an event list + */ +export const findEventByPtr = ( + events: gdEventsList, + ptr: number +): ?gdBaseEvent => { + if (!events || !ptr) return null; + + for (let i = 0; i < events.getEventsCount(); i++) { + const event = events.getEventAt(i); + if (!event) continue; + + if (event.ptr === ptr) return event; + + // Recursively search sub-events + if (event.canHaveSubEvents && event.canHaveSubEvents()) { + const subEvents = event.getSubEvents(); + if (subEvents) { + const found = findEventByPtr(subEvents, ptr); + if (found) return found; + } + } + } + + return null; +}; + +/** + * Recursively search for an event by its pointer and return it with its parent list and index + */ +export type EventLocation = {| + event: gdBaseEvent, + eventsList: gdEventsList, + indexInList: number, +|}; + +export const findEventLocationByPtr = ( + events: gdEventsList, + ptr: number +): ?EventLocation => { + if (!events || !ptr) return null; + + for (let i = 0; i < events.getEventsCount(); i++) { + const event = events.getEventAt(i); + if (!event) continue; + + if (event.ptr === ptr) { + return { event, eventsList: events, indexInList: i }; + } + + // Recursively search sub-events + if (event.canHaveSubEvents && event.canHaveSubEvents()) { + const subEvents = event.getSubEvents(); + if (subEvents) { + const found = findEventLocationByPtr(subEvents, ptr); + if (found) return found; + } + } + } + + return null; +}; diff --git a/newIDE/app/src/EventsSheet/Bookmarks/index.js b/newIDE/app/src/EventsSheet/Bookmarks/index.js new file mode 100644 index 000000000000..9c750cffd54b --- /dev/null +++ b/newIDE/app/src/EventsSheet/Bookmarks/index.js @@ -0,0 +1,131 @@ +// @flow +import { Trans, t } from '@lingui/macro'; +import * as React from 'react'; +import Background from '../../UI/Background'; +import { Column, Line } from '../../UI/Grid'; +import IconButton from '../../UI/IconButton'; +import Text from '../../UI/Text'; +import { LineStackLayout, ColumnStackLayout } from '../../UI/Layout'; +import StarBorder from '@material-ui/icons/StarBorder'; +import ShareExternal from '../../UI/CustomSvgIcons/ShareExternal'; +import Delete from '@material-ui/icons/Delete'; +import Cross from '../../UI/CustomSvgIcons/Cross'; +import { type Bookmark } from './BookmarksUtils'; +import EmptyMessage from '../../UI/EmptyMessage'; +import ScrollView from '../../UI/ScrollView'; +import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext'; +import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; +import './BookmarksPanel.css'; + +type Props = {| + bookmarks: Array, + onNavigateToBookmark: (bookmark: Bookmark) => void, + onDeleteBookmark: (bookmarkId: string) => void, + onClose: () => void, + isOpen: boolean, +|}; + +const BookmarksPanel = ({ + bookmarks, + onNavigateToBookmark, + onDeleteBookmark, + onClose, + isOpen, +}: Props) => { + const gdevelopTheme = React.useContext(GDevelopThemeContext); + const { isMobile } = useResponsiveWindowSize(); + + // Handle keyboard shortcuts + React.useEffect( + () => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, + [isOpen, onClose] + ); + + return ( +
+ + + + + + + Bookmarks + + + + + + + + + {bookmarks.length === 0 ? ( +
+ + + No bookmarks yet. Right-click on an event and select "Add + Bookmark" to bookmark it. + + +
+ ) : ( + + + {bookmarks.map(bookmark => ( +
+ onNavigateToBookmark(bookmark)} + tooltip={t`Go to event`} + > + + + + {bookmark.name} + + onDeleteBookmark(bookmark.id)} + tooltip={t`Delete bookmark`} + > + + +
+ ))} +
+
+ )} +
+
+
+ ); +}; + +export default BookmarksPanel; diff --git a/newIDE/app/src/EventsSheet/EventsTree/SortableEventsTree.js b/newIDE/app/src/EventsSheet/EventsTree/SortableEventsTree.js index e075b26cd6ac..95d4fb41000a 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/SortableEventsTree.js +++ b/newIDE/app/src/EventsSheet/EventsTree/SortableEventsTree.js @@ -42,6 +42,7 @@ type RowItemData = { onVisibilityToggle: ({| node: SortableTreeNode |}) => void, scaffoldBlockPxWidth: number, searchFocusOffset: ?number, + bookmarkFocusId: ?string, }; type Props = {| @@ -57,6 +58,7 @@ type Props = {| }) => boolean, searchQuery?: any, searchFocusOffset?: ?number, + bookmarkFocusId?: ?string, className?: string, reactVirtualizedListProps?: { ref?: (list: { @@ -244,6 +246,7 @@ const TreeRow = ({ onVisibilityToggle, scaffoldBlockPxWidth, searchFocusOffset, + bookmarkFocusId, } = data; const entry = flatData[index]; @@ -254,6 +257,11 @@ const TreeRow = ({ const isSearchMatch = matchIndexSet.has(index); const isSearchFocus = searchFocusOffset != null && matchIndexes[searchFocusOffset] === index; + const isBookmarkFocus = + bookmarkFocusId != null && + node.event && + node.event.getEventBookmarkId() === bookmarkFocusId; + const isFocused = isSearchFocus || isBookmarkFocus; const scaffold = lowerSiblingCounts.map((lowerSiblingCount, i) => { const isNodeDepth = i === depth - 1; @@ -327,8 +335,8 @@ const TreeRow = ({
@@ -353,6 +361,7 @@ const SortableEventsTree = ({ searchMethod, searchQuery, searchFocusOffset, + bookmarkFocusId, className, reactVirtualizedListProps, }: Props) => { @@ -431,6 +440,7 @@ const SortableEventsTree = ({ onVisibilityToggle, scaffoldBlockPxWidth, searchFocusOffset, + bookmarkFocusId, }), [ flatData, @@ -439,6 +449,7 @@ const SortableEventsTree = ({ onVisibilityToggle, scaffoldBlockPxWidth, searchFocusOffset, + bookmarkFocusId, ] ); diff --git a/newIDE/app/src/EventsSheet/EventsTree/index.js b/newIDE/app/src/EventsSheet/EventsTree/index.js index 3bf1f74def40..efa51c0ebe52 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/index.js +++ b/newIDE/app/src/EventsSheet/EventsTree/index.js @@ -346,6 +346,7 @@ type EventsTreeProps = {| searchResults: ?Array, searchFocusOffset: ?number, + bookmarkFocusId: ?string, onEventMoved: (previousRowIndex: number, nextRowIndex: number) => void, onEndEditingEvent: (event: gdBaseEvent) => void, @@ -1116,7 +1117,12 @@ const EventsTree = React.forwardRef( searchMethod={_isNodeHighlighted} searchQuery={props.searchResults} searchFocusOffset={props.searchFocusOffset} - className={props.searchResults ? eventsTreeWithSearchResults : ''} + bookmarkFocusId={props.bookmarkFocusId} + className={ + props.searchResults || props.bookmarkFocusId + ? eventsTreeWithSearchResults + : '' + } reactVirtualizedListProps={{ ref: list => { _list.current = list; diff --git a/newIDE/app/src/EventsSheet/Toolbar.js b/newIDE/app/src/EventsSheet/Toolbar.js index 1af61272bfe5..a637e33e664f 100644 --- a/newIDE/app/src/EventsSheet/Toolbar.js +++ b/newIDE/app/src/EventsSheet/Toolbar.js @@ -18,6 +18,7 @@ import ToolbarSearchIcon from '../UI/CustomSvgIcons/ToolbarSearch'; import EditSceneIcon from '../UI/CustomSvgIcons/EditScene'; import { getShortcutDisplayName, useShortcutMap } from '../KeyboardShortcuts'; import AddLocalVariableIcon from '../UI/CustomSvgIcons/LocalVariable'; +import StarBorder from '@material-ui/icons/StarBorder'; type Props = {| onAddStandardEvent: () => void, @@ -39,6 +40,7 @@ type Props = {| redo: () => void, canRedo: boolean, onToggleSearchPanel: () => void, + onToggleBookmarksPanel: () => void, onOpenSettings?: ?() => void, settingsIcon?: React.Node, moveEventsIntoNewGroup: () => void, @@ -66,6 +68,7 @@ const Toolbar = React.memo(function Toolbar({ redo, canRedo, onToggleSearchPanel, + onToggleBookmarksPanel, onOpenSettings, settingsIcon, moveEventsIntoNewGroup, @@ -224,6 +227,15 @@ const Toolbar = React.memo(function Toolbar({ > + + onToggleBookmarksPanel()} + tooltip={t`Bookmarks`} + > + + {onOpenSettings && } {onOpenSettings && ( , searchFocusOffset: ?number, + showBookmarksPanel: boolean, + bookmarks: Array, + bookmarkFocusId: ?string, + layoutVariablesDialogOpen: boolean, allEventsMetadata: Array, @@ -311,6 +324,10 @@ export class EventsSheetComponentWithoutHandle extends React.Component< searchResults: null, searchFocusOffset: null, + showBookmarksPanel: false, + bookmarks: [], + bookmarkFocusId: null, + layoutVariablesDialogOpen: false, allEventsMetadata: [], @@ -334,6 +351,10 @@ export class EventsSheetComponentWithoutHandle extends React.Component< this.resourceExternallyChangedCallbackId = registerOnResourceExternallyChangedCallback( this.onResourceExternallyChanged.bind(this) ); + + // Load bookmarks from localStorage and scan events + // Load bookmarks by scanning events + this._refreshBookmarks(); } componentWillUnmount() { unregisterOnResourceExternallyChangedCallback( @@ -380,6 +401,8 @@ export class EventsSheetComponentWithoutHandle extends React.Component< positionsBeforeAction: [], positionAfterAction: [], }); + // Refresh bookmarks since events were modified externally + this._refreshBookmarks(); } ); }; @@ -417,6 +440,7 @@ export class EventsSheetComponentWithoutHandle extends React.Component< onOpenSettings={this.props.onOpenSettings} settingsIcon={this.props.settingsIcon} onToggleSearchPanel={this._toggleSearchPanel} + onToggleBookmarksPanel={this._toggleBookmarksPanel} canMoveEventsIntoNewGroup={hasSomethingSelected(this.state.selection)} moveEventsIntoNewGroup={this.moveEventsIntoNewGroup} onOpenSceneVariables={this.editLayoutVariables} @@ -465,6 +489,113 @@ export class EventsSheetComponentWithoutHandle extends React.Component< this.setState({ showSearchPanel: false }); }; + _refreshBookmarks = () => { + if (!this.props.events) return; + try { + const bookmarks = scanEventsForBookmarks(this.props.events); + this.setState({ bookmarks }); + } catch (err) { + console.error('Error refreshing bookmarks:', err); + } + }; + + _toggleBookmarksPanel = () => { + this.setState( + prevState => ({ + showBookmarksPanel: !prevState.showBookmarksPanel, + }), + () => { + // Refresh bookmarks when opening the panel + if (this.state.showBookmarksPanel) { + this._refreshBookmarks(); + } + } + ); + }; + + _toggleBookmark = () => { + try { + const selectedEvents = getSelectedEventContexts(this.state.selection); + if (selectedEvents.length === 0) return; + + const eventContext = selectedEvents[selectedEvents.length - 1]; + const { event } = eventContext; + + const bookmarkId = event.getEventBookmarkId && event.getEventBookmarkId(); + const isBookmarked = bookmarkId && bookmarkId.length > 0; + + event.setEventBookmarkId(isBookmarked ? '' : uuidv4()); + + this._refreshBookmarks(); + if (this.props.unsavedChanges) { + this.props.unsavedChanges.triggerUnsavedChanges(); + } + } catch (err) { + console.error('Error toggling bookmark:', err); + } + }; + + _isEventBookmarked = (): boolean => { + const selectedEvents = getSelectedEventContexts(this.state.selection); + if (selectedEvents.length === 0) return false; + + const eventContext = selectedEvents[selectedEvents.length - 1]; + const bookmarkId = eventContext.event.getEventBookmarkId && eventContext.event.getEventBookmarkId(); + return bookmarkId && bookmarkId.length > 0; + }; + + _navigateToBookmark = (bookmark: Bookmark) => { + const eventLocation = findEventLocationByPtr( + this.props.events, + bookmark.eventPtr + ); + + if (!eventLocation) { + // Event no longer exists - remove bookmark + this._deleteBookmark(bookmark.id); + return; + } + + const { event, eventsList, indexInList } = eventLocation; + + // Scroll to and unfold the event + this._ensureUnfoldedAndScrollTo(() => event); + + // Select the event + this.selectEvent({ + eventsList, + event, + indexInList, + projectScopedContainersAccessor: this.props + .projectScopedContainersAccessor, + }); + + // Focus the bookmark for visual highlight + this.setState({ bookmarkFocusId: bookmark.id }); + }; + + _deleteBookmark = (bookmarkId: string) => { + // Find the event with this bookmark ID and clear it + const bookmark = this.state.bookmarks.find(b => b.id === bookmarkId); + if (!bookmark) return; + + const event = findEventByPtr(this.props.events, bookmark.eventPtr); + if (event) { + try { + event.setEventBookmarkId(''); + if (this.props.unsavedChanges) { + this.props.unsavedChanges.triggerUnsavedChanges(); + } + } catch (err) { + console.error('Error removing bookmark from event:', err); + } + } else { + console.warn('Bookmarked event no longer exists'); + } + + this._refreshBookmarks(); + }; + addSubEvent = () => { const { project } = this.props; @@ -643,6 +774,8 @@ export class EventsSheetComponentWithoutHandle extends React.Component< positionsBeforeAction: positions, positionAfterAction: positions, }); + // Refresh bookmarks in case the event color was changed + this._refreshBookmarks(); } this.setState({ textEditedEvent: null, @@ -771,6 +904,8 @@ export class EventsSheetComponentWithoutHandle extends React.Component< positionsBeforeAction: positions, positionAfterAction: positions, }); + // Refresh bookmarks in case the event was a group/comment with color change + this._refreshBookmarks(); } } ); @@ -854,172 +989,186 @@ export class EventsSheetComponentWithoutHandle extends React.Component< if (this._eventsTree) this._eventsTree.unfoldToLevel(level); }; - _buildEventContextMenu = (i18n: I18nType) => [ - { - label: i18n._(t`Edit`), - click: () => this.openEventTextDialog(), - visible: - filterEditableWithEventTextDialog( - getSelectedEvents(this.state.selection) - ).length > 0, - }, - { - label: i18n._(t`Copy`), - click: () => this.copySelection(), - accelerator: 'CmdOrCtrl+C', - }, - { - label: i18n._(t`Cut`), - click: () => this.cutSelection(), - accelerator: 'CmdOrCtrl+X', - }, - { - label: i18n._(t`Paste`), - click: () => this.pasteEvents(), - enabled: hasClipboardEvents(), - accelerator: 'CmdOrCtrl+V', - }, - { - label: i18n._(t`Delete`), - click: () => this.deleteSelection(), - accelerator: 'Delete', - }, - { - label: i18n._(t`Toggle Disabled`), - click: () => this.toggleDisabled(), - enabled: this._selectionCanToggleDisabled(), - accelerator: getShortcutDisplayName( - this.props.shortcutMap['TOGGLE_EVENT_DISABLED'] || 'KeyD' - ), - }, - { - label: i18n._(t`Remove the Else`), - click: () => - this._replaceSelectedEventType('BuiltinCommonInstructions::Standard'), - visible: this._selectionIsElseEvent(), - }, - { type: 'separator' }, - { - label: i18n._(t`Add`), - submenu: [ - { - label: i18n._(t`New Event Below`), - click: () => { - this.addNewEvent('BuiltinCommonInstructions::Standard'); + _buildEventContextMenu = (i18n: I18nType) => { + const isEventBookmarked = this._isEventBookmarked(); + + return [ + { + label: i18n._(t`Edit`), + click: () => this.openEventTextDialog(), + visible: + filterEditableWithEventTextDialog( + getSelectedEvents(this.state.selection) + ).length > 0, + }, + { + label: i18n._(t`Copy`), + click: () => this.copySelection(), + accelerator: 'CmdOrCtrl+C', + }, + { + label: i18n._(t`Cut`), + click: () => this.cutSelection(), + accelerator: 'CmdOrCtrl+X', + }, + { + label: i18n._(t`Paste`), + click: () => this.pasteEvents(), + enabled: hasClipboardEvents(), + accelerator: 'CmdOrCtrl+V', + }, + { + label: i18n._(t`Delete`), + click: () => this.deleteSelection(), + accelerator: 'Delete', + }, + { + label: i18n._(t`Toggle Disabled`), + click: () => this.toggleDisabled(), + enabled: this._selectionCanToggleDisabled(), + accelerator: getShortcutDisplayName( + this.props.shortcutMap['TOGGLE_EVENT_DISABLED'] || 'KeyD' + ), + }, + { + label: i18n._(t`Remove the Else`), + click: () => + this._replaceSelectedEventType('BuiltinCommonInstructions::Standard'), + visible: this._selectionIsElseEvent(), + }, + { type: 'separator' }, + { + label: isEventBookmarked + ? i18n._(t`Remove Bookmark`) + : i18n._(t`Add Bookmark`), + click: () => this._toggleBookmark(), + visible: hasEventSelected(this.state.selection), + }, + { type: 'separator' }, + { + label: i18n._(t`Add`), + submenu: [ + { + label: i18n._(t`New Event Below`), + click: () => { + this.addNewEvent('BuiltinCommonInstructions::Standard'); + }, + accelerator: getShortcutDisplayName( + this.props.shortcutMap['ADD_STANDARD_EVENT'] + ), }, - accelerator: getShortcutDisplayName( - this.props.shortcutMap['ADD_STANDARD_EVENT'] - ), - }, - { - label: i18n._(t`Sub Event`), - click: () => this.addSubEvent(), - enabled: this._selectionCanHaveSubEvents(), - accelerator: getShortcutDisplayName( - this.props.shortcutMap['ADD_SUBEVENT'] - ), - }, - { - label: i18n._(t`Local Variable`), - click: () => this.addLocalVariable(), - enabled: this._selectionCanHaveLocalVariables(), - accelerator: getShortcutDisplayName( - this.props.shortcutMap['ADD_LOCAL_VARIABLE'] - ), - }, - { - label: i18n._(t`Comment`), - click: () => { - this.addNewEvent('BuiltinCommonInstructions::Comment'); + { + label: i18n._(t`Sub Event`), + click: () => this.addSubEvent(), + enabled: this._selectionCanHaveSubEvents(), + accelerator: getShortcutDisplayName( + this.props.shortcutMap['ADD_SUBEVENT'] + ), }, - accelerator: getShortcutDisplayName( - this.props.shortcutMap['ADD_COMMENT_EVENT'] - ), - }, - ...this.state.allEventsMetadata - .filter( - metadata => - metadata.type !== 'BuiltinCommonInstructions::Standard' && - metadata.type !== 'BuiltinCommonInstructions::Comment' - ) - .map(metadata => ({ - label: metadata.fullName, + { + label: i18n._(t`Local Variable`), + click: () => this.addLocalVariable(), + enabled: this._selectionCanHaveLocalVariables(), + accelerator: getShortcutDisplayName( + this.props.shortcutMap['ADD_LOCAL_VARIABLE'] + ), + }, + { + label: i18n._(t`Comment`), click: () => { - this.addNewEvent(metadata.type); - }, - })), - ], - }, - { - label: i18n._(t`Replace`), - submenu: [ - { - label: i18n._(t`Make it a Else for the previous event`), - click: () => - this._replaceSelectedEventType('BuiltinCommonInstructions::Else'), - enabled: this._selectionIsStandardEvent(), - }, - { type: 'separator' }, - { - label: i18n._(t`Extract Events to a Function`), - click: () => this.extractEventsToFunction(), - }, - { - label: i18n._(t`Move Events into a Group`), - click: () => this.moveEventsIntoNewGroup(), - accelerator: getShortcutDisplayName( - this.props.shortcutMap['MOVE_EVENTS_IN_NEW_GROUP'] - ), - }, - { type: 'separator' }, - { - label: i18n._(t`Analyze Objects Used in this Event`), - click: this._openEventsContextAnalyzer, - }, - ], - }, - { type: 'separator' }, - { - label: i18n._(t`Events Sheet`), - submenu: [ - { - label: i18n._(t`Zoom In`), - click: () => this.onZoomEvent('IN')(), - accelerator: 'CmdOrCtrl+=', - enabled: - this.props.preferences.values.eventsSheetZoomLevel < zoomLevel.max, - }, - { - label: i18n._(t`Zoom Out`), - click: () => this.onZoomEvent('OUT')(), - accelerator: 'CmdOrCtrl+-', - enabled: - this.props.preferences.values.eventsSheetZoomLevel > zoomLevel.min, - }, - { type: 'separator' }, - { - label: i18n._(t`Collapse All`), - click: this.collapseAll, - }, - { - label: i18n._(t`Expand All to Level`), - submenu: [ - { - label: i18n._(t`All`), - click: () => this.expandToLevel(-1), + this.addNewEvent('BuiltinCommonInstructions::Comment'); }, - { type: 'separator' }, - ...[0, 1, 2, 3, 4, 5, 6, 7, 8].map(index => { - return { - label: i18n._(t`Level ${index + 1}`), - click: () => this.expandToLevel(index), - }; - }), - ], - }, - ], - }, - ]; + accelerator: getShortcutDisplayName( + this.props.shortcutMap['ADD_COMMENT_EVENT'] + ), + }, + ...this.state.allEventsMetadata + .filter( + metadata => + metadata.type !== 'BuiltinCommonInstructions::Standard' && + metadata.type !== 'BuiltinCommonInstructions::Comment' + ) + .map(metadata => ({ + label: metadata.fullName, + click: () => { + this.addNewEvent(metadata.type); + }, + })), + ], + }, + { + label: i18n._(t`Replace`), + submenu: [ + { + label: i18n._(t`Make it a Else for the previous event`), + click: () => + this._replaceSelectedEventType('BuiltinCommonInstructions::Else'), + enabled: this._selectionIsStandardEvent(), + }, + { type: 'separator' }, + { + label: i18n._(t`Extract Events to a Function`), + click: () => this.extractEventsToFunction(), + }, + { + label: i18n._(t`Move Events into a Group`), + click: () => this.moveEventsIntoNewGroup(), + accelerator: getShortcutDisplayName( + this.props.shortcutMap['MOVE_EVENTS_IN_NEW_GROUP'] + ), + }, + { type: 'separator' }, + { + label: i18n._(t`Analyze Objects Used in this Event`), + click: this._openEventsContextAnalyzer, + }, + ], + }, + { type: 'separator' }, + { + label: i18n._(t`Events Sheet`), + submenu: [ + { + label: i18n._(t`Zoom In`), + click: () => this.onZoomEvent('IN')(), + accelerator: 'CmdOrCtrl+=', + enabled: + this.props.preferences.values.eventsSheetZoomLevel < + zoomLevel.max, + }, + { + label: i18n._(t`Zoom Out`), + click: () => this.onZoomEvent('OUT')(), + accelerator: 'CmdOrCtrl+-', + enabled: + this.props.preferences.values.eventsSheetZoomLevel > + zoomLevel.min, + }, + { type: 'separator' }, + { + label: i18n._(t`Collapse All`), + click: this.collapseAll, + }, + { + label: i18n._(t`Expand All to Level`), + submenu: [ + { + label: i18n._(t`All`), + click: () => this.expandToLevel(-1), + }, + { type: 'separator' }, + ...[0, 1, 2, 3, 4, 5, 6, 7, 8].map(index => { + return { + label: i18n._(t`Level ${index + 1}`), + click: () => this.expandToLevel(index), + }; + }), + ], + }, + ], + }, + ]; + }; _selectionIsStandardEvent = () => { const eventContext = getLastSelectedEventContext(this.state.selection); @@ -1470,6 +1619,8 @@ export class EventsSheetComponentWithoutHandle extends React.Component< positionsBeforeAction: eventRowIndex, positionAfterAction: eventRowIndex, }); + // Refresh bookmarks in case the event content changed + this._refreshBookmarks(); }; _getChangedEventRows = (events: Array) => { @@ -1560,6 +1711,8 @@ export class EventsSheetComponentWithoutHandle extends React.Component< }, 70); } this.updateToolbar(); + // Refresh bookmarks in case events were added/deleted/changed + this._refreshBookmarks(); } ); }); @@ -1619,6 +1772,8 @@ export class EventsSheetComponentWithoutHandle extends React.Component< }, 70); } this.updateToolbar(); + // Refresh bookmarks in case events were added/deleted/changed + this._refreshBookmarks(); } ); }); @@ -2097,6 +2252,7 @@ export class EventsSheetComponentWithoutHandle extends React.Component< onOpenLayout={onOpenLayout} searchResults={eventsSearchResultEvents} searchFocusOffset={searchFocusOffset} + bookmarkFocusId={this.state.bookmarkFocusId} onEventMoved={this._onEventMoved} onEndEditingEvent={this._onEndEditingStringEvent} showObjectThumbnails={ @@ -2147,6 +2303,19 @@ export class EventsSheetComponentWithoutHandle extends React.Component< /> )} + Bookmarks panel} + scope="scene-events-bookmarks" + onClose={() => this.setState({ showBookmarksPanel: false })} + > + this.setState({ showBookmarksPanel: false })} + /> + ( ...styles.container, height: props.noFullHeight ? undefined : '100%', width: props.width ? props.width : undefined, - flex: props.noExpand ? undefined : 1, + flex: props.noExpand || props.expand === false ? undefined : 1, ...(props.maxWidth ? styles.maxWidth : undefined), }} background="dark" diff --git a/newIDE/app/src/UI/ErrorBoundary.js b/newIDE/app/src/UI/ErrorBoundary.js index 05815299618d..88d3af2cd32a 100644 --- a/newIDE/app/src/UI/ErrorBoundary.js +++ b/newIDE/app/src/UI/ErrorBoundary.js @@ -56,6 +56,7 @@ type ErrorBoundaryScope = | 'scene-events' | 'scene-events-search' | 'scene-events-instruction-editor' + | 'scene-events-bookmarks' | 'debugger' | 'resources' | 'extension-editor' diff --git a/newIDE/app/src/UI/Text.js b/newIDE/app/src/UI/Text.js index daf79ece5ec9..a8cd066d7314 100644 --- a/newIDE/app/src/UI/Text.js +++ b/newIDE/app/src/UI/Text.js @@ -64,6 +64,7 @@ type Props = {| fontVariantNumeric?: 'tabular-nums', |}, tooltip?: string, + className?: string, |}; type Interface = {||}; diff --git a/newIDE/app/src/stories/componentStories/EventsSheet/EventsTree.stories.js b/newIDE/app/src/stories/componentStories/EventsSheet/EventsTree.stories.js index 572d6d06bf03..563cbd18f7ca 100644 --- a/newIDE/app/src/stories/componentStories/EventsSheet/EventsTree.stories.js +++ b/newIDE/app/src/stories/componentStories/EventsSheet/EventsTree.stories.js @@ -68,6 +68,7 @@ export const DefaultMediumScreenScopeInLayout = () => ( onOpenLayout={action('open layout')} searchResults={null} searchFocusOffset={null} + bookmarkFocusId={null} onEventMoved={() => {}} showObjectThumbnails={true} screenType={'normal'} @@ -124,6 +125,7 @@ export const DefaultSmallScreenScopeInLayout = () => ( onOpenLayout={action('open layout')} searchResults={null} searchFocusOffset={null} + bookmarkFocusId={null} onEventMoved={() => {}} showObjectThumbnails={true} screenType={'normal'} @@ -177,6 +179,7 @@ export const DefaultMediumScreenScopeNotInLayout = () => ( onOpenLayout={action('open layout')} searchResults={null} searchFocusOffset={null} + bookmarkFocusId={null} onEventMoved={() => {}} showObjectThumbnails={true} screenType={'normal'} @@ -233,6 +236,7 @@ export const EmptySmallScreenScopeInALayout = () => ( onOpenLayout={action('open layout')} searchResults={null} searchFocusOffset={null} + bookmarkFocusId={null} onEventMoved={() => {}} showObjectThumbnails={true} screenType={'normal'}