diff --git a/public/locales/en/panel-screenspacerenderable.json b/public/locales/en/panel-screenspacerenderable.json
index 8d646fe32..4fb7118eb 100644
--- a/public/locales/en/panel-screenspacerenderable.json
+++ b/public/locales/en/panel-screenspacerenderable.json
@@ -1,26 +1,118 @@
{
- "display-name-input": {
- "title": "Display name",
- "placeholder": "Slide name"
- },
- "image-input": {
- "tab-title": "Image",
- "title": "Path or URL to image",
- "placeholder": "Path / URL",
- "button-label": "Add"
- },
- "website-input": {
- "tab-title": "Website",
- "title": "URL to website",
- "placeholder": "URL",
- "button-label": "Add"
+ "add-modal": {
+ "button-label": "Add",
+ "title": "Add Screenspace Renderable",
+ "name-input": {
+ "title": "Display name",
+ "placeholder": "Enter a name to show in the GUI"
+ },
+ "image": {
+ "tab-title": "Image",
+ "title": "Path or URL to image",
+ "placeholder": "C:/path/to/image.png or https://example.com/image.png",
+ "error": {
+ "title": "Error: invalid base64 encoding format",
+ "description": "Cannot parse invalid format '{{format}}', supported base64 encoding images are 'png' and 'jpeg'"
+ }
+ },
+ "website": {
+ "tab-title": "Website",
+ "title": "Website URL",
+ "placeholder": "https://example.com",
+ "test-button": {
+ "label": "Test",
+ "tooltip": "Test website link (open in browser)"
+ }
+ },
+ "video": {
+ "tab-title": "Video",
+ "title": "Path to video file",
+ "placeholder": "C:/path/to/video.mp4",
+ "loop": {
+ "label": "Loop video"
+ },
+ "play-audio": {
+ "label": "Play audio"
+ }
+ },
+ "text": {
+ "tab-title": "Text",
+ "title": "Text content",
+ "placeholder": "Enter the text to display"
+ },
+ "add-button": {
+ "label": "Add",
+ "disabled-tooltip": "Please provide some valid content for the Screenspace renderable."
+ }
},
"added-slides": {
- "empty-slides": "No active slides",
- "remove-slide-aria-label": "Remove slide"
+ "search-placeholder": "Search...",
+ "empty": "Add Screenspace renderables to view them here.",
+ "tips": "Tip: Try adding an image or video file by dragging and dropping it."
+ },
+ "no-selection-hint": "Select an item to view its details",
+ "more-menu": {
+ "aria-label": "Open more menu",
+ "delete-button": {
+ "label": "Delete",
+ "info": "Remove this Screenspace renderable"
+ },
+ "delete-confirm-modal": {
+ "title": "Delete Screenspace Renderable",
+ "are-you-sure": "Are you sure you want to remove the Screenspace renderable",
+ "remove-button": "Remove",
+ "cancel-button": "Cancel"
+ },
+ "pop-out": "Pop out"
},
- "error": {
- "title": "Error: invalid base64 encoding format",
- "description": "Cannot parse invalid format '{{format}}', supported base64 encoding images are 'png' and 'jpeg'"
+ "renderable-view": {
+ "placement-tab": {
+ "title": "Placement",
+ "mode-switch": {
+ "aria-label": "Placement mode",
+ "label-rae": "Spherical (RAE)",
+ "label-xyz": "Cartesian (XYZ)"
+ },
+ "spherical-controls": {
+ "label": "Spherical position",
+ "description": "Screenspace position in spherical coordinates (radius, azimuth, elevation). Radius is an advanced property hidden for other user levels. It controls the distance to the screenspace plane, allowing objects to be layered at different depths.",
+ "radius": {
+ "label": "Radius"
+ },
+ "azimuth": {
+ "label": "Azimuth"
+ },
+ "elevation": {
+ "label": "Elevation"
+ }
+ },
+ "cartesian-controls": {
+ "label": "Cartesian position",
+ "description": "Screenspace position in Cartesian coordinates (x, y, z). The z-coordinate is an advanced property hidden for other user levels. It controls the distance to the screenspace plane, allowing objects to be layered at different depths."
+ },
+ "local-rotation": {
+ "label": "Local rotation",
+ "roll": {
+ "label": "Roll"
+ },
+ "pitch": {
+ "label": "Pitch"
+ },
+ "yaw": {
+ "label": "Yaw"
+ },
+ "reset-button": {
+ "tooltip": "Reset",
+ "aria-label": "Reset local rotation"
+ }
+ }
+ },
+ "style-tab": {
+ "title": "Style"
+ },
+ "renderable-tab": {
+ "title": "Renderable",
+ "tooltip": "Main properties of the Screenspace renderable, including the ones for the specific type."
+ }
}
}
diff --git a/src/components/AngleInput/AngleInput.tsx b/src/components/AngleInput/AngleInput.tsx
new file mode 100644
index 000000000..2252d4036
--- /dev/null
+++ b/src/components/AngleInput/AngleInput.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useId, useState } from 'react';
+import { AngleSlider, Group, Stack } from '@mantine/core';
+import { useThrottledCallback } from '@mantine/hooks';
+
+import { NumericInput } from '@/components/Input/NumericInput/NumericInput';
+
+interface Props {
+ value: number; // Radians
+ label: React.ReactNode;
+ onChange?: (value: number) => void; // Radians
+ disabled?: boolean;
+}
+
+export function AngleInput({ value, label, onChange, disabled }: Props) {
+ const [currentAngle, setCurrentAngle] = useState(radiansToDegrees(value));
+ const [isInteracting, setIsInteracting] = useState(false);
+ const throttledNumberInputChange = useThrottledCallback((numericValue: number) => {
+ onChange?.(degreesToRadians(numericValue));
+ }, 300);
+
+ const labelId = useId();
+
+ function radiansToDegrees(radians: number) {
+ return (radians * 180) / Math.PI;
+ }
+
+ function degreesToRadians(degrees: number) {
+ return (degrees * Math.PI) / 180;
+ }
+
+ useEffect(() => {
+ if (!isInteracting) {
+ setCurrentAngle(radiansToDegrees(value));
+ }
+ }, [value, isInteracting]);
+
+ return (
+
+ {label}
+
+ {
+ setIsInteracting(true);
+ setCurrentAngle(newValue);
+ onChange?.(degreesToRadians(newValue));
+ }}
+ onChangeEnd={() => setIsInteracting(false)}
+ formatLabel={(labelValue) => `${Math.round(labelValue)}°`}
+ disabled={disabled}
+ aria-labelledby={labelId}
+ />
+ setIsInteracting(true)}
+ onEnter={(newValue) => {
+ const numericValue = Number(newValue);
+ if (numericValue === undefined) {
+ return;
+ }
+ setCurrentAngle(numericValue);
+ throttledNumberInputChange(numericValue);
+ }}
+ onFocus={() => setIsInteracting(true)}
+ onBlur={() => setIsInteracting(false)}
+ disabled={disabled}
+ aria-labelledby={labelId}
+ />
+
+
+ );
+}
diff --git a/src/components/PropertyOwner/PropertyOwner.tsx b/src/components/PropertyOwner/PropertyOwner.tsx
index 5eaf536f4..5d7fb9cd5 100644
--- a/src/components/PropertyOwner/PropertyOwner.tsx
+++ b/src/components/PropertyOwner/PropertyOwner.tsx
@@ -1,3 +1,5 @@
+import { useMemo } from 'react';
+
import {
useHasVisibleChildren,
usePropertyOwner,
@@ -7,7 +9,7 @@ import { useAppSelector } from '@/redux/hooks';
import { propertySelectors } from '@/redux/propertytree/propertySlice';
import { Uri } from '@/types/types';
import { displayName } from '@/util/propertyTreeHelpers';
-import { isGlobeLayersUri, parentTypeUri } from '@/util/uris';
+import { identifierFromUri, isGlobeLayersUri, parentTypeUri } from '@/util/uris';
import { GlobeLayersPropertyOwner } from './Custom/GlobeLayers/GlobeLayersPropertyOwner';
import { VideoPlayerPropertyOwner } from './Custom/VideoPlayer/VideoPlayerPropertyOwner';
@@ -18,7 +20,11 @@ interface Props {
uri: Uri;
expandedOnDefault?: boolean;
showOnlyChildren?: boolean;
- hideSubowners?: boolean;
+ /**
+ * If true, hide all subowners. If array, hide only the subowners with identifiers in
+ * the array
+ */
+ hideSubowners?: boolean | string[];
}
export function PropertyOwner({
@@ -48,7 +54,19 @@ export function PropertyOwner({
const hasVisibleChildren = useHasVisibleChildren(uri);
const visibleProperties = useVisibleProperties(propertyOwner);
- const subowners = hideSubowners ? [] : (propertyOwner.subowners ?? []);
+
+ const subowners = useMemo(() => {
+ if (hideSubowners === true) {
+ return [];
+ }
+
+ if (Array.isArray(hideSubowners)) {
+ return (propertyOwner.subowners ?? []).filter(
+ (subowner) => !hideSubowners.includes(identifierFromUri(subowner))
+ );
+ }
+ return propertyOwner.subowners ?? [];
+ }, [propertyOwner.subowners, hideSubowners]);
// If there is no content to show for the current visibility settings, we don't want to
// render this property owner
diff --git a/src/hooks/userLevel.ts b/src/hooks/userLevel.ts
new file mode 100644
index 000000000..2d2ee0464
--- /dev/null
+++ b/src/hooks/userLevel.ts
@@ -0,0 +1,15 @@
+import { usePropertyValue } from '@/hooks/properties';
+import { PropertyVisibilityNumber } from '@/types/enums';
+
+export function useIsAdvancedUserLevel(): boolean {
+ const userLevel = usePropertyValue(
+ 'OptionProperty',
+ 'OpenSpaceEngine.PropertyVisibility'
+ );
+
+ if (userLevel === undefined) {
+ throw new Error('User level property is undefined');
+ }
+
+ return userLevel >= PropertyVisibilityNumber.AdvancedUser;
+}
diff --git a/src/icons/icons.tsx b/src/icons/icons.tsx
index 0f8ed9ed0..5938f47a0 100644
--- a/src/icons/icons.tsx
+++ b/src/icons/icons.tsx
@@ -15,6 +15,11 @@ export {
FaPlus as PlusIcon,
FaRegSave as SaveIcon
} from 'react-icons/fa';
+export {
+ FaArrowsLeftRight as ArrowsLeftRightIcon,
+ FaArrowsUpDown as ArrowsUpDownIcon,
+ FaArrowRotateLeft as RotateLeftIcon
+} from 'react-icons/fa6';
export {
FiChevronsDown as ChevronsDownIcon,
FiChevronsUp as ChevronsUpIcon
@@ -24,17 +29,19 @@ export {
GiNightSky as NightSkyIcon,
GiPencil as PencilIcon
} from 'react-icons/gi';
-export { GoBrowser as BrowserIcon } from 'react-icons/go';
+export { GoBrowser as BrowserIcon, GoVideo as VideoIcon } from 'react-icons/go';
export { GoMoveToEnd as GoToEnd, GoMoveToStart as GoToStart } from 'react-icons/go';
export { ImSphere as GridSphereIcon } from 'react-icons/im';
export { IoMdGlobe as GlobeIcon, IoMdTime as TimeIcon } from 'react-icons/io';
export {
IoInformationCircleOutline as InformationCircleOutlineIcon,
IoInformation as InformationIcon,
- IoTelescopeOutline as TelescopeIcon
+ IoTelescopeOutline as TelescopeIcon,
+ IoText as TextIcon
} from 'react-icons/io5';
export {
LuFilePlus2 as AddFileIcon,
+ LuFileClock as FileClockIcon,
LuFileText as FileTextIcon,
LuFolderUp as FolderBackIcon,
LuFolder as FolderIcon,
@@ -101,6 +108,7 @@ export {
MdSettings as SettingsIcon,
MdStop as StopIcon,
MdSunny as SunIcon,
+ MdShortText as TextShortIcon,
MdTimer as TimerIcon,
MdArrowUpward as UpArrowIcon,
MdVideocam as VideocamIcon,
@@ -140,6 +148,7 @@ export {
TbCube as SceneIcon,
TbScript as ScriptLogIcon,
TbServer as ServerIcon,
+ TbShape as ShapeIcon,
TbWorldWww as WebIcon
} from 'react-icons/tb';
export {
diff --git a/src/panels/GeoLocationPanel/AddedCustomNodes.tsx b/src/panels/GeoLocationPanel/AddedCustomNodes.tsx
index b673f731e..65170bc34 100644
--- a/src/panels/GeoLocationPanel/AddedCustomNodes.tsx
+++ b/src/panels/GeoLocationPanel/AddedCustomNodes.tsx
@@ -7,7 +7,7 @@ import { useAppSelector } from '@/redux/hooks';
import { GeoLocationGroupKey } from '@/util/keys';
import { useAnchorNode } from '@/util/propertyTreeHooks';
import { identifierFromUri, sgnUri } from '@/util/uris';
-import { useRemoveSceneGraphNodeModal } from '@/util/useRemoveSceneGraphNode';
+import { useRemoveSceneGraphNodeModal } from '@/util/removeModalsHooks';
export function AddedCustomNodes() {
const { t } = useTranslation('panel-geolocation', { keyPrefix: 'added-custom-nodes' });
diff --git a/src/panels/Scene/SceneGraphNode/SceneGraphNodeMoreMenu.tsx b/src/panels/Scene/SceneGraphNode/SceneGraphNodeMoreMenu.tsx
index 25715881e..c67fda7d5 100644
--- a/src/panels/Scene/SceneGraphNode/SceneGraphNodeMoreMenu.tsx
+++ b/src/panels/Scene/SceneGraphNode/SceneGraphNodeMoreMenu.tsx
@@ -13,7 +13,7 @@ import { Uri } from '@/types/types';
import { displayName } from '@/util/propertyTreeHelpers';
import { useAnchorNode } from '@/util/propertyTreeHooks';
import { identifierFromUri } from '@/util/uris';
-import { useRemoveSceneGraphNodeModal } from '@/util/useRemoveSceneGraphNode';
+import { useRemoveSceneGraphNodeModal } from '@/util/removeModalsHooks';
import { useWindowLayoutProvider } from '@/windowmanagement/WindowLayout/hooks';
import { SceneGraphNodeView } from './SceneGraphNodeView';
diff --git a/src/panels/ScreenSpaceRenderablePanel/Add/AddModal.tsx b/src/panels/ScreenSpaceRenderablePanel/Add/AddModal.tsx
new file mode 100644
index 000000000..935747f01
--- /dev/null
+++ b/src/panels/ScreenSpaceRenderablePanel/Add/AddModal.tsx
@@ -0,0 +1,239 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ Box,
+ Button,
+ Group,
+ Modal,
+ Stack,
+ Switch,
+ Tabs,
+ TextInput,
+ Tooltip
+} from '@mantine/core';
+import { useDisclosure } from '@mantine/hooks';
+
+import { MaybeTooltip } from '@/components/MaybeTooltip/MaybeTooltip';
+import {
+ AddPhotoIcon,
+ InsertPhotoIcon,
+ OpenInBrowserIcon,
+ PlusIcon,
+ TextIcon,
+ VideoIcon,
+ WebIcon
+} from '@/icons/icons';
+import { IconSize } from '@/types/enums';
+
+import { useAddScreenSpaceRenderable } from './hooks';
+
+function removeSurroundingQuotes(value: string) {
+ return value.replace(/^(['"])(.*)\1$/, '$2');
+}
+
+function getFileNameFromUrl(data: string) {
+ const urlParts = data.split('/');
+ const lastPart = urlParts[urlParts.length - 1];
+ return lastPart.split('.')[0]; // Remove file extension if present
+}
+
+export function AddModal() {
+ const { t } = useTranslation('panel-screenspacerenderable', { keyPrefix: 'add-modal' });
+
+ const [slideName, setSlideName] = useState('');
+ const [slideData, setSlideData] = useState('');
+
+ // Some video-specific options
+ const [shouldLoop, setShouldLoop] = useState(true);
+ const [playAudio, setPlayAudio] = useState(false);
+
+ const [activeTab, setActiveTab] = useState('images');
+
+ const [opened, { open, close }] = useDisclosure(false);
+
+ const { addImage, addWebpage, addVideo, addText } = useAddScreenSpaceRenderable();
+
+ const isAddButtonDisabled = !slideData;
+
+ function onTabChange(value: string | null) {
+ if (value) {
+ setActiveTab(value);
+ setSlideData('');
+ }
+ }
+
+ function generateNameFromData(data: string) {
+ switch (activeTab) {
+ case 'images':
+ return getFileNameFromUrl(data);
+ case 'web':
+ return data.replace(/^https?:\/\//, '');
+ case 'video':
+ return getFileNameFromUrl(data);
+ case 'text':
+ return data;
+ default:
+ return 'Unnamed';
+ }
+ }
+
+ function onAdd() {
+ const name = slideName || generateNameFromData(slideData) || 'Unnamed';
+ const sanitizedData = removeSurroundingQuotes(slideData.trim());
+
+ switch (activeTab) {
+ case 'images':
+ addImage(name, sanitizedData);
+ break;
+ case 'web':
+ addWebpage(name, sanitizedData);
+ break;
+ case 'video':
+ addVideo(name, sanitizedData, {
+ shouldLoop: shouldLoop,
+ playAudio: playAudio
+ });
+ break;
+ case 'text':
+ addText(name, sanitizedData);
+ break;
+ default:
+ throw new Error(`Unknown tab value: ${activeTab}`);
+ }
+ onClose();
+ }
+
+ function onClose() {
+ setSlideName('');
+ setSlideData('');
+ close();
+ }
+
+ return (
+ <>
+ } onClick={open}>
+ {t('button-label')}
+
+
+
+ {t('title')}
+
+ }
+ centered
+ >
+
+ setSlideName(event.currentTarget.value)}
+ placeholder={t('name-input.placeholder')}
+ label={t('name-input.title')}
+ />
+
+
+ }>
+ {t('image.tab-title')}
+
+ }>
+ {t('website.tab-title')}
+
+ }>
+ {t('video.tab-title')}
+
+ }>
+ {t('text.tab-title')}
+
+
+
+
+ setSlideData(event.currentTarget.value)}
+ placeholder={t('image.placeholder')}
+ label={t('image.title')}
+ required
+ />
+
+
+
+
+ setSlideData(event.currentTarget.value)}
+ placeholder={t('website.placeholder')}
+ label={t('website.title')}
+ flex={1}
+ required
+ />
+
+ }
+ onClick={() =>
+ window.open(slideData, '_blank', 'noopener,noreferrer')
+ }
+ mt={'xs'}
+ >
+ {t('website.test-button.label')}
+
+
+
+
+
+
+ setSlideData(event.currentTarget.value)}
+ placeholder={t('video.placeholder')}
+ label={t('video.title')}
+ required
+ />
+
+ setShouldLoop(event.currentTarget.checked)}
+ mt={'xs'}
+ />
+ setPlayAudio(event.currentTarget.checked)}
+ mt={'xs'}
+ />
+
+
+
+
+ setSlideData(event.currentTarget.value)}
+ placeholder={t('text.placeholder')}
+ label={t('text.title')}
+ required
+ />
+
+
+
+
+ }
+ variant={'filled'}
+ onClick={onAdd}
+ disabled={isAddButtonDisabled}
+ mt={'md'}
+ >
+ {t('add-button.label')}
+
+
+
+
+ >
+ );
+}
diff --git a/src/panels/ScreenSpaceRenderablePanel/Add/hooks.ts b/src/panels/ScreenSpaceRenderablePanel/Add/hooks.ts
new file mode 100644
index 000000000..8b8c10c0d
--- /dev/null
+++ b/src/panels/ScreenSpaceRenderablePanel/Add/hooks.ts
@@ -0,0 +1,140 @@
+import { useTranslation } from 'react-i18next';
+
+import { useOpenSpaceApi } from '@/api/hooks';
+import { useAppDispatch } from '@/redux/hooks';
+import { handleNotificationLogging } from '@/redux/logging/loggingMiddleware';
+import { NotificationLevel } from '@/types/enums';
+import { Identifier } from '@/types/types';
+
+interface ScreenSpaceRenderable {
+ Identifier: Identifier;
+ Name: string;
+}
+
+interface ScreenSpaceImage extends ScreenSpaceRenderable {
+ Type: 'ScreenSpaceImageLocal' | 'ScreenSpaceImageOnline';
+ TexturePath?: string;
+ URL?: string;
+}
+
+interface ScreenSpaceBrowser extends ScreenSpaceRenderable {
+ Type: 'ScreenSpaceBrowser';
+ Url: string;
+}
+
+interface ScreenSpaceVideo extends ScreenSpaceRenderable {
+ Type: 'ScreenSpaceVideo';
+ Video: string; // Path to video file
+ LoopVideo?: boolean; // Whether the video should loop
+ PlayAudio?: boolean; // Whether the video should play audio
+}
+
+interface ScreenSpaceText extends ScreenSpaceRenderable {
+ Type: 'ScreenSpaceText';
+ Text: string;
+}
+
+export function useAddScreenSpaceRenderable() {
+ const { t } = useTranslation('panel-screenspacerenderable', { keyPrefix: 'add-modal' });
+
+ const luaApi = useOpenSpaceApi();
+ const dispatch = useAppDispatch();
+
+ async function addImage(name: string, slideURL: string) {
+ const osIdentifier = (await luaApi?.makeIdentifier(name)) ?? slideURL;
+
+ const renderable: ScreenSpaceImage = {
+ Identifier: osIdentifier,
+ Name: name,
+ Type: 'ScreenSpaceImageLocal'
+ };
+
+ let urlOrPath = slideURL;
+ if (slideURL.startsWith('data:image/')) {
+ let url = slideURL;
+ // Someone tried to paste a base64 encoded image. It starts with the text:
+ // data:image/{png/jpeg};base,
+ // followed by the rest of the image data in base64 encoding
+ url = url.substring('data:image/'.length);
+
+ const filetype = url.substring(0, url.indexOf(';'));
+ if (filetype !== 'png' && filetype !== 'jpeg') {
+ dispatch(
+ handleNotificationLogging(
+ t('image.error.title'),
+ t('image.error.description', { format: filetype }),
+ NotificationLevel.Error
+ )
+ );
+ return;
+ }
+
+ // Remove the remaining header information, at which point it becomes the data
+ const data = url.substring(url.indexOf(',') + 1);
+
+ // eslint-disable-next-line no-template-curly-in-string
+ const tempPath = await luaApi?.absPath('${TEMPORARY}');
+ const localPath = `${tempPath}/screenspace-slide-${name}.${filetype}`;
+ await luaApi?.saveBase64File(localPath, data);
+ urlOrPath = localPath;
+ }
+
+ const isHttpSlide = urlOrPath.indexOf('http') === 0;
+ if (isHttpSlide) {
+ renderable.Type = 'ScreenSpaceImageOnline';
+ renderable.URL = urlOrPath;
+ } else {
+ renderable.Type = 'ScreenSpaceImageLocal';
+ renderable.TexturePath = urlOrPath;
+ }
+
+ luaApi?.addScreenSpaceRenderable(renderable);
+ }
+
+ async function addWebpage(name: string, slideURL: string) {
+ const osIdentifier = (await luaApi?.makeIdentifier(name)) ?? slideURL;
+
+ const renderable: ScreenSpaceBrowser = {
+ Identifier: osIdentifier,
+ Name: name,
+ Type: 'ScreenSpaceBrowser',
+ Url: slideURL
+ };
+
+ luaApi?.addScreenSpaceRenderable(renderable);
+ }
+
+ async function addVideo(
+ name: string,
+ slideURL: string,
+ { shouldLoop, playAudio }: { shouldLoop?: boolean; playAudio?: boolean } = {}
+ ) {
+ const osIdentifier = (await luaApi?.makeIdentifier(name)) ?? slideURL;
+
+ const renderable: ScreenSpaceVideo = {
+ Identifier: osIdentifier,
+ Name: name,
+ Type: 'ScreenSpaceVideo',
+ Video: slideURL,
+ LoopVideo: shouldLoop,
+ PlayAudio: playAudio
+ };
+
+ luaApi?.addScreenSpaceRenderable(renderable);
+ }
+
+ async function addText(name: string, textContent: string) {
+ const osIdentifier = (await luaApi?.makeIdentifier(name)) ?? name;
+
+ const renderable: ScreenSpaceText = {
+ Identifier: osIdentifier,
+ Name: name,
+ Type: 'ScreenSpaceText',
+ Text: textContent
+ };
+
+ luaApi?.addScreenSpaceRenderable(renderable);
+ }
+
+ return { addImage, addWebpage, addVideo, addText };
+}
diff --git a/src/panels/ScreenSpaceRenderablePanel/ImageTab.tsx b/src/panels/ScreenSpaceRenderablePanel/ImageTab.tsx
deleted file mode 100644
index affa25c97..000000000
--- a/src/panels/ScreenSpaceRenderablePanel/ImageTab.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Button, Group, TextInput } from '@mantine/core';
-
-import { useOpenSpaceApi } from '@/api/hooks';
-import { AddPhotoIcon } from '@/icons/icons';
-import { useAppDispatch } from '@/redux/hooks';
-import { handleNotificationLogging } from '@/redux/logging/loggingMiddleware';
-import { IconSize, NotificationLevel } from '@/types/enums';
-import { Identifier } from '@/types/types';
-
-interface ScreenSpaceRenderable {
- Identifier: Identifier;
- Name: string;
- Type: 'ScreenSpaceImageLocal' | 'ScreenSpaceImageOnline';
- TexturePath?: string;
- URL?: string;
-}
-
-export function ImageTab() {
- const { t } = useTranslation('panel-screenspacerenderable');
-
- const [slideName, setSlideName] = useState('');
- const [slideURL, setSlideURL] = useState('');
- const luaApi = useOpenSpaceApi();
- const dispatch = useAppDispatch();
-
- const isButtonDisabled = !slideName || !slideURL;
-
- async function addSlide() {
- const osIdentifier = (await luaApi?.makeIdentifier(slideName)) ?? slideName;
-
- const renderable: ScreenSpaceRenderable = {
- Identifier: osIdentifier,
- Name: slideName,
- Type: 'ScreenSpaceImageLocal'
- };
-
- let urlOrPath = slideURL;
- if (slideURL.startsWith('data:image/')) {
- let url = slideURL;
- // Someone tried to paste a base64 encoded image. It starts with the text:
- // data:image/{png/jpeg};base,
- // followed by the rest of the image data in base64 encoding
- url = url.substring('data:image/'.length);
-
- const filetype = url.substring(0, url.indexOf(';'));
- if (filetype !== 'png' && filetype !== 'jpeg') {
- dispatch(
- handleNotificationLogging(
- t('error.title'),
- t('error.description', { format: filetype }),
- NotificationLevel.Error
- )
- );
- return;
- }
-
- // Remove the remaining header information, at which point it becomes the data
- const data = url.substring(url.indexOf(',') + 1);
-
- // eslint-disable-next-line no-template-curly-in-string
- const tempPath = await luaApi?.absPath('${TEMPORARY}');
- const localPath = `${tempPath}/screenspace-slide-${slideName}.${filetype}`;
- await luaApi?.saveBase64File(localPath, data);
- urlOrPath = localPath;
- }
-
- const isHttpSlide = urlOrPath.indexOf('http') === 0;
- if (isHttpSlide) {
- renderable.Type = 'ScreenSpaceImageOnline';
- renderable.URL = urlOrPath;
- } else {
- renderable.Type = 'ScreenSpaceImageLocal';
- renderable.TexturePath = urlOrPath;
- }
-
- luaApi?.addScreenSpaceRenderable(renderable);
- setSlideName('');
- setSlideURL('');
- }
-
- return (
-
- setSlideName(event.currentTarget.value)}
- placeholder={t('display-name-input.placeholder')}
- label={t('display-name-input.title')}
- />
- setSlideURL(event.currentTarget.value)}
- placeholder={t('image-input.placeholder')}
- label={t('image-input.title')}
- />
- }
- disabled={isButtonDisabled}
- >
- {t('image-input.button-label')}
-
-
- );
-}
diff --git a/src/panels/ScreenSpaceRenderablePanel/Placement/CartesianControls.tsx b/src/panels/ScreenSpaceRenderablePanel/Placement/CartesianControls.tsx
new file mode 100644
index 000000000..ea278a933
--- /dev/null
+++ b/src/panels/ScreenSpaceRenderablePanel/Placement/CartesianControls.tsx
@@ -0,0 +1,133 @@
+import { useId } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Group, Text } from '@mantine/core';
+
+import { NumericInput } from '@/components/Input/NumericInput/NumericInput';
+import { NumericSlider } from '@/components/Input/NumericInput/NumericSlider/NumericSlider';
+import { useProperty } from '@/hooks/properties';
+import { useIsAdvancedUserLevel } from '@/hooks/userLevel';
+import { ArrowsLeftRightIcon, ArrowsUpDownIcon } from '@/icons/icons';
+import { IconSize } from '@/types/enums';
+import { Uri } from '@/types/types';
+
+import { PropertyGroupContainer } from './PropertyGroupContainer';
+
+interface Props {
+ propertyUri: Uri;
+}
+
+export function CartesianControls({ propertyUri }: Props) {
+ const { t } = useTranslation('panel-screenspacerenderable', {
+ keyPrefix: 'renderable-view.placement-tab.cartesian-controls'
+ });
+ const [value, setValue, meta] = useProperty('Vec3Property', propertyUri);
+
+ const isAdvancedUser = useIsAdvancedUserLevel();
+
+ const xLabelId = useId();
+ const yLabelId = useId();
+ const zLabelId = useId();
+
+ if (!value || !meta) {
+ throw Error(`Missing property with uri: ${propertyUri}`);
+ }
+
+ return (
+
+
+
+ x
+
+ setValue([Number(newValue), value[1], value[2]])}
+ />
+
+ setValue([Number(newValue), value[1], value[2]])}
+ />
+
+
+
+ y
+
+ setValue([value[0], Number(newValue), value[2]])}
+ />
+
+ setValue([value[0], Number(newValue), value[2]])}
+ />
+
+ {isAdvancedUser && (
+
+
+ z
+
+ setValue([value[0], value[1], Number(newValue)])}
+ />
+ setValue([value[0], value[1], Number(newValue)])}
+ />
+
+ )}
+
+ );
+}
diff --git a/src/panels/ScreenSpaceRenderablePanel/Placement/LocalRotationControls.tsx b/src/panels/ScreenSpaceRenderablePanel/Placement/LocalRotationControls.tsx
new file mode 100644
index 000000000..486ea26bd
--- /dev/null
+++ b/src/panels/ScreenSpaceRenderablePanel/Placement/LocalRotationControls.tsx
@@ -0,0 +1,93 @@
+import { useTranslation } from 'react-i18next';
+import { ActionIcon, Group, Text, Tooltip } from '@mantine/core';
+
+import { useProperty } from '@/hooks/properties';
+import {
+ ArrowsLeftRightIcon,
+ ArrowsUpDownIcon,
+ CancelIcon,
+ RotateLeftIcon
+} from '@/icons/icons';
+import { IconSize } from '@/types/enums';
+import { Uri } from '@/types/types';
+
+import { AngleInput } from '../../../components/AngleInput/AngleInput';
+
+import { PropertyGroupContainer } from './PropertyGroupContainer';
+
+interface Props {
+ propertyUri: Uri;
+}
+
+export function LocalRotationControls({ propertyUri }: Props) {
+ const { t } = useTranslation('panel-screenspacerenderable', {
+ keyPrefix: 'renderable-view.placement-tab.local-rotation'
+ });
+
+ const [value, setValue, meta] = useProperty('Vec3Property', propertyUri);
+
+ if (!value || !meta) {
+ throw Error(`Missing property with uri: ${propertyUri}`);
+ }
+
+ return (
+
+
+ setValue([Number(newValue), value[1], value[2]])}
+ disabled={meta.isReadOnly}
+ label={
+
+
+ {t('roll.label')}
+
+ }
+ />
+ setValue([value[0], Number(newValue), value[2]])}
+ disabled={meta.isReadOnly}
+ label={
+
+
+ {t('pitch.label')}
+
+ }
+ />
+
+ setValue([value[0], value[1], Number(newValue)])}
+ disabled={meta.isReadOnly}
+ label={
+
+
+ {t('yaw.label')}
+
+ }
+ />
+
+
+ setValue([0, 0, 0])}
+ >
+
+
+
+
+
+ );
+}
diff --git a/src/panels/ScreenSpaceRenderablePanel/Placement/PropertyGroupContainer.tsx b/src/panels/ScreenSpaceRenderablePanel/Placement/PropertyGroupContainer.tsx
new file mode 100644
index 000000000..9a7c8cd07
--- /dev/null
+++ b/src/panels/ScreenSpaceRenderablePanel/Placement/PropertyGroupContainer.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import { Paper, PaperProps } from '@mantine/core';
+
+import { PropertyLabel } from '@/components/Property/PropertyLabel';
+import { useProperty } from '@/hooks/properties';
+import { PropertyTypeKey } from '@/types/Property/property';
+import { Uri } from '@/types/types';
+
+interface Props extends React.PropsWithChildren, PaperProps {
+ children: React.ReactNode;
+ uri: Uri;
+ type: PropertyTypeKey;
+ name?: string;
+ description?: string;
+}
+
+export function PropertyGroupContainer({
+ children,
+ uri,
+ type,
+ name,
+ description,
+ ...props
+}: Props) {
+ const [value, , meta] = useProperty(type, uri);
+ const accessibleLabelId = React.useId();
+
+ if (!value || !meta) {
+ throw Error(`Missing property with uri: ${uri}`);
+ }
+
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/src/panels/ScreenSpaceRenderablePanel/Placement/RadiusAzimuthElevationControls.tsx b/src/panels/ScreenSpaceRenderablePanel/Placement/RadiusAzimuthElevationControls.tsx
new file mode 100644
index 000000000..ffc6df1ee
--- /dev/null
+++ b/src/panels/ScreenSpaceRenderablePanel/Placement/RadiusAzimuthElevationControls.tsx
@@ -0,0 +1,100 @@
+import { useId } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Group, Text } from '@mantine/core';
+
+import { NumericInput } from '@/components/Input/NumericInput/NumericInput';
+import { NumericSlider } from '@/components/Input/NumericInput/NumericSlider/NumericSlider';
+import { useProperty } from '@/hooks/properties';
+import { useIsAdvancedUserLevel } from '@/hooks/userLevel';
+import { ArrowsLeftRightIcon, ArrowsUpDownIcon } from '@/icons/icons';
+import { IconSize } from '@/types/enums';
+import { Uri } from '@/types/types';
+
+import { AngleInput } from '../../../components/AngleInput/AngleInput';
+
+import { PropertyGroupContainer } from './PropertyGroupContainer';
+
+interface Props {
+ propertyUri: Uri;
+}
+
+export function RadiusAzimuthElevationControls({ propertyUri }: Props) {
+ const { t } = useTranslation('panel-screenspacerenderable', {
+ keyPrefix: 'renderable-view.placement-tab.spherical-controls'
+ });
+
+ const [value, setValue, meta] = useProperty('Vec3Property', propertyUri);
+
+ const isAdvancedUser = useIsAdvancedUserLevel();
+ const radiusLabelId = useId();
+
+ if (!value || !meta) {
+ throw Error(`Missing property with uri: ${propertyUri}`);
+ }
+
+ return (
+
+
+ setValue([value[0], Number(newValue), value[2]])}
+ disabled={meta.isReadOnly}
+ label={
+
+
+ {t('azimuth.label')}
+
+ }
+ />
+
+ setValue([value[0], value[1], Number(newValue)])}
+ disabled={meta.isReadOnly}
+ label={
+
+
+ {t('elevation.label')}
+
+ }
+ />
+
+ {isAdvancedUser && (
+
+
+ {t('radius.label')}
+
+ setValue([Number(newValue), value[1], value[2]])}
+ aria-labelledby={radiusLabelId}
+ />
+ setValue([Number(newValue), value[1], value[2]])}
+ aria-labelledby={radiusLabelId}
+ />
+
+ )}
+
+ );
+}
diff --git a/src/panels/ScreenSpaceRenderablePanel/Placement/ScreenSpacePlacementOwner.tsx b/src/panels/ScreenSpaceRenderablePanel/Placement/ScreenSpacePlacementOwner.tsx
new file mode 100644
index 000000000..ccafda158
--- /dev/null
+++ b/src/panels/ScreenSpaceRenderablePanel/Placement/ScreenSpacePlacementOwner.tsx
@@ -0,0 +1,72 @@
+import { useTranslation } from 'react-i18next';
+import { Box, Group, SegmentedControl } from '@mantine/core';
+
+import { PropertyOwnerChildren } from '@/components/PropertyOwner/PropertyOwnerChildren';
+import { useProperty } from '@/hooks/properties';
+import { usePropertyOwner, useVisibleProperties } from '@/hooks/propertyOwner';
+import { Uri } from '@/types/types';
+
+import { CartesianControls } from './CartesianControls';
+import { LocalRotationControls } from './LocalRotationControls';
+import { RadiusAzimuthElevationControls } from './RadiusAzimuthElevationControls';
+
+interface Props {
+ uri: Uri;
+}
+
+export function ScreenSpacePlacementOwner({ uri }: Props) {
+ const { t } = useTranslation('panel-screenspacerenderable', {
+ keyPrefix: 'renderable-view.placement-tab'
+ });
+
+ const propertyOwner = usePropertyOwner(uri);
+
+ if (!propertyOwner) {
+ throw Error(`No property owner found for uri: ${uri}`);
+ }
+
+ const visibleProperties = useVisibleProperties(propertyOwner);
+
+ const uris = {
+ useRae: `${uri}.UseRadiusAzimuthElevation`,
+ posCartesian: `${uri}.CartesianPosition`,
+ posRae: `${uri}.RadiusAzimuthElevation`,
+ localRotation: `${uri}.Rotation`
+ };
+
+ const [useRae, setUseRae] = useProperty('BoolProperty', uris.useRae);
+
+ const hideProperties = Object.values(uris);
+
+ const filteredProperties = visibleProperties.filter(
+ (property) => !hideProperties.includes(property)
+ );
+
+ return (
+ <>
+
+
+ setUseRae(value === 'RAE')}
+ aria-label={t('mode-switch.aria-label')}
+ />
+
+ {useRae ? (
+
+ ) : (
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderableListItem.tsx b/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderableListItem.tsx
new file mode 100644
index 000000000..832d5c8fd
--- /dev/null
+++ b/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderableListItem.tsx
@@ -0,0 +1,57 @@
+import { Button, Group } from '@mantine/core';
+
+import { PropertyOwnerVisibilityCheckbox } from '@/components/PropertyOwner/VisiblityCheckbox';
+import { ThreePartHeader } from '@/components/ThreePartHeader/ThreePartHeader';
+import { TruncatedText } from '@/components/TruncatedText/TruncatedText';
+import { usePropertyValue } from '@/hooks/properties';
+import { usePropertyOwner, usePropertyOwnerVisibility } from '@/hooks/propertyOwner';
+import { Uri } from '@/types/types';
+
+import { ScreenSpaceRenderableMoreMenu } from './ScreenSpaceRenderableMoreMenu';
+import { ScreenSpaceRenderableTypeIcon } from './TypeIcon';
+
+interface Props {
+ uri: Uri;
+ onClick?: () => void;
+}
+
+export function ScreenSpaceRenderableListItem({ uri, onClick }: Props) {
+ const propertyOwner = usePropertyOwner(uri);
+
+ if (!propertyOwner) {
+ throw Error(`No property owner found for uri: ${uri}`);
+ }
+
+ const { visibility, setVisibility } = usePropertyOwnerVisibility(uri);
+ const type = usePropertyValue('StringProperty', `${uri}.Type`);
+
+ return (
+
+ {propertyOwner.name}
+
+ }
+ leftSection={
+
+ }
+ rightSection={
+
+
+
+
+ }
+ />
+ );
+}
diff --git a/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderableMoreMenu.tsx b/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderableMoreMenu.tsx
new file mode 100644
index 000000000..5d2322330
--- /dev/null
+++ b/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderableMoreMenu.tsx
@@ -0,0 +1,67 @@
+import { useTranslation } from 'react-i18next';
+import { ActionIcon, Menu } from '@mantine/core';
+
+import { TruncatedText } from '@/components/TruncatedText/TruncatedText';
+import { usePropertyOwner } from '@/hooks/propertyOwner';
+import { DeleteIcon, OpenWindowIcon, VerticalDotsIcon } from '@/icons/icons';
+import { Uri } from '@/types/types';
+import { displayName } from '@/util/propertyTreeHelpers';
+import { useRemoveScreenSpaceRenderableModal } from '@/util/removeModalsHooks';
+import { useWindowLayoutProvider } from '@/windowmanagement/WindowLayout/hooks';
+
+import { ScreenSpaceRenderableView } from './ScreenSpaceRenderableView';
+
+interface Props {
+ uri: Uri;
+}
+
+export function ScreenSpaceRenderableMoreMenu({ uri }: Props) {
+ const { t } = useTranslation('panel-screenspacerenderable', {
+ keyPrefix: 'more-menu'
+ });
+
+ const propertyOwner = usePropertyOwner(uri);
+ const confirmRemove = useRemoveScreenSpaceRenderableModal();
+
+ const { addWindow } = useWindowLayoutProvider();
+
+ if (!propertyOwner) {
+ return <>>;
+ }
+
+ const name = displayName(propertyOwner);
+
+ function openInNewWindow() {
+ addWindow(, {
+ id: 'screenspace-' + uri,
+ title: name,
+ position: 'right'
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderablePanel.tsx b/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderablePanel.tsx
index 37f6ca9df..f6f736bbc 100644
--- a/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderablePanel.tsx
+++ b/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderablePanel.tsx
@@ -1,84 +1,119 @@
+import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { ActionIcon, Box, Divider, Group, Tabs, Text } from '@mantine/core';
+import { Box, Group, Stack, Text, ThemeIcon } from '@mantine/core';
-import { useOpenSpaceApi } from '@/api/hooks';
-import { PropertyOwner } from '@/components/PropertyOwner/PropertyOwner';
+import { FilterList } from '@/components/FilterList/FilterList';
+import { ResizeableContent } from '@/components/ResizeableContent/ResizeableContent';
import { usePropertyOwner } from '@/hooks/propertyOwner';
-import { InsertPhotoIcon, MinusIcon, WebIcon } from '@/icons/icons';
-import { IconSize } from '@/types/enums';
+import { InsertPhotoIcon } from '@/icons/icons';
+import { useAppSelector } from '@/redux/hooks';
+import { propertyOwnerSelectors } from '@/redux/propertytree/propertyOwnerSlice';
+import { propertySelectors } from '@/redux/propertytree/propertySlice';
import { Uri } from '@/types/types';
import { ScreenSpaceKey } from '@/util/keys';
-import { ImageTab } from './ImageTab';
-import { WebpageTab } from './WebpageTab';
+import { AddModal } from './Add/AddModal';
+import { ScreenSpaceRenderableListItem } from './ScreenSpaceRenderableListItem';
+import { ScreenSpaceRenderableView } from './ScreenSpaceRenderableView';
export function ScreenSpaceRenderablePanel() {
const { t } = useTranslation('panel-screenspacerenderable');
+ const [selectedRenderable, setSelectedRenderable] = useState(null);
- const luaApi = useOpenSpaceApi();
const screenSpacePropertyOwner = usePropertyOwner(ScreenSpaceKey);
+ const propertyOwners = useAppSelector((state) =>
+ propertyOwnerSelectors.selectEntities(state)
+ );
- const renderables = screenSpacePropertyOwner?.subowners ?? [];
+ const properties = useAppSelector((state) => propertySelectors.selectEntities(state));
- function removeSlide(uri: Uri) {
- const identifier = uri.split('.').pop();
+ const renderables = useMemo(
+ () =>
+ screenSpacePropertyOwner?.subowners.filter((uri) => {
+ const isHidden = properties[`${uri}.GuiHidden`]?.value;
+ return !isHidden;
+ }) ?? [],
+ [screenSpacePropertyOwner, properties]
+ );
- if (!identifier) {
- return;
- }
+ function onItemClick(uri: Uri) {
+ setSelectedRenderable((prevSelected) => (prevSelected === uri ? null : uri));
+ }
- luaApi?.removeScreenSpaceRenderable(identifier);
+ function renderListItem(uri: Uri) {
+ return (
+
+ onItemClick(uri)} />
+
+ );
}
return (
<>
-
-
- }>
- {t('image-input.tab-title')}
-
- }>
- {t('website-input.tab-title')}
-
-
+
+
+
+
+
+
+
+
+ {renderables.length === 0 ? (
+ // @TODO: Replace with Mantine's EmptyState component when library is updated
+
+
+
+
+
+ {t('added-slides.empty')}
+
+
+ {t('added-slides.tips')}
+
+
+ ) : (
+ <>
+
+
+ {renderables.map((uri) => renderListItem(uri))}
+
-
-
-
-
+
+ propertyOwners[uri]?.name
+ .toLowerCase()
+ .includes(searchString.toLowerCase())
+ }
+ >
+
+
+
-
-
-
-
-
-
- {renderables.length === 0 ? (
- {t('added-slides.empty-slides')}
- ) : (
- renderables.map((uri) => (
-
-
-
+
+ {selectedRenderable ? (
+
+ ) : (
+ {t('no-selection-hint')}
+ )}
- removeSlide(uri)}
- color={'red'}
- variant={'outline'}
- aria-label={`${t('added-slides.remove-slide-aria-label')}: ${uri})`}
- >
-
-
-
- ))
- )}
+ >
+ )}
+
>
);
}
diff --git a/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderableView.tsx b/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderableView.tsx
new file mode 100644
index 000000000..4a51b12ea
--- /dev/null
+++ b/src/panels/ScreenSpaceRenderablePanel/ScreenSpaceRenderableView.tsx
@@ -0,0 +1,93 @@
+import { useTranslation } from 'react-i18next';
+import { Box, Group, Tabs, Tooltip } from '@mantine/core';
+
+import { PropertyOwner } from '@/components/PropertyOwner/PropertyOwner';
+import { PropertyOwnerVisibilityCheckbox } from '@/components/PropertyOwner/VisiblityCheckbox';
+import { ThreePartHeader } from '@/components/ThreePartHeader/ThreePartHeader';
+import { usePropertyValue } from '@/hooks/properties';
+import { usePropertyOwner, usePropertyOwnerVisibility } from '@/hooks/propertyOwner';
+import { IconSize } from '@/types/enums';
+import { Uri } from '@/types/types';
+
+import { ScreenSpacePlacementOwner } from './Placement/ScreenSpacePlacementOwner';
+import { ScreenSpaceRenderableMoreMenu } from './ScreenSpaceRenderableMoreMenu';
+import { ScreenSpaceRenderableTypeIcon } from './TypeIcon';
+
+interface Props {
+ uri: Uri;
+}
+
+export function ScreenSpaceRenderableView({ uri }: Props) {
+ const { t } = useTranslation('panel-screenspacerenderable', {
+ keyPrefix: 'renderable-view'
+ });
+
+ const propertyOwner = usePropertyOwner(uri);
+
+ // Extract some custom propertyowners
+ const placementOwner = usePropertyOwner(`${uri}.Placement`);
+ const styleOwner = usePropertyOwner(`${uri}.Style`);
+ const hideSubowners = ['Placement', 'Style'];
+
+ const type = usePropertyValue('StringProperty', `${uri}.Type`);
+
+ const { visibility, setVisibility } = usePropertyOwnerVisibility(uri);
+
+ if (!propertyOwner) {
+ return <>>;
+ }
+
+ if (!placementOwner || !styleOwner) {
+ throw Error(`Missing placement or style property owner for uri: ${uri}`);
+ }
+
+ return (
+ <>
+
+
+ }
+ rightSection={
+
+
+
+
+ }
+ />
+
+
+
+
+ {t('renderable-tab.title')}
+
+
+
+ {t('placement-tab.title')}
+
+
+
+ {t('style-tab.title')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/panels/ScreenSpaceRenderablePanel/TypeIcon.tsx b/src/panels/ScreenSpaceRenderablePanel/TypeIcon.tsx
new file mode 100644
index 000000000..aff03fe45
--- /dev/null
+++ b/src/panels/ScreenSpaceRenderablePanel/TypeIcon.tsx
@@ -0,0 +1,55 @@
+import { Tooltip } from '@mantine/core';
+
+import {
+ CalendarIcon,
+ FileClockIcon,
+ InsertPhotoIcon,
+ SceneIcon,
+ ShapeIcon,
+ TelescopeIcon,
+ TextIcon,
+ TextShortIcon,
+ VideoIcon,
+ WebIcon
+} from '@/icons/icons';
+
+interface Props {
+ type: string | undefined;
+ size?: number;
+}
+
+const TypeConfig = {
+ ScreenSpaceBrowser: { label: 'Web page', Icon: WebIcon },
+ ScreenSpaceImageLocal: { label: 'Image', Icon: InsertPhotoIcon },
+ ScreenSpaceImageOnline: { label: 'Image', Icon: InsertPhotoIcon },
+ ScreenSpaceVideo: { label: 'Video', Icon: VideoIcon },
+ ScreenSpaceRenderableRenderable: { label: 'Renderable', Icon: SceneIcon },
+ ScreenSpaceText: { label: 'Text', Icon: TextIcon },
+ ScreenSpaceDate: { label: 'Date', Icon: CalendarIcon },
+ ScreenSpaceSkyBrowser: { label: 'SkyBrowser', Icon: TelescopeIcon },
+ ScreenSpaceInsetBlackout: { label: 'Blackout inset', Icon: ShapeIcon },
+ ScreenSpaceTimeVaryingImageOnline: {
+ label: 'Time-varying image',
+ Icon: FileClockIcon
+ },
+ ScreenSpaceDashboard: { label: 'Dashboard', Icon: TextShortIcon }
+} as const;
+
+export function ScreenSpaceRenderableTypeIcon({ type, size }: Props) {
+ if (!type) {
+ return <>>;
+ }
+
+ const config = TypeConfig[type as keyof typeof TypeConfig];
+ if (!config) {
+ return <>>;
+ }
+
+ const { label, Icon } = config;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/panels/ScreenSpaceRenderablePanel/WebpageTab.tsx b/src/panels/ScreenSpaceRenderablePanel/WebpageTab.tsx
deleted file mode 100644
index 0707ff69f..000000000
--- a/src/panels/ScreenSpaceRenderablePanel/WebpageTab.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Button, Group, TextInput } from '@mantine/core';
-
-import { useOpenSpaceApi } from '@/api/hooks';
-import { AddBrowserIcon } from '@/icons/icons';
-import { IconSize } from '@/types/enums';
-import { Identifier } from '@/types/types';
-
-interface ScreenSpaceBrowser {
- Identifier: Identifier;
- Name: string;
- Type: 'ScreenSpaceBrowser';
- Url: string;
-}
-
-export function WebpageTab() {
- const { t } = useTranslation('panel-screenspacerenderable');
-
- const [slideName, setSlideName] = useState('');
- const [slideURL, setSlideURL] = useState('');
- const luaApi = useOpenSpaceApi();
-
- const isAddButtonDisabled = !slideName || !slideURL;
-
- async function addSlide() {
- const osIdentifier = (await luaApi?.makeIdentifier(slideName)) ?? slideName;
-
- const renderable: ScreenSpaceBrowser = {
- Identifier: osIdentifier,
- Name: slideName,
- Type: 'ScreenSpaceBrowser',
- Url: slideURL
- };
-
- luaApi?.addScreenSpaceRenderable(renderable);
- setSlideName('');
- setSlideURL('');
- }
-
- return (
-
- setSlideName(event.currentTarget.value)}
- placeholder={t('display-name-input.placeholder')}
- label={t('display-name-input.title')}
- />
- setSlideURL(event.currentTarget.value)}
- placeholder={t('website-input.placeholder')}
- label={t('website-input.title')}
- />
- }
- disabled={isAddButtonDisabled}
- >
- {t('website-input.button-label')}
-
-
- );
-}
diff --git a/src/theme/mantineTheme.ts b/src/theme/mantineTheme.ts
index 16348bb36..f54c75df4 100644
--- a/src/theme/mantineTheme.ts
+++ b/src/theme/mantineTheme.ts
@@ -9,6 +9,7 @@ import {
Divider,
Menu,
MultiSelect,
+ NumberInput,
RangeSlider,
Select,
Slider,
@@ -130,6 +131,13 @@ export const theme = createTheme({
}
}
},
+ NumberInput: NumberInput.extend({
+ defaultProps: {
+ stepHoldDelay: 500,
+ // Increase speed as the user holds down the stepper button
+ stepHoldInterval: (t) => Math.max(1000 / t ** 2, 25)
+ }
+ }),
Select: Select.extend({
defaultProps: {
scrollAreaProps: { type: 'auto', scrollbarSize: 8 }
diff --git a/src/util/useRemoveSceneGraphNode.tsx b/src/util/removeModalsHooks.tsx
similarity index 65%
rename from src/util/useRemoveSceneGraphNode.tsx
rename to src/util/removeModalsHooks.tsx
index 25e7d36d3..c9bd46657 100644
--- a/src/util/useRemoveSceneGraphNode.tsx
+++ b/src/util/removeModalsHooks.tsx
@@ -54,3 +54,38 @@ export function useRemoveSceneGraphNodeModal() {
return removeSceneGraphNode;
}
+
+export function useRemoveScreenSpaceRenderableModal() {
+ const { t } = useTranslation('panel-screenspacerenderable', {
+ keyPrefix: 'more-menu'
+ });
+
+ const luaApi = useOpenSpaceApi();
+
+ return function removeScreenSpaceRenderable(
+ identifier: Identifier,
+ name?: string,
+ onRemove?: () => void
+ ) {
+ modals.openConfirmModal({
+ title: t('delete-confirm-modal.title'),
+ children: (
+
+ ),
+ labels: {
+ confirm: t('delete-confirm-modal.remove-button'),
+ cancel: t('delete-confirm-modal.cancel-button')
+ },
+ confirmProps: { color: 'red', variant: 'filled' },
+ onConfirm: () => {
+ if (luaApi) {
+ luaApi.removeScreenSpaceRenderable(identifier);
+ onRemove?.();
+ }
+ }
+ });
+ };
+}