diff --git a/packages/client-core/i18n/en/admin.json b/packages/client-core/i18n/en/admin.json index d40aa031d7..d4ca45826f 100755 --- a/packages/client-core/i18n/en/admin.json +++ b/packages/client-core/i18n/en/admin.json @@ -571,6 +571,7 @@ "subtitle": "Edit Metabase Settings", "siteUrl": "Site Url", "secretKey": "Secret Key", + "environment": "Environment", "expiration": "Expiration", "crashDashboardId": "Crash Dashboard Id" }, diff --git a/packages/client-core/i18n/en/editor.json b/packages/client-core/i18n/en/editor.json index cbd6e57567..d02eab69e9 100755 --- a/packages/client-core/i18n/en/editor.json +++ b/packages/client-core/i18n/en/editor.json @@ -72,7 +72,7 @@ "unknownStatus": "Unknown Status", "CORS": "Possibly a CORS error", "urlFetchError": "Failed to fetch \"{{url}}\"", - "invalidSceneName": "Scene name must be 4-64 characters long, using only alphanumeric characters, hyphens, and underscores." + "invalidSceneName": "Scene name must be 4-64 characters long, using only alphanumeric characters and hyphens, and begin and end with an alphanumeric." }, "viewport": { "title": "Viewport", diff --git a/packages/client-core/src/admin/components/settings/tabs/metabase.tsx b/packages/client-core/src/admin/components/settings/tabs/metabase.tsx index 40b4fe6f52..ee76c62584 100644 --- a/packages/client-core/src/admin/components/settings/tabs/metabase.tsx +++ b/packages/client-core/src/admin/components/settings/tabs/metabase.tsx @@ -44,6 +44,7 @@ const MetabaseTab = forwardRef(({ open }: { open: boolean }, ref: React.MutableR const id = useHookstate(undefined) const siteUrl = useHookstate('') const secretKey = useHookstate('') + const environment = useHookstate('') const expiration = useHookstate(10) const crashDashboardId = useHookstate('') const metabaseSettingMutation = useMutation(metabaseSettingPath) @@ -55,6 +56,7 @@ const MetabaseTab = forwardRef(({ open }: { open: boolean }, ref: React.MutableR id.set(data[0].id) siteUrl.set(data[0].siteUrl) secretKey.set(data[0].secretKey) + environment.set(data[0].environment) expiration.set(data[0].expiration) crashDashboardId.set(data[0].crashDashboardId || '') } @@ -63,13 +65,14 @@ const MetabaseTab = forwardRef(({ open }: { open: boolean }, ref: React.MutableR const handleSubmit = (event) => { event.preventDefault() - if (!siteUrl.value || !secretKey.value) return + if (!siteUrl.value || !secretKey.value || !environment.value) return state.loading.set(true) const setting = { siteUrl: siteUrl.value, secretKey: secretKey.value, + environment: environment.value, crashDashboardId: crashDashboardId.value } @@ -90,6 +93,7 @@ const MetabaseTab = forwardRef(({ open }: { open: boolean }, ref: React.MutableR id.set(data[0].id) siteUrl.set(data[0].siteUrl) secretKey.set(data[0].secretKey) + environment.set(data[0].environment) expiration.set(data[0].expiration) crashDashboardId.set(data[0].crashDashboardId || '') } @@ -112,6 +116,13 @@ const MetabaseTab = forwardRef(({ open }: { open: boolean }, ref: React.MutableR onChange={(e) => siteUrl.set(e.target.value)} /> + environment.set(e.target.value)} + /> + :"/\\|?*\u0000-\u001F].*)[^\s_<>:"/\\|?*\u0000-\u001F]{1,64}$/ // eslint-disable-next-line no-control-regex export const WINDOWS_RESERVED_NAME_REGEX = /^(con|prn|aux|nul|com\d|lpt\d)$/i -export const VALID_SCENE_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]{2,62}[a-zA-Z0-9_\-]$/ +export const VALID_SCENE_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]{2,62}[a-zA-Z0-9]$/ export const VALID_HEIRARCHY_SEARCH_REGEX = /[.*+?^${}()|[\]\\]/g /** diff --git a/packages/common/src/schemas/integrations/metabase/metabase-setting.schema.ts b/packages/common/src/schemas/integrations/metabase/metabase-setting.schema.ts index 4877d8a324..cf3f8063c1 100644 --- a/packages/common/src/schemas/integrations/metabase/metabase-setting.schema.ts +++ b/packages/common/src/schemas/integrations/metabase/metabase-setting.schema.ts @@ -40,6 +40,7 @@ export const metabaseSettingSchema = Type.Object( }), siteUrl: Type.String(), secretKey: Type.String(), + environment: Type.String(), crashDashboardId: Type.Optional(Type.String()), expiration: Type.Number(), createdAt: Type.String({ format: 'date-time' }), @@ -70,6 +71,7 @@ export const metabaseSettingQueryProperties = Type.Pick(metabaseSettingSchema, [ 'id', 'siteUrl', 'secretKey', + 'environment', 'crashDashboardId' ]) diff --git a/packages/common/src/schemas/media/file-browser.schema.ts b/packages/common/src/schemas/media/file-browser.schema.ts index 391fae4e59..cd490d4173 100644 --- a/packages/common/src/schemas/media/file-browser.schema.ts +++ b/packages/common/src/schemas/media/file-browser.schema.ts @@ -84,7 +84,8 @@ export const fileBrowserPatchSchema = Type.Intersect( project: Type.String(), body: Type.Any(), // Buffer | string contentType: Type.Optional(Type.String()), - storageProviderName: Type.Optional(Type.String()) + storageProviderName: Type.Optional(Type.String()), + fileName: Type.Optional(Type.String()) }) ], { diff --git a/packages/common/src/schemas/media/invalidation.schema.ts b/packages/common/src/schemas/media/invalidation.schema.ts index 8207d46f4a..fdd8afa4d7 100644 --- a/packages/common/src/schemas/media/invalidation.schema.ts +++ b/packages/common/src/schemas/media/invalidation.schema.ts @@ -48,7 +48,7 @@ export const invalidationSchema = Type.Object( export interface InvalidationType extends Static {} // Schema for creating new entries -export const invalidationDataSchema = Type.Partial(invalidationSchema, { $id: 'InvalidationData' }) +export const invalidationDataSchema = Type.Pick(invalidationSchema, ['path'], { $id: 'InvalidationData' }) export interface InvalidationData extends Static {} // Schema for allowed query properties diff --git a/packages/common/src/schemas/recording/recording.schema.ts b/packages/common/src/schemas/recording/recording.schema.ts index a4a28c3f24..e73ce7344c 100644 --- a/packages/common/src/schemas/recording/recording.schema.ts +++ b/packages/common/src/schemas/recording/recording.schema.ts @@ -69,7 +69,7 @@ export interface RecordingDatabaseType extends Omit { } // Schema for creating new entries -export const recordingDataSchema = Type.Partial(recordingSchema, { +export const recordingDataSchema = Type.Pick(recordingSchema, ['schema'], { $id: 'RecordingData' }) export interface RecordingData extends Static {} @@ -81,7 +81,7 @@ export const recordingPatchSchema = Type.Partial(recordingSchema, { export interface RecordingPatch extends Static {} // Schema for allowed query properties -export const recordingQueryProperties = Type.Pick(recordingSchema, ['id', 'userId']) +export const recordingQueryProperties = Type.Pick(recordingSchema, ['id', 'userId', 'createdAt']) export const recordingQuerySchema = Type.Intersect( [ querySyntax(recordingQueryProperties), diff --git a/packages/editor/src/components/toolbar/Toolbar.tsx b/packages/editor/src/components/toolbar/Toolbar.tsx index 4ecf87012e..798b785122 100644 --- a/packages/editor/src/components/toolbar/Toolbar.tsx +++ b/packages/editor/src/components/toolbar/Toolbar.tsx @@ -34,6 +34,7 @@ import { GLTFModifiedState } from '@etherealengine/engine/src/gltf/GLTFDocumentS import { getMutableState, getState, useHookstate, useMutableState } from '@etherealengine/hyperflux' import { useFind } from '@etherealengine/spatial/src/common/functions/FeathersHooks' import { ContextMenu } from '@etherealengine/ui/src/components/tailwind/ContextMenu' +import { SidebarButton } from '@etherealengine/ui/src/components/tailwind/SidebarButton' import Button from '@etherealengine/ui/src/primitives/tailwind/Button' import { t } from 'i18next' import React from 'react' @@ -201,10 +202,9 @@ export default function Toolbar() {
{toolbarMenu.map(({ name, action, hotkey }, index) => (
- +
))}
diff --git a/packages/engine/src/scene/components/MediaComponent.ts b/packages/engine/src/scene/components/MediaComponent.ts index 28702b70fd..99e1a27195 100644 --- a/packages/engine/src/scene/components/MediaComponent.ts +++ b/packages/engine/src/scene/components/MediaComponent.ts @@ -231,6 +231,8 @@ export const MediaComponent = defineComponent({ if (typeof json.seekTime === 'number') component.seekTime.set(json.seekTime) if (typeof json.autoplay === 'boolean') component.autoplay.set(json.autoplay) + + if (typeof json.synchronize === 'boolean') component.synchronize.set(json.synchronize) }) }, diff --git a/packages/server-core/src/integrations/metabase/metabase-setting/metabase-setting.seed.ts b/packages/server-core/src/integrations/metabase/metabase-setting/metabase-setting.seed.ts index bb830a8893..814be9d4c3 100644 --- a/packages/server-core/src/integrations/metabase/metabase-setting/metabase-setting.seed.ts +++ b/packages/server-core/src/integrations/metabase/metabase-setting/metabase-setting.seed.ts @@ -42,6 +42,7 @@ export async function seed(knex: Knex): Promise { { siteUrl: process.env.METABASE_SITE_URL!, secretKey: process.env.METABASE_SECRET_KEY!, + environment: process.env.METABASE_ENVIRONMENT!, crashDashboardId: process.env.METABASE_CRASH_DASHBOARD_ID!, expiration: isNaN(parseInt(process.env.METABASE_EXPIRATION!)) ? 10 : parseInt(process.env.METABASE_EXPIRATION!) } diff --git a/packages/server-core/src/integrations/metabase/metabase-setting/migrations/20240816095000_metabase-environment-column.ts b/packages/server-core/src/integrations/metabase/metabase-setting/migrations/20240816095000_metabase-environment-column.ts new file mode 100644 index 0000000000..391b52006f --- /dev/null +++ b/packages/server-core/src/integrations/metabase/metabase-setting/migrations/20240816095000_metabase-environment-column.ts @@ -0,0 +1,74 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import { metabaseSettingPath } from '@etherealengine/common/src/schemas/integrations/metabase/metabase-setting.schema' +import type { Knex } from 'knex' + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function up(knex: Knex): Promise { + await knex.raw('SET FOREIGN_KEY_CHECKS=0') + + const tableExists = await knex.schema.hasTable(metabaseSettingPath) + if (tableExists) { + const environmentExists = await knex.schema.hasColumn(metabaseSettingPath, 'environment') + if (environmentExists === false) { + await knex.schema.alterTable(metabaseSettingPath, async (table) => { + table.string('environment').nullable() + }) + + const metabaseSettings = await knex.table(metabaseSettingPath).first() + + if (metabaseSettings) { + await knex.table(metabaseSettingPath).update({ + environment: process.env.METABASE_ENVIRONMENT + }) + } + } + } + await knex.raw('SET FOREIGN_KEY_CHECKS=1') +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function down(knex: Knex): Promise { + await knex.raw('SET FOREIGN_KEY_CHECKS=0') + + const tableExists = await knex.schema.hasTable(metabaseSettingPath) + if (tableExists) { + const environmentExists = await knex.schema.hasColumn(metabaseSettingPath, 'environment') + if (environmentExists) { + await knex.schema.alterTable(metabaseSettingPath, async (table) => { + table.dropColumn('environment') + }) + } + } + + await knex.raw('SET FOREIGN_KEY_CHECKS=1') +} diff --git a/packages/server-core/src/integrations/metabase/metabase-url/metabase-url.hooks.ts b/packages/server-core/src/integrations/metabase/metabase-url/metabase-url.hooks.ts index 4f5c1f0333..69e1ea8800 100644 --- a/packages/server-core/src/integrations/metabase/metabase-url/metabase-url.hooks.ts +++ b/packages/server-core/src/integrations/metabase/metabase-url/metabase-url.hooks.ts @@ -47,6 +47,7 @@ export const metabaseCrashDashboard = async (context: HookContext schemaHooks.validateData(fileBrowserUpdateValidator)], + update: [schemaHooks.validateData(fileBrowserUpdateValidator)], patch: [ (context) => { context[SYNC] = false return context }, - () => schemaHooks.validateData(fileBrowserPatchValidator) + schemaHooks.validateData(fileBrowserPatchValidator) ], remove: [] }, diff --git a/packages/server-core/src/media/invalidation/invalidation.hooks.ts b/packages/server-core/src/media/invalidation/invalidation.hooks.ts index e555a2e912..1dec204476 100755 --- a/packages/server-core/src/media/invalidation/invalidation.hooks.ts +++ b/packages/server-core/src/media/invalidation/invalidation.hooks.ts @@ -45,15 +45,12 @@ export default { before: { all: [ disallow('external'), - () => schemaHooks.validateQuery(invalidationQueryValidator), + schemaHooks.validateQuery(invalidationQueryValidator), schemaHooks.resolveQuery(invalidationQueryResolver) ], find: [], get: [], - create: [ - () => schemaHooks.validateData(invalidationDataValidator), - schemaHooks.resolveData(invalidationDataResolver) - ], + create: [schemaHooks.validateData(invalidationDataValidator), schemaHooks.resolveData(invalidationDataResolver)], update: [disallow()], patch: [disallow()], remove: [] diff --git a/packages/server-core/src/projects/project/github-helper.ts b/packages/server-core/src/projects/project/github-helper.ts index b73fc4ec51..0908890397 100755 --- a/packages/server-core/src/projects/project/github-helper.ts +++ b/packages/server-core/src/projects/project/github-helper.ts @@ -63,7 +63,7 @@ import { createExecutorJob } from '../../k8s-job-helper' import { getFileKeysRecursive } from '../../media/storageprovider/storageProviderUtils' import { getStorageProvider } from '../../media/storageprovider/storageprovider' import { useGit } from '../../util/gitHelperFunctions' -import { getProjectPushJobBody } from './project-helper' +import { cleanProjectName, getProjectPushJobBody } from './project-helper' import { ProjectParams } from './project.class' // 30 MB. GitHub's documentation says that the blob upload cutoff is 50MB, but in testing, some files that were around @@ -353,7 +353,7 @@ export const pushProjectToGithub = async ( returnData: '', status: 'pending' }) - const projectJobName = project.name.toLowerCase().replace(/[^a-z0-9-.]/g, '-') + const projectJobName = cleanProjectName(project.name) const jobBody = await getProjectPushJobBody(app, project, user, reset, newJob.id, commitSHA) await app.service(apiJobPath).patch(newJob.id, { name: jobBody.metadata!.name diff --git a/packages/server-core/src/projects/project/project-helper.ts b/packages/server-core/src/projects/project/project-helper.ts index df8d6a015c..cf558bec07 100644 --- a/packages/server-core/src/projects/project/project-helper.ts +++ b/packages/server-core/src/projects/project/project-helper.ts @@ -1042,7 +1042,7 @@ export async function getProjectUpdateJobBody( command.push(data.reset.toString()) } - const projectJobName = data.name.toLowerCase().replace(/[^a-z0-9-.]/g, '-') + const projectJobName = cleanProjectName(data.name) const labels = { 'etherealengine/projectUpdater': 'true', @@ -1090,7 +1090,7 @@ export async function getProjectPushJobBody( command.push(storageProviderName) } - const projectJobName = project.name.toLowerCase().replace(/[^a-z0-9-.]/g, '-') + const projectJobName = cleanProjectName(project.name) const labels = { 'etherealengine/projectPusher': 'true', @@ -1104,7 +1104,7 @@ export async function getProjectPushJobBody( } export const getCronJobBody = (project: ProjectType, image: string): object => { - const projectJobName = project.name.toLowerCase().replace(/[^a-z0-9-.]/g, '-') + const projectJobName = cleanProjectName(project.name) return { metadata: { name: `${process.env.RELEASE_NAME}-${projectJobName}-auto-update`, @@ -1180,7 +1180,7 @@ export async function getDirectoryArchiveJobBody( jobId ] - const projectJobName = projectName.toLowerCase().replace(/[^a-z0-9-.]/g, '-') + const projectJobName = cleanProjectName(projectName) const labels = { 'etherealengine/directoryArchiver': 'true', @@ -1540,7 +1540,7 @@ export const updateProject = async ( returned.needsRebuild = typeof data.needsRebuild === 'boolean' ? data.needsRebuild : true if (returned.name !== projectName) - await app.service(projectPath).patch(existingProject!.id, { + await app.service(projectPath).patch(returned.id, { name: projectName }) @@ -1861,3 +1861,10 @@ export const uploadLocalProjectToProvider = async ( const assetsOnly = !fs.existsSync(path.join(projectRootPath, 'xrengine.config.ts')) return { files: results.filter((success) => !!success) as string[], assetsOnly } } + +export const cleanProjectName = (name: string) => { + const returned = name.toLowerCase().replace(/[^a-zA-Z0-9-.]/g, '-') + if (!/[a-zA-Z0-9]/.test(returned[0])) return cleanProjectName(name.slice(1)) + if (!/[a-zA-Z0-9]/.test(returned[returned.length - 1])) return cleanProjectName(name.slice(0, returned.length - 1)) + return returned +} diff --git a/packages/server-core/src/projects/project/project.hooks.ts b/packages/server-core/src/projects/project/project.hooks.ts index 9366dc4b4e..71cfd23123 100644 --- a/packages/server-core/src/projects/project/project.hooks.ts +++ b/packages/server-core/src/projects/project/project.hooks.ts @@ -74,6 +74,7 @@ import logger from '../../ServerLogger' import { useGit } from '../../util/gitHelperFunctions' import { checkAppOrgStatus, checkUserOrgWriteStatus, checkUserRepoWriteStatus } from './github-helper' import { + cleanProjectName, deleteProjectFilesInStorageProvider, engineVersion, getProjectConfig, @@ -556,7 +557,7 @@ const updateProjectJob = async (context: HookContext) => { returnData: '', status: 'pending' }) - const projectJobName = data.name.toLowerCase().replace(/[^a-z0-9-.]/g, '-') + const projectJobName = cleanProjectName(data.name) const jobBody = await getProjectUpdateJobBody( data, context.app, diff --git a/packages/server-core/src/recording/recording-resource/recording-resource.hooks.ts b/packages/server-core/src/recording/recording-resource/recording-resource.hooks.ts index 71ebd5bb77..80189d0970 100755 --- a/packages/server-core/src/recording/recording-resource/recording-resource.hooks.ts +++ b/packages/server-core/src/recording/recording-resource/recording-resource.hooks.ts @@ -50,20 +50,20 @@ export default { before: { all: [ - () => schemaHooks.validateQuery(recordingResourceQueryValidator), + schemaHooks.validateQuery(recordingResourceQueryValidator), schemaHooks.resolveQuery(recordingResourceQueryResolver) ], find: [iff(isProvider('external'), verifyScope('recording', 'read'))], get: [iff(isProvider('external'), verifyScope('recording', 'read'))], create: [ iff(isProvider('external'), verifyScope('recording', 'write'), verifyScope('settings', 'write')), - () => schemaHooks.validateData(recordingResourceDataValidator), + schemaHooks.validateData(recordingResourceDataValidator), schemaHooks.resolveData(recordingResourceDataResolver) ], update: [disallow()], patch: [ iff(isProvider('external'), verifyScope('recording', 'write')), - () => schemaHooks.validateData(recordingResourcePatchValidator), + schemaHooks.validateData(recordingResourcePatchValidator), schemaHooks.resolveData(recordingResourcePatchResolver) ], remove: [iff(isProvider('external'), verifyScope('recording', 'write'))] diff --git a/packages/server-core/src/recording/recording/recording.hooks.ts b/packages/server-core/src/recording/recording/recording.hooks.ts index 2a5f8536af..2c0b94797d 100755 --- a/packages/server-core/src/recording/recording/recording.hooks.ts +++ b/packages/server-core/src/recording/recording/recording.hooks.ts @@ -87,7 +87,7 @@ export default { }, before: { - all: [() => schemaHooks.validateQuery(recordingQueryValidator), schemaHooks.resolveQuery(recordingQueryResolver)], + all: [schemaHooks.validateQuery(recordingQueryValidator), schemaHooks.resolveQuery(recordingQueryResolver)], find: [ iff( isProvider('external'), @@ -100,13 +100,13 @@ export default { create: [ iff(isProvider('external'), verifyScope('recording', 'write')), setLoggedinUserInBody('userId'), - () => schemaHooks.validateData(recordingDataValidator), + schemaHooks.validateData(recordingDataValidator), schemaHooks.resolveData(recordingDataResolver) ], update: [disallow()], patch: [ iff(isProvider('external'), verifyScope('recording', 'write')), - () => schemaHooks.validateData(recordingPatchValidator), + schemaHooks.validateData(recordingPatchValidator), schemaHooks.resolveData(recordingPatchResolver) ], remove: [iff(isProvider('external'), verifyScope('recording', 'write')), ensureRecording] diff --git a/packages/spatial/src/input/functions/ClientInputFunctions.ts b/packages/spatial/src/input/functions/ClientInputFunctions.ts new file mode 100644 index 0000000000..454dc137b2 --- /dev/null +++ b/packages/spatial/src/input/functions/ClientInputFunctions.ts @@ -0,0 +1,283 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +/** + * @fileoverview + * Contains function definitions used by ClientInputSystem and other ClientInput related modules + */ + +import { + defineQuery, + Entity, + getComponent, + getMutableComponent, + getOptionalComponent, + hasComponent, + Not, + UndefinedEntity, + UUIDComponent +} from '@etherealengine/ecs' +import { Quaternion, Vector3 } from 'three' +import { PI, Q_IDENTITY, Vector3_Zero } from '../../common/constants/MathConstants' +import { TransformComponent } from '../../SpatialModule' +import { getAncestorWithComponent } from '../../transform/components/EntityTree' +import { TransformGizmoTagComponent } from '../../transform/components/TransformComponent' +import { XRSpaceComponent } from '../../xr/XRComponents' +import { XRUIComponent } from '../../xrui/components/XRUIComponent' +import { DefaultButtonAlias, InputComponent } from '../components/InputComponent' +import { InputPointerComponent } from '../components/InputPointerComponent' +import { InputSourceComponent } from '../components/InputSourceComponent' +import { ButtonState, ButtonStateMap, createInitialButtonState, MouseButton } from '../state/ButtonState' +import { HeuristicData, HeuristicFunctions, IntersectionData } from './ClientInputHeuristics' + +/** radian threshold for rotating state*/ +const ROTATING_THRESHOLD = 1.5 * (PI / 180) + +/** squared distance threshold for dragging state */ +const DRAGGING_THRESHOLD = 0.001 + +/** anti-garbage variable!! value not to be used unless you set values just before use*/ +const _pointerPositionVector3 = new Vector3() + +export function preventDefault(e) { + e.preventDefault() +} + +export const preventDefaultKeyDown = (evt) => { + if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return + if (evt.code === 'Tab') evt.preventDefault() + // prevent DOM tab selection and spacebar/enter button toggling (since it interferes with avatar controls) + if (evt.code === 'Space' || evt.code === 'Enter') evt.preventDefault() +} + +export function updateGamepadInput(eid: Entity) { + const inputSource = getComponent(eid, InputSourceComponent) + const gamepad = inputSource.source.gamepad + const buttons = inputSource.buttons + // const buttonDownPos = inputSource.buttonDownPositions as WeakMap + // log buttons + // if (source.gamepad) { + // for (let i = 0; i < source.gamepad.buttons.length; i++) { + // const button = source.gamepad.buttons[i] + // if (button.pressed) console.log('button ' + i + ' pressed: ' + button.pressed) + // } + // } + + if (!gamepad) return + const gamepadButtons = gamepad.buttons + if (!gamepadButtons.length) return + + const pointer = getOptionalComponent(eid, InputPointerComponent) + const xrTransform = getOptionalComponent(eid, TransformComponent) + + for (let i = 0; i < gamepadButtons.length; i++) { + const gamepadButton = gamepadButtons[i] + if (!buttons[i] && (gamepadButton.pressed || gamepadButton.touched)) { + buttons[i] = createInitialButtonState(eid, gamepadButton) + } + const buttonState = buttons[i] as ButtonState + if (buttonState && (gamepadButton.pressed || gamepadButton.touched)) { + if (!buttonState.pressed && gamepadButton.pressed) { + buttonState.down = true + buttonState.downPosition = new Vector3() + buttonState.downRotation = new Quaternion() + + if (pointer) { + buttonState.downPosition.set(pointer.position.x, pointer.position.y, 0) + //TODO maybe map pointer rotation/swing/twist to downRotation here once we map the pointer events to that (think Apple pencil) + } else if (hasComponent(eid, XRSpaceComponent) && xrTransform) { + buttonState.downPosition.copy(xrTransform.position) + buttonState.downRotation.copy(xrTransform.rotation) + } + } + buttonState.pressed = gamepadButton.pressed + buttonState.touched = gamepadButton.touched + buttonState.value = gamepadButton.value + + if (buttonState.downPosition) { + //if not yet dragging, compare distance to drag threshold and begin if appropriate + if (!buttonState.dragging) { + if (pointer) _pointerPositionVector3.set(pointer.position.x, pointer.position.y, 0) + const squaredDistance = buttonState.downPosition.distanceToSquared( + pointer ? _pointerPositionVector3 : xrTransform?.position ?? Vector3_Zero + ) + + if (squaredDistance > DRAGGING_THRESHOLD) { + buttonState.dragging = true + } + } + + //if not yet rotating, compare distance to drag threshold and begin if appropriate + if (!buttonState.rotating) { + const angleRadians = buttonState.downRotation!.angleTo( + pointer ? Q_IDENTITY : xrTransform?.rotation ?? Q_IDENTITY + ) + if (angleRadians > ROTATING_THRESHOLD) { + buttonState.rotating = true + } + } + } + } else if (buttonState) { + buttonState.up = true + } + } +} +export const setInputSources = (startEntity: Entity, inputSources: Entity[]) => { + const inputEntity = getAncestorWithComponent(startEntity, InputComponent) + if (!inputEntity) return + const inputComponent = getComponent(inputEntity, InputComponent) + + for (const sinkEntityUUID of inputComponent.inputSinks) { + const sinkEntity = sinkEntityUUID === 'Self' ? inputEntity : UUIDComponent.getEntityByUUID(sinkEntityUUID) //TODO why is this not sending input to my sinks + const sinkInputComponent = getMutableComponent(sinkEntity, InputComponent) + sinkInputComponent.inputSources.merge(inputSources) + } +} + +export function updatePointerDragging(pointerEntity: Entity, event: PointerEvent) { + const inputSourceComponent = getOptionalComponent(pointerEntity, InputSourceComponent) + if (!inputSourceComponent) return + + const state = inputSourceComponent.buttons as ButtonStateMap + + let button = MouseButton.PrimaryClick + if (event.type === 'pointermove') { + if ((event as MouseEvent).button === 1) button = MouseButton.AuxiliaryClick + else if ((event as MouseEvent).button === 2) button = MouseButton.SecondaryClick + } + const btn = state[button] + if (btn && !btn.dragging) { + const pointer = getOptionalComponent(pointerEntity, InputPointerComponent) + + if (btn.pressed && btn.downPosition) { + //if not yet dragging, compare distance to drag threshold and begin if appropriate + if (!btn.dragging) { + pointer + ? _pointerPositionVector3.set(pointer.position.x, pointer.position.y, 0) + : _pointerPositionVector3.copy(Vector3_Zero) + const squaredDistance = btn.downPosition.distanceToSquared(_pointerPositionVector3) + + if (squaredDistance > DRAGGING_THRESHOLD) { + btn.dragging = true + } + } + } + } +} + +export function cleanupButton( + key: string, + buttons: ButtonStateMap>>, + hasFocus: boolean +) { + const button = buttons[key] + if (button?.down) button.down = false + if (button?.up || !hasFocus) delete buttons[key] +} + +export const redirectPointerEventsToXRUI = (cameraEntity: Entity, evt: PointerEvent) => { + const pointerEntity = InputPointerComponent.getPointerByID(cameraEntity, evt.pointerId) + const inputSource = getOptionalComponent(pointerEntity, InputSourceComponent) + if (!inputSource) return + for (const i of inputSource.intersections) { + const entity = i.entity + const xrui = getOptionalComponent(entity, XRUIComponent) + if (!xrui) continue + xrui.updateWorldMatrix(true, true) + const raycaster = inputSource.raycaster + const hit = xrui.hitTest(raycaster.ray) + if (hit && hit.intersection.object.visible) { + hit.target.dispatchEvent(new (evt.constructor as any)(evt.type, evt)) + hit.target.focus() + return + } + } +} + +const nonSpatialInputSource = defineQuery([InputSourceComponent, Not(TransformComponent)]) + +export function assignInputSources( + sourceEid: Entity, + capturedEntity: Entity, + data: HeuristicData, + heuristic: HeuristicFunctions +) { + const isSpatialInput = hasComponent(sourceEid, TransformComponent) + + const intersectionData = new Set([] as IntersectionData[]) + + if (isSpatialInput) heuristic.raycastedInput(sourceEid, intersectionData, data, heuristic) + + const sortedIntersections = Array.from(intersectionData).sort((a, b) => { + // - if a < b + // + if a > b + // 0 if equal + const aNum = hasComponent(a.entity, TransformGizmoTagComponent) ? -1 : 0 + const bNum = hasComponent(b.entity, TransformGizmoTagComponent) ? -1 : 0 + //aNum - bNum : 0 if equal, -1 if a has tag and b doesn't, 1 if a doesnt have tag and b does + return Math.sign(a.distance - b.distance) + (aNum - bNum) + }) + const sourceState = getMutableComponent(sourceEid, InputSourceComponent) + + //TODO check all inputSources sorted by distance list of InputComponents from query, probably similar to the spatialInputQuery + //Proximity check ONLY if we have no raycast results, as it is always lower priority + if ( + capturedEntity === UndefinedEntity && + sortedIntersections.length === 0 && + !hasComponent(sourceEid, InputPointerComponent) + ) { + heuristic.proximity(isSpatialInput, sourceEid, sortedIntersections, intersectionData) + } + + const inputPointerComponent = getOptionalComponent(sourceEid, InputPointerComponent) + if (inputPointerComponent) { + sortedIntersections.push({ entity: inputPointerComponent.cameraEntity, distance: 0 }) + } + + sourceState.intersections.set(sortedIntersections) + + const finalInputSources = Array.from(new Set([sourceEid, ...nonSpatialInputSource()])) + + //if we have a capturedEntity, only run on the capturedEntity, not the sortedIntersections + if (capturedEntity !== UndefinedEntity) { + ClientInputFunctions.setInputSources(capturedEntity, finalInputSources) + } else { + for (const intersection of sortedIntersections) { + ClientInputFunctions.setInputSources(intersection.entity, finalInputSources) + } + } +} + +export const ClientInputFunctions = { + preventDefault, + preventDefaultKeyDown, + updateGamepadInput, + setInputSources, + updatePointerDragging, + cleanupButton, + redirectPointerEventsToXRUI, + assignInputSources +} +export default ClientInputFunctions diff --git a/packages/spatial/src/input/functions/ClientInputHeuristics.ts b/packages/spatial/src/input/functions/ClientInputHeuristics.ts new file mode 100644 index 0000000000..215ba6a5b5 --- /dev/null +++ b/packages/spatial/src/input/functions/ClientInputHeuristics.ts @@ -0,0 +1,281 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +/** + * @fileoverview Contains function declarations describing the heuristics used by ClientInputSystem. + */ + +import { Object3DUtils } from '@etherealengine/common/src/utils/Object3DUtils' +import { + defineQuery, + Engine, + Entity, + EntityUUID, + getComponent, + getOptionalComponent, + hasComponent, + Not, + UndefinedEntity, + UUIDComponent +} from '@etherealengine/ecs' +import { InteractableComponent } from '@etherealengine/engine/src/interaction/components/InteractableComponent' +import { getState } from '@etherealengine/hyperflux' +import { Mesh, MeshBasicMaterial, Object3D, Quaternion, Ray, Raycaster, Vector3 } from 'three' +import { CameraComponent } from '../../camera/components/CameraComponent' +import { ObjectDirection } from '../../common/constants/MathConstants' +import { EngineState } from '../../EngineState' +import { Physics, RaycastArgs } from '../../physics/classes/Physics' +import { GroupComponent } from '../../renderer/components/GroupComponent' +import { MeshComponent } from '../../renderer/components/MeshComponent' +import { SceneComponent } from '../../renderer/components/SceneComponents' +import { VisibleComponent } from '../../renderer/components/VisibleComponent' +import { ObjectLayers } from '../../renderer/constants/ObjectLayers' +import { BoundingBoxComponent } from '../../transform/components/BoundingBoxComponents' +import { TransformComponent, TransformGizmoTagComponent } from '../../transform/components/TransformComponent' +import { XRScenePlacementComponent } from '../../xr/XRScenePlacementComponent' +import { XRState } from '../../xr/XRState' +import { XRUIComponent } from '../../xrui/components/XRUIComponent' +import { InputComponent } from '../components/InputComponent' +import { InputState } from '../state/InputState' + +const _worldPosInputSourceComponent = new Vector3() +const _worldPosInputComponent = new Vector3() + +export type IntersectionData = { + entity: Entity + distance: number +} + +export type HeuristicData = { + quaternion: Quaternion + ray: Ray + raycast: RaycastArgs + caster: Raycaster + hitTarget: Vector3 +} + +export type HeuristicFunctions = { + editor: typeof ClientInputHeuristics.findEditor + xrui: typeof ClientInputHeuristics.findXRUI + physicsColliders: typeof ClientInputHeuristics.findPhysicsColliders + bboxes: typeof ClientInputHeuristics.findBBoxes + meshes: typeof ClientInputHeuristics.findMeshes + proximity: typeof ClientInputHeuristics.findProximity + raycastedInput: typeof ClientInputHeuristics.findRaycastedInput +} + +/**Proximity query */ +const spatialInputObjectsQuery = defineQuery([ + InputComponent, + VisibleComponent, + TransformComponent, + Not(CameraComponent), + Not(XRScenePlacementComponent) +]) + +export function findProximity( + isSpatialInput: boolean, + sourceEid: Entity, + sortedIntersections: IntersectionData[], + intersectionData: Set +) { + const isCameraAttachedToAvatar = XRState.isCameraAttachedToAvatar + + //use sourceEid if controller (one InputSource per controller), otherwise use avatar rather than InputSource-emulated-pointer + const selfAvatarEntity = UUIDComponent.getEntityByUUID((Engine.instance.userID + '_avatar') as EntityUUID) //would prefer a better way to do this + const inputSourceEntity = isCameraAttachedToAvatar && isSpatialInput ? sourceEid : selfAvatarEntity + + // Skip Proximity Heuristic when the entity is undefined + if (inputSourceEntity === UndefinedEntity) return + + TransformComponent.getWorldPosition(inputSourceEntity, _worldPosInputSourceComponent) + + //TODO spatialInputObjects or inputObjects? - inputObjects requires visible and group components + for (const inputEntity of spatialInputObjectsQuery()) { + if (inputEntity === selfAvatarEntity) continue + const inputComponent = getComponent(inputEntity, InputComponent) + + TransformComponent.getWorldPosition(inputEntity, _worldPosInputComponent) + const distSquared = _worldPosInputSourceComponent.distanceToSquared(_worldPosInputComponent) + + //closer than our current closest AND within inputSource's activation distance + if (inputComponent.activationDistance * inputComponent.activationDistance > distSquared) { + //using this object type out of convenience (intersectionsData is also guaranteed empty in this flow) + intersectionData.add({ entity: inputEntity, distance: distSquared }) //keeping it as distSquared for now to avoid extra square root calls + } + } + + const closestEntities = Array.from(intersectionData) + if (closestEntities.length === 0) return + if (closestEntities.length > 1) { + //sort if more than 1 entry + closestEntities.sort((a, b) => { + //prioritize anything with an InteractableComponent if otherwise equal + const aNum = hasComponent(a.entity, InteractableComponent) ? -1 : 0 + const bNum = hasComponent(b.entity, InteractableComponent) ? -1 : 0 + //aNum - bNum : 0 if equal, -1 if a has tag and b doesn't, 1 if a doesnt have tag and b does + return Math.sign(a.distance - b.distance) + (aNum - bNum) + }) + } + sortedIntersections.push({ + entity: closestEntities[0].entity, + distance: Math.sqrt(closestEntities[0].distance) + }) +} + +/**Editor InputComponent raycast query */ +const inputObjectsQuery = defineQuery([InputComponent, VisibleComponent, GroupComponent]) + +/** @todo abstract into heuristic api */ +const gizmoPickerObjectsQuery = defineQuery([ + InputComponent, + GroupComponent, + VisibleComponent, + TransformGizmoTagComponent +]) + +export function findEditor(intersectionData: Set, caster: Raycaster) { + const pickerObj = gizmoPickerObjectsQuery() // gizmo heuristic + const inputObj = inputObjectsQuery() + + const objects = (pickerObj.length > 0 ? pickerObj : inputObj) // gizmo heuristic + .map((eid) => getComponent(eid, GroupComponent)) + .flat() + pickerObj.length > 0 + ? caster.layers.enable(ObjectLayers.TransformGizmo) + : caster.layers.disable(ObjectLayers.TransformGizmo) + const hits = caster.intersectObjects(objects, true) + for (const hit of hits) { + const parentObject = Object3DUtils.findAncestor(hit.object, (obj) => !obj.parent) + if (parentObject?.entity) { + intersectionData.add({ entity: parentObject.entity, distance: hit.distance }) + } + } +} + +const xruiQuery = defineQuery([VisibleComponent, XRUIComponent]) + +export function findXRUI(intersectionData: Set, ray: Ray) { + for (const entity of xruiQuery()) { + const xrui = getComponent(entity, XRUIComponent) + const layerHit = xrui.hitTest(ray) + if ( + !layerHit || + !layerHit.intersection.object.visible || + (layerHit.intersection.object as Mesh).material?.opacity < 0.01 + ) + continue + intersectionData.add({ entity, distance: layerHit.intersection.distance }) + } +} + +const sceneQuery = defineQuery([SceneComponent]) + +export function findPhysicsColliders(intersectionData: Set, raycast: RaycastArgs) { + for (const entity of sceneQuery()) { + const world = Physics.getWorld(entity) + if (!world) continue + + const hits = Physics.castRay(world, raycast) + for (const hit of hits) { + if (!hit.entity) continue + intersectionData.add({ entity: hit.entity, distance: hit.distance }) + } + } +} + +const boundingBoxesQuery = defineQuery([VisibleComponent, BoundingBoxComponent]) + +export function findBBoxes(intersectionData: Set, ray: Ray, hitTarget: Vector3) { + const inputState = getState(InputState) + for (const entity of inputState.inputBoundingBoxes) { + const boundingBox = getOptionalComponent(entity, BoundingBoxComponent) + if (!boundingBox) continue + const hit = ray.intersectBox(boundingBox.box, hitTarget) + if (hit) { + intersectionData.add({ entity, distance: ray.origin.distanceTo(hitTarget) }) + } + } +} + +const meshesQuery = defineQuery([VisibleComponent, MeshComponent]) + +export function findMeshes(intersectionData: Set, isEditing: boolean, caster: Raycaster) { + const inputState = getState(InputState) + const objects = (isEditing ? meshesQuery() : Array.from(inputState.inputMeshes)) // gizmo heuristic + .filter((eid) => hasComponent(eid, GroupComponent)) + .map((eid) => getComponent(eid, GroupComponent)) + .flat() + + const hits = caster.intersectObjects(objects, true) + for (const hit of hits) { + const parentObject = Object3DUtils.findAncestor(hit.object, (obj) => obj.entity != undefined) + if (parentObject) { + intersectionData.add({ entity: parentObject.entity, distance: hit.distance }) + } + } +} + +export function findRaycastedInput( + sourceEid: Entity, + intersectionData: Set, + data: HeuristicData, + heuristic: HeuristicFunctions +) { + const sourceRotation = TransformComponent.getWorldRotation(sourceEid, data.quaternion) + data.raycast.direction.copy(ObjectDirection.Forward).applyQuaternion(sourceRotation) + + TransformComponent.getWorldPosition(sourceEid, data.raycast.origin).addScaledVector(data.raycast.direction, -0.01) + data.ray.set(data.raycast.origin, data.raycast.direction) + data.caster.set(data.raycast.origin, data.raycast.direction) + data.caster.layers.enable(ObjectLayers.Scene) + + const isEditing = getState(EngineState).isEditing + // only heuristic is scene objects when in the editor + if (isEditing) { + heuristic.editor(intersectionData, data.caster) + } else { + // 1st heuristic is XRUI + heuristic.xrui(intersectionData, data.ray) + // 2nd heuristic is physics colliders + heuristic.physicsColliders(intersectionData, data.raycast) + + // 3rd heuristic is bboxes + heuristic.bboxes(intersectionData, data.ray, data.hitTarget) + } + // 4th heuristic is meshes + heuristic.meshes(intersectionData, isEditing, data.caster) +} + +export const ClientInputHeuristics = { + findProximity, + findEditor, + findXRUI, + findPhysicsColliders, + findBBoxes, + findMeshes, + findRaycastedInput +} +export default ClientInputHeuristics diff --git a/packages/spatial/src/input/functions/ClientInputHooks.tsx b/packages/spatial/src/input/functions/ClientInputHooks.tsx new file mode 100644 index 0000000000..fd4a683b58 --- /dev/null +++ b/packages/spatial/src/input/functions/ClientInputHooks.tsx @@ -0,0 +1,376 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +/** + * @fileoverview + * Contains declarations for the functions and hooks used by ClientInputSystem.reactor. + */ + +import { + createEntity, + Engine, + Entity, + getComponent, + getOptionalComponent, + removeEntity, + setComponent, + useEntityContext +} from '@etherealengine/ecs' +import { getState, useImmediateEffect, useMutableState } from '@etherealengine/hyperflux' +import { useEffect } from 'react' +import { Vector3 } from 'three' +import { NameComponent } from '../../common/NameComponent' +import { RendererComponent } from '../../renderer/WebGLRendererSystem' +import { TransformComponent } from '../../SpatialModule' +import { EntityTreeComponent, useAncestorWithComponent } from '../../transform/components/EntityTree' +import { XRState } from '../../xr/XRState' +import { DefaultButtonAlias, InputComponent } from '../components/InputComponent' +import { InputPointerComponent } from '../components/InputPointerComponent' +import { InputSourceComponent } from '../components/InputSourceComponent' +import { AnyButton, ButtonState, ButtonStateMap, createInitialButtonState, MouseButton } from '../state/ButtonState' +import { InputState } from '../state/InputState' +import ClientInputFunctions from './ClientInputFunctions' +import normalizeWheel from './normalizeWheel' + +export const useNonSpatialInputSources = () => { + useEffect(() => { + const eid = createEntity() + setComponent(eid, InputSourceComponent, {}) + setComponent(eid, NameComponent, 'InputSource-nonspatial') + const inputSourceComponent = getComponent(eid, InputSourceComponent) + + document.addEventListener('DOMMouseScroll', ClientInputFunctions.preventDefault, false) + document.addEventListener('gesturestart', ClientInputFunctions.preventDefault) + document.addEventListener('keydown', ClientInputFunctions.preventDefaultKeyDown, false) + + const onKeyEvent = (event: KeyboardEvent) => { + ClientInputFunctions.preventDefaultKeyDown(event) + const element = event.target as HTMLElement + // Сheck which excludes the possibility of controlling the avatar when typing in a text field + if (element?.tagName === 'INPUT' || element?.tagName === 'SELECT' || element?.tagName === 'TEXTAREA') return + + const code = event.code + const down = event.type === 'keydown' + + const buttonState = inputSourceComponent.buttons + if (down) buttonState[code] = createInitialButtonState(eid) + else if (buttonState[code]) buttonState[code].up = true + } + document.addEventListener('keyup', onKeyEvent) + document.addEventListener('keydown', onKeyEvent) + + const handleTouchDirectionalPad = (event: CustomEvent): void => { + const { stick, value }: { stick: 'LeftStick' | 'RightStick'; value: { x: number; y: number } } = event.detail + if (!stick) return + const index = stick === 'LeftStick' ? 0 : 2 + const axes = inputSourceComponent.source.gamepad!.axes as number[] + axes[index + 0] = value.x + axes[index + 1] = value.y + } + document.addEventListener('touchstickmove', handleTouchDirectionalPad) + + document.addEventListener('touchgamepadbuttondown', (event: CustomEvent) => { + const buttonState = inputSourceComponent.buttons + buttonState[event.detail.button] = createInitialButtonState(eid) + }) + + document.addEventListener('touchgamepadbuttonup', (event: CustomEvent) => { + const buttonState = inputSourceComponent.buttons + if (buttonState[event.detail.button]) buttonState[event.detail.button].up = true + }) + + return () => { + document.removeEventListener('DOMMouseScroll', ClientInputFunctions.preventDefault, false) + document.removeEventListener('gesturestart', ClientInputFunctions.preventDefault) + document.removeEventListener('keyup', onKeyEvent) + document.removeEventListener('keydown', onKeyEvent) + document.removeEventListener('touchstickmove', handleTouchDirectionalPad) + removeEntity(eid) + } + }, []) +} + +export const useGamepadInputSources = () => { + useEffect(() => { + const addGamepad = (e: GamepadEvent) => { + console.log('[ClientInputSystem] found gamepad', e.gamepad) + const eid = createEntity() + setComponent(eid, InputSourceComponent, { gamepad: e.gamepad }) + setComponent(eid, NameComponent, 'InputSource-gamepad-' + e.gamepad.id) + } + const removeGamepad = (e: GamepadEvent) => { + console.log('[ClientInputSystem] lost gamepad', e.gamepad) + NameComponent.entitiesByName['InputSource-gamepad-' + e.gamepad.id]?.forEach(removeEntity) + } + window.addEventListener('gamepadconnected', addGamepad) + window.addEventListener('gamepaddisconnected', removeGamepad) + return () => { + window.removeEventListener('gamepadconnected', addGamepad) + window.removeEventListener('gamepaddisconnected', removeGamepad) + } + }, []) +} + +export const useXRInputSources = () => { + const xrState = useMutableState(XRState) + + useEffect(() => { + const session = xrState.session.value + if (!session) return + + const addInputSource = (source: XRInputSource) => { + const eid = createEntity() + setComponent(eid, InputSourceComponent, { source }) + setComponent(eid, EntityTreeComponent, { + parentEntity: + source.targetRayMode === 'tracked-pointer' ? Engine.instance.localFloorEntity : Engine.instance.viewerEntity + }) + setComponent(eid, TransformComponent) + setComponent(eid, NameComponent, 'InputSource-handed:' + source.handedness + '-mode:' + source.targetRayMode) + } + + const removeInputSource = (source: XRInputSource) => { + const entity = InputSourceComponent.entitiesByInputSource.get(source) + if (entity) removeEntity(entity) + } + + if (session.inputSources) { + for (const inputSource of session.inputSources) addInputSource(inputSource) + } + + const onInputSourcesChanged = (event: XRInputSourceChangeEvent) => { + event.added.map(addInputSource) + event.removed.map(removeInputSource) + } + + const onXRSelectStart = (event: XRInputSourceEvent) => { + const eid = InputSourceComponent.entitiesByInputSource.get(event.inputSource) + if (!eid) return + const inputSourceComponent = getComponent(eid, InputSourceComponent) + if (!inputSourceComponent) return + const state = inputSourceComponent.buttons as ButtonStateMap + state.PrimaryClick = createInitialButtonState(eid) + } + const onXRSelectEnd = (event: XRInputSourceEvent) => { + const eid = InputSourceComponent.entitiesByInputSource.get(event.inputSource) + if (!eid) return + const inputSourceComponent = getComponent(eid, InputSourceComponent) + if (!inputSourceComponent) return + const state = inputSourceComponent.buttons as ButtonStateMap + if (!state.PrimaryClick) return + state.PrimaryClick.up = true + } + + session.addEventListener('inputsourceschange', onInputSourcesChanged) + session.addEventListener('selectstart', onXRSelectStart) + session.addEventListener('selectend', onXRSelectEnd) + + return () => { + session.removeEventListener('inputsourceschange', onInputSourcesChanged) + session.removeEventListener('selectstart', onXRSelectStart) + session.removeEventListener('selectend', onXRSelectEnd) + } + }, [xrState.session]) +} + +export const CanvasInputReactor = () => { + const cameraEntity = useEntityContext() + const xrState = useMutableState(XRState) + useEffect(() => { + if (xrState.session.value) return // pointer input sources are automatically handled by webxr + + const rendererComponent = getComponent(cameraEntity, RendererComponent) + const canvas = rendererComponent.canvas! + + /** Clear mouse events */ + const pointerButtons = ['PrimaryClick', 'AuxiliaryClick', 'SecondaryClick'] as AnyButton[] + const clearPointerState = (entity: Entity) => { + const inputSourceComponent = getComponent(entity, InputSourceComponent) + const state = inputSourceComponent.buttons + for (const button of pointerButtons) { + const val = state[button] as ButtonState + if (!val?.up && val?.pressed) (state[button] as ButtonState).up = true + } + } + + const onPointerEnter = (event: PointerEvent) => { + const pointerEntity = createEntity() + setComponent(pointerEntity, NameComponent, 'InputSource-emulated-pointer') + setComponent(pointerEntity, TransformComponent) + setComponent(pointerEntity, InputSourceComponent) + setComponent(pointerEntity, InputPointerComponent, { + pointerId: event.pointerId, + cameraEntity + }) + ClientInputFunctions.redirectPointerEventsToXRUI(cameraEntity, event) + } + + const onPointerOver = (event: PointerEvent) => { + ClientInputFunctions.redirectPointerEventsToXRUI(cameraEntity, event) + } + + const onPointerOut = (event: PointerEvent) => { + ClientInputFunctions.redirectPointerEventsToXRUI(cameraEntity, event) + } + + const onPointerLeave = (event: PointerEvent) => { + const pointerEntity = InputPointerComponent.getPointerByID(cameraEntity, event.pointerId) + ClientInputFunctions.redirectPointerEventsToXRUI(cameraEntity, event) + removeEntity(pointerEntity) + } + + const onPointerClick = (event: PointerEvent) => { + const pointerEntity = InputPointerComponent.getPointerByID(cameraEntity, event.pointerId) + const inputSourceComponent = getOptionalComponent(pointerEntity, InputSourceComponent) + if (!inputSourceComponent) return + + const down = event.type === 'pointerdown' + + let button = MouseButton.PrimaryClick + if (event.button === 1) button = MouseButton.AuxiliaryClick + else if (event.button === 2) button = MouseButton.SecondaryClick + + const state = inputSourceComponent.buttons as ButtonStateMap + if (down) { + state[button] = createInitialButtonState(pointerEntity) //down, pressed, touched = true + + const pointer = getOptionalComponent(pointerEntity, InputPointerComponent) + if (pointer) { + state[button]!.downPosition = new Vector3(pointer.position.x, pointer.position.y, 0) + //rotation will never be defined for the mouse or touch + } + } else if (state[button]) { + state[button]!.up = true + } + + ClientInputFunctions.redirectPointerEventsToXRUI(cameraEntity, event) + } + + const onPointerMove = (event: PointerEvent) => { + const pointerEntity = InputPointerComponent.getPointerByID(cameraEntity, event.pointerId) + const pointerComponent = getOptionalComponent(pointerEntity, InputPointerComponent) + if (!pointerComponent) return + + pointerComponent.position.set( + ((event.clientX - canvas.getBoundingClientRect().x) / canvas.clientWidth) * 2 - 1, + ((event.clientY - canvas.getBoundingClientRect().y) / canvas.clientHeight) * -2 + 1 + ) + + ClientInputFunctions.updatePointerDragging(pointerEntity, event) + ClientInputFunctions.redirectPointerEventsToXRUI(cameraEntity, event) + } + + const onVisibilityChange = (event: Event) => { + if ( + document.visibilityState === 'hidden' || + !canvas.checkVisibility({ + checkOpacity: true, + checkVisibilityCSS: true + }) + ) { + InputPointerComponent.getPointersForCamera(cameraEntity).forEach(clearPointerState) + } + } + + const onClick = (evt: PointerEvent) => { + ClientInputFunctions.redirectPointerEventsToXRUI(cameraEntity, evt) + } + + const onWheelEvent = (event: WheelEvent) => { + const pointer = InputPointerComponent.getPointersForCamera(cameraEntity)[0] + if (!pointer) return + const inputSourceComponent = getComponent(pointer, InputSourceComponent) + const normalizedValues = normalizeWheel(event) + const axes = inputSourceComponent.source.gamepad!.axes as number[] + axes[0] = normalizedValues.spinX + axes[1] = normalizedValues.spinY + } + + canvas.addEventListener('dragstart', ClientInputFunctions.preventDefault, false) + canvas.addEventListener('contextmenu', ClientInputFunctions.preventDefault) + canvas.addEventListener('pointerenter', onPointerEnter) + canvas.addEventListener('pointerover', onPointerOver) + canvas.addEventListener('pointerout', onPointerOut) + canvas.addEventListener('pointerleave', onPointerLeave) + canvas.addEventListener('pointermove', onPointerMove, { passive: true, capture: true }) + canvas.addEventListener('pointerup', onPointerClick) + canvas.addEventListener('pointerdown', onPointerClick) + canvas.addEventListener('blur', onVisibilityChange) + canvas.addEventListener('visibilitychange', onVisibilityChange) + canvas.addEventListener('click', onClick) + canvas.addEventListener('wheel', onWheelEvent, { passive: true, capture: true }) + + return () => { + canvas.removeEventListener('dragstart', ClientInputFunctions.preventDefault, false) + canvas.removeEventListener('contextmenu', ClientInputFunctions.preventDefault) + canvas.removeEventListener('pointerenter', onPointerEnter) + canvas.removeEventListener('pointerover', onPointerOver) + canvas.removeEventListener('pointerout', onPointerOut) + canvas.removeEventListener('pointerleave', onPointerLeave) + canvas.removeEventListener('pointermove', onPointerMove) + canvas.removeEventListener('pointerup', onPointerClick) + canvas.removeEventListener('pointerdown', onPointerClick) + canvas.removeEventListener('blur', onVisibilityChange) + canvas.removeEventListener('visibilitychange', onVisibilityChange) + canvas.removeEventListener('click', onClick) + canvas.removeEventListener('wheel', onWheelEvent) + } + }, [xrState.session]) + + return null +} + +export const MeshInputReactor = () => { + const entity = useEntityContext() + const shouldReceiveInput = !!useAncestorWithComponent(entity, InputComponent) + + useImmediateEffect(() => { + const inputState = getState(InputState) + if (shouldReceiveInput) inputState.inputMeshes.add(entity) + else inputState.inputMeshes.delete(entity) + }, [shouldReceiveInput]) + return null +} + +export const BoundingBoxInputReactor = () => { + const entity = useEntityContext() + const shouldReceiveInput = !!useAncestorWithComponent(entity, InputComponent) + useImmediateEffect(() => { + const inputState = getState(InputState) + if (shouldReceiveInput) inputState.inputBoundingBoxes.add(entity) + else inputState.inputBoundingBoxes.delete(entity) + }, [shouldReceiveInput]) + return null +} + +export const ClientInputHooks = { + useNonSpatialInputSources, + useGamepadInputSources, + useXRInputSources, + CanvasInputReactor, + MeshInputReactor, + BoundingBoxInputReactor +} +export default ClientInputHooks diff --git a/packages/spatial/src/input/systems/ClientInputSystem.tsx b/packages/spatial/src/input/systems/ClientInputSystem.tsx old mode 100755 new mode 100644 index 80cec9d226..91da80e552 --- a/packages/spatial/src/input/systems/ClientInputSystem.tsx +++ b/packages/spatial/src/input/systems/ClientInputSystem.tsx @@ -24,185 +24,49 @@ Ethereal Engine. All Rights Reserved. */ import { Not } from 'bitecs' -import React, { useEffect } from 'react' -import { Mesh, MeshBasicMaterial, Object3D, Quaternion, Ray, Raycaster, Vector3 } from 'three' +import React from 'react' +import { Quaternion, Ray, Raycaster, Vector3 } from 'three' import { isClient } from '@etherealengine/common/src/utils/getEnvironment' -import { Object3DUtils } from '@etherealengine/common/src/utils/Object3DUtils' -import { - getComponent, - getMutableComponent, - getOptionalComponent, - hasComponent, - setComponent -} from '@etherealengine/ecs/src/ComponentFunctions' -import { Engine } from '@etherealengine/ecs/src/Engine' -import { Entity, EntityUUID, UndefinedEntity } from '@etherealengine/ecs/src/Entity' -import { createEntity, removeEntity, useEntityContext } from '@etherealengine/ecs/src/EntityFunctions' -import { defineQuery, QueryReactor } from '@etherealengine/ecs/src/QueryFunctions' +import { getComponent, getMutableComponent, hasComponent } from '@etherealengine/ecs/src/ComponentFunctions' +import { UndefinedEntity } from '@etherealengine/ecs/src/Entity' +import { QueryReactor, defineQuery } from '@etherealengine/ecs/src/QueryFunctions' import { defineSystem } from '@etherealengine/ecs/src/SystemFunctions' import { InputSystemGroup, PresentationSystemGroup } from '@etherealengine/ecs/src/SystemGroups' -import { getMutableState, getState, useImmediateEffect, useMutableState } from '@etherealengine/hyperflux' -import { EngineState } from '@etherealengine/spatial/src/EngineState' -import { - EntityTreeComponent, - getAncestorWithComponent, - useAncestorWithComponent -} from '@etherealengine/spatial/src/transform/components/EntityTree' +import { getMutableState, getState } from '@etherealengine/hyperflux' -import { UUIDComponent } from '@etherealengine/ecs' -import { InteractableComponent } from '@etherealengine/engine/src/interaction/components/InteractableComponent' import { CameraComponent } from '../../camera/components/CameraComponent' -import { ObjectDirection, PI, Q_IDENTITY, Vector3_Zero } from '../../common/constants/MathConstants' -import { NameComponent } from '../../common/NameComponent' -import { Physics, RaycastArgs } from '../../physics/classes/Physics' +import { ObjectDirection } from '../../common/constants/MathConstants' +import { RaycastArgs } from '../../physics/classes/Physics' import { CollisionGroups } from '../../physics/enums/CollisionGroups' import { getInteractionGroups } from '../../physics/functions/getInteractionGroups' import { SceneQueryType } from '../../physics/types/PhysicsTypes' -import { GroupComponent } from '../../renderer/components/GroupComponent' +import { RendererComponent } from '../../renderer/WebGLRendererSystem' import { MeshComponent } from '../../renderer/components/MeshComponent' -import { SceneComponent } from '../../renderer/components/SceneComponents' import { VisibleComponent } from '../../renderer/components/VisibleComponent' -import { ObjectLayers } from '../../renderer/constants/ObjectLayers' -import { RendererComponent } from '../../renderer/WebGLRendererSystem' import { BoundingBoxComponent } from '../../transform/components/BoundingBoxComponents' -import { TransformComponent, TransformGizmoTagComponent } from '../../transform/components/TransformComponent' +import { TransformComponent } from '../../transform/components/TransformComponent' import { XRSpaceComponent } from '../../xr/XRComponents' -import { XRScenePlacementComponent } from '../../xr/XRScenePlacementComponent' import { XRState } from '../../xr/XRState' import { XRUIComponent } from '../../xrui/components/XRUIComponent' -import { DefaultButtonAlias, InputComponent } from '../components/InputComponent' +import { InputComponent } from '../components/InputComponent' import { InputPointerComponent } from '../components/InputPointerComponent' import { InputSourceComponent } from '../components/InputSourceComponent' -import normalizeWheel from '../functions/normalizeWheel' -import { AnyButton, ButtonState, ButtonStateMap, createInitialButtonState, MouseButton } from '../state/ButtonState' +import ClientInputFunctions from '../functions/ClientInputFunctions' +import ClientInputHeuristics, { HeuristicData, HeuristicFunctions } from '../functions/ClientInputHeuristics' +import ClientInputHooks from '../functions/ClientInputHooks' +import { ButtonState, ButtonStateMap } from '../state/ButtonState' import { InputState } from '../state/InputState' -/** squared distance threshold for dragging state */ -const DRAGGING_THRESHOLD = 0.001 - -/** radian threshold for rotating state*/ -const ROTATING_THRESHOLD = 1.5 * (PI / 180) - -/** anti-garbage variable!! value not to be used unless you set values just before use*/ -const pointerPositionVector3 = new Vector3() - -function preventDefault(e) { - e.preventDefault() -} - -const preventDefaultKeyDown = (evt) => { - if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return - if (evt.code === 'Tab') evt.preventDefault() - // prevent DOM tab selection and spacebar/enter button toggling (since it interferes with avatar controls) - if (evt.code === 'Space' || evt.code === 'Enter') evt.preventDefault() -} - -export function updateGamepadInput(eid: Entity) { - const inputSource = getComponent(eid, InputSourceComponent) - const gamepad = inputSource.source.gamepad - const buttons = inputSource.buttons - // const buttonDownPos = inputSource.buttonDownPositions as WeakMap - // log buttons - // if (source.gamepad) { - // for (let i = 0; i < source.gamepad.buttons.length; i++) { - // const button = source.gamepad.buttons[i] - // if (button.pressed) console.log('button ' + i + ' pressed: ' + button.pressed) - // } - // } - - if (!gamepad) return - const gamepadButtons = gamepad.buttons - if (gamepadButtons.length) { - const pointer = getOptionalComponent(eid, InputPointerComponent) - const xrTransform = getOptionalComponent(eid, TransformComponent) - - for (let i = 0; i < gamepadButtons.length; i++) { - const gamepadButton = gamepadButtons[i] - if (!buttons[i] && (gamepadButton.pressed || gamepadButton.touched)) { - buttons[i] = createInitialButtonState(eid, gamepadButton) - } - const buttonState = buttons[i] as ButtonState - if (buttonState && (gamepadButton.pressed || gamepadButton.touched)) { - if (!buttonState.pressed && gamepadButton.pressed) { - buttonState.down = true - buttonState.downPosition = new Vector3() - buttonState.downRotation = new Quaternion() - - if (pointer) { - buttonState.downPosition.set(pointer.position.x, pointer.position.y, 0) - //TODO maybe map pointer rotation/swing/twist to downRotation here once we map the pointer events to that (think Apple pencil) - } else if (hasComponent(eid, XRSpaceComponent) && xrTransform) { - buttonState.downPosition.copy(xrTransform.position) - buttonState.downRotation.copy(xrTransform.rotation) - } - } - buttonState.pressed = gamepadButton.pressed - buttonState.touched = gamepadButton.touched - buttonState.value = gamepadButton.value - - if (buttonState.downPosition) { - //if not yet dragging, compare distance to drag threshold and begin if appropriate - if (!buttonState.dragging) { - if (pointer) pointerPositionVector3.set(pointer.position.x, pointer.position.y, 0) - const squaredDistance = buttonState.downPosition.distanceToSquared( - pointer ? pointerPositionVector3 : xrTransform?.position ?? Vector3_Zero - ) - - if (squaredDistance > DRAGGING_THRESHOLD) { - buttonState.dragging = true - } - } - - //if not yet rotating, compare distance to drag threshold and begin if appropriate - if (!buttonState.rotating) { - const angleRadians = buttonState.downRotation!.angleTo( - pointer ? Q_IDENTITY : xrTransform?.rotation ?? Q_IDENTITY - ) - if (angleRadians > ROTATING_THRESHOLD) { - buttonState.rotating = true - } - } - } - } else if (buttonState) { - buttonState.up = true - } - } - } -} - -const pointers = defineQuery([InputPointerComponent, InputSourceComponent, Not(XRSpaceComponent)]) -const xrSpaces = defineQuery([XRSpaceComponent, TransformComponent]) -const spatialInputSourceQuery = defineQuery([InputSourceComponent, TransformComponent]) +const pointersQuery = defineQuery([InputPointerComponent, InputSourceComponent, Not(XRSpaceComponent)]) +const xrSpacesQuery = defineQuery([XRSpaceComponent, TransformComponent]) const inputSourceQuery = defineQuery([InputSourceComponent]) -const nonSpatialInputSourceQuery = defineQuery([InputSourceComponent, Not(TransformComponent)]) -const inputs = defineQuery([InputComponent]) - -const worldPosInputSourceComponent = new Vector3() -const worldPosInputComponent = new Vector3() - +const inputsQuery = defineQuery([InputComponent]) const xruiQuery = defineQuery([VisibleComponent, XRUIComponent]) -const boundingBoxesQuery = defineQuery([VisibleComponent, BoundingBoxComponent]) - -const meshesQuery = defineQuery([VisibleComponent, MeshComponent]) -const sceneQuery = defineQuery([SceneComponent]) - -/**Editor InputComponent raycast query */ -const inputObjects = defineQuery([InputComponent, VisibleComponent, GroupComponent]) -/**Proximity query */ -const spatialInputObjects = defineQuery([ - InputComponent, - VisibleComponent, - TransformComponent, - Not(CameraComponent), - Not(XRScenePlacementComponent) -]) -/** @todo abstract into heuristic api */ -const gizmoPickerObjects = defineQuery([InputComponent, GroupComponent, VisibleComponent, TransformGizmoTagComponent]) -const rayRotation = new Quaternion() +const _rayRotation = new Quaternion() -const inputRaycast = { +const _inputRaycast = { type: SceneQueryType.Closest, origin: new Vector3(), direction: new Vector3(), @@ -210,48 +74,64 @@ const inputRaycast = { groups: getInteractionGroups(CollisionGroups.Default, CollisionGroups.Default), excludeRigidBody: undefined // } as RaycastArgs - -const inputRay = new Ray() -const raycaster = new Raycaster() -const bboxHitTarget = new Vector3() - -const quat = new Quaternion() +const _quat = new Quaternion() +const _inputRay = new Ray() +const _raycaster = new Raycaster() +const _bboxHitTarget = new Vector3() + +const _heuristicData = { + quaternion: _quat, + ray: _inputRay, + raycast: _inputRaycast, + caster: _raycaster, + hitTarget: _bboxHitTarget +} as HeuristicData + +const _heuristicFunctions = { + editor: ClientInputHeuristics.findEditor, + xrui: ClientInputHeuristics.findXRUI, + physicsColliders: ClientInputHeuristics.findPhysicsColliders, + bboxes: ClientInputHeuristics.findBBoxes, + meshes: ClientInputHeuristics.findMeshes, + proximity: ClientInputHeuristics.findProximity, + raycastedInput: ClientInputHeuristics.findRaycastedInput +} as HeuristicFunctions const execute = () => { const capturedEntity = getMutableState(InputState).capturingEntity.value InputState.setCapturingEntity(UndefinedEntity, true) - for (const eid of inputs()) - if (getComponent(eid, InputComponent).inputSources.length) - getMutableComponent(eid, InputComponent).inputSources.set([]) + for (const eid of inputsQuery()) { + if (!getComponent(eid, InputComponent).inputSources.length) continue + getMutableComponent(eid, InputComponent).inputSources.set([]) + } // update 2D screen-based (driven by pointer api) input sources - for (const eid of pointers()) { + for (const eid of pointersQuery()) { const pointer = getComponent(eid, InputPointerComponent) const inputSource = getComponent(eid, InputSourceComponent) - const camera = getOptionalComponent(pointer.cameraEntity, CameraComponent) - if (!camera) continue //when we reparent viewport we lose the camera temporarily + const camera = getComponent(pointer.cameraEntity, CameraComponent) pointer.movement.copy(pointer.position).sub(pointer.lastPosition) pointer.lastPosition.copy(pointer.position) inputSource.raycaster.setFromCamera(pointer.position, camera) TransformComponent.position.x[eid] = inputSource.raycaster.ray.origin.x TransformComponent.position.y[eid] = inputSource.raycaster.ray.origin.y TransformComponent.position.z[eid] = inputSource.raycaster.ray.origin.z - rayRotation.setFromUnitVectors(ObjectDirection.Forward, inputSource.raycaster.ray.direction) - TransformComponent.rotation.x[eid] = rayRotation.x - TransformComponent.rotation.y[eid] = rayRotation.y - TransformComponent.rotation.z[eid] = rayRotation.z - TransformComponent.rotation.w[eid] = rayRotation.w + _rayRotation.setFromUnitVectors(ObjectDirection.Forward, inputSource.raycaster.ray.direction) + TransformComponent.rotation.x[eid] = _rayRotation.x + TransformComponent.rotation.y[eid] = _rayRotation.y + TransformComponent.rotation.z[eid] = _rayRotation.z + TransformComponent.rotation.w[eid] = _rayRotation.w TransformComponent.dirtyTransforms[eid] = true } // update xr input sources const xrFrame = getState(XRState).xrFrame - for (const eid of xrSpaces()) { + for (const eid of xrSpacesQuery()) { const space = getComponent(eid, XRSpaceComponent) const pose = xrFrame?.getPose(space.space, space.baseSpace) - if (!pose) continue // @note Clause Guard. This was nested as if (pose) { ... } + if (!pose) continue TransformComponent.position.x[eid] = pose.transform.position.x TransformComponent.position.y[eid] = pose.transform.position.y TransformComponent.position.z[eid] = pose.transform.position.z @@ -269,356 +149,30 @@ const execute = () => { // assign input sources (InputSourceComponent) to input sinks (InputComponent), foreach on InputSourceComponents for (const sourceEid of inputSourceQuery()) { - // @note This function was a ~200 sloc block nested inside this `for` block, - // which also contained two other sub-nested blocks of 100 and 50 sloc each - assignInputSources(sourceEid, capturedEntity) + ClientInputFunctions.assignInputSources(sourceEid, capturedEntity, _heuristicData, _heuristicFunctions) } for (const sourceEid of inputSourceQuery()) { - updateGamepadInput(sourceEid) + ClientInputFunctions.updateGamepadInput(sourceEid) } } -const setInputSources = (startEntity: Entity, inputSources: Entity[]) => { - const inputEntity = getAncestorWithComponent(startEntity, InputComponent) - if (inputEntity) { - const inputComponent = getComponent(inputEntity, InputComponent) - - for (const sinkEntityUUID of inputComponent.inputSinks) { - const sinkEntity = sinkEntityUUID === 'Self' ? inputEntity : UUIDComponent.getEntityByUUID(sinkEntityUUID) //TODO why is this not sending input to my sinks - const sinkInputComponent = getMutableComponent(sinkEntity, InputComponent) - sinkInputComponent.inputSources.merge(inputSources) - } - } -} - -const useNonSpatialInputSources = () => { - useEffect(() => { - const eid = createEntity() - setComponent(eid, InputSourceComponent, {}) - setComponent(eid, NameComponent, 'InputSource-nonspatial') - const inputSourceComponent = getComponent(eid, InputSourceComponent) - - document.addEventListener('DOMMouseScroll', preventDefault, false) - document.addEventListener('gesturestart', preventDefault) - document.addEventListener('keydown', preventDefaultKeyDown, false) - - const onKeyEvent = (event: KeyboardEvent) => { - preventDefaultKeyDown(event) - const element = event.target as HTMLElement - // Сheck which excludes the possibility of controlling the avatar when typing in a text field - if (element?.tagName === 'INPUT' || element?.tagName === 'SELECT' || element?.tagName === 'TEXTAREA') return - - const code = event.code - const down = event.type === 'keydown' - - const buttonState = inputSourceComponent.buttons - if (down) buttonState[code] = createInitialButtonState(eid) - else if (buttonState[code]) buttonState[code].up = true - } - document.addEventListener('keyup', onKeyEvent) - document.addEventListener('keydown', onKeyEvent) - - const handleTouchDirectionalPad = (event: CustomEvent): void => { - const { stick, value }: { stick: 'LeftStick' | 'RightStick'; value: { x: number; y: number } } = event.detail - if (!stick) return - const index = stick === 'LeftStick' ? 0 : 2 - const axes = inputSourceComponent.source.gamepad!.axes as number[] - axes[index + 0] = value.x - axes[index + 1] = value.y - } - document.addEventListener('touchstickmove', handleTouchDirectionalPad) - - document.addEventListener('touchgamepadbuttondown', (event: CustomEvent) => { - const buttonState = inputSourceComponent.buttons - buttonState[event.detail.button] = createInitialButtonState(eid) - }) - - document.addEventListener('touchgamepadbuttonup', (event: CustomEvent) => { - const buttonState = inputSourceComponent.buttons - if (buttonState[event.detail.button]) buttonState[event.detail.button].up = true - }) - - return () => { - document.removeEventListener('DOMMouseScroll', preventDefault, false) - document.removeEventListener('gesturestart', preventDefault) - document.removeEventListener('keyup', onKeyEvent) - document.removeEventListener('keydown', onKeyEvent) - document.removeEventListener('touchstickmove', handleTouchDirectionalPad) - removeEntity(eid) - } - }, []) -} - -const useGamepadInputSources = () => { - useEffect(() => { - const addGamepad = (e: GamepadEvent) => { - console.log('[ClientInputSystem] found gamepad', e.gamepad) - const eid = createEntity() - setComponent(eid, InputSourceComponent, { gamepad: e.gamepad }) - setComponent(eid, NameComponent, 'InputSource-gamepad-' + e.gamepad.id) - } - const removeGamepad = (e: GamepadEvent) => { - console.log('[ClientInputSystem] lost gamepad', e.gamepad) - NameComponent.entitiesByName['InputSource-gamepad-' + e.gamepad.id]?.forEach(removeEntity) - } - window.addEventListener('gamepadconnected', addGamepad) - window.addEventListener('gamepaddisconnected', removeGamepad) - return () => { - window.removeEventListener('gamepadconnected', addGamepad) - window.removeEventListener('gamepaddisconnected', removeGamepad) - } - }, []) -} - -const CanvasInputReactor = () => { - const cameraEntity = useEntityContext() - const xrState = useMutableState(XRState) - useEffect(() => { - if (xrState.session.value) return // pointer input sources are automatically handled by webxr - - const rendererComponent = getComponent(cameraEntity, RendererComponent) - const canvas = rendererComponent.canvas! - - /** Clear mouse events */ - const pointerButtons = ['PrimaryClick', 'AuxiliaryClick', 'SecondaryClick'] as AnyButton[] - const clearPointerState = (entity: Entity) => { - const inputSourceComponent = getComponent(entity, InputSourceComponent) - const state = inputSourceComponent.buttons - for (const button of pointerButtons) { - const val = state[button] as ButtonState - if (!val?.up && val?.pressed) (state[button] as ButtonState).up = true - } - } - - const onPointerEnter = (event: PointerEvent) => { - const pointerEntity = createEntity() - setComponent(pointerEntity, NameComponent, 'InputSource-emulated-pointer') - setComponent(pointerEntity, TransformComponent) - setComponent(pointerEntity, InputSourceComponent) - setComponent(pointerEntity, InputPointerComponent, { - pointerId: event.pointerId, - cameraEntity - }) - redirectPointerEventsToXRUI(cameraEntity, event) - } - - const onPointerOver = (event: PointerEvent) => { - redirectPointerEventsToXRUI(cameraEntity, event) - } - - const onPointerOut = (event: PointerEvent) => { - redirectPointerEventsToXRUI(cameraEntity, event) - } - - const onPointerLeave = (event: PointerEvent) => { - const pointerEntity = InputPointerComponent.getPointerByID(cameraEntity, event.pointerId) - redirectPointerEventsToXRUI(cameraEntity, event) - removeEntity(pointerEntity) - } - - const onPointerClick = (event: PointerEvent) => { - const pointerEntity = InputPointerComponent.getPointerByID(cameraEntity, event.pointerId) - const inputSourceComponent = getOptionalComponent(pointerEntity, InputSourceComponent) - if (!inputSourceComponent) return - - const down = event.type === 'pointerdown' - - let button = MouseButton.PrimaryClick - if (event.button === 1) button = MouseButton.AuxiliaryClick - else if (event.button === 2) button = MouseButton.SecondaryClick - - const state = inputSourceComponent.buttons as ButtonStateMap - if (down) { - state[button] = createInitialButtonState(pointerEntity) //down, pressed, touched = true - - const pointer = getOptionalComponent(pointerEntity, InputPointerComponent) - if (pointer) { - state[button]!.downPosition = new Vector3(pointer.position.x, pointer.position.y, 0) - //rotation will never be defined for the mouse or touch - } - } else if (state[button]) { - state[button]!.up = true - } - - redirectPointerEventsToXRUI(cameraEntity, event) - } - - const onPointerMove = (event: PointerEvent) => { - const pointerEntity = InputPointerComponent.getPointerByID(cameraEntity, event.pointerId) - const pointerComponent = getOptionalComponent(pointerEntity, InputPointerComponent) - if (!pointerComponent) return - - pointerComponent.position.set( - ((event.clientX - canvas.getBoundingClientRect().x) / canvas.clientWidth) * 2 - 1, - ((event.clientY - canvas.getBoundingClientRect().y) / canvas.clientHeight) * -2 + 1 - ) - - updatePointerDragging(pointerEntity, event) - redirectPointerEventsToXRUI(cameraEntity, event) - } - - const onVisibilityChange = (event: Event) => { - if ( - document.visibilityState === 'hidden' || - !canvas.checkVisibility({ - checkOpacity: true, - checkVisibilityCSS: true - }) - ) { - InputPointerComponent.getPointersForCamera(cameraEntity).forEach(clearPointerState) - } - } - - const onClick = (evt: PointerEvent) => { - redirectPointerEventsToXRUI(cameraEntity, evt) - } - - const onWheelEvent = (event: WheelEvent) => { - const pointer = InputPointerComponent.getPointersForCamera(cameraEntity)[0] - if (!pointer) return - const inputSourceComponent = getComponent(pointer, InputSourceComponent) - const normalizedValues = normalizeWheel(event) - const axes = inputSourceComponent.source.gamepad!.axes as number[] - axes[0] = normalizedValues.spinX - axes[1] = normalizedValues.spinY - } - - canvas.addEventListener('dragstart', preventDefault, false) - canvas.addEventListener('contextmenu', preventDefault) - canvas.addEventListener('pointerenter', onPointerEnter) - canvas.addEventListener('pointerover', onPointerOver) - canvas.addEventListener('pointerout', onPointerOut) - canvas.addEventListener('pointerleave', onPointerLeave) - canvas.addEventListener('pointermove', onPointerMove, { passive: true, capture: true }) - canvas.addEventListener('pointerup', onPointerClick) - canvas.addEventListener('pointerdown', onPointerClick) - canvas.addEventListener('blur', onVisibilityChange) - canvas.addEventListener('visibilitychange', onVisibilityChange) - canvas.addEventListener('click', onClick) - canvas.addEventListener('wheel', onWheelEvent, { passive: true, capture: true }) - - return () => { - canvas.removeEventListener('dragstart', preventDefault, false) - canvas.removeEventListener('contextmenu', preventDefault) - canvas.removeEventListener('pointerenter', onPointerEnter) - canvas.removeEventListener('pointerover', onPointerOver) - canvas.removeEventListener('pointerout', onPointerOut) - canvas.removeEventListener('pointerleave', onPointerLeave) - canvas.removeEventListener('pointermove', onPointerMove) - canvas.removeEventListener('pointerup', onPointerClick) - canvas.removeEventListener('pointerdown', onPointerClick) - canvas.removeEventListener('blur', onVisibilityChange) - canvas.removeEventListener('visibilitychange', onVisibilityChange) - canvas.removeEventListener('click', onClick) - canvas.removeEventListener('wheel', onWheelEvent) - } - }, [xrState.session]) - - return null -} - -const useXRInputSources = () => { - const xrState = useMutableState(XRState) - - useEffect(() => { - const session = xrState.session.value - if (!session) return - - const addInputSource = (source: XRInputSource) => { - const eid = createEntity() - setComponent(eid, InputSourceComponent, { source }) - setComponent(eid, EntityTreeComponent, { - parentEntity: - source.targetRayMode === 'tracked-pointer' ? Engine.instance.localFloorEntity : Engine.instance.viewerEntity - }) - setComponent(eid, TransformComponent) - setComponent(eid, NameComponent, 'InputSource-handed:' + source.handedness + '-mode:' + source.targetRayMode) - } - - const removeInputSource = (source: XRInputSource) => { - const entity = InputSourceComponent.entitiesByInputSource.get(source) - if (entity) removeEntity(entity) - } - - if (session.inputSources) { - for (const inputSource of session.inputSources) addInputSource(inputSource) - } - - const onInputSourcesChanged = (event: XRInputSourceChangeEvent) => { - event.added.map(addInputSource) - event.removed.map(removeInputSource) - } - - const onXRSelectStart = (event: XRInputSourceEvent) => { - const eid = InputSourceComponent.entitiesByInputSource.get(event.inputSource) - if (!eid) return - const inputSourceComponent = getComponent(eid, InputSourceComponent) - if (!inputSourceComponent) return - const state = inputSourceComponent.buttons as ButtonStateMap - state.PrimaryClick = createInitialButtonState(eid) - } - const onXRSelectEnd = (event: XRInputSourceEvent) => { - const eid = InputSourceComponent.entitiesByInputSource.get(event.inputSource) - if (!eid) return - const inputSourceComponent = getComponent(eid, InputSourceComponent) - if (!inputSourceComponent) return - const state = inputSourceComponent.buttons as ButtonStateMap - if (!state.PrimaryClick) return - state.PrimaryClick.up = true - } - - session.addEventListener('inputsourceschange', onInputSourcesChanged) - session.addEventListener('selectstart', onXRSelectStart) - session.addEventListener('selectend', onXRSelectEnd) - - return () => { - session.removeEventListener('inputsourceschange', onInputSourcesChanged) - session.removeEventListener('selectstart', onXRSelectStart) - session.removeEventListener('selectend', onXRSelectEnd) - } - }, [xrState.session]) -} - const reactor = () => { if (!isClient) return null - useNonSpatialInputSources() - useGamepadInputSources() - useXRInputSources() + ClientInputHooks.useNonSpatialInputSources() + ClientInputHooks.useGamepadInputSources() + ClientInputHooks.useXRInputSources() return ( <> - - - + + + ) } -const MeshInputReactor = () => { - const entity = useEntityContext() - const shouldReceiveInput = !!useAncestorWithComponent(entity, InputComponent) - - useImmediateEffect(() => { - const inputState = getState(InputState) - if (shouldReceiveInput) inputState.inputMeshes.add(entity) - else inputState.inputMeshes.delete(entity) - }, [shouldReceiveInput]) - return null -} - -const BoundingBoxInputReactor = () => { - const entity = useEntityContext() - const shouldReceiveInput = !!useAncestorWithComponent(entity, InputComponent) - useImmediateEffect(() => { - const inputState = getState(InputState) - if (shouldReceiveInput) inputState.inputBoundingBoxes.add(entity) - else inputState.inputBoundingBoxes.delete(entity) - }, [shouldReceiveInput]) - return null -} - export const ClientInputSystem = defineSystem({ uuid: 'ee.engine.input.ClientInputSystem', insert: { before: InputSystemGroup }, @@ -626,37 +180,6 @@ export const ClientInputSystem = defineSystem({ reactor }) -function updatePointerDragging(pointerEntity: Entity, event: PointerEvent) { - const inputSourceComponent = getOptionalComponent(pointerEntity, InputSourceComponent) - if (!inputSourceComponent) return - - const state = inputSourceComponent.buttons as ButtonStateMap - - let button = MouseButton.PrimaryClick - if (event.type === 'pointermove') { - if ((event as MouseEvent).button === 1) button = MouseButton.AuxiliaryClick - else if ((event as MouseEvent).button === 2) button = MouseButton.SecondaryClick - } - const btn = state[button] - if (btn && !btn.dragging) { - const pointer = getOptionalComponent(pointerEntity, InputPointerComponent) - - if (btn.pressed && btn.downPosition) { - //if not yet dragging, compare distance to drag threshold and begin if appropriate - if (!btn.dragging) { - pointer - ? pointerPositionVector3.set(pointer.position.x, pointer.position.y, 0) - : pointerPositionVector3.copy(Vector3_Zero) - const squaredDistance = btn.downPosition.distanceToSquared(pointerPositionVector3) - - if (squaredDistance > DRAGGING_THRESHOLD) { - btn.dragging = true - } - } - } - } -} - function cleanupButton( key: string, buttons: ButtonStateMap>>, @@ -691,248 +214,3 @@ export const ClientInputCleanupSystem = defineSystem({ insert: { after: PresentationSystemGroup }, execute: cleanupInputs }) - -const redirectPointerEventsToXRUI = (cameraEntity: Entity, evt: PointerEvent) => { - const pointerEntity = InputPointerComponent.getPointerByID(cameraEntity, evt.pointerId) - const inputSource = getOptionalComponent(pointerEntity, InputSourceComponent) - if (!inputSource) return - for (const i of inputSource.intersections) { - const entity = i.entity - const xrui = getOptionalComponent(entity, XRUIComponent) - if (!xrui) continue - xrui.updateWorldMatrix(true, true) - const raycaster = inputSource.raycaster - const hit = xrui.hitTest(raycaster.ray) - if (hit && hit.intersection.object.visible) { - hit.target.dispatchEvent(new (evt.constructor as any)(evt.type, evt)) - hit.target.focus() - return - } - } -} - -type IntersectionData = { - entity: Entity - distance: number -} - -function applyRaycastedInputHeuristics(sourceEid: Entity, intersectionData: Set) { - const sourceRotation = TransformComponent.getWorldRotation(sourceEid, quat) - inputRaycast.direction.copy(ObjectDirection.Forward).applyQuaternion(sourceRotation) - - TransformComponent.getWorldPosition(sourceEid, inputRaycast.origin).addScaledVector(inputRaycast.direction, -0.01) - inputRay.set(inputRaycast.origin, inputRaycast.direction) - raycaster.set(inputRaycast.origin, inputRaycast.direction) - raycaster.layers.enable(ObjectLayers.Scene) - - const isEditing = getState(EngineState).isEditing - // only heuristic is scene objects when in the editor - if (isEditing) { - applyHeuristicEditor(intersectionData) - } else { - // 1st heuristic is XRUI - applyHeuristicXRUI(intersectionData) - // 2nd heuristic is physics colliders - applyHeuristicPhysicsColliders(intersectionData) - - // 3rd heuristic is bboxes - applyHeuristicBBoxes(intersectionData) - } - // 4th heuristic is meshes - applyHeuristicMeshes(intersectionData, isEditing) -} - -function assignInputSources(sourceEid: Entity, capturedEntity: Entity) { - const isSpatialInput = hasComponent(sourceEid, TransformComponent) - - const intersectionData = new Set([] as IntersectionData[]) - - // @note This function was a ~100 sloc block nested inside this if block - if (isSpatialInput) applyRaycastedInputHeuristics(sourceEid, intersectionData) - - const sortedIntersections = Array.from(intersectionData).sort((a, b) => { - // - if a < b - // + if a > b - // 0 if equal - const aNum = hasComponent(a.entity, TransformGizmoTagComponent) ? -1 : 0 - const bNum = hasComponent(b.entity, TransformGizmoTagComponent) ? -1 : 0 - //aNum - bNum : 0 if equal, -1 if a has tag and b doesn't, 1 if a doesnt have tag and b does - return Math.sign(a.distance - b.distance) + (aNum - bNum) - }) - const sourceState = getMutableComponent(sourceEid, InputSourceComponent) - - //TODO check all inputSources sorted by distance list of InputComponents from query, probably similar to the spatialInputQuery - //Proximity check ONLY if we have no raycast results, as it is always lower priority - if ( - capturedEntity === UndefinedEntity && - sortedIntersections.length === 0 && - !hasComponent(sourceEid, InputPointerComponent) - ) { - // @note This function was a ~50sloc block nested inside this if block - applyHeuristicProximity(isSpatialInput, sourceEid, sortedIntersections, intersectionData) - } - - const inputPointerComponent = getOptionalComponent(sourceEid, InputPointerComponent) - if (inputPointerComponent) { - sortedIntersections.push({ entity: inputPointerComponent.cameraEntity, distance: 0 }) - } - - sourceState.intersections.set(sortedIntersections) - - const finalInputSources = Array.from(new Set([sourceEid, ...nonSpatialInputSourceQuery()])) - - //if we have a capturedEntity, only run on the capturedEntity, not the sortedIntersections - if (capturedEntity !== UndefinedEntity) { - setInputSources(capturedEntity, finalInputSources) - } else { - for (const intersection of sortedIntersections) { - setInputSources(intersection.entity, finalInputSources) - } - } -} - -function applyHeuristicProximity( - isSpatialInput: boolean, - sourceEid: Entity, - sortedIntersections: IntersectionData[], - intersectionData: Set -) { - const isCameraAttachedToAvatar = XRState.isCameraAttachedToAvatar - - //use sourceEid if controller (one InputSource per controller), otherwise use avatar rather than InputSource-emulated-pointer - const selfAvatarEntity = UUIDComponent.getEntityByUUID((Engine.instance.userID + '_avatar') as EntityUUID) //would prefer a better way to do this - const inputSourceEntity = isCameraAttachedToAvatar && isSpatialInput ? sourceEid : selfAvatarEntity - - // Skip Proximity Heuristic when the entity is undefined - // @note Clause Guard. This entire function was a block nested inside if (inputSourceEntity !== UndefinedEntity) { ... } - if (inputSourceEntity === UndefinedEntity) return - - TransformComponent.getWorldPosition(inputSourceEntity, worldPosInputSourceComponent) - - //TODO spatialInputObjects or inputObjects? - inputObjects requires visible and group components - for (const inputEntity of spatialInputObjects()) { - if (inputEntity === selfAvatarEntity) continue - const inputComponent = getComponent(inputEntity, InputComponent) - - TransformComponent.getWorldPosition(inputEntity, worldPosInputComponent) - const distSquared = worldPosInputSourceComponent.distanceToSquared(worldPosInputComponent) - - //closer than our current closest AND within inputSource's activation distance - if (inputComponent.activationDistance * inputComponent.activationDistance > distSquared) { - //using this object type out of convenience (intersectionsData is also guaranteed empty in this flow) - intersectionData.add({ entity: inputEntity, distance: distSquared }) //keeping it as distSquared for now to avoid extra square root calls - } - } - const closestEntities = Array.from(intersectionData) - if (closestEntities.length > 0) { - if (closestEntities.length === 1) { - sortedIntersections.push({ - entity: closestEntities[0].entity, - distance: Math.sqrt(closestEntities[0].distance) - }) - } else { - //sort if more than 1 entry - closestEntities.sort((a, b) => { - //prioritize anything with an InteractableComponent if otherwise equal - const aNum = hasComponent(a.entity, InteractableComponent) ? -1 : 0 - const bNum = hasComponent(b.entity, InteractableComponent) ? -1 : 0 - //aNum - bNum : 0 if equal, -1 if a has tag and b doesn't, 1 if a doesnt have tag and b does - return Math.sign(a.distance - b.distance) + (aNum - bNum) - }) - sortedIntersections.push({ - entity: closestEntities[0].entity, - distance: Math.sqrt(closestEntities[0].distance) - }) - } - } -} - -function applyHeuristicEditor(intersectionData: Set) { - const pickerObj = gizmoPickerObjects() // gizmo heuristic - const inputObj = inputObjects() - - const objects = (pickerObj.length > 0 ? pickerObj : inputObj) // gizmo heuristic - .map((eid) => getComponent(eid, GroupComponent)) - .flat() - pickerObj.length > 0 - ? raycaster.layers.enable(ObjectLayers.TransformGizmo) - : raycaster.layers.disable(ObjectLayers.TransformGizmo) - const hits = raycaster.intersectObjects(objects, true) - for (const hit of hits) { - const parentObject = Object3DUtils.findAncestor(hit.object, (obj) => !obj.parent) - if (parentObject?.entity) { - intersectionData.add({ entity: parentObject.entity, distance: hit.distance }) - } - } -} - -function applyHeuristicXRUI(intersectionData: Set) { - for (const entity of xruiQuery()) { - const xrui = getComponent(entity, XRUIComponent) - const layerHit = xrui.hitTest(inputRay) - if ( - !layerHit || - !layerHit.intersection.object.visible || - (layerHit.intersection.object as Mesh).material?.opacity < 0.01 - ) - continue - intersectionData.add({ entity, distance: layerHit.intersection.distance }) - } -} - -function applyHeuristicPhysicsColliders(intersectionData: Set) { - for (const entity of sceneQuery()) { - const world = Physics.getWorld(entity) - if (!world) continue - - const hits = Physics.castRay(world, inputRaycast) - for (const hit of hits) { - if (!hit.entity) continue - intersectionData.add({ entity: hit.entity, distance: hit.distance }) - } - } -} - -function applyHeuristicBBoxes(intersectionData: Set) { - const inputState = getState(InputState) - for (const entity of inputState.inputBoundingBoxes) { - const boundingBox = getOptionalComponent(entity, BoundingBoxComponent) - if (!boundingBox) continue - const hit = inputRay.intersectBox(boundingBox.box, bboxHitTarget) - if (hit) { - intersectionData.add({ entity, distance: inputRay.origin.distanceTo(bboxHitTarget) }) - } - } -} - -function applyHeuristicMeshes(intersectionData: Set, isEditing: boolean) { - const inputState = getState(InputState) - const objects = (isEditing ? meshesQuery() : Array.from(inputState.inputMeshes)) // gizmo heuristic - .filter((eid) => hasComponent(eid, GroupComponent)) - .map((eid) => getComponent(eid, GroupComponent)) - .flat() - - const hits = raycaster.intersectObjects(objects, true) - for (const hit of hits) { - const parentObject = Object3DUtils.findAncestor(hit.object, (obj) => obj.entity != undefined) - if (parentObject) { - intersectionData.add({ entity: parentObject.entity, distance: hit.distance }) - } - } -} - -/** - * @private - * @description Private Access Only. Exports for use within unit tests. */ -export const PRIVATE = { - assignInputSources, - - applyHeuristicProximity, - - applyRaycastedInputHeuristics, - applyHeuristicEditor, - applyHeuristicXRUI, - applyHeuristicPhysicsColliders, - applyHeuristicBBoxes, - applyHeuristicMeshes -} diff --git a/packages/ui/src/components/editor/panels/Assets/container/index.tsx b/packages/ui/src/components/editor/panels/Assets/container/index.tsx index 75677ce558..62de188807 100644 --- a/packages/ui/src/components/editor/panels/Assets/container/index.tsx +++ b/packages/ui/src/components/editor/panels/Assets/container/index.tsx @@ -211,7 +211,7 @@ const ResourceFile = (props: { className="mb-3 flex h-auto min-w-40 cursor-pointer flex-col items-center text-center" > diff --git a/packages/ui/src/components/editor/panels/Hierarchy/container/index.tsx b/packages/ui/src/components/editor/panels/Hierarchy/container/index.tsx index 09252525c2..ce27956fd9 100644 --- a/packages/ui/src/components/editor/panels/Hierarchy/container/index.tsx +++ b/packages/ui/src/components/editor/panels/Hierarchy/container/index.tsx @@ -173,6 +173,14 @@ function HierarchyPanelContents(props: { sceneURL: string; rootEntity: Entity; i } }) + useHotkeys(`${cmdOrCtrlString}+r`, (e) => { + e.preventDefault() + const selectedEntities = SelectionState.getSelectedEntities() + for (const entity of selectedEntities) { + onRenameNode(entity) + } + }) + const MemoTreeNode = useCallback( (props: HierarchyTreeNodeProps) => ( onRenameNode(contextSelectedItem!)} + endIcon={cmdOrCtrlString + ' + r'} > {t('editor:hierarchy.lbl-rename')} diff --git a/packages/ui/src/components/tailwind/SidebarButton/index.tsx b/packages/ui/src/components/tailwind/SidebarButton/index.tsx new file mode 100644 index 0000000000..22cddb0dc1 --- /dev/null +++ b/packages/ui/src/components/tailwind/SidebarButton/index.tsx @@ -0,0 +1,36 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import Button from '@etherealengine/ui/src/primitives/tailwind/Button' +import React from 'react' +import { twMerge } from 'tailwind-merge' + +export function SidebarButton({ children, className, ...rest }) { + return ( + + ) +} diff --git a/packages/ui/src/primitives/tailwind/Button/index.tsx b/packages/ui/src/primitives/tailwind/Button/index.tsx index 8daeef8fec..e43206985d 100644 --- a/packages/ui/src/primitives/tailwind/Button/index.tsx +++ b/packages/ui/src/primitives/tailwind/Button/index.tsx @@ -32,7 +32,7 @@ export interface ButtonProps extends React.HTMLAttributes { endIcon?: ReactNode children?: ReactNode size?: 'small' | 'medium' | 'large' - variant?: 'primary' | 'secondary' | 'outline' | 'danger' | 'success' | 'transparent' | 'sidebar' + variant?: 'primary' | 'secondary' | 'outline' | 'danger' | 'success' | 'transparent' disabled?: boolean fullWidth?: boolean rounded?: 'partial' | 'full' | 'none' @@ -59,8 +59,7 @@ const variants = { outline: 'border border-solid border-theme-primary bg-theme-surface-main dark:bg-theme-highlight text-theme-primary', danger: 'bg-red-500', success: 'bg-teal-700', - transparent: 'bg-transparent dark:bg-transparent', - sidebar: 'bg-[#141619]' + transparent: 'bg-transparent dark:bg-transparent' } const Button = React.forwardRef( diff --git a/packages/ui/src/primitives/tailwind/Radio/index.tsx b/packages/ui/src/primitives/tailwind/Radio/index.tsx index 4455038ba9..f4da3d2e74 100644 --- a/packages/ui/src/primitives/tailwind/Radio/index.tsx +++ b/packages/ui/src/primitives/tailwind/Radio/index.tsx @@ -6,8 +6,8 @@ Version 1.0. (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. The License is based on the Mozilla Public License Version 1.1, but Sections 14 -and 15 have been added to cover use of software over a computer network and -provide for limited attribution for the Original Developer. In addition, +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, Exhibit A has been modified to be consistent with Exhibit B. Software distributed under the License is distributed on an "AS IS" basis, @@ -19,7 +19,7 @@ The Original Code is Ethereal Engine. The Original Developer is the Initial Developer. The Initial Developer of the Original Code is the Ethereal Engine team. -All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 Ethereal Engine. All Rights Reserved. */ @@ -32,6 +32,7 @@ export const RadioRoot = ({ onChange, selected, className, + labelClassName, disabled, description }: { @@ -40,6 +41,7 @@ export const RadioRoot = ({ onChange: React.ChangeEventHandler selected: boolean className?: string + labelClassName?: string disabled?: boolean description?: string }) => { @@ -49,7 +51,7 @@ export const RadioRoot = ({