diff --git a/examples/basic-dom/useDrag.ts b/examples/basic-dom/useDrag.ts index 7f21101193..079a9febcf 100644 --- a/examples/basic-dom/useDrag.ts +++ b/examples/basic-dom/useDrag.ts @@ -30,6 +30,9 @@ export type UseDragOpts = { onDrag: (dx: number, dy: number, event: MouseEvent) => void } +/** + * @deprecated Deprecated in favor of `useChordial` + */ export default function useDrag( target: HTMLElement | undefined | null, opts: UseDragOpts, diff --git a/package.json b/package.json index e8c489fc35..dcf299e33f 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "jsonc-parser": "^3.1.0", "lint-staged": "^13.0.3", "node-gyp": "^9.1.0", - "prettier": "^3.0.2", + "prettier": "^3.1.1", "sade": "^1.8.1", "typescript": "5.1.6", "yaml": "^2.3.1" diff --git a/packages/app/package.json b/packages/app/package.json index 487d7e344e..4d0fcb22b3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -50,6 +50,7 @@ "next": "latest", "next-auth": "^4.23.2", "npm-run-all": "^4.1.5", + "oauth4webapi": "^2.4.0", "pg": "^8.11.2", "prisma": "^4.12.0", "react": "18.2.0", diff --git a/packages/app/prisma/migrations/20231127144216_/migration.sql b/packages/app/prisma/migrations/20231127144216_/migration.sql new file mode 100644 index 0000000000..11ea81afc6 --- /dev/null +++ b/packages/app/prisma/migrations/20231127144216_/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - You are about to drop the `LibAuthenticationFlow` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- CreateEnum +CREATE TYPE "DeviceAuthorizationFlowState" AS ENUM ('initialized', 'userAllowedAuth', 'userDeniedAuth', 'tokenAlreadyUsed'); + +-- DropTable +DROP TABLE "LibAuthenticationFlow"; + +-- DropEnum +DROP TYPE "LibAuthenticationFlowState"; + +-- CreateTable +CREATE TABLE "DeviceAuthorizationFlow" ( + "deviceCode" TEXT NOT NULL, + "userCode" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL, + "lastCheckTime" TIMESTAMPTZ NOT NULL, + "nounce" TEXT NOT NULL, + "state" "DeviceAuthorizationFlowState" NOT NULL DEFAULT 'initialized', + "tokens" TEXT NOT NULL, + + CONSTRAINT "DeviceAuthorizationFlow_pkey" PRIMARY KEY ("deviceCode") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DeviceAuthorizationFlow_userCode_key" ON "DeviceAuthorizationFlow"("userCode"); diff --git a/packages/app/prisma/migrations/20231127153849_pkce/migration.sql b/packages/app/prisma/migrations/20231127153849_pkce/migration.sql new file mode 100644 index 0000000000..689afbaf23 --- /dev/null +++ b/packages/app/prisma/migrations/20231127153849_pkce/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "DeviceAuthorizationFlow" ADD COLUMN "codeChallenge" TEXT NOT NULL DEFAULT '', +ADD COLUMN "codeChallengeMethod" TEXT NOT NULL DEFAULT 'S256'; diff --git a/packages/app/prisma/migrations/20231202190130_scopes/migration.sql b/packages/app/prisma/migrations/20231202190130_scopes/migration.sql new file mode 100644 index 0000000000..6dad8b9a1c --- /dev/null +++ b/packages/app/prisma/migrations/20231202190130_scopes/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "DeviceAuthorizationFlow" ADD COLUMN "scopes" JSONB NOT NULL DEFAULT '[]'; diff --git a/packages/app/prisma/schema.prisma b/packages/app/prisma/schema.prisma index 2f492e53b1..c8d4f18ba1 100644 --- a/packages/app/prisma/schema.prisma +++ b/packages/app/prisma/schema.prisma @@ -115,17 +115,21 @@ model VerificationToken { @@unique([identifier, token]) } -model LibAuthenticationFlow { - preAuthenticationToken String @id // a random token generated by the server, and shared with the libray. This token is the key the library will use to obtain the final access/refersh tokens - userCode String @unique // a random, short token generated by the server. The user will then be redirected to [app]/auth/?userCode=[userCode]. At that URL, the user can log in via credentials, or if they're already logged in, they'll be asked whether to grant the library permission to edit projects - createdAt DateTime @db.Timestamptz() // the time the flow started. If older than a certain interval, the flow is considered expired/ - lastCheckTime DateTime @db.Timestamptz() // the time the client last checked the status of this flow. If shorter than a certain interval, the client will be told to slow down. - clientFlowToken String // a random token generated by the client. It'll be returned once the final access/refresh tokens are generated, so the client can make sure the tokens belong to the authentication flow it initiated - state LibAuthenticationFlowState @default(initialized) - tokens String // will be non-empty if state=1. It'll contain a json object containing access/refresh tokens +// We're using OAuth2's device authorization flow to authorize `@theatre/studio` hosted in random origins. +model DeviceAuthorizationFlow { + deviceCode String @id // a random token generated by the server, and shared with the libray. This token is the key the library will use to obtain the final access/refersh tokens + userCode String @unique // a random, short token generated by the server. The user will then be redirected to [app]/auth/?userCode=[userCode]. At that URL, the user can log in via credentials, or if they're already logged in, they'll be asked whether to grant the library permission to edit projects + createdAt DateTime @db.Timestamptz() // the time the flow started. If older than a certain interval, the flow is considered expired/ + lastCheckTime DateTime @db.Timestamptz() // the time the client last checked the status of this flow. If shorter than a certain interval, the client will be told to slow down. + nounce String // a random token generated by the client. It'll be included in the final idToken, so the client can make sure the tokens belong to the authentication flow it initiated + codeChallenge String @default("") // code_challenge as per https://tools.ietf.org/html/rfc7636 + codeChallengeMethod String @default("S256") // code_challenge_method as per https://tools.ietf.org/html/rfc7636 (currently only "S256" is supported) + state DeviceAuthorizationFlowState @default(initialized) + tokens String // will be non-empty if state=1. It'll contain a json object containing access/refresh tokens + scopes Json @default("[]") } -enum LibAuthenticationFlowState { +enum DeviceAuthorizationFlowState { initialized userAllowedAuth userDeniedAuth diff --git a/packages/app/src/app/api/jwt-public-key/route.ts b/packages/app/src/app/api/jwt-public-key/route.ts new file mode 100644 index 0000000000..667844e945 --- /dev/null +++ b/packages/app/src/app/api/jwt-public-key/route.ts @@ -0,0 +1,21 @@ +import {NextResponse} from 'next/server' +import {allowCors} from '~/utils' + +async function handler(req: Request) { + if (req.method === 'OPTIONS') { + const res = new Response(null, {status: 204}) + allowCors(res) + + return res + } + + const res = NextResponse.json({ + publicKey: process.env.STUDIO_AUTH_JWT_PUBLIC_KEY, + }) + + allowCors(res) + + return res +} + +export {handler as GET, handler as OPTIONS} diff --git a/packages/app/src/app/api/studio-auth/route.ts b/packages/app/src/app/api/studio-auth/route.ts new file mode 100644 index 0000000000..c8015e2817 --- /dev/null +++ b/packages/app/src/app/api/studio-auth/route.ts @@ -0,0 +1,131 @@ +import type {NextRequest} from 'next/server' +import {NextResponse} from 'next/server' +import prisma from 'src/prisma' + +import {getAppSession, studioAuth} from 'src/utils/authUtils' +import {userCodeLength} from '~/server/studio-api/routes/studioAuthRouter' +import {studioAccessScopes} from '~/types' +import {type $IntentionalAny} from '@theatre/utils/types' + +export const dynamic = 'force-dynamic' + +async function libAuth(req: NextRequest) { + const userCode = req.nextUrl.searchParams.get('userCode') + + if (typeof userCode !== 'string' || userCode.length !== userCodeLength) { + return NextResponse.json( + { + error: `userCode must be a string of length ${userCodeLength}`, + }, + {status: 400}, + ) + } + + const row = await prisma.deviceAuthorizationFlow.findFirst({ + where: { + userCode, + }, + }) + if (row === null) { + return NextResponse.json( + { + error: + 'This authentication flow either does not exist, or has already been used. Try again from the studio.', + }, + {status: 404}, + ) + } + + const session = await getAppSession() + + // if no session, redirect to login + if (!session || !session.user) { + console.log('s', req.nextUrl.host, req.nextUrl.hostname, req.nextUrl.origin) + const redirectUrl = new URL( + `/api/auth/signin?callbackUrl=${encodeURIComponent( + req.nextUrl.toString(), + )}`, + req.nextUrl.origin, + ) + return NextResponse.redirect(redirectUrl) + } + + if (row.state === 'tokenAlreadyUsed') { + return NextResponse.json( + { + error: + 'This authentication flow has already been used. Try again from the studio.', + }, + {status: 400}, + ) + } + + if (row.state === 'userDeniedAuth') { + return NextResponse.json( + { + error: + 'This authentication flow has been denied by the user. Try again from the studio.', + }, + {status: 400}, + ) + } + + if (row.state === 'userAllowedAuth') { + return NextResponse.json( + { + error: + 'This authentication flow has already been used. Try again from the studio.', + }, + {status: 400}, + ) + } + + if (row.state !== 'initialized') { + return NextResponse.json( + { + error: `This authentication flow is in an invalid state. Try again from the studio.`, + }, + {status: 500}, + ) + } + + const user = session.user + const nounce = row.nounce + const scopes = row.scopes + + if (!studioAccessScopes.scopes.parse(scopes)) { + console.error(`bad scopes`, scopes) + await prisma.deviceAuthorizationFlow.delete({ + where: {deviceCode: row.deviceCode}, + }) + return NextResponse.json( + { + error: `This authentication flow is in an invalid state. Try again from the studio.`, + }, + {status: 500}, + ) + } + + const {refreshToken, accessToken} = await studioAuth.createSession( + nounce, + user, + scopes as $IntentionalAny, + ) + + await prisma.deviceAuthorizationFlow.update({ + where: { + deviceCode: row.deviceCode, + }, + data: { + state: 'userAllowedAuth', + tokens: JSON.stringify({ + accessToken, + refreshToken, + }), + }, + }) + + return NextResponse.json('success', {status: 200}) +} + +export {libAuth as GET} diff --git a/packages/app/src/app/api/studio-trpc/[trpc]/route.ts b/packages/app/src/app/api/studio-trpc/[trpc]/route.ts new file mode 100644 index 0000000000..3e6474c3eb --- /dev/null +++ b/packages/app/src/app/api/studio-trpc/[trpc]/route.ts @@ -0,0 +1,41 @@ +import {fetchRequestHandler} from '@trpc/server/adapters/fetch' +import type {NextRequest} from 'next/server' +import {createTRPCContext} from '~/server/api/trpc' +import {studioTrpcRouter} from '~/server/studio-api/root' +import {allowCors} from '~/utils' + +// we don't need the trpc routes' responses to be cached +export const dynamic = 'force-dynamic' + +const handler = async (req: NextRequest) => { + if (req.method === 'OPTIONS') { + const res = new Response(null, { + status: 204, + }) + allowCors(res) + return res + } + + const res = await fetchRequestHandler({ + endpoint: '/api/studio-trpc', + req, + router: studioTrpcRouter, + createContext: () => createTRPCContext(), + onError: + process.env.NODE_ENV === 'development' + ? ({path, error}) => { + console.error( + `❌ studio-trpc failed on ${path ?? ''}: ${ + error.message + }`, + ) + } + : undefined, + }) + + allowCors(res) + + return res +} + +export {handler as GET, handler as POST, handler as OPTIONS} diff --git a/packages/app/src/app/api/trpc/[trpc]/route.ts b/packages/app/src/app/api/trpc/[trpc]/route.ts index dbe85852da..6f8a284d48 100644 --- a/packages/app/src/app/api/trpc/[trpc]/route.ts +++ b/packages/app/src/app/api/trpc/[trpc]/route.ts @@ -1,16 +1,12 @@ import {fetchRequestHandler} from '@trpc/server/adapters/fetch' -import {type NextRequest} from 'next/server' - +import type {NextRequest} from 'next/server' import {appRouter} from '~/server/api/root' import {createTRPCContext} from '~/server/api/trpc' -const handler = async (req: NextRequest) => { - // Since these endpoints are for @theatre/studio as a library that can be used on any origin, - // we must allow CORS requests from any origin. - if (req.method === 'OPTIONS') { - return new Response() - } +// we don't need the trpc routes' responses to be cached +export const dynamic = 'force-dynamic' +const handler = async (req: NextRequest) => { const res = await fetchRequestHandler({ endpoint: '/api/trpc', req, @@ -26,12 +22,7 @@ const handler = async (req: NextRequest) => { : undefined, }) - res.headers.set('Access-Control-Allow-Origin', '*') - res.headers.set('Access-Control-Request-Method', '*') - res.headers.set('Access-Control-Allow-Methods', '*') - res.headers.set('Access-Control-Allow-Headers', '*') - return res } -export {handler as GET, handler as POST} +export {handler as GET, handler as POST, handler as OPTIONS} diff --git a/packages/app/src/pages.bak/api/jwt-public-key.ts b/packages/app/src/pages.bak/api/jwt-public-key.ts deleted file mode 100644 index 547fc87374..0000000000 --- a/packages/app/src/pages.bak/api/jwt-public-key.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type {NextApiRequest, NextApiResponse} from 'next' - -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { - res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Access-Control-Request-Method', '*') - res.setHeader('Access-Control-Allow-Methods', '*') - res.setHeader('Access-Control-Allow-Headers', '*') - if (req.method === 'OPTIONS') { - res.writeHead(200) - res.end() - return - } - - if (req.method !== 'GET') { - res.status(405).json({error: 'Method not allowed'}) - return - } - res.status(200).json({publicKey: process.env.STUDIO_AUTH_JWT_PUBLIC_KEY}) -} diff --git a/packages/app/src/pages.bak/api/studio-auth.ts b/packages/app/src/pages.bak/api/studio-auth.ts deleted file mode 100644 index 285abb7402..0000000000 --- a/packages/app/src/pages.bak/api/studio-auth.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type {NextApiRequest, NextApiResponse} from 'next' -import prisma from 'src/prisma' - -import {getAppSession, studioAuth} from 'src/utils/authUtils' -import {userCodeLength} from '~/server/api/routes/studioAuthRouter' - -export default async function libAuth( - req: NextApiRequest, - res: NextApiResponse, -) { - if (req.method !== 'GET') { - res.status(405).json({error: 'Method not allowed'}) - return - } - const query = req.query as Record - const {userCode} = query - if (typeof userCode !== 'string' || userCode.length !== userCodeLength) { - res.status(400).json({ - error: `userCode must be a string of length ${userCodeLength}`, - }) - return - } - - const row = await prisma.libAuthenticationFlow.findFirst({ - where: { - userCode, - }, - }) - if (row === null) { - res.status(404).json({ - error: - 'This authentication flow either does not exist, or has already been used. Try again from the studio.', - }) - return - } - - const session = await getAppSession(req, res) - - // if no session, redirect to login - if (!session || !session.user) { - res.redirect(`/api/auth/signin?callbackUrl=${encodeURIComponent(req.url!)}`) - return - } - - if (row.state === 'tokenAlreadyUsed') { - res.status(400).json({ - error: - 'This authentication flow has already been used. Try again from the studio.', - }) - return - } - - if (row.state === 'userDeniedAuth') { - res.status(400).json({ - error: - 'This authentication flow has been denied by the user. Try again from the studio.', - }) - return - } - - if (row.state === 'userAllowedAuth') { - res.status(400).json({ - error: - 'This authentication flow has already been used. Try again from the studio.', - }) - return - } - - if (row.state !== 'initialized') { - res.status(500).json({ - error: `This authentication flow is in an invalid state. Try again from the studio.`, - }) - return - } - - const user = session.user - - const {refreshToken, accessToken} = await studioAuth.createSession(user) - - await prisma.libAuthenticationFlow.update({ - where: { - preAuthenticationToken: row.preAuthenticationToken, - }, - data: { - state: 'userAllowedAuth', - tokens: JSON.stringify({ - accessToken, - refreshToken, - }), - }, - }) - - res.status(200).json('success') -} diff --git a/packages/app/src/server/api/root.ts b/packages/app/src/server/api/root.ts index eb90cab2b6..d6a9e80de1 100644 --- a/packages/app/src/server/api/root.ts +++ b/packages/app/src/server/api/root.ts @@ -1,4 +1,3 @@ -import {studioAuthRouter} from './routes/studioAuthRouter' import * as t from './trpc' import {projectsRouter} from './routes/projectsRouter' import {workspaceRouter} from './routes/workspaceRouter' @@ -6,8 +5,6 @@ import {teamsRouter} from './routes/teamsRouter' import {meRouter} from './routes/meRouter' export const appRouter = t.createRouter({ - syncServerUrl: t.publicProcedure.query(() => `ws://localhost:3001/api/trpc`), - studioAuth: studioAuthRouter, projects: projectsRouter, workspaces: workspaceRouter, teams: teamsRouter, diff --git a/packages/app/src/server/studio-api/root.ts b/packages/app/src/server/studio-api/root.ts new file mode 100644 index 0000000000..19749254b3 --- /dev/null +++ b/packages/app/src/server/studio-api/root.ts @@ -0,0 +1,9 @@ +import {studioAuthRouter} from './routes/studioAuthRouter' +import * as t from '../api/trpc' + +export const studioTrpcRouter = t.createRouter({ + syncServerUrl: t.publicProcedure.query(() => `ws://localhost:3001/api/trpc`), + studioAuth: studioAuthRouter, +}) + +export type StudioTRPCRouter = typeof studioTrpcRouter diff --git a/packages/app/src/server/api/routes/studioAuthRouter.ts b/packages/app/src/server/studio-api/routes/studioAuthRouter.ts similarity index 62% rename from packages/app/src/server/api/routes/studioAuthRouter.ts rename to packages/app/src/server/studio-api/routes/studioAuthRouter.ts index b446567351..36cdcf7393 100644 --- a/packages/app/src/server/api/routes/studioAuthRouter.ts +++ b/packages/app/src/server/studio-api/routes/studioAuthRouter.ts @@ -1,23 +1,43 @@ import {z} from 'zod' import {nanoid} from 'nanoid' -import type {AccessTokenPayload} from 'src/utils/authUtils' import {studioAuth} from 'src/utils/authUtils' import {v4} from 'uuid' -import * as t from '../trpc' +import * as t from '../../api/trpc' import prisma from 'src/prisma' +import {calculatePKCECodeChallenge} from 'oauth4webapi' +import type {studioAuthTokens} from '~/types' +import {studioAccessScopes} from '~/types' export const userCodeLength = 8 -export const FLOW_CHECK_INTERVAL = 5000 +export const FLOW_CHECK_INTERVAL = 1000 export const studioAuthRouter = t.createRouter({ - getPreAuthenticationToken: t.publicProcedure + deviceCode: t.publicProcedure .input( z.object({ - clientFlowToken: z + nounce: z .string() - .length(36) + .min(32) .describe( - `This is a random string that should be unique for each client flow. It is generated by the client, and will be returned to the client in the \`preAuthenticationToken\` so that the client can match the \`preAuthenticationToken\` to the original client flow.`, + `This is a random string that should be unique for each client flow. It is generated by the client, will be included in the refresh token.`, + ), + codeChallenge: z + .string() + .min(43) + .max(1024) + .describe(`The code_challenge as defined in OAuth RFC 7636`), + codeChallengeMethod: z + .enum(['S256']) + .describe(`The code_challenge_method as defined in OAuth RFC 7636`), + + scopes: studioAccessScopes.scopes.describe( + `The scopes the client is requesting access to. In case \`originalIdToken\` is provided, only the additional scopes should be defined here`, + ), + originalIdToken: z + .string() + .optional() + .describe( + `In case the client (the studio) already has an idToken but is requesting more access, it should provide the original idToken. (This happens e.g. when the studio has access to workspaceA, but now also needs access to workspaceB)`, ), }), ) @@ -26,11 +46,11 @@ export const studioAuthRouter = t.createRouter({ interval: z .number() .int() - .min(5000) + .min(1000) .describe( - 'If 5000, it means the library should check the `urlToGetTokens` every 5000ms or longer.', + 'If 1000, it means the library should check the `$.tokens()` every 1000ms or longer.', ), - userAuthUrl: z + verificationUriComplete: z .string() .url() .describe( @@ -38,24 +58,24 @@ export const studioAuthRouter = t.createRouter({ `for the user to log in. Note that if the user is already logged ` + `into the app, they won't be prompted to log in again.`, ), - preAuthenticationToken: z + deviceCode: z .string() .min(72) - .describe( - `A unique token that should be passed to $.getTokensFromPreAuthenticationToken()`, - ), + .describe(`A unique token that should be passed to $.tokens()`), }), ) .mutation(async (opts) => { const userCode = nanoid(userCodeLength) - const preAuthenticationToken = v4() + v4() + const deviceCode = v4() + v4() - await prisma.libAuthenticationFlow.create({ + await prisma.deviceAuthorizationFlow.create({ data: { - clientFlowToken: opts.input.clientFlowToken, + nounce: opts.input.nounce, createdAt: new Date().toISOString(), lastCheckTime: new Date().toISOString(), - preAuthenticationToken, + codeChallenge: opts.input.codeChallenge, + codeChallengeMethod: opts.input.codeChallengeMethod, + deviceCode, tokens: '', userCode: userCode, state: 'initialized', @@ -64,20 +84,21 @@ export const studioAuthRouter = t.createRouter({ return { interval: FLOW_CHECK_INTERVAL, - userAuthUrl: + verificationUriComplete: process.env.NEXT_PUBLIC_WEBAPP_URL + `/api/studio-auth?userCode=${userCode}`, - preAuthenticationToken, + deviceCode, } }), - getTokensFromPreAuthenticationToken: t.publicProcedure + tokens: t.publicProcedure .input( z.object({ - preAuthenticationToken: z + deviceCode: z .string() - .describe( - `The \`preAuthenticationToken\` returned by libAuthentication.getPreAuthenticationToken()`, - ), + .describe(`The \`deviceCode\` generated by deviceCode()`), + codeVerifier: z + .string() + .describe(`The \`codeVerifier\` as defined in 7636`), }), ) .output( @@ -85,8 +106,9 @@ export const studioAuthRouter = t.createRouter({ z.object({ isError: z.literal(true), error: z.enum([ - 'invalidPreAuthenticationToken', - 'userDeniedLogin', + 'invalidDeviceCode', + 'invalidCodeVerifier', + 'userDeniedAuth', 'slowDown', 'notYetReady', ]), @@ -95,29 +117,24 @@ export const studioAuthRouter = t.createRouter({ z.object({ isError: z.literal(false), accessToken: z.string(), - refreshToken: z.string(), - clientFlowToken: z - .string() - .describe( - `The clientFlowToken passed to libAuthentication.getPreAuthenticationToken()`, - ), + idToken: z.string(), }), ]), ) .mutation(async ({input}) => { - const flow = await prisma.libAuthenticationFlow.findFirst({ - where: {preAuthenticationToken: input.preAuthenticationToken}, + const flow = await prisma.deviceAuthorizationFlow.findFirst({ + where: {deviceCode: input.deviceCode}, }) if (!flow) { return { isError: true, - error: 'invalidPreAuthenticationToken', + error: 'invalidDeviceCode', errorMessage: - 'The preAutenticationToken is invalid. It may also have been expired, or already used.', + 'The deviceCode is invalid. It may also have been expired, or already used.', } } - await prisma.libAuthenticationFlow.update({ - where: {preAuthenticationToken: input.preAuthenticationToken}, + await prisma.deviceAuthorizationFlow.update({ + where: {deviceCode: input.deviceCode}, data: {lastCheckTime: new Date().toISOString()}, }) // if flow.lastCheckTime is more recent than 5 seconds ago, return the same thing as last time @@ -139,34 +156,42 @@ export const studioAuthRouter = t.createRouter({ error: 'notYetReady', errorMessage: `The user hasn't decided to grant/deny access yet.`, } - break case 'userDeniedAuth': return { isError: true, - error: 'userDeniedLogin', + error: 'userDeniedAuth', errorMessage: `The user denied access.`, } - break case 'userAllowedAuth': const tokens = JSON.parse(flow.tokens) - await prisma.libAuthenticationFlow.update({ - where: {preAuthenticationToken: input.preAuthenticationToken}, + const codeChallenge = await calculatePKCECodeChallenge( + input.codeVerifier, + ) + if (codeChallenge !== flow.codeChallenge) { + return { + isError: true, + error: 'invalidCodeVerifier', + errorMessage: `The codeVerifier is invalid.`, + } + } + + await prisma.deviceAuthorizationFlow.update({ + where: {deviceCode: input.deviceCode}, data: {state: 'tokenAlreadyUsed'}, }) return { isError: false, accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - clientFlowToken: flow.clientFlowToken, + idToken: tokens.refreshToken, } // otherwise default: console.error('Invalid state', flow.state) return { isError: true, - error: 'invalidPreAuthenticationToken', + error: 'invalidDeviceCode', errorMessage: 'The preAutenticationToken is invalid. It may also have been expired, or already used.', } @@ -250,6 +275,33 @@ export const studioAuthRouter = t.createRouter({ } } }), + destroyIdToken: t.publicProcedure + .input(z.object({idToken: z.string()})) + .output( + z.union([ + z.object({ + isError: z.literal(true), + error: z.enum(['unknown']), + errorMessage: z.string(), + }), + z.object({ + isError: z.literal(false), + }), + ]), + ) + .mutation(async ({input}) => { + try { + await studioAuth.destroySession(input.idToken) + return {isError: false} + } catch (err) { + console.error(err) + return { + isError: true, + error: 'unknown', + errorMessage: `An unknown error occured.`, + } + } + }), canIEditProject: t.publicProcedure .input( @@ -268,7 +320,7 @@ export const studioAuthRouter = t.createRouter({ ]), ) .query(async (opts) => { - let payload!: AccessTokenPayload + let payload!: studioAuthTokens.AccessTokenPayload try { payload = await studioAuth.verifyStudioAccessTokenOrThrow(opts) } catch (err) { diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 9bdf986e75..028804e254 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -1,2 +1,39 @@ -export type $IntentionalAny = any -export type $FixMe = any +import {z} from 'zod' + +export namespace studioAccessScopes { + export const listWorkspaces = z + .literal(`workspaces-list`) + .describe( + `This scope allows the client (studio) to get the list of workspaces the user has access to, including their ids, names, thumbnails, and last edit time`, + ) + + export type ListWorkspaces = z.infer + export const editWorkspace = z + .custom<`edit-workspace:${string}`>((v) => + typeof v === 'string' && /^edit-workspace\:([a-zA-Z0-9\n\-]+)$/.test(v) + ? true + : false, + ) + .describe( + `This scope allows the client (studio) to edit a specific workspace (assuming the user has access to it).`, + ) + export type EditWorkspace = z.infer + + export const scope = z.union([listWorkspaces, editWorkspace]) + export type Scope = z.infer + + export const scopes = z.array(scope) + export type Scopes = z.infer +} + +export namespace studioAuthTokens { + export const accessTokenPayload = z.object({ + userId: z.string(), + email: z.string(), + scopes: studioAccessScopes.scopes, + }) + export type AccessTokenPayload = z.infer + + export const idTokenPayload = accessTokenPayload.extend({nounce: z.string()}) + export type IdTokenPayload = z.infer +} diff --git a/packages/app/src/utils/authUtils.ts b/packages/app/src/utils/authUtils.ts index 3ca69d9126..b17c2a52f1 100644 --- a/packages/app/src/utils/authUtils.ts +++ b/packages/app/src/utils/authUtils.ts @@ -3,11 +3,10 @@ import type { NextApiRequest, NextApiResponse, } from 'next' -import type {User} from '../../prisma/client-generated' +import type {LibSession, User} from '../../prisma/client-generated' import prisma from '../prisma' -import {v4} from 'uuid' import * as jose from 'jose' -import type {$IntentionalAny} from 'src/types' +import type {studioAccessScopes} from 'src/types' import {TRPCError} from '@trpc/server' import {z} from 'zod' import type {AuthOptions} from 'next-auth' @@ -15,6 +14,8 @@ import {getServerSession} from 'next-auth' import GithubProvider from 'next-auth/providers/github' import {PrismaAdapter} from '@auth/prisma-adapter' import type {Adapter} from 'next-auth/adapters' +import type {studioAuthTokens} from 'src/types' +import type {$FixMe, $IntentionalAny} from '@theatre/utils/types' // Extend NextAuth Session type to include all fields from the User model declare module 'next-auth' { @@ -58,54 +59,103 @@ export function getAppSession( return getServerSession(...args, nextAuthConfig) } -export type AccessTokenPayload = { - userId: string - email: string -} - export namespace studioAuth { + export const jwtAlg = 'RS256' export const input = z.object({accessToken: z.string()}) - function generateRefreshToken() { - return v4() + v4() + v4() + v4() + async function generateIdToken( + nounce: string, + user: User, + scopes: studioAccessScopes.Scopes, + expirationTime: Date, + ): Promise { + const privateKey = await privateKeyPromise + const payload: studioAuthTokens.IdTokenPayload = { + userId: user.id, + email: user.email ?? '', + nounce, + scopes, + } + const jwt = await new jose.SignJWT(payload) + .setProtectedHeader({alg: jwtAlg}) + .setIssuedAt() + .setExpirationTime(expirationTime.getTime()) + .sign(privateKey) + + return jwt + } + + export async function parseAndVerifyIdToken( + idToken: string, + ): Promise { + const privateKey = await privateKeyPromise + + try { + const s = await jose.jwtVerify(idToken, privateKey, { + algorithms: [jwtAlg], + }) + return s.payload as $FixMe + } catch (err) { + console.log(`parseAndVerifyIdToken failed:`, err) + return undefined + } + } + + export function getIdTokenClaimsWithoutVerifying( + idToken: string, + ): undefined | studioAuthTokens.IdTokenPayload { + try { + const s = jose.decodeJwt(idToken) + return s as $FixMe + } catch (err) { + console.log(`getIdTokenClaimsWithoutVerifying failed:`, err) + return undefined + } } /** * Generates an access token for the given user. */ - async function generateAccessToken(user: User): Promise { + async function generateAccessToken( + user: User, + scopes: studioAccessScopes.Scopes, + ): Promise { const privateKey = await privateKeyPromise - const payload: AccessTokenPayload = { + const payload: studioAuthTokens.AccessTokenPayload = { userId: user.id, email: user.email ?? '', + scopes, } const jwt = await new jose.SignJWT(payload) .setProtectedHeader({alg: 'RS256'}) .setIssuedAt() - .setExpirationTime('1h') + .setExpirationTime('2h') .sign(privateKey) return jwt } export async function createSession( + nounce: string, user: User, + scopes: studioAccessScopes.Scopes, ): Promise<{refreshToken: string; accessToken: string}> { - const refreshToken = generateRefreshToken() + // now + 2 months + const validUntil = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30 * 2) + + const idToken = await generateIdToken(nounce, user, scopes, validUntil) const session = await prisma.libSession.create({ data: { createdAt: new Date().toISOString(), - refreshToken, - validUntil: - // now + 2 months - new Date(Date.now() + 1000 * 60 * 60 * 24 * 30 * 2).toISOString(), + refreshToken: idToken, + validUntil: validUntil.toISOString(), userId: user.id, }, }) - const accessToken = await generateAccessToken(user) + const accessToken = await generateAccessToken(user, scopes) - return {refreshToken, accessToken} + return {refreshToken: idToken, accessToken} } export async function destroySession(refreshToken: string) { @@ -120,11 +170,11 @@ export namespace studioAuth { * Returns a new accessToken, and a new refreshToken. The old refreshToken is invalidated. */ export async function refreshSession( - refreshToken: string, + originalIdToken: string, ): Promise<{refreshToken: string; accessToken: string}> { const session = await prisma.libSession.findUnique({ where: { - refreshToken, + refreshToken: originalIdToken, }, }) @@ -132,38 +182,33 @@ export namespace studioAuth { throw new Error(`Invalid refresh token`) } + let originalSuccessorSession: null | LibSession = null + + // client has already tried to get a new refresh token using this refresh token. if (session.succeededByRefreshToken) { + // there is a grace period in which the old refresh token is still valid (in case the new one didn't reach the client due to a network error or a race condition) if (session.successorLinkExpresAt! < new Date()) { + // the grace period is over, the old refresh token is now invalid. let's delete it. await destroySession(session.refreshToken) throw new Error(`Invalid refresh token`) } else { - const newSession = await prisma.libSession.findUnique({ + // the grace period is still active, so a new id token has been issued. We should now find and remove that token before we issue another one + originalSuccessorSession = await prisma.libSession.findUnique({ where: { refreshToken: session.succeededByRefreshToken, }, }) - if (!newSession) { - throw new Error(`Invalid refresh token`) - } - - const user = await prisma.user.findUnique({ - where: { - id: newSession.userId, - }, - }) - - if (!user) { + // well, the new refresh token been removed for some reason. at this point, the client has to re-authenticate. + if (!originalSuccessorSession) { + // let's GC the old token while we're at it + await destroySession(session.refreshToken) throw new Error(`Invalid refresh token`) } - - const accessToken = await generateAccessToken(user) - - return {refreshToken: newSession.refreshToken, accessToken} } } - // session is expired + // the refresh token is expired if (session.validUntil < new Date()) { await destroySession(session.refreshToken) throw new Error(`Invalid refresh token`) @@ -179,17 +224,26 @@ export namespace studioAuth { throw new Error(`Invalid refresh token`) } - const {refreshToken: newRefreshToken, accessToken} = - await createSession(user) + const {nounce, scopes} = getIdTokenClaimsWithoutVerifying(originalIdToken)! + + const {refreshToken: newRefreshToken, accessToken} = await createSession( + nounce, + user, + scopes, + ) await prisma.libSession.update({ - where: {refreshToken}, + where: {refreshToken: originalIdToken}, data: { succeededByRefreshToken: newRefreshToken, successorLinkExpresAt: new Date(Date.now() + 60).toISOString(), }, }) + if (originalSuccessorSession) { + await destroySession(originalSuccessorSession.refreshToken) + } + return {refreshToken: newRefreshToken, accessToken} } @@ -197,7 +251,7 @@ export namespace studioAuth { input: { studioAuth: {accessToken: string} } - }): Promise { + }): Promise { const publicKey = await publicKeyPromise try { const res = await jose.jwtVerify( @@ -210,7 +264,7 @@ export namespace studioAuth { const {payload} = res as $IntentionalAny - return payload as AccessTokenPayload + return payload as studioAuthTokens.AccessTokenPayload } catch (e) { throw new TRPCError({ code: 'UNAUTHORIZED', diff --git a/packages/app/src/utils/index.ts b/packages/app/src/utils/index.ts new file mode 100644 index 0000000000..b05505dbaa --- /dev/null +++ b/packages/app/src/utils/index.ts @@ -0,0 +1,6 @@ +export function allowCors(res: Response) { + res.headers.set('Access-Control-Allow-Origin', '*') + res.headers.set('Access-Control-Request-Method', '*') + res.headers.set('Access-Control-Allow-Methods', '*') + res.headers.set('Access-Control-Allow-Headers', '*') +} diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 9bfabeb98c..34d5b1638b 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -17,6 +17,7 @@ "baseUrl": ".", "composite": true, "incremental": true, + "rootDir": "../..", "plugins": [ { "name": "next" @@ -24,7 +25,8 @@ ], "paths": { "@prisma/client": ["./prisma/client-generated"], - "~/*": ["./src/*"] + "~/*": ["./src/*"], + "@theatre/utils/*": ["../utils/src/*"] }, "noEmit": false }, @@ -35,5 +37,6 @@ ".next/types/**/*.ts", "prisma/seed.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules"], + "references": [{"path": "../utils"}] } diff --git a/packages/saaz/src/rogue.ts b/packages/saaz/src/rogue.ts index 57d4f88e6e..6f6d1eb349 100644 --- a/packages/saaz/src/rogue.ts +++ b/packages/saaz/src/rogue.ts @@ -271,10 +271,11 @@ const traps: ProxyHandler = { if (Object.hasOwn(mapProps, prop)) { const value = mapProps[prop] - if (!isCell(value)) + if (!isCell(value)) { throw Error( `mapProps[${prop}] is not an ahistoric cell. this is a bug.`, ) + } if (value.$type[0] === 'deleted') return undefined if (value.$type[0] === 'boxed') { const boxedValue = diff --git a/packages/sync-server/src/trpc/routes/projectStateRouter.ts b/packages/sync-server/src/trpc/routes/projectStateRouter.ts index 307c174e5d..36e1bcdd07 100644 --- a/packages/sync-server/src/trpc/routes/projectStateRouter.ts +++ b/packages/sync-server/src/trpc/routes/projectStateRouter.ts @@ -1,6 +1,5 @@ import {z} from 'zod' import {createRouter, procedure} from '..' -import appClient from 'src/appClient' import {getSaazBack} from 'src/saaz' import {observable} from '@trpc/server/observable' @@ -8,20 +7,20 @@ const studioAuth = z.object({ accessToken: z.string(), }) -type Session = { - _accessToken: string -} +// type Session = { +// _accessToken: string +// } -export async function ensureSessionHasAccessToProject( - session: Session, - projectId: string, -) { - const {canEdit} = await appClient.studioAuth.canIEditProject.query({ - studioAuth: {accessToken: session._accessToken}, - projectId, - }) - return canEdit -} +// export async function ensureSessionHasAccessToProject( +// session: Session, +// projectId: string, +// ) { +// const {canEdit} = await appClient.studioAuth.canIEditProject.query({ +// studioAuth: {accessToken: session._accessToken}, +// projectId, +// }) +// return canEdit +// } export const projectState = createRouter({ saaz_applyUpdates: procedure diff --git a/packages/sync-server/src/utils/authUtils.ts b/packages/sync-server/src/utils/authUtils.ts index 74e8e0ec24..0706637ccb 100644 --- a/packages/sync-server/src/utils/authUtils.ts +++ b/packages/sync-server/src/utils/authUtils.ts @@ -1,8 +1,8 @@ import * as jose from 'jose' import {TRPCError} from '@trpc/server' -import type {AccessTokenPayload} from '@theatre/app/src/utils/authUtils' -import type {$IntentionalAny} from 'src/types' import {appHost} from 'src/appClient' +import type {studioAuthTokens} from '@theatre/app/types' +import type {$IntentionalAny} from '@theatre/utils/types' const jwtPublicKey = fetch(appHost + `/api/jwt-public-key`) .then((response) => response.json()) @@ -11,12 +11,13 @@ const jwtPublicKey = fetch(appHost + `/api/jwt-public-key`) export type Session = { _accessToken: string -} & AccessTokenPayload +} & studioAuthTokens.AccessTokenPayload export async function verifyAccessTokenOrThrow(opts: { input: {studioAuth: {accessToken: string}} }): Promise { const {accessToken} = opts.input.studioAuth + console.log('verifying ', accessToken) const publicKey = await jwtPublicKey try { @@ -24,12 +25,13 @@ export async function verifyAccessTokenOrThrow(opts: { maxTokenAge: '1h', }) - const {payload}: {payload: AccessTokenPayload} = res as $IntentionalAny + const {payload}: {payload: studioAuthTokens.AccessTokenPayload} = + res as $IntentionalAny + console.log('authorized') return {_accessToken: accessToken, ...payload} } catch (e) { - console.log(`e`, e) - console.log('jwt invalid') + console.log('unauthorized') throw new TRPCError({ code: 'UNAUTHORIZED', cause: 'InvalidSession', diff --git a/packages/theatric/package.json b/packages/theatric/package.json index 7ff1bb8b90..3068572694 100644 --- a/packages/theatric/package.json +++ b/packages/theatric/package.json @@ -42,7 +42,8 @@ "dependencies": { "@theatre/core": "workspace:*", "@theatre/react": "workspace:*", - "@theatre/studio": "workspace:*" + "@theatre/studio": "workspace:*", + "oauth4webapi": "^2.4.0" }, "peerDependencies": { "react": "*" diff --git a/packages/utils/src/basicFSM.ts b/packages/utils/src/basicFSM.ts index 2bac7649af..531b78c4f8 100644 --- a/packages/utils/src/basicFSM.ts +++ b/packages/utils/src/basicFSM.ts @@ -9,7 +9,7 @@ type TransitionFn = ( take: TakeFn, ) => void -type Actor = { +type Actor = { pointer: Pointer send: (s: EventType) => void } @@ -22,7 +22,7 @@ export function basicFSM( ): (actorOpts?: { name?: string log?: boolean -}) => Actor { +}) => Actor { return (actorOpts) => { const log = actorOpts?.log !== true diff --git a/packages/utils/src/delay.ts b/packages/utils/src/delay.ts index c134cdca72..611b9c4e6f 100644 --- a/packages/utils/src/delay.ts +++ b/packages/utils/src/delay.ts @@ -1,4 +1,29 @@ -const delay = (dur: number) => - new Promise((resolve) => setTimeout(resolve, dur)) +const delay = (dur: number, abortSignal?: AbortSignal) => + abortSignal ? delayWithAbort(dur, abortSignal) : delayWithoutAbort(dur) + +function delayWithoutAbort(dur: number) { + return new Promise((resolve) => { + setTimeout(resolve, dur) + }) +} + +function delayWithAbort(dur: number, abortSignal: AbortSignal) { + return new Promise((resolve, reject) => { + let pending = true + const timeout = setTimeout(() => { + pending = false + resolve(void 0) + }, dur) + + const onAbort = () => { + if (!pending) return + pending = false + clearTimeout(timeout) + reject(new Error('Aborted')) + abortSignal.removeEventListener('abort', onAbort) + } + abortSignal.addEventListener('abort', onAbort) + }) +} export default delay diff --git a/packages/utils/src/persistAtom.ts b/packages/utils/src/persistAtom.ts index 267ce9acdb..0538547a43 100644 --- a/packages/utils/src/persistAtom.ts +++ b/packages/utils/src/persistAtom.ts @@ -4,12 +4,17 @@ import debounce from 'lodash-es/debounce' const lastStateByStore = new WeakMap, {}>() +export type AtomPersistor = { + refresh: () => void + flush: () => void +} + export const persistAtom = ( atom: Atom<{}>, pointer: Pointer<{}>, onInitialize: () => void, storageKey: string, -) => { +): AtomPersistor => { const loadState = (s: {}) => { atom.setByPointer(pointer, s) } @@ -38,6 +43,16 @@ export const persistAtom = ( window.addEventListener('beforeunload', () => schedulePersist.flush()) } + return { + refresh: () => { + schedulePersist.flush() + loadFromPersistentStorage() + }, + flush: () => { + schedulePersist.flush() + }, + } + function loadFromPersistentStorage() { const persistedS = localStorage.getItem(storageKey) if (persistedS) { diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index 4bf4326d2b..efd2d7119d 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -6,13 +6,6 @@ export type $IntentionalAny = any export type Asset = {type: 'image'; id: string | undefined} export type File = {type: 'file'; id: string | undefined} -export type GenericAction = {type: string; payload: unknown} - -export type ReduxReducer = ( - s: undefined | State, - action: unknown, -) => State - export type VoidFn = () => void /** diff --git a/theatre/package.json b/theatre/package.json index 953a08e4cf..6b550c5541 100644 --- a/theatre/package.json +++ b/theatre/package.json @@ -75,7 +75,7 @@ "react-dom": "^18.2.0", "react-error-boundary": "^3.1.3", "react-hot-toast": "^2.4.0", - "react-icons": "^4.2.0", + "react-icons": "^4.12.0", "react-is": "^17.0.2", "react-merge-refs": "^2.0.2", "react-shadow": "^20.4.0", diff --git a/theatre/studio/src/Auth.ts b/theatre/studio/src/Auth.ts new file mode 100644 index 0000000000..2b3eff9051 --- /dev/null +++ b/theatre/studio/src/Auth.ts @@ -0,0 +1,574 @@ +import type { Prism} from '@theatre/dataverse'; +import {Atom, prism, val} from '@theatre/dataverse' +import {TRPCClientError} from '@trpc/client' +import delay from '@theatre/utils/delay' +import type {$FixMe, $IntentionalAny} from '@theatre/utils/types' +import {defer} from '@theatre/utils/defer' +import { + generateRandomCodeVerifier, + calculatePKCECodeChallenge, +} from 'oauth4webapi' +import type {Studio} from './Studio' +import getStudio from './getStudio' +import {decodeJwt} from 'jose' +import type {studioAccessScopes, studioAuthTokens} from '@theatre/app/types' +import type {AtomPersistor} from '@theatre/utils/persistAtom' +import {persistAtom} from '@theatre/utils/persistAtom' + +type PersistentState = + | {loggedIn: false} + | {loggedIn: true; idToken: string; accessToken: string} + +type ProcedureState = + | { + type: 'authorize' + deviceTokenFlowState: DeviceTokenFlowState | undefined + } + | { + type: 'expandScope' + additionalScope: studioAccessScopes.Scopes + deviceTokenFlowState: DeviceTokenFlowState | undefined + } + | { + type: 'refreshTokens' + } + | { + type: 'destroyIdToken' + } + +type CurrentProcedure = { + abortController: AbortController + promise: Promise + procedureState: ProcedureState +} + +type EphemeralState = { + loaded: boolean + currentProcedure: undefined | CurrentProcedure +} + +type DeviceTokenFlowState = + | { + type: 'waitingForDeviceCode' + // codeChallenge: string + // codeVerifier: string + } + | { + type: 'codeReady' + verificationUriComplete: string + // codeChallenge: string + // codeVerifier: string + // userCode: string + // deviceCode: string + // lastTokenRequestTime: number | undefined + // interval: number + } + +const bcId = Math.random().toString(36).slice(2) + +export type AuthDerivedState = + | 'loading' + | { + loggedIn: false + procedureState: undefined | ProcedureState + } + | { + loggedIn: true + user: {email: string} + procedureState: undefined | ProcedureState + } + +export default class Auth { + private _persistentState = new Atom({loggedIn: false}) + private _atomPersistor: AtomPersistor | undefined + private _broadcastChannel: BroadcastChannel | undefined + + private _ephemeralState = new Atom({ + currentProcedure: undefined, + loaded: false, + }) + + private _readyDeferred = defer() + readonly derivedState: Prism + + constructor(readonly studio: Studio) { + const persistAtomDeferred = defer() + + void this.studio._optsPromise.then((o) => { + if (o.usePersistentStorage === true) { + this._atomPersistor = persistAtom( + this._persistentState as $IntentionalAny as Atom<{}>, + this._persistentState.pointer as $IntentionalAny, + () => persistAtomDeferred.resolve(), + o.persistenceKey + 'auth', + ) + + const bc = new BroadcastChannel(`theatrejs-auth-${o.persistenceKey}`) + this._broadcastChannel = bc + // listen to changes from other tabs + bc.addEventListener('message', (e) => { + if (e.data !== bcId) { + this._atomPersistor?.refresh() + } + }) + } else { + persistAtomDeferred.resolve() + } + }) + + void persistAtomDeferred.promise.then(() => { + this._readyDeferred.resolve() + }) + + void this._readyDeferred.promise.then(() => { + this._ephemeralState.setByPointer((p) => p.loaded, true) + }) + + this.derivedState = prism((): AuthDerivedState => { + const ephemeralState = val(this._ephemeralState.pointer) + if (!ephemeralState.loaded) { + return 'loading' + } + const persistentState = val(this._persistentState.pointer) + if (persistentState.loggedIn) { + return { + loggedIn: true, + user: getIdTokenClaimsWithoutVerifying(persistentState.idToken) ?? { + email: 'unknown', + }, + procedureState: ephemeralState.currentProcedure?.procedureState, + } + } else { + return { + loggedIn: false, + procedureState: ephemeralState.currentProcedure?.procedureState, + } + } + }) + } + + get ready() { + return this._readyDeferred.promise + } + + private async _acquireLock( + abortController: AbortController, + initialState: ProcedureState, + cb: ( + abortSignal: AbortSignal, + setState: (procedureState: ProcedureState) => void, + ) => Promise, + ): Promise { + if (this._readyDeferred.status !== 'resolved') { + throw new Error('Not ready') + } + if (this._ephemeralState.get().currentProcedure) { + throw new Error('Already running a procedure') + } + + const d = defer() + + try { + await navigator.locks.request( + 'theatrejs-auth', + {mode: 'exclusive', ifAvailable: true}, + async (possibleLock) => { + if (!possibleLock) { + d.reject(new Error('Failed to acquire lock')) + return + } + + if (abortController.signal.aborted) { + d.reject(new Error('Aborted')) + return + } + + const currentProcedure: CurrentProcedure = { + abortController, + promise: d.promise.then(() => {}), + procedureState: initialState, + } + this._ephemeralState.setByPointer( + (p) => p.currentProcedure, + currentProcedure, + ) + this._atomPersistor?.refresh() + const setProcedureState = (procedureState: ProcedureState) => { + this._ephemeralState.setByPointer((p) => p.currentProcedure, { + ...currentProcedure, + procedureState, + }) + } + + try { + const ret = await cb(abortController.signal, setProcedureState) + this._atomPersistor?.flush() + this._broadcastChannel?.postMessage(bcId) + d.resolve(ret) + } catch (err) { + d.reject(err) + } + }, + ) + } finally { + this._ephemeralState.setByPointer((p) => p.currentProcedure, undefined) + } + + return d.promise + } + + /** + * Runs the authorization procedure. Will error if already authorized, or if another procedure is already running. + */ + async authorize() { + const abortController = new AbortController() + const initialState: ProcedureState = { + type: 'authorize', + deviceTokenFlowState: undefined, + } + await this._acquireLock( + abortController, + initialState, + async (abortSignal, setState) => { + const persistentState = this._persistentState.get() + + if (persistentState.loggedIn) { + throw new Error('Already authorized') + } + + const result = await tokenProcedures.deviceAuthorizationFlow( + ['workspaces-list'], + undefined, + (state) => { + setState({...initialState, deviceTokenFlowState: state}) + }, + abortSignal, + ) + + if (!result.success) { + throw new Error(`Failed to authorize: ${result.error}: ${result.msg}`) + } + + const {accessToken, idToken} = result + this._persistentState.set({loggedIn: true, accessToken, idToken}) + }, + ) + } + + /** + * Runs the authorization procedure. Will if another procedure is already running. + */ + async deauthorize() { + const abortController = new AbortController() + const initialState: ProcedureState = { + type: 'destroyIdToken', + } + await this._acquireLock( + abortController, + initialState, + async (abortSignal) => { + const persistentState = this._persistentState.get() + + if (!persistentState.loggedIn) { + return + } + + const result = await tokenProcedures.destroyIdToken( + persistentState.idToken, + abortSignal, + ) + + if (!result.success) { + throw new Error( + `Failed to deauthorize: ${result.error}: ${result.msg}`, + ) + } + + this._persistentState.set({loggedIn: false}) + }, + ) + } + + // /** + // * Resolves with the access token. If no token is set, it'll wait until one is. Note that this will _NOT_ trigger authentication, + // * so if this function is called and the user is not authenticated, it'll wait forever. + // */ + // private async _getValidAccessToken(): Promise { + // // no ongoing authentication atm + // if (!this._ephemeralState.get().authFlowState) { + // const authState = this._persistentState.get() + // // and the access token is available + // if (authState) { + // return authState.accessToken + // } else { + // throw new Error('Not authenticated') + // } + // } else { + // const accessToken = await this._waitForAccessToken() + // return accessToken + // } + // } + + /** + * If logged in, returns the access token. If not, it'll wait until the user + * initiaites a login flow, and then return the access token. + */ + // private async _waitForAccessToken(): Promise { + // await this._readyDeferred.promise + // const notReady = {} + // const accessToken = await waitForPrism( + // prism(() => { + // const s = val(this._mishmashState.pointer) + + // if (s.type === 'loggedIn') { + // return s.accessToken + // } + // return notReady + // }), + // (v): v is string => v !== notReady, + // ) + // return accessToken as $IntentionalAny + // } + + private _getAccessToken(): string | undefined { + const s = val(this._persistentState.pointer) + if (s.loggedIn) { + return s.accessToken + } + return undefined + } + + async _refreshTokens() { + // TODO + } + + async wrapTrpcProcedureWithAuth< + Input, + Ret, + Fn extends ( + input: Input & {studioAuth: {accessToken: string}}, + opts: $IntentionalAny, + ) => Promise, + >( + fn: Fn, + args: [Input, $IntentionalAny], + path: string[], + retriesLeft: number = 3, + ): Promise { + await this.ready + const accessToken = this._getAccessToken() + if (!accessToken) { + throw new Error('Not authenticated') + } + const [input, opts] = args + try { + const isSubscribe = path[path.length - 1] === 'subscribe' + if (isSubscribe) { + const response = fn({...input, studioAuth: {accessToken}}, opts) + return response + } else { + const response = await fn({...input, studioAuth: {accessToken}}, opts) + return response + } + } catch (err) { + console.log('err', err) + if (err instanceof TRPCClientError && err.data.code === 'UNAUTHORIZED') { + console.log('is unaothorized error') + if (retriesLeft <= 0) { + return Promise.reject(err) + } + // this is a 401, which means as long as we have a valid accessToken, we should be able to retry the request + await this._refreshTokens() + return this.wrapTrpcProcedureWithAuth(fn, args, path, retriesLeft - 1) + } else { + return Promise.reject(err) + } + } + } +} + +namespace tokenProcedures { + /** + * Runs a device authorization flow, and returns the access token and id token if successful. + * + * @param scope - The scopes to request. If originalIdToken is provided, the scopes will be expanded to include the scopes of the original token. + * @param originalIdToken - The original id token, if any. If provided, the scopes will be expanded to include the scopes of the original token. + * @param stateChange - A callback that will be called whenever the state of the flow changes. + * @param abortSignal - The abort signal to use for the flow. + * @returns + */ + export async function deviceAuthorizationFlow( + scope: studioAccessScopes.Scopes, + originalIdToken: string | undefined, + stateChange: (s: DeviceTokenFlowState) => void, + abortSignal?: AbortSignal, + ): Promise< + | {success: true; accessToken: string; idToken: string} + | {success: false; error: 'userDenied' | 'unknown'; msg?: string} + > { + const appLink = await getStudio()._rawLinks.app + + let outerTries = 0 + outer: while (outerTries < 2) { + outerTries++ + + if (abortSignal?.aborted) { + return {success: false, error: 'unknown', msg: 'Aborted'} + } + + const nounce = generateRandomCodeVerifier() + const codeVerifier = generateRandomCodeVerifier() + const codeChallenge = await calculatePKCECodeChallenge(codeVerifier) + + stateChange({ + type: 'waitingForDeviceCode', + // codeChallenge, + // codeVerifier, + }) + + const flowInitResult = await appLink.api.studioAuth.deviceCode.mutate( + { + nounce, + codeChallenge, + codeChallengeMethod: 'S256', + scopes: scope, + originalIdToken, + }, + {signal: abortSignal}, + ) + + stateChange({ + type: 'codeReady', + // codeChallenge, + // codeVerifier, + // deviceCode: flowInitResult.deviceCode, + verificationUriComplete: flowInitResult.verificationUriComplete, + // userCode: '', + // interval: flowInitResult.interval, + // lastTokenRequestTime: undefined, + }) + + // window.open(flowInitResult.verificationUriComplete, '_blank') + + inner: while (true) { + if (abortSignal?.aborted) { + return {success: false, error: 'unknown', msg: 'Aborted'} + } + await delay(flowInitResult.interval + 1000, abortSignal) + try { + const result = await appLink.api.studioAuth.tokens.mutate( + { + deviceCode: flowInitResult.deviceCode, + codeVerifier, + }, + {signal: abortSignal}, + ) + if (result.isError) { + if (result.error === 'invalidDeviceCode') { + console.error(result) + continue outer + } else if (result.error === 'userDeniedAuth') { + return {success: false, error: 'userDenied'} + } else if (result.error === 'notYetReady') { + continue inner + } else { + const msg = `Unknown error returned from app-studio-trpc: ${result.error} - ${result.errorMessage}` + console.error(msg) + return {success: false, error: 'unknown', msg} + } + } else { + const {idToken, accessToken} = result + + // TODO verify that the refresh token has the correct nounce + if (false) { + console.warn('The request returned an invalid nounce') + continue outer + } + + return {success: true, accessToken, idToken} + } + } catch (err) { + console.error(err) + return { + success: false, + error: 'unknown', + msg: (err as $IntentionalAny).message, + } + } + } + } + return {success: false, error: 'unknown'} + } + + export async function refreshTokens( + originalIdToken: string, + abortSignal?: AbortSignal, + ): Promise< + | {success: true; accessToken: string; idToken: string} + | {success: false; error: 'invalidIdToken' | 'unknown'; msg: string} + > { + const appLink = await getStudio()._rawLinks.app + let tries = 0 + const MAX_TRIES = 8 + while (true) { + if (abortSignal?.aborted) { + return {success: false, error: 'unknown', msg: 'Aborted'} + } + tries++ + if (tries > MAX_TRIES) { + return {success: false, error: 'unknown', msg: 'Too many tries'} + } + const result = await appLink.api.studioAuth.refreshAccessToken.mutate( + {refreshToken: originalIdToken}, + {signal: abortSignal}, + ) + if (result.isError) { + if (result.error === 'invalidRefreshToken') { + throw new Error('Invalid refresh token') + } else { + if (tries > MAX_TRIES) { + return {success: false, error: 'unknown', msg: result.errorMessage} + } + continue + } + } else { + return { + accessToken: result.accessToken, + idToken: result.refreshToken, + success: true, + } + } + } + } + + export async function destroyIdToken( + idToken: string, + abortSignal?: AbortSignal, + ): Promise< + | {success: true} + | {success: false; error: 'invalidIdToken' | 'unknown'; msg: string} + > { + const appLink = await getStudio()._rawLinks.app + const result = await appLink.api.studioAuth.destroyIdToken.mutate( + {idToken: idToken}, + {signal: abortSignal}, + ) + if (result.isError) { + return {success: false, error: 'unknown', msg: result.errorMessage} + } else { + return {success: true} + } + } +} + +function getIdTokenClaimsWithoutVerifying( + idToken: string, +): undefined | studioAuthTokens.IdTokenPayload { + try { + const s = decodeJwt(idToken) + return s as $FixMe + } catch (err) { + console.log(`getIdTokenClaimsWithoutVerifying failed:`, err) + return undefined + } +} diff --git a/theatre/studio/src/Storno/Storno.ts b/theatre/studio/src/Storno/Storno.ts new file mode 100644 index 0000000000..093c848b9d --- /dev/null +++ b/theatre/studio/src/Storno/Storno.ts @@ -0,0 +1,102 @@ +import type {Prism} from '@theatre/dataverse' +import {Atom, prism, val} from '@theatre/dataverse' +import type {$FixMe} from '@theatre/utils/types' + +type State = { + loginState: + | {loggedIn: false} + | {loggedIn: true; accessToken: string; refreshToken: string} + + authenticateProcessState: + | { + // the authentication flow is not started, becuase either the user is already logged in, or the user hasn't tried to log in yet + type: 'idle' + } + | { + // the user tried to log in, and we're waiting for the server to generate a checkToken, after which we'll redirect the user to the authentication page of the server + type: 'autnenticating/waiting-for-checkToken' + // the time at which the user tried to log in + time: number + // a random string generated on the client, which will be used to identify the authentication flow + clientFlowToken: string + // the time at which waiting for checkToken will expire + expiresAt: number + } + | { + // the user tried to log in, but the server failed to generate a checkToken. We should display an error message to the user + type: 'authenticating/waiting-for-checkToken/error' + error: {code: string; message: string} + // the time at which the error was received + time: number + } + | { + // we've received a checkToken + type: 'autnenticating/waiting-for-accessToken' + checkToken: string + // the url to which we should redirect the user + userAuthUrl: string + // the interval at which we should poll the server for the access token + interval: number + // the clientFlowToken that we sent to the server + clientFlowToken: string + // the time at which waiting for accessToken will expire + expiresAt: number + } + | { + // for some reason, the server did not send us an access token. We should display an error message to the user + type: 'autnenticating/waiting-for-accessToken/error' + error: + | { + // user denied this session access + code: 0 + message: string + } + | { + // other error + code: 1 + message: string + } + } + | { + // we've received an access/refreshtoken (saved to loginState). This state is used to display a success message to the user, after which we'll switch to idle + type: 'success' + time: number + } +} + +type UserInfo = { + email: string + displayName: string + avatarUrl: string + id: string +} + +export default class Storno { + protected _atom: Atom + + protected _userInfo: Prism + + constructor() { + this._atom = new Atom({ + loginState: {loggedIn: false}, + authenticateProcessState: {type: 'idle'}, + }) + + this._userInfo = prism(() => { + const loginState = val(this._atom.pointer.loginState) + if (!loginState.loggedIn) { + return null + } + + const {accessToken} = loginState + const payload = accessToken as $FixMe // TODO: decode payload + // let's trust that the payload is correct + + return payload.userInfo + }) + } + + get userInfoPr(): Prism { + return null as $FixMe + } +} diff --git a/theatre/studio/src/Studio.ts b/theatre/studio/src/Studio.ts index 468ee2c7c8..3bd499d1cc 100644 --- a/theatre/studio/src/Studio.ts +++ b/theatre/studio/src/Studio.ts @@ -8,7 +8,12 @@ import type { ITransactionPrivateApi, } from './StudioStore/StudioStore' import StudioStore from './StudioStore/StudioStore' -import type {IExtension, IStudio, PaneClassDefinition} from './TheatreStudio' +import type { + IExtension, + IStudio, + PaneClassDefinition, + _StudioInitializeOpts, +} from './TheatreStudio' import TheatreStudio from './TheatreStudio' import {nanoid} from 'nanoid/non-secure' import type Project from '@theatre/core/projects/Project' @@ -32,6 +37,13 @@ import {notify} from './notify' import type {RafDriverPrivateAPI} from '@theatre/core/rafDrivers' import {persistAtom} from '@theatre/utils/persistAtom' import produce from 'immer' +import Storno from './Storno/Storno' +import Auth from './Auth' +import type {$IntentionalAny} from '@theatre/utils/types' +import AppLink from './SyncStore/AppLink' +import SyncServerLink from './SyncStore/SyncServerLink' +import type {TrpcClientWrapped} from './SyncStore/utils'; +import { wrapTrpcClientWithAuth} from './SyncStore/utils' const DEFAULT_PERSISTENCE_KEY = 'theatre-0.4' @@ -65,6 +77,13 @@ studio.initialize() \`\`\` ` +export type StudioOpts = { + serverUrl: string + persistenceKey: string + usePersistentStorage: boolean + rafDriver: RafDriverPrivateAPI +} + export type UpdateCheckerResponse = | {hasUpdates: true; newVersion: string; releasePage: string} | {hasUpdates: false} @@ -81,7 +100,9 @@ export class Studio { readonly projectsP: Pointer> = this._projectsProxy.pointer - private readonly _store = new StudioStore() + readonly _store: StudioStore + readonly auth: Auth + private readonly _storno = new Storno() private _corePrivateApi: CoreBits['privateAPI'] | undefined @@ -93,11 +114,6 @@ export class Studio { */ private _coreAtom = new Atom<{core?: CoreExports}>({}) - /** - * A Deferred that will resolve once studio is initialized (and its state is read from storage) - */ - private readonly _initializedDeferred: Deferred = defer() - readonly ephemeralAtom = new Atom<{ // reflects the value of _initializedDeferred.promise. Since it's in an atom, it can be accessed via a pointer initialized: boolean @@ -141,6 +157,20 @@ export class Studio { */ private _coreBits: CoreBits | undefined + private _optsDeferred: Deferred + + private readonly _initializedPromise: Promise + + readonly _rawLinks: { + app: Promise + syncServer: Promise + } + + readonly authedLinks: { + app: TrpcClientWrapped + syncServer: TrpcClientWrapped + } + get ticker(): Ticker { if (!this._rafDriver) { throw new Error( @@ -156,8 +186,67 @@ export class Studio { return this._store.atomP } + get _optsPromise() { + return this._optsDeferred.promise + } + constructor() { this.address = {studioId: nanoid(10)} + this._optsDeferred = defer() + + const syncServerLinkDeferred = defer() + const syncServerLink = syncServerLinkDeferred.promise + const appLink = this._optsPromise.then( + ({serverUrl}): AppLink => + typeof window === 'undefined' + ? (null as $IntentionalAny) + : new AppLink(serverUrl), + ) + + if (typeof window !== 'undefined') { + void appLink + .then((appLink) => { + return appLink.api.syncServerUrl.query().then((url) => { + syncServerLinkDeferred.resolve(new SyncServerLink(url)) + }) + }) + .catch((err) => { + syncServerLinkDeferred.reject(err) + console.error(err) + }) + } else { + syncServerLinkDeferred.resolve(null as $IntentionalAny) + } + + this._rawLinks = {app: appLink, syncServer: syncServerLink} + + this.auth = + typeof window !== 'undefined' ? new Auth(this) : (null as $IntentionalAny) + + this.authedLinks = { + syncServer: wrapTrpcClientWithAuth( + this._rawLinks.syncServer.then((s) => s.api), + (fn: any, args: any[], path): any => { + return this.auth.wrapTrpcProcedureWithAuth( + fn, + args as $IntentionalAny, + path, + ) + }, + ), + + app: wrapTrpcClientWithAuth( + this._rawLinks.app.then((s) => s.api), + (fn: any, args: any[], path): any => + this.auth.wrapTrpcProcedureWithAuth( + fn, + args as $IntentionalAny, + path, + ), + ), + } + + this._store = new StudioStore(this) this.publicApi = new TheatreStudio(this) this.ui = new UI(this) @@ -174,52 +263,17 @@ export class Studio { } }, 100) } - } - - async initialize(opts?: Parameters[0]) { - if (!this._coreBits) { - throw new Error( - `You seem to have imported \`@theatre/studio\` without importing \`@theatre/core\`. Make sure to include an import of \`@theatre/core\` before calling \`studio.initializer()\`.`, - ) - } - - if (this._initializeFnCalled) { - return this._initializedDeferred.promise - } - this._initializeFnCalled = true - - if (this._didWarnAboutNotInitializing) { - console.warn(STUDIO_INITIALIZED_LATE_MSG) - } - - const storeOpts: Parameters[0] = { - persistenceKey: DEFAULT_PERSISTENCE_KEY, - usePersistentStorage: true, - serverUrl: process.env.BACKEND_URL ?? 'https://app.theatrejs.com', - } - if (typeof opts?.serverUrl == 'string') { - if ( - // a fully formed url - opts.serverUrl.match(/^(https?:)?\/\//) && - // not ending with a slash - !opts.serverUrl.endsWith('/') - ) { - storeOpts.serverUrl = opts.serverUrl - } else { - throw new Error( - 'parameter `serverUrl` in `studio.initialize({serverUrl})` must be either undefined or a fully formed url (e.g. `https://app.theatrejs.com`)', - ) - } - } + this._initializedPromise = this._init() - if (typeof opts?.persistenceKey === 'string') { - storeOpts.persistenceKey = opts.persistenceKey - } + this._initializedPromise.catch((reason) => { + console.error(reason) + return Promise.reject(reason) + }) + } - if (opts?.usePersistentStorage === false || typeof window === 'undefined') { - storeOpts.usePersistentStorage = false - } + async _init() { + const storeOpts = await this._optsDeferred.promise const ahistoricAtomInitializedD = defer() if (storeOpts.usePersistentStorage) { @@ -235,42 +289,13 @@ export class Studio { ahistoricAtomInitializedD.resolve() } - if (opts?.__experimental_rafDriver) { - if ( - opts.__experimental_rafDriver.type !== 'Theatre_RafDriver_PublicAPI' - ) { - throw new Error( - 'parameter `rafDriver` in `studio.initialize({__experimental_rafDriver})` must be either be undefined, or the return type of core.createRafDriver()', - ) - } - - const rafDriverPrivateApi = this._coreBits.privateAPI( - opts.__experimental_rafDriver, - ) - if (!rafDriverPrivateApi) { - // TODO - need to educate the user about this edge case - throw new Error( - 'parameter `rafDriver` in `studio.initialize({__experimental_rafDriver})` seems to come from a different version of `@theatre/core` than the version that is attached to `@theatre/studio`', - ) - } - this._rafDriver = rafDriverPrivateApi - } else { - this._rafDriver = this._coreBits.getCoreRafDriver() - } - - try { - await this._store.initialize(storeOpts) - } catch (e) { - this._initializedDeferred.reject(e) - return - } + this._rafDriver = storeOpts.rafDriver if (process.env.NODE_ENV !== 'test' && typeof window !== 'undefined') { await this.ui.ready } await ahistoricAtomInitializedD.promise - this._initializedDeferred.resolve() this.ephemeralAtom.setByPointer((p) => p.initialized, true) if (process.env.NODE_ENV !== 'test') { @@ -281,8 +306,31 @@ export class Studio { } } + async initialize(opts?: _StudioInitializeOpts) { + if (!this._coreBits) { + throw new Error( + `You seem to have imported \`@theatre/studio\` without importing \`@theatre/core\`. Make sure to include an import of \`@theatre/core\` before calling \`studio.initializer()\`.`, + ) + } + + if (this._initializeFnCalled) { + return this._initializedPromise + } + this._initializeFnCalled = true + + if (this._didWarnAboutNotInitializing) { + console.warn(STUDIO_INITIALIZED_LATE_MSG) + } + + if (this._optsDeferred.status === 'pending') { + this._optsDeferred.resolve(sanitizeOpts(opts, this._coreBits)) + } + + return this._initializedPromise + } + get initialized(): Promise { - return this._initializedDeferred.promise + return this._initializedPromise } get initializedP(): Pointer { @@ -334,12 +382,6 @@ export class Studio { return this._store.transaction(fn, undoable) } - authenticate( - opts: Parameters[0], - ): ReturnType { - return this._store.authenticate(opts) - } - __dev_startHistoryFromScratch(newHistoricPart: StudioHistoricState) { return this._store.__dev_startHistoryFromScratch(newHistoricPart) } @@ -639,3 +681,59 @@ export class Studio { this._store.__experimental_clearPersistentStorage(persistenceKey) } } + +function sanitizeOpts( + opts: _StudioInitializeOpts | undefined, + coreBits: CoreBits, +): StudioOpts { + const storeOpts: StudioOpts = { + persistenceKey: DEFAULT_PERSISTENCE_KEY, + usePersistentStorage: true, + serverUrl: process.env.BACKEND_URL ?? 'https://app.theatrejs.com', + rafDriver: coreBits.getCoreRafDriver(), + } + + if (typeof opts?.serverUrl == 'string') { + if ( + // a fully formed url + opts.serverUrl.match(/^(https?:)?\/\//) && + // not ending with a slash + !opts.serverUrl.endsWith('/') + ) { + storeOpts.serverUrl = opts.serverUrl + } else { + throw new Error( + 'parameter `serverUrl` in `studio.initialize({serverUrl})` must be either undefined or a fully formed url (e.g. `https://app.theatrejs.com`)', + ) + } + } + + if (typeof opts?.persistenceKey === 'string') { + storeOpts.persistenceKey = opts.persistenceKey + } + + if (opts?.usePersistentStorage === false || typeof window === 'undefined') { + storeOpts.usePersistentStorage = false + } + + if (opts?.__experimental_rafDriver) { + if (opts?.__experimental_rafDriver.type !== 'Theatre_RafDriver_PublicAPI') { + throw new Error( + 'parameter `rafDriver` in `studio.initialize({__experimental_rafDriver})` must be either be undefined, or the return type of core.createRafDriver()', + ) + } + + const rafDriverPrivateApi = coreBits.privateAPI( + opts.__experimental_rafDriver, + ) + if (!rafDriverPrivateApi) { + // TODO - need to educate the user about this edge case + throw new Error( + 'parameter `rafDriver` in `studio.initialize({__experimental_rafDriver})` seems to come from a different version of `@theatre/core` than the version that is attached to `@theatre/studio`', + ) + } + storeOpts.rafDriver = rafDriverPrivateApi + } + + return storeOpts +} diff --git a/theatre/studio/src/StudioStore/StudioStore.ts b/theatre/studio/src/StudioStore/StudioStore.ts index 581aae3687..b1493de8e2 100644 --- a/theatre/studio/src/StudioStore/StudioStore.ts +++ b/theatre/studio/src/StudioStore/StudioStore.ts @@ -4,7 +4,6 @@ import type { StudioHistoricState, StudioState, } from '@theatre/sync-server/state/types' -import {defer} from '@theatre/utils/defer' import type {$FixMe, $IntentionalAny, VoidFn} from '@theatre/utils/types' import {Atom} from '@theatre/dataverse' import type {Pointer} from '@theatre/dataverse' @@ -12,12 +11,6 @@ import type {Draft} from 'immer' import type {OnDiskState} from '@theatre/sync-server/state/types/core' import * as Saaz from '@theatre/saaz' import type {ProjectId} from '@theatre/sync-server/state/types/core' -import AppLink from '@theatre/studio/SyncStore/AppLink' -import type { - TrpcClientWrapped, -} from '@theatre/studio/SyncStore/SyncStoreAuth'; -import SyncStoreAuth from '@theatre/studio/SyncStore/SyncStoreAuth' -import SyncServerLink from '@theatre/studio/SyncStore/SyncServerLink' import {schema} from '@theatre/sync-server/state/schema' import type { IInvokableDraftEditors, @@ -26,6 +19,8 @@ import type { } from '@theatre/sync-server/state/schema' import createTransactionPrivateApi from './createTransactionPrivateApi' import {SaazBack} from '@theatre/saaz' +import type {Studio} from '@theatre/studio/Studio' +import getStudio from '@theatre/studio/getStudio' export type Drafts = { historic: Draft @@ -33,12 +28,6 @@ export type Drafts = { ephemeral: Draft } -export type StudioStoreOptions = { - serverUrl: string - persistenceKey: string - usePersistentStorage: boolean -} - export interface ITransactionPrivateApi { set(pointer: Pointer, value: T): void unset(pointer: Pointer): void @@ -56,15 +45,6 @@ export default class StudioStore { private readonly _atom: Atom readonly atomP: Pointer - private _appLink: Promise - private _syncServerLink: Promise - private _auth: SyncStoreAuth - - private _state = new Atom<{ - ready: boolean - }>({ready: false}) - - private _optionsDeferred = defer() private _saaz: Saaz.SaazFront< {$schemaVersion: number}, IStateEditors, @@ -72,47 +52,7 @@ export default class StudioStore { StudioState > - constructor() { - const syncServerLinkDeferred = defer() - this._syncServerLink = syncServerLinkDeferred.promise - this._appLink = this._optionsDeferred.promise.then(({serverUrl}) => - typeof window === 'undefined' && false - ? (null as $IntentionalAny) - : new AppLink(serverUrl), - ) - - if (typeof window !== 'undefined') { - void this._appLink - .then((appLink) => { - return appLink.api.syncServerUrl.query().then((url) => { - syncServerLinkDeferred.resolve(new SyncServerLink(url)) - }) - }) - .catch((err) => { - syncServerLinkDeferred.reject(err) - console.error(err) - }) - } else { - syncServerLinkDeferred.resolve(null as $IntentionalAny) - } - - this._auth = - typeof window !== 'undefined' - ? new SyncStoreAuth( - this._optionsDeferred.promise, - this._appLink, - this._syncServerLink, - ) - : (null as $IntentionalAny) - - if (typeof window !== 'undefined') { - void this._auth.ready.then(() => { - this._state.setByPointer((p) => p.ready, true) - }) - } else { - this._state.setByPointer((p) => p.ready, true) - } - + constructor(readonly studio: Studio) { const backend = typeof window === 'undefined' ? new SaazBack({ @@ -121,15 +61,14 @@ export default class StudioStore { schema, }) : createTrpcBackend( - this._optionsDeferred.promise.then((opts) => opts.persistenceKey), - this.syncServerApi, + this.studio._optsPromise.then((opts) => opts.persistenceKey), ) const saaz = new Saaz.SaazFront({ schema, dbName: 'test', storageAdapter: - typeof window === 'undefined' || process.env.NODE_ENV === 'test' + typeof window === 'undefined' || process.env.NODE_ENV === 'test' || true ? new Saaz.FrontMemoryAdapter() : new Saaz.FrontIDBAdapter('blah', 'test'), backend, @@ -145,14 +84,6 @@ export default class StudioStore { this.atomP = this._atom.pointer } - async initialize(opts: { - serverUrl: string - persistenceKey: string - usePersistentStorage: boolean - }): Promise { - this._optionsDeferred.resolve(opts) - } - getState(): StudioState { return this._atom.get() // return this._reduxStore.getState() @@ -301,23 +232,20 @@ export default class StudioStore { // return generatedOnDiskState } - authenticate(opts?: Parameters[0]) { - return this._auth.authenticate(opts) - } + // get appApi(): TrpcClientWrapped { + // return this.auth.appApi + // } - get appApi(): TrpcClientWrapped { - return this._auth.appApi - } - - get syncServerApi(): TrpcClientWrapped { - return this._auth.syncServerApi - } + // get syncServerApi(): TrpcClientWrapped { + // return this.auth.syncServerApi + // } } + function createTrpcBackend( dbNamePromise: Promise, - syncServerApi: StudioStore['syncServerApi'], ): Saaz.SaazBackInterface { const applyUpdates: Saaz.SaazBackInterface['applyUpdates'] = async (opts) => { + const syncServerApi = await getStudio().authedLinks.syncServer const dbName = await dbNamePromise return await syncServerApi.projectState.saaz_applyUpdates.mutate({ dbName, @@ -329,6 +257,8 @@ function createTrpcBackend( opts, ) => { const dbName = await dbNamePromise + const syncServerApi = await getStudio().authedLinks.syncServer + return await syncServerApi.projectState.saaz_updatePresence.mutate({ dbName, opts, @@ -338,6 +268,7 @@ function createTrpcBackend( const getUpdatesSinceClock: Saaz.SaazBackInterface['getUpdatesSinceClock'] = async (opts) => { const dbName = await dbNamePromise + const syncServerApi = await getStudio().authedLinks.syncServer return await syncServerApi.projectState.saaz_getUpdatesSinceClock.query({ dbName, @@ -348,6 +279,7 @@ function createTrpcBackend( const getLastIncorporatedPeerClock: Saaz.SaazBackInterface['getLastIncorporatedPeerClock'] = async (opts) => { const dbName = await dbNamePromise + const syncServerApi = await getStudio().authedLinks.syncServer return await syncServerApi.projectState.saaz_getLastIncorporatedPeerClock.query( { @@ -359,6 +291,7 @@ function createTrpcBackend( const closePeer: Saaz.SaazBackInterface['closePeer'] = async (opts) => { const dbName = await dbNamePromise + const syncServerApi = await getStudio().authedLinks.syncServer return await syncServerApi.projectState.saaz_closePeer.mutate({ dbName, @@ -371,6 +304,7 @@ function createTrpcBackend( onUpdate, ) => { const dbName = await dbNamePromise + const syncServerApi = await getStudio().authedLinks.syncServer const subscription = syncServerApi.projectState.saaz_subscribe.subscribe( { diff --git a/theatre/studio/src/SyncStore/AppLink.ts b/theatre/studio/src/SyncStore/AppLink.ts index 7447f83b69..c314ab0b9f 100644 --- a/theatre/studio/src/SyncStore/AppLink.ts +++ b/theatre/studio/src/SyncStore/AppLink.ts @@ -1,22 +1,31 @@ -import type {AppRouter} from '@theatre/app/server/api/root' +import type {StudioTRPCRouter} from '@theatre/app/server/studio-api/root' import type {CreateTRPCProxyClient} from '@trpc/client' -import {createTRPCProxyClient, httpBatchLink} from '@trpc/client' +import { + createTRPCProxyClient, + loggerLink, + unstable_httpBatchStreamLink, +} from '@trpc/client' import superjson from 'superjson' export default class AppLink { - private _client!: CreateTRPCProxyClient + private _client!: CreateTRPCProxyClient constructor(private _webAppUrl: string) { if (process.env.NODE_ENV === 'test') return - this._client = createTRPCProxyClient({ + this._client = createTRPCProxyClient({ links: [ - httpBatchLink({ - url: _webAppUrl + '/api/trpc', - async headers() { - return { - // authorization: getAuthCookie(), - } + loggerLink({ + console: { + log: (arg0, ...rest) => console.info('AppLink ' + arg0, ...rest), + error: (arg0, ...rest) => console.error('AppLink ' + arg0, ...rest), }, + enabled: (opts) => + (process.env.NODE_ENV === 'development' && + typeof window !== 'undefined') || + (opts.direction === 'down' && opts.result instanceof Error), + }), + unstable_httpBatchStreamLink({ + url: _webAppUrl + '/api/studio-trpc', }), ], transformer: superjson, diff --git a/theatre/studio/src/SyncStore/SyncServerLink.ts b/theatre/studio/src/SyncStore/SyncServerLink.ts index 92670770f6..ba1f39e6c6 100644 --- a/theatre/studio/src/SyncStore/SyncServerLink.ts +++ b/theatre/studio/src/SyncStore/SyncServerLink.ts @@ -19,7 +19,16 @@ export default class SyncServerLink { this._client = createTRPCProxyClient({ links: [ loggerLink({ - enabled: (opts) => false, + console: { + log: (arg0, ...rest) => + console.info('SyncServerLink ' + arg0, ...rest), + error: (arg0, ...rest) => + console.error('SyncServerLink ' + arg0, ...rest), + }, + enabled: (opts) => + (process.env.NODE_ENV === 'development' && + typeof window !== 'undefined') || + (opts.direction === 'down' && opts.result instanceof Error), }), wsLink({client: wsClient}), ], diff --git a/theatre/studio/src/SyncStore/SyncStoreAuth.ts b/theatre/studio/src/SyncStore/SyncStoreAuth.ts deleted file mode 100644 index 3565b184ec..0000000000 --- a/theatre/studio/src/SyncStore/SyncStoreAuth.ts +++ /dev/null @@ -1,366 +0,0 @@ -import {Atom, prism, val} from '@theatre/dataverse' -import {TRPCClientError} from '@trpc/client' -import {v4} from 'uuid' -import delay from '@theatre/utils/delay' -import type {$IntentionalAny} from '@theatre/utils/types' -import waitForPrism from '@theatre/utils/waitForPrism' -import {defer} from '@theatre/utils/defer' -import {persistAtom} from '@theatre/utils/persistAtom' -import type SyncServerLink from './SyncServerLink' -import type AppLink from './AppLink' -import {get} from 'lodash-es' -import type {StudioStoreOptions} from '@theatre/studio/StudioStore/StudioStore' - -export default class SyncStoreAuth { - private _persistentState = new Atom< - | undefined - | { - accessToken: string - refreshToken: string - } - >(undefined) - - private _ephemeralState = new Atom<{ - authFlowState: - | 'autnenticating/waiting-for-checkToken' - | 'autnenticating/waiting-for-accessToken' - | undefined - ready: boolean - }>({authFlowState: undefined, ready: false}) - - public readonly ready: Promise - - public readonly syncServerApi: TrpcClientWrapped - - public readonly appApi: TrpcClientWrapped - - constructor( - protected readonly _options: Promise, - protected readonly _appLink: Promise, - protected readonly _syncServerLink: Promise, - ) { - const persistAtomDeferred = defer() - this.ready = persistAtomDeferred.promise - void this.ready.then(() => - this._ephemeralState.setByPointer((p) => p.ready, true), - ) - void _options.then((o) => { - if (o.usePersistentStorage === true) { - persistAtom( - this._persistentState as $IntentionalAny as Atom<{}>, - this._persistentState.pointer as $IntentionalAny, - () => persistAtomDeferred.resolve(), - o.persistenceKey + 'auth', - ) - } else { - persistAtomDeferred.resolve() - } - }) - - this.syncServerApi = wrapTrpcClientWithAuth( - this._syncServerLink.then((s) => s.api), - (fn: any, args: any[], path): any => { - return this._wrapTrpcProcedureWithAuth( - fn, - args as $IntentionalAny, - path, - ) - }, - ) - - this.appApi = wrapTrpcClientWithAuth( - this._appLink.then((s) => s.api), - (fn: any, args: any[], path): any => - this._wrapTrpcProcedureWithAuth(fn, args as $IntentionalAny, path), - ) - } - - async authenticate( - opts: {skipIfAlreadyAuthenticated?: boolean} = {}, - ): Promise< - | { - success: false - error: - | 'alreadyAuthenticated' - | 'alreadyAuthenticating' - | 'userDeniedLogin' - | 'unknown' - msg?: string - } - | {success: true} - > { - { - if (this._persistentState.get()) { - if (opts.skipIfAlreadyAuthenticated) { - return {success: true} - } else { - return {success: false, error: 'alreadyAuthenticated'} - } - } - - if (this._ephemeralState.get().authFlowState) { - return {success: false, error: 'alreadyAuthenticating'} - } - } - - const appLink = await this._appLink - - let outerTries = 0 - outer: while (outerTries < 2) { - outerTries++ - this._ephemeralState.setByPointer( - (p) => p.authFlowState, - 'autnenticating/waiting-for-checkToken', - ) - try { - const clientFlowToken = v4() - const flowInitResult = - await appLink.api.studioAuth.getPreAuthenticationToken.mutate({ - clientFlowToken, - }) - - window.open(flowInitResult.userAuthUrl, '_blank') - - inner: while (true) { - await delay(flowInitResult.interval + 1000) - try { - const result = - await appLink.api.studioAuth.getTokensFromPreAuthenticationToken.mutate( - {preAuthenticationToken: flowInitResult.preAuthenticationToken}, - ) - if (result.isError) { - if (result.error === 'invalidPreAuthenticationToken') { - console.error(result) - continue outer - } else if (result.error === 'userDeniedLogin') { - return {success: false, error: 'userDeniedLogin'} - } else if (result.error === 'notYetReady') { - continue inner - } else { - const msg = `Unknown error returned from app-studio-trpc: ${result.error} - ${result.errorMessage}` - console.error(msg) - return {success: false, error: 'unknown', msg} - } - } else if (result.clientFlowToken !== clientFlowToken) { - console.warn('The user returend an invalid clientFlowToken') - console.error(result) - - // TODO report this? - continue outer - } else { - this._persistentState.set({ - accessToken: result.accessToken, - refreshToken: result.refreshToken, - }) - return {success: true} - } - } catch (err) { - console.error(err) - return { - success: false, - error: 'unknown', - msg: (err as $IntentionalAny).message, - } - } - } - } finally { - this._ephemeralState.setByPointer((p) => p.authFlowState, undefined) - } - } - return {success: false, error: 'unknown'} - } - - async deauthenticate() { - if (this._ephemeralState.get().authFlowState) { - throw new Error('Already authenticating') - } - const authState = this._persistentState.get() - if (!authState) { - throw new Error('Not authenticated') - } - - this._persistentState.set(undefined) - - await ( - await this._appLink - ).api.studioAuth.invalidateRefreshToken.mutate({ - refreshToken: authState.refreshToken, - }) - } - - /** - * Resolves with the access token. If no token is set, it'll wait until one is. Note that this will _NOT_ trigger authentication, - * so if this function is called and the user is not authenticated, it'll wait forever. - */ - private async _getValidAccessToken(): Promise { - // no ongoing authentication atm - if (!this._ephemeralState.get().authFlowState) { - const authState = this._persistentState.get() - // and the access token is available - if (authState) { - return authState.accessToken - } else { - throw new Error('Not authenticated') - } - } else { - const accessToken = await this._waitForAccessToken() - return accessToken - } - } - - private async _waitForAccessToken(): Promise { - const accessToken = await waitForPrism( - prism(() => { - if (!val(this._ephemeralState.pointer.ready)) { - return undefined - } - - if (val(this._ephemeralState.pointer.authFlowState) !== undefined) { - return undefined - } - - const authState = val(this._persistentState.pointer) - if (!authState) { - return undefined - } else { - return authState.accessToken - } - }), - (v) => v !== undefined, - ) - return accessToken! - } - - async waitUntilAuthenticated(): Promise { - await this.ready - await this._waitForAccessToken() - } - - async _refreshAccessToken() { - const authState = this._persistentState.get() - if (!authState) { - throw new Error('Not authenticated') - } - - const result = await ( - await this._appLink - ).api.studioAuth.refreshAccessToken.mutate({ - refreshToken: authState.refreshToken, - }) - - if (result.isError) { - if (result.error === 'invalidRefreshToken') { - this._persistentState.set(undefined) - throw new Error('Invalid refresh token') - } else { - throw new Error( - `Unknown error returned from app-studio-trpc: ${result.error} - ${result.errorMessage}`, - ) - } - } else { - this._persistentState.set({ - accessToken: result.accessToken, - refreshToken: result.refreshToken, - }) - } - } - - private async _wrapTrpcProcedureWithAuth< - Input, - Ret, - Fn extends ( - input: Input & {studioAuth: {accessToken: string}}, - opts: $IntentionalAny, - ) => Promise, - >( - fn: Fn, - args: [Input, $IntentionalAny], - path: string[], - retriesLeft: number = 3, - ): Promise { - await this.ready - await this.authenticate({skipIfAlreadyAuthenticated: true}) - const accessToken = await this._waitForAccessToken() - const [input, opts] = args - try { - const isSubscribe = path[path.length - 1] === 'subscribe' - if (isSubscribe) { - const response = fn({...input, studioAuth: {accessToken}}, opts) - return response - } else { - const response = await fn({...input, studioAuth: {accessToken}}, opts) - return response - } - } catch (err) { - console.log('err', err) - if (err instanceof TRPCClientError && err.data.code === 'UNAUTHORIZED') { - console.log('is unaothorized error') - if (retriesLeft <= 0) { - return Promise.reject(err) - } - // this is a 401, which means as long as we have a valid accessToken, we should be able to retry the request - await this._refreshAccessToken() - return this._wrapTrpcProcedureWithAuth(fn, args, path, retriesLeft - 1) - } else { - return Promise.reject(err) - } - } - } -} - -type AnyFn = (...args: any[]) => any - -function wrapTrpcClientWithAuth( - clientPromise: Promise, - enhancer: (originalFn: AnyFn, args: any[], path: string[]) => any, -): TrpcClientWrapped { - const handlers = { - get: (target: PathedTarget, prop: string): any => { - const subTarget: PathedTarget = (() => {}) as $IntentionalAny - subTarget.path = [...target.path, prop] - - if (prop === 'query' || prop === 'mutate' || prop === 'subscribe') { - return proxyProcedure(subTarget, prop) - } else { - return new Proxy(subTarget, handlers) - } - }, - } - - const proxyProcedure = (target: PathedTarget, prop: string) => { - return new Proxy(target, { - apply: async (_target: PathedTarget, thisArg: any, argArray: any) => { - const client = await clientPromise - const fn = get(client, target.path) - return await enhancer(fn, argArray, target.path) - }, - ...handlers, - }) - } - - type PathedTarget = {path: string[]} & (() => {}) - const rootTarget: PathedTarget = (() => {}) as $IntentionalAny - rootTarget.path = [] - - const pr = new Proxy(rootTarget, handlers) - return pr as $IntentionalAny -} - -export type TrpcClientWrapped = { - [K in keyof Client]: K extends 'query' | 'mutate' | 'subscribe' - ? TrpcProcedureWrapped - : Client[K] extends {} - ? TrpcClientWrapped - : Client[K] -} - -type TrpcProcedureWrapped = Procedure extends ( - input: infer OriginalInput, -) => infer Ret - ? (input: Omit) => Ret - : Procedure extends ( - input: infer OriginalInput, - secondArg: infer SecondArg, - ) => infer Ret - ? (input: Omit, secondArg: SecondArg) => Ret - : Procedure diff --git a/theatre/studio/src/SyncStore/SyncStuffToolbar.tsx b/theatre/studio/src/SyncStore/SyncStuffToolbar.tsx deleted file mode 100644 index b3c5138961..0000000000 --- a/theatre/studio/src/SyncStore/SyncStuffToolbar.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, {useEffect, useRef} from 'react' -import usePopover from '@theatre/studio/uiComponents/Popover/usePopover' -import ToolbarIconButton from '@theatre/studio/uiComponents/toolbar/ToolbarIconButton' -import {Cube} from '@theatre/studio/uiComponents/icons' -import styled from 'styled-components' -import type {$FixMe} from '@theatre/dataverse/src/types' -import getStudio from '@theatre/studio/getStudio' - -const SyncStuffToolbar: React.FC<{}> = (props) => { - const panel = usePopover( - { - debugName: 'panel', - closeOnClickOutside: false, - closeWhenPointerIsDistant: false, - }, - () => , - ) - const panelTriggerRef = useRef(null) - - useEffect(() => { - panel.open({clientX: 0, clientY: 0}, panelTriggerRef.current!) - }, []) - - return ( - <> - {panel.node} - { - panel.toggle(e, panelTriggerRef.current!) - }} - > - - - - ) -} - -const Container = styled.div` - width: 400px; - border-radius: 2px; - padding: 8px; - background-color: rgba(42, 45, 50, 0.9); - position: absolute; - display: flex; - flex-direction: column; - align-items: stretch; - justify-content: flex-start; - box-shadow: - 0px 1px 1px rgba(0, 0, 0, 0.25), - 0px 2px 6px rgba(0, 0, 0, 0.15); - backdrop-filter: blur(14px); - pointer-events: auto; - // makes the edges of the item highlights match the rounded corners - overflow: hidden; - - @supports not (backdrop-filter: blur()) { - background-color: rgba(42, 45, 50, 0.98); - } - - color: rgba(255, 255, 255, 0.9); - - & a { - // Fix colors of links to not be default - color: inherit; - } -` - -const TheStuff = React.forwardRef( - (props: {}, ref: React.Ref) => { - useEffect(() => { - void getStudio() - .authenticate({skipIfAlreadyAuthenticated: true}) - .then((res) => { - console.log('auth result', res) - if (!res.success) { - console.error(`err`, res) - return - } - // return syncStore.appApi.projects.create.mutate({}).then((res) => { - // const {id} = res - // console.log('id', id) - // return syncStore.syncServerApi.projectState.set.mutate({ - // id, - // data: JSON.stringify('yeah baby'), - // }) - // }) - }) - }, []) - return here be auth stuff - }, -) - -export default SyncStuffToolbar diff --git a/theatre/studio/src/SyncStore/utils.ts b/theatre/studio/src/SyncStore/utils.ts new file mode 100644 index 0000000000..62b1708d2a --- /dev/null +++ b/theatre/studio/src/SyncStore/utils.ts @@ -0,0 +1,59 @@ +import {get} from 'lodash-es' +import type {$IntentionalAny} from '@theatre/utils/types' + +type AnyFn = (...args: any[]) => any + +export function wrapTrpcClientWithAuth( + clientPromise: Promise, + enhancer: (originalFn: AnyFn, args: any[], path: string[]) => any, +): TrpcClientWrapped { + const handlers = { + get: (target: PathedTarget, prop: string): any => { + const subTarget: PathedTarget = (() => {}) as $IntentionalAny + subTarget.path = [...target.path, prop] + + if (prop === 'query' || prop === 'mutate' || prop === 'subscribe') { + return proxyProcedure(subTarget, prop) + } else { + return new Proxy(subTarget, handlers) + } + }, + } + + const proxyProcedure = (target: PathedTarget, prop: string) => { + return new Proxy(target, { + apply: async (_target: PathedTarget, thisArg: any, argArray: any) => { + const client = await clientPromise + const fn = get(client, target.path) + return await enhancer(fn, argArray, target.path) + }, + ...handlers, + }) + } + + type PathedTarget = {path: string[]} & (() => {}) + const rootTarget: PathedTarget = (() => {}) as $IntentionalAny + rootTarget.path = [] + + const pr = new Proxy(rootTarget, handlers) + return pr as $IntentionalAny +} + +export type TrpcClientWrapped = { + [K in keyof Client]: K extends 'query' | 'mutate' | 'subscribe' + ? TrpcProcedureWrapped + : Client[K] extends {} + ? TrpcClientWrapped + : Client[K] +} + +type TrpcProcedureWrapped = Procedure extends ( + input: infer OriginalInput, +) => infer Ret + ? (input: Omit) => Ret + : Procedure extends ( + input: infer OriginalInput, + secondArg: infer SecondArg, + ) => infer Ret + ? (input: Omit, secondArg: SecondArg) => Ret + : Procedure diff --git a/theatre/studio/src/UIRoot/PointerCapturing.tsx b/theatre/studio/src/UIRoot/PointerCapturing.tsx index 5acf1323f6..ae5bbd4ad0 100644 --- a/theatre/studio/src/UIRoot/PointerCapturing.tsx +++ b/theatre/studio/src/UIRoot/PointerCapturing.tsx @@ -33,6 +33,9 @@ let currentCapture: null | CaptureInfo = null const isPointerBeingCaptured = () => currentCapture != null +/** + * @deprecated Once all the `usePopover()`/`useDrag()` calls are removed, we should move this to one of the actors under `useChordial()` + */ export function createPointerCapturing(forDebugName: string) { /** keep track of the captures being made by this user of {@link usePointerCapturing} */ let localCapture: CaptureInfo | null @@ -81,6 +84,7 @@ export function createPointerCapturing(forDebugName: string) { } /** + * @deprecated Once all the `usePopover()`/`useDrag()` calls are removed, we should move this to one of the actors under `useChordial()` * Used to ensure we're locking drag and pointer events to a single place in the UI logic. * Without this, we can much more easily accidentally create multiple drag handlers on * child / parent dom elements which both `useDrag`, for example. diff --git a/theatre/studio/src/UIRoot/UIRoot.tsx b/theatre/studio/src/UIRoot/UIRoot.tsx index 56482ee01a..7267c22f61 100644 --- a/theatre/studio/src/UIRoot/UIRoot.tsx +++ b/theatre/studio/src/UIRoot/UIRoot.tsx @@ -4,7 +4,7 @@ import {val} from '@theatre/dataverse' import React, {useEffect} from 'react' import styled, {createGlobalStyle} from 'styled-components' import PanelsRoot from './PanelsRoot' -import GlobalToolbar from '@theatre/studio/toolbars/GlobalToolbar' +import GlobalToolbar from '@theatre/studio/toolbars/GlobalToolbar/GlobalToolbar' import useRefAndState from '@theatre/studio/utils/useRefAndState' import {PortalContext} from 'reakit' import type {$IntentionalAny} from '@theatre/utils/types' diff --git a/theatre/studio/src/assets/logo.png b/theatre/studio/src/assets/logo.png new file mode 100644 index 0000000000..697e8aee94 Binary files /dev/null and b/theatre/studio/src/assets/logo.png differ diff --git a/theatre/studio/src/css.tsx b/theatre/studio/src/css.tsx index 2e28313892..f2fa7f64d5 100644 --- a/theatre/studio/src/css.tsx +++ b/theatre/studio/src/css.tsx @@ -53,22 +53,140 @@ export const panelUtils = { const GlobalStyle = typeof window !== 'undefined' ? createGlobalStyle` - :host { - all: initial; - color: white; - font: 11px -apple-system, BlinkMacSystemFont, Segoe WPC, Segoe Editor, - HelveticaNeue-Light, Ubuntu, Droid Sans, sans-serif; - } + :host { + all: initial; + color: white; + font: + 11px -apple-system, + BlinkMacSystemFont, + Segoe WPC, + Segoe Editor, + HelveticaNeue-Light, + Ubuntu, + Droid Sans, + sans-serif; - * { - padding: 0; - margin: 0; - font-size: 100%; - font: inherit; - vertical-align: baseline; - list-style: none; - } -` + // external links + a[href^='http'] { + text-decoration: none; + text-decoration-line: underline; + text-decoration-color: #888; + position: relative; + display: inline-block; + margin-left: 0.4em; + + &:hover, + &:active { + text-decoration-color: #ccc; + } + } + + // from tailwind + .text-xs { + font-size: 0.75rem; /* 12px */ + line-height: 1rem; /* 16px */ + } + .text-sm { + font-size: 0.875rem; /* 14px */ + line-height: 1.25rem; /* 20px */ + } + .text-base { + font-size: 1rem; /* 16px */ + line-height: 1.5rem; /* 24px */ + } + .text-lg { + font-size: 1.125rem; /* 18px */ + line-height: 1.75rem; /* 28px */ + } + .text-xl { + font-size: 1.25rem; /* 20px */ + line-height: 1.75rem; /* 28px */ + } + .text-2xl { + font-size: 1.5rem; /* 24px */ + line-height: 2rem; /* 32px */ + } + .text-3xl { + font-size: 1.875rem; /* 30px */ + line-height: 2.25rem; /* 36px */ + } + .text-4xl { + font-size: 2.25rem; /* 36px */ + line-height: 2.5rem; /* 40px */ + } + .text-5xl { + font-size: 3rem; /* 48px */ + line-height: 1; + } + .text-6xl { + font-size: 3.75rem; /* 60px */ + line-height: 1; + } + .text-7xl { + font-size: 4.5rem; /* 72px */ + line-height: 1; + } + .text-8xl { + font-size: 6rem; /* 96px */ + line-height: 1; + } + .text-9xl { + font-size: 8rem; /* 128px */ + line-height: 1; + } + + .font-thin { + font-weight: 100; + } + .font-extralight { + font-weight: 200; + } + .font-light { + font-weight: 300; + } + .font-normal { + font-weight: 400; + } + .font-medium { + font-weight: 500; + } + .font-semibold { + font-weight: 600; + } + .font-bold { + font-weight: 700; + } + .font-extrabold { + font-weight: 800; + } + .font-black { + font-weight: 900; + } + + .text-left { + text-align: left; + } + .text-center { + text-align: center; + } + .text-right { + text-align: right; + } + + .text-color-pale { + color: #CCC; + } + } + + * { + padding: 0; + margin: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; + list-style: none; + } + ` : ({} as ReturnType) export const PortalLayer = styled.div` diff --git a/theatre/studio/src/panels/BasePanel/ExtensionPaneWrapper.tsx b/theatre/studio/src/panels/BasePanel/ExtensionPaneWrapper.tsx index 110c66f250..2d5a183c5e 100644 --- a/theatre/studio/src/panels/BasePanel/ExtensionPaneWrapper.tsx +++ b/theatre/studio/src/panels/BasePanel/ExtensionPaneWrapper.tsx @@ -8,7 +8,7 @@ import BasePanel from './BasePanel' import PanelDragZone from './PanelDragZone' import PanelWrapper from './PanelWrapper' import {ErrorBoundary} from 'react-error-boundary' -import {IoClose} from 'react-icons/all' +import {IoClose} from 'react-icons/io5' import getStudio from '@theatre/studio/getStudio' import {panelZIndexes} from '@theatre/studio/panels/BasePanel/common' import type {PaneInstanceId, UIPanelId} from '@theatre/sync-server/state/types' diff --git a/theatre/studio/src/panels/DetailPanel/DeterminePropEditorForDetail/DetailCompoundPropEditor.tsx b/theatre/studio/src/panels/DetailPanel/DeterminePropEditorForDetail/DetailCompoundPropEditor.tsx index 077464a7fa..8509bfbf6a 100644 --- a/theatre/studio/src/panels/DetailPanel/DeterminePropEditorForDetail/DetailCompoundPropEditor.tsx +++ b/theatre/studio/src/panels/DetailPanel/DeterminePropEditorForDetail/DetailCompoundPropEditor.tsx @@ -24,7 +24,7 @@ import type {IDetailSimplePropEditorProps} from './DetailSimplePropEditor' import {useEditingToolsForSimplePropInDetailsPanel} from '@theatre/studio/propEditors/useEditingToolsForSimpleProp' import {usePrism} from '@theatre/react' import {val} from '@theatre/dataverse' -import {HiOutlineChevronRight} from 'react-icons/all' +import {HiOutlineChevronRight} from 'react-icons/hi' import memoizeFn from '@theatre/utils/memoizeFn' import {collapsedMap} from './collapsedMap' import useChordial from '@theatre/studio/uiComponents/chordial/useChodrial' diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/AnyCompositeRow.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/AnyCompositeRow.tsx index 07493a2c0d..08d701d0fd 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/AnyCompositeRow.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/AnyCompositeRow.tsx @@ -7,7 +7,7 @@ import type { } from '@theatre/studio/panels/SequenceEditorPanel/layout/tree' import type {VoidFn} from '@theatre/utils/types' import React, {useRef} from 'react' -import {HiOutlineChevronRight} from 'react-icons/all' +import {HiOutlineChevronRight} from 'react-icons/hi' import styled from 'styled-components' import {propNameTextCSS} from '@theatre/studio/propEditors/utils/propNameTextCSS' import {usePropHighlightMouseEnter} from './usePropHighlightMouseEnter' diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeConnector.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeConnector.tsx index 0854a2a3be..7ae8a4e6fe 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeConnector.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeConnector.tsx @@ -210,7 +210,7 @@ function useConnectorContextMenu( ) { return useContextMenu(node, { displayName: 'Aggregate Tween', - menuItems: () => { + items: () => { // see AGGREGATE_COPY_PASTE.md for explanation of this // code that makes some keyframes with paths for copying // to clipboard diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeDot.tsx index 9d8fc887ae..f730f21d68 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeDot.tsx @@ -100,10 +100,10 @@ export function AggregateKeyframeDot( props.editorProps.viewModel.type === 'sheet' ? null : props.editorProps.viewModel.type === 'sheetObject' - ? sheetObjectBuild(props.editorProps.viewModel, cur.keyframes) - ?.children ?? null - : propWithChildrenBuild(props.editorProps.viewModel, cur.keyframes) - ?.children ?? null, + ? sheetObjectBuild(props.editorProps.viewModel, cur.keyframes) + ?.children ?? null + : propWithChildrenBuild(props.editorProps.viewModel, cur.keyframes) + ?.children ?? null, ) const presence = usePresence(props.utils.itemKey) @@ -157,7 +157,7 @@ function useAggregateKeyframeContextMenu( ) { return useContextMenu(target, { displayName: 'Aggregate Keyframe', - menuItems: () => { + items: () => { const viewModel = props.editorProps.viewModel const selection = props.editorProps.selection @@ -199,13 +199,13 @@ function useAggregateKeyframeContextMenu( viewModel.type === 'sheet' ? [] : viewModel.type === 'sheetObject' - ? [viewModel.sheetObject.address.objectKey] - : viewModel.type === 'propWithChildren' - ? [ - viewModel.sheetObject.address.objectKey, - ...viewModel.pathToProp, - ] - : [] // should be unreachable unless new viewModel/leaf types are added + ? [viewModel.sheetObject.address.objectKey] + : viewModel.type === 'propWithChildren' + ? [ + viewModel.sheetObject.address.objectKey, + ...viewModel.pathToProp, + ] + : [] // should be unreachable unless new viewModel/leaf types are added const commonPath = commonRootOfPathsToProps([ basePathRelativeToSheet, ...kfs.map((kf) => kf.pathToProp), diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx index abd86e8928..cbbf10c0e0 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx @@ -303,7 +303,7 @@ function useAggregatedKeyframeTrackContextMenu( return useContextMenu(node, { onOpen: debugOnOpen, displayName: 'Aggregate Keyframe Track', - menuItems: () => { + items: () => { const selectionKeyframes = pointerToPrism( getStudio()!.atomP.ahistoric.clipboard.keyframesWithRelativePaths, diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx index ef6d6c230f..b961db4818 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx @@ -149,7 +149,7 @@ function useBasicKeyframedTrackContextMenu( ) { return useContextMenu(node, { displayName: 'Keyframe Track', - menuItems: () => { + items: () => { const selectionKeyframes = val( getStudio()!.atomP.ahistoric.clipboard.keyframesWithRelativePaths, diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx index dc71d83fea..bdcfe3058a 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx @@ -246,7 +246,7 @@ function useConnectorContextMenu( return useContextMenu(node, { displayName: 'Tween', - menuItems: () => { + items: () => { const copyableKeyframes = copyableKeyframesFromSelection( props.leaf.sheetObject.address.projectId, props.leaf.sheetObject.address.sheetId, diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx index 56bf483728..8fd02eff64 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx @@ -172,7 +172,7 @@ function useSingleKeyframeContextMenu( ) { return useContextMenu(target, { displayName: 'Keyframe', - menuItems: () => { + items: () => { const copyableKeyframes = copyableKeyframesFromSelection( props.leaf.sheetObject.address.projectId, props.leaf.sheetObject.address.sheetId, diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx index 80775d8c87..68f3cce1a5 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx @@ -15,7 +15,7 @@ import { includeLockFrameStampAttrs, useLockFrameStampPosition, } from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' -import {GoChevronLeft, GoChevronRight} from 'react-icons/all' +import {GoChevronLeft, GoChevronRight} from 'react-icons/go' import LengthEditorPopover from './LengthEditorPopover' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover' diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Curve.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Curve.tsx index aa97840ac6..6d9db32c05 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Curve.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Curve.tsx @@ -117,7 +117,7 @@ function useConnectorContextMenu(node: SVGElement | null, props: IProps) { ] return useContextMenu(node, { - menuItems: () => { + items: () => { return [ { type: 'normal', diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx index 2ad72450fd..ad02fcfd34 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx @@ -262,7 +262,7 @@ function useOurDrags(node: SVGCircleElement | null, props: IProps): void { function useOurContextMenu(node: SVGCircleElement | null, props: IProps) { return useContextMenu(node, { - menuItems: () => { + items: () => { return [ { type: 'normal', diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx index 3cf7afd25e..ec696bf8b1 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx @@ -209,7 +209,7 @@ function useDragKeyframe(options: { function useKeyframeContextMenu(node: SVGCircleElement | null, props: IProps) { return useContextMenu(node, { - menuItems: () => { + items: () => { return [ { type: 'normal', diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx index 660c294168..0cd5de249b 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx @@ -265,7 +265,7 @@ function useDragKeyframe(options: { function useKeyframeContextMenu(node: SVGCircleElement | null, props: IProps) { return useContextMenu(node, { - menuItems: () => { + items: () => { return [ { type: 'normal', diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditorToggle.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditorToggle.tsx index 549378ad18..71d1a6f5a8 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditorToggle.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditorToggle.tsx @@ -5,7 +5,7 @@ import getStudio from '@theatre/studio/getStudio' import React, {useCallback} from 'react' import styled from 'styled-components' import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' -import {VscTriangleUp} from 'react-icons/all' +import {VscTriangleUp} from 'react-icons/vsc' import {includeLockFrameStampAttrs} from './FrameStampPositionProvider' const Container = styled.button` diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx index 58ee8b6f05..3f75c4d348 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx @@ -133,7 +133,7 @@ const FocusRangeStrip: React.FC<{ ) const [contextMenu] = useContextMenu(rangeStripNode, { - menuItems: () => { + items: () => { const sheet = val(layoutP.sheet) const existingRange = existingRangeD.getValue() return [ diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Markers/MarkerDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Markers/MarkerDot.tsx index 500c86eae2..827cdcd5a6 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Markers/MarkerDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Markers/MarkerDot.tsx @@ -211,7 +211,7 @@ function useMarkerContextMenu( }, ) { return useContextMenu(node, { - menuItems() { + items() { return [ { type: 'normal', diff --git a/theatre/studio/src/toolbars/GlobalToolbar.tsx b/theatre/studio/src/toolbars/GlobalToolbar.tsx deleted file mode 100644 index 6473bec642..0000000000 --- a/theatre/studio/src/toolbars/GlobalToolbar.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import {usePrism, useVal} from '@theatre/react' -import getStudio from '@theatre/studio/getStudio' -import React, {useMemo, useRef} from 'react' -import styled from 'styled-components' -import type {$IntentionalAny} from '@theatre/dataverse/dist/types' -import useTooltip from '@theatre/studio/uiComponents/Popover/useTooltip' -import ErrorTooltip from '@theatre/studio/uiComponents/Popover/ErrorTooltip' -import BasicTooltip from '@theatre/studio/uiComponents/Popover/BasicTooltip' -import {val} from '@theatre/dataverse' -import ExtensionToolbar from './ExtensionToolbar/ExtensionToolbar' -import PinButton from './PinButton' -import { - Details, - Ellipsis, - Outline, - Bell, -} from '@theatre/studio/uiComponents/icons' -import DoubleChevronLeft from '@theatre/studio/uiComponents/icons/DoubleChevronLeft' -import DoubleChevronRight from '@theatre/studio/uiComponents/icons/DoubleChevronRight' -import ToolbarIconButton from '@theatre/studio/uiComponents/toolbar/ToolbarIconButton' -import usePopover from '@theatre/studio/uiComponents/Popover/usePopover' -import MoreMenu from './MoreMenu/MoreMenu' -import { - useNotifications, - useEmptyNotificationsTooltip, -} from '@theatre/studio/notify' - -const Container = styled.div` - height: 36px; - pointer-events: none; - - display: flex; - justify-content: space-between; - padding: 12px; -` - -const NumberOfConflictsIndicator = styled.div` - color: white; - width: 14px; - height: 14px; - background: #d00; - border-radius: 4px; - text-align: center; - line-height: 14px; - font-weight: 600; - font-size: 8px; - position: relative; - left: -6px; - top: -11px; - margin-right: -14px; - box-shadow: 0 4px 6px -4px #00000059; -` - -const SubContainer = styled.div` - display: flex; - gap: 8px; -` - -const HasUpdatesBadge = styled.div<{type: 'info' | 'warning'}>` - position: absolute; - background: ${({type}) => (type === 'info' ? '#40aaa4' : '#f59e0b')}; - width: 6px; - height: 6px; - border-radius: 50%; - right: -2px; - top: -2px; -` - -const GroupDivider = styled.div` - position: absolute; - height: 32px; - width: 1px; - background: #373b40; - opacity: 0.4; -` - -let showedVisualTestingWarning = false - -const GlobalToolbar: React.FC = () => { - const conflicts = usePrism(() => { - const ephemeralStateOfAllProjects = val( - getStudio().ephemeralAtom.pointer.coreByProject, - ) - return Object.entries(ephemeralStateOfAllProjects) - .map(([projectId, state]) => ({projectId, state})) - .filter( - ({state}) => - state.loadingState.type === 'browserStateIsNotBasedOnDiskState', - ) - }, []) - const [triggerTooltip, triggerButtonRef] = useTooltip( - {enabled: conflicts.length > 0, enterDelay: conflicts.length > 0 ? 0 : 200}, - () => - conflicts.length > 0 ? ( - - {conflicts.length === 1 - ? `There is a state conflict in project "${conflicts[0].projectId}". Select the project in the outline below in order to fix it.` - : `There are ${conflicts.length} projects that have state conflicts. They are highlighted in the outline below. `} - - ) : ( - - <>Outline - - ), - ) - - const outlinePinned = useVal(getStudio().atomP.ahistoric.pinOutline) ?? true - const detailsPinned = useVal(getStudio().atomP.ahistoric.pinDetails) ?? true - const hasUpdates = - useVal( - getStudio().ahistoricAtom.pointer.updateChecker.result.hasUpdates, - ) === true - - const moreMenu = usePopover( - () => { - const triggerBounds = moreMenuTriggerRef.current!.getBoundingClientRect() - return { - debugName: 'More Menu', - - constraints: { - maxX: triggerBounds.right, - maxY: 8, - // MVP: Don't render the more menu all the way to the left - // when it doesn't fit on the screen height - // See https://linear.app/theatre/issue/P-178/bug-broken-updater-ui-in-simple-html-page - // 1/10 There's a better way to solve this. - // 1/10 Perhaps consider separate constraint like "rightSideMinX" & for future: "bottomSideMinY" - // 2/10 Or, consider constraints being a function of the dimensions of the box => constraints. - minX: triggerBounds.left - 140, - minY: 8, - }, - verticalGap: 2, - } - }, - () => { - return - }, - ) - const moreMenuTriggerRef = useRef(null) - - const showUpdatesBadge = useMemo(() => { - if (window.__IS_VISUAL_REGRESSION_TESTING) { - if (!showedVisualTestingWarning) { - showedVisualTestingWarning = true - console.warn( - "Visual regression testing enabled, so we're showing the updates badge unconditionally", - ) - } - } - if (hasUpdates || window.__IS_VISUAL_REGRESSION_TESTING) { - return true - } - - return hasUpdates - }, [hasUpdates]) - - const {hasNotifications} = useNotifications() - - const [notificationsTooltip, notificationsTriggerRef] = - useEmptyNotificationsTooltip() - - return ( - - - {triggerTooltip} - { - const prev = val(getStudio().atomP.ahistoric.pinOutline) - getStudio().transaction(({stateEditors}) => { - stateEditors.studio.ahistoric.setPinOutline(!(prev ?? true)) - }) - }} - icon={} - pinHintIcon={} - unpinHintIcon={} - pinned={outlinePinned} - /> - {conflicts.length > 0 ? ( - - {conflicts.length} - - ) : null} - - - - {notificationsTooltip} - { - const prev = val(getStudio().atomP.ahistoric.pinNotifications) - getStudio().transaction(({stateEditors}) => { - stateEditors.studio.ahistoric.setPinNotifications( - !(prev ?? false), - ) - }) - }} - icon={} - pinHintIcon={} - unpinHintIcon={} - pinned={useVal(getStudio().atomP.ahistoric.pinNotifications) ?? false} - > - {hasNotifications && } - - {moreMenu.node} - { - moreMenu.toggle(e, moreMenuTriggerRef.current!) - }} - > - - {showUpdatesBadge && } - - { - const prev = val(getStudio().atomP.ahistoric.pinDetails) - getStudio().transaction(({stateEditors}) => { - stateEditors.studio.ahistoric.setPinDetails(!(prev ?? true)) - }) - }} - icon={
} - pinHintIcon={} - unpinHintIcon={} - pinned={detailsPinned} - /> - - - ) -} - -export default GlobalToolbar diff --git a/theatre/studio/src/toolbars/GlobalToolbar/GlobalToolbar.tsx b/theatre/studio/src/toolbars/GlobalToolbar/GlobalToolbar.tsx new file mode 100644 index 0000000000..f7cd0b0107 --- /dev/null +++ b/theatre/studio/src/toolbars/GlobalToolbar/GlobalToolbar.tsx @@ -0,0 +1,157 @@ +import {useVal} from '@theatre/react' +import getStudio from '@theatre/studio/getStudio' +import React from 'react' +import styled from 'styled-components' +import ExtensionToolbar from '@theatre/studio/toolbars/ExtensionToolbar/ExtensionToolbar' +import { + useNotifications, + useEmptyNotificationsTooltip, +} from '@theatre/studio/notify' +import { + uesConflicts, + useOutlineTriggerTooltip, + useMoreMenu, + useShouldShowUpdatesBadge, +} from './globalToolbarHooks' +import LeftStrip from './LeftStrip/LeftStrip' +import RightStrip from './RightStrip/RightStrip' + +const Container = styled.div` + position: fixed; + left: 0; + right: 0; + top: 12px; + height: 42px; + display: flex; + justify-content: space-between; + pointer-events: none; +` + +const NumberOfConflictsIndicator = styled.div` + color: white; + width: 14px; + height: 14px; + background: #d00; + border-radius: 4px; + text-align: center; + line-height: 14px; + font-weight: 600; + font-size: 8px; + position: relative; + left: -6px; + top: -11px; + margin-right: -14px; + box-shadow: 0 4px 6px -4px #00000059; +` + +const SubContainer = styled.div` + display: flex; + gap: 8px; +` + +const HasUpdatesBadge = styled.div<{type: 'info' | 'warning'}>` + position: absolute; + background: ${({type}) => (type === 'info' ? '#40aaa4' : '#f59e0b')}; + width: 6px; + height: 6px; + border-radius: 50%; + right: -2px; + top: -2px; +` + +const GlobalToolbar: React.FC = () => { + const conflicts = uesConflicts() + const [outlineTriggerTooltip, outlineTriggerRef] = + useOutlineTriggerTooltip(conflicts) + + const outlinePinned = useVal(getStudio().atomP.ahistoric.pinOutline) ?? true + const detailsPinned = useVal(getStudio().atomP.ahistoric.pinDetails) ?? true + + const {moreMenu, moreMenuTriggerRef} = useMoreMenu() + + const showUpdatesBadge = useShouldShowUpdatesBadge() + + const {hasNotifications} = useNotifications() + + const [notificationsTooltip, notificationsTriggerRef] = + useEmptyNotificationsTooltip() + + return ( + + + + + {outlineTriggerTooltip} + + {/* { + const prev = val(getStudio().atomP.ahistoric.pinOutline) + getStudio().transaction(({stateEditors}) => { + stateEditors.studio.ahistoric.setPinOutline(!(prev ?? true)) + }) + }} + icon={} + pinHintIcon={} + unpinHintIcon={} + pinned={outlinePinned} + /> */} + {/* */} + {conflicts.length > 0 ? ( + + {conflicts.length} + + ) : null} + + + + + {/* + {notificationsTooltip} + { + const prev = val(getStudio().atomP.ahistoric.pinNotifications) + getStudio().transaction(({stateEditors}) => { + stateEditors.studio.ahistoric.setPinNotifications( + !(prev ?? false), + ) + }) + }} + icon={} + pinHintIcon={} + unpinHintIcon={} + pinned={useVal(getStudio().atomP.ahistoric.pinNotifications) ?? false} + > + {hasNotifications && } + */} + {/* {moreMenu.node} + { + moreMenu.toggle(e, moreMenuTriggerRef.current!) + }} + > + + {showUpdatesBadge && } + */} + {/* { + const prev = val(getStudio().atomP.ahistoric.pinDetails) + getStudio().transaction(({stateEditors}) => { + stateEditors.studio.ahistoric.setPinDetails(!(prev ?? true)) + }) + }} + icon={
} + pinHintIcon={} + unpinHintIcon={} + pinned={detailsPinned} + /> */} + + + ) +} + +export default GlobalToolbar diff --git a/theatre/studio/src/toolbars/GlobalToolbar/LeftStrip/AppButton/AppButton.tsx b/theatre/studio/src/toolbars/GlobalToolbar/LeftStrip/AppButton/AppButton.tsx new file mode 100644 index 0000000000..61b4e03d66 --- /dev/null +++ b/theatre/studio/src/toolbars/GlobalToolbar/LeftStrip/AppButton/AppButton.tsx @@ -0,0 +1,70 @@ +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' +import useChordial from '@theatre/studio/uiComponents/chordial/useChodrial' +import React from 'react' +import styled from 'styled-components' +import logo from '@theatre/studio/assets/logo.png' +import DropdownChevron from '@theatre/studio/uiComponents/icons/DropdownChevron' +import BaseMenu from '@theatre/studio/uiComponents/simpleContextMenu/ContextMenu/BaseMenu' + +const Container = styled.div` + height: 100%; + width: 42px; + display: flex; + justify-content: center; + align-items: center; + position: relative; + ${pointerEventsAutoInNormalMode}; + &:hover { + --chevron-down: 1; + background: rgba(255, 255, 255, 0.08); + } +` + +const Logo = styled.img` + width: 16px; + height: 15px; +` + +const appButtonTitle = 'Theatre.js 0.8' +const AppButton: React.FC<{}> = (props) => { + const s = useChordial(() => { + return { + items: [], + title: appButtonTitle, + invoke: { + type: 'popover', + render: ({close}) => { + return ( + {}}, + {type: 'separator'}, + {label: 'Chat with us', callback: () => {}}, + {label: 'Help', callback: () => {}}, + {label: 'Changelog', callback: () => {}}, + {type: 'separator'}, + {label: 'Github', callback: () => {}}, + {label: 'Discord', callback: () => {}}, + {label: 'Twitter', callback: () => {}}, + {type: 'separator'}, + {label: 'Settings', callback: () => {}}, + ]} + displayName={appButtonTitle} + /> + ) + }, + }, + } + }) + return ( + <> + + + + + + ) +} + +export default AppButton diff --git a/theatre/studio/src/toolbars/GlobalToolbar/LeftStrip/LeftStrip.tsx b/theatre/studio/src/toolbars/GlobalToolbar/LeftStrip/LeftStrip.tsx new file mode 100644 index 0000000000..59eb55beaa --- /dev/null +++ b/theatre/studio/src/toolbars/GlobalToolbar/LeftStrip/LeftStrip.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import styled from 'styled-components' +import AppButton from './AppButton/AppButton' +import WorkspaceButton from './WorkspaceButton/WorkspaceButton' + +const Container = styled.div` + height: 28px; + flex-shrink: 0; + flex-grow: 1; + display: flex; + align-items: center; + flex-direction: row; + margin-left: 13px; + + border-radius: 3px; + background: rgba(47, 50, 53, 0.88); + box-shadow: 0px 4px 4px -1px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(10px); +` + +const Separator = styled.div` + background: rgba(0, 0, 0, 0.24); + width: 1px; + height: 100%; +` + +const LeftStrip: React.FC<{}> = (props) => { + return ( + + + + + + ) +} + +export default LeftStrip diff --git a/theatre/studio/src/toolbars/GlobalToolbar/LeftStrip/WorkspaceButton/WorkspaceButton.tsx b/theatre/studio/src/toolbars/GlobalToolbar/LeftStrip/WorkspaceButton/WorkspaceButton.tsx new file mode 100644 index 0000000000..c24cf55ef2 --- /dev/null +++ b/theatre/studio/src/toolbars/GlobalToolbar/LeftStrip/WorkspaceButton/WorkspaceButton.tsx @@ -0,0 +1,43 @@ +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' +import DropdownChevron from '@theatre/studio/uiComponents/icons/DropdownChevron' +import React from 'react' +import styled from 'styled-components' + +const Container = styled.div` + display: flex; + align-items: center; + gap: 4px; + text-wrap: nowrap; + padding: 0 12px; + height: 100%; + font-weight: 500; + ${pointerEventsAutoInNormalMode}; + cursor: default; + &:hover { + --chevron-down: 1; + background: rgba(255, 255, 255, 0.08); + } +` + +const Team = styled.span` + color: rgba(255, 255, 255, 0.38); +` +const Separator = styled.span` + color: rgba(255, 255, 255, 0.38); +` +const WorkspaceName = styled.span`` + +const WorkspaceButton: React.FC<{}> = (props) => { + const team = `Team Freight` + const wsName = `Solar Play` + return ( + + {team} + {`/`} + {wsName} + + + ) +} + +export default WorkspaceButton diff --git a/theatre/studio/src/toolbars/MoreMenu/MoreMenu.tsx b/theatre/studio/src/toolbars/GlobalToolbar/MoreMenu/MoreMenu.tsx similarity index 100% rename from theatre/studio/src/toolbars/MoreMenu/MoreMenu.tsx rename to theatre/studio/src/toolbars/GlobalToolbar/MoreMenu/MoreMenu.tsx diff --git a/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/AuthState/AuthState.tsx b/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/AuthState/AuthState.tsx new file mode 100644 index 0000000000..ec0da878dd --- /dev/null +++ b/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/AuthState/AuthState.tsx @@ -0,0 +1,31 @@ +import {useVal} from '@theatre/react' +import getStudio from '@theatre/studio/getStudio' +import ToolbarButton from '@theatre/studio/uiComponents/toolbar/ToolbarButton' +import React from 'react' +import Unauthorized from './Unauthorized' +import Avatar from './Avatar' + +const auth = getStudio().auth + +const AuthState: React.FC<{}> = (props) => { + const authState = useVal(auth.derivedState) + + if (authState === 'loading') { + return <> + } + + if (!authState.loggedIn) { + return + } else { + return ( + <> + {}}> + Share + + + + ) + } +} + +export default AuthState diff --git a/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/AuthState/Avatar.tsx b/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/AuthState/Avatar.tsx new file mode 100644 index 0000000000..a73c97603f --- /dev/null +++ b/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/AuthState/Avatar.tsx @@ -0,0 +1,114 @@ +import type {AuthDerivedState} from '@theatre/studio/Auth' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' +import getStudio from '@theatre/studio/getStudio' +import type {ContextMenuItem} from '@theatre/studio/uiComponents/chordial/chordialInternals' +import useChordial from '@theatre/studio/uiComponents/chordial/useChodrial' +import BaseMenu from '@theatre/studio/uiComponents/simpleContextMenu/ContextMenu/BaseMenu' +import React from 'react' +import styled from 'styled-components' + +const Container = styled.div` + width: 28px; + height: 28px; + position: relative; + ${pointerEventsAutoInNormalMode}; + + &:active { + transform: translateY(1px); + } +` + +const AvatarImage = styled.img` + width: 100%; + height: 100%; + aspect-ratio: 1; + border-radius: 50px; + --box-shadow-color: rgba(0, 0, 0, 0.7); + box-shadow: 0px 4px 4px -1px var(--box-shadow-color); + + ${Container}:hover & { + --box-shadow-color: rgba(0, 0, 0, 1); + } +` + +const auth = getStudio().auth + +const Stroke = styled.div` + position: absolute; + inset: -2px; + border-radius: 50px; + background: rgba(151, 208, 249, 0.6); + + ${Container}:hover & { + background: rgba(151, 208, 249, 0.9); + transform: scale(1.05); + } + + ${Container}:active & { + background: rgba(151, 208, 249, 0.9); + transform: scale(1.05); + } + + backdrop-filter: blur(2px); + + --gradient-cutoff: 62%; + + // add a mask, so that a circle is cut out of the stroke + mask-image: radial-gradient( + circle at center, + transparent 0%, + transparent var(--gradient-cutoff), + black var(--gradient-cutoff) + ); +` + +type Props = {authState: Extract} + +const Avatar: React.FC = (props) => { + // const p = usePopover( + // () => { + // return {debugName: 'Avatar popover'} + // }, + // , + // ) + const c = useChordial(() => { + const userFullName = 'Aria Minaei' + return { + title: `User: ${userFullName}`, + menuTitle: 'User', + items: [], + invoke: { + type: 'popover', + render: ({close}) => { + const items: ContextMenuItem[] = [ + {label: 'My account', type: 'normal', callback: () => {}}, + {type: 'separator'}, + {label: 'Help', type: 'normal', callback: () => {}}, + {label: 'Keyboard shortcuts', type: 'normal', callback: () => {}}, + {type: 'separator'}, + { + label: 'Log out', + type: 'normal', + callback: () => auth.deauthorize(), + }, + ] + + return + }, + }, + } + }) + + const url = `https://pbs.twimg.com/profile_images/1367137683/aria_400x400.jpg` + + return ( + <> + + + + + + ) +} + +export default Avatar diff --git a/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/AuthState/Unauthorized.tsx b/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/AuthState/Unauthorized.tsx new file mode 100644 index 0000000000..b5728e977d --- /dev/null +++ b/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/AuthState/Unauthorized.tsx @@ -0,0 +1,97 @@ +import type {AuthDerivedState} from '@theatre/studio/Auth' +import getStudio from '@theatre/studio/getStudio' +import ToolbarButton from '@theatre/studio/uiComponents/toolbar/ToolbarButton' +import React from 'react' +import SimplePopover from '@theatre/studio/uiComponents/Popover/SimplePopover' +import styled from 'styled-components' + +const ThePopover = styled(SimplePopover)` + width: 380px; + padding: 24px 14px; + font-weight: 500; + backdrop-filter: blur(8px) contrast(65%) brightness(59%); + --popover-bg: rgb(58 59 67); + --popover-outer-stroke: rgb(99 100 112); + box-shadow: rgb(0 0 0 / 55%) 1px 8px 13px 6px; +` + +const P1 = styled.p` + margin-bottom: 1em; +` + +const auth = getStudio().auth + +const Unauthorized: React.FC<{ + authState: Extract +}> = ({authState}) => { + // const authState: Extract = { + // loggedIn: false, + // procedureState: { + // type: 'authorize', + // deviceTokenFlowState: { + // type: 'waitingForDeviceCode', + // // verificationUriComplete: 'https://google.com', + // }, + // }, + // } + + const buttonRef = React.useRef(null) + + const {procedureState} = authState + if (!procedureState) + return ( + auth.authorize()}> + Log in + + ) + + let popoverBody = null + if (procedureState.type === 'authorize') { + const {deviceTokenFlowState} = procedureState + if (deviceTokenFlowState?.type === 'waitingForDeviceCode') { + popoverBody = ( + <> + Logging you in... +

+ Waiting for OAuth token. +

+ + ) + } else if (deviceTokenFlowState?.type === 'codeReady') { + popoverBody = ( + <> + + Complete log in in the popup. + +

+ Popup didn't show up?{' '} + + Try this link. + +

+ + ) + } else { + popoverBody = `Logging in...` + } + } + + return ( + <> + + {procedureState.type === 'authorize' ? 'Logging in' : 'Log in'} + + {popoverBody && ( + + {popoverBody} + + )} + + ) + return <> +} + +export default Unauthorized diff --git a/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/AuthState/shared.tsx b/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/AuthState/shared.tsx new file mode 100644 index 0000000000..336ce12bb9 --- /dev/null +++ b/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/AuthState/shared.tsx @@ -0,0 +1 @@ +export {} diff --git a/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/RightStrip.tsx b/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/RightStrip.tsx new file mode 100644 index 0000000000..feca696f2c --- /dev/null +++ b/theatre/studio/src/toolbars/GlobalToolbar/RightStrip/RightStrip.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import styled from 'styled-components' +import AuthState from './AuthState/AuthState' + +const Container = styled.div` + margin-right: 12px; + display: flex; + align-items: flex-start; + justify-content: flex-end; + gap: 12px; + flex-wrap: nowrap; +` + +const RightStrip: React.FC<{}> = (props) => { + return ( + + + + ) +} + +export default RightStrip diff --git a/theatre/studio/src/toolbars/GlobalToolbar/globalToolbarHooks.tsx b/theatre/studio/src/toolbars/GlobalToolbar/globalToolbarHooks.tsx new file mode 100644 index 0000000000..08ed55b4d3 --- /dev/null +++ b/theatre/studio/src/toolbars/GlobalToolbar/globalToolbarHooks.tsx @@ -0,0 +1,98 @@ +import {usePrism, useVal} from '@theatre/react' +import getStudio from '@theatre/studio/getStudio' +import React, {useMemo, useRef} from 'react' +import useTooltip from '@theatre/studio/uiComponents/Popover/useTooltip' +import ErrorTooltip from '@theatre/studio/uiComponents/Popover/ErrorTooltip' +import BasicTooltip from '@theatre/studio/uiComponents/Popover/BasicTooltip' +import {val} from '@theatre/dataverse' +import usePopover from '@theatre/studio/uiComponents/Popover/usePopover' +import MoreMenu from './MoreMenu/MoreMenu' + +let showedVisualTestingWarning = false + +export function useOutlineTriggerTooltip( + conflicts: ReturnType, +) { + return useTooltip( + {enabled: conflicts.length > 0, enterDelay: conflicts.length > 0 ? 0 : 200}, + () => + conflicts.length > 0 ? ( + + {conflicts.length === 1 + ? `There is a state conflict in project "${conflicts[0].projectId}". Select the project in the outline below in order to fix it.` + : `There are ${conflicts.length} projects that have state conflicts. They are highlighted in the outline below. `} + + ) : ( + + <>Outline + + ), + ) +} + +export function uesConflicts() { + return usePrism(() => { + const ephemeralStateOfAllProjects = val( + getStudio().ephemeralAtom.pointer.coreByProject, + ) + return Object.entries(ephemeralStateOfAllProjects) + .map(([projectId, state]) => ({projectId, state})) + .filter( + ({state}) => + state.loadingState.type === 'browserStateIsNotBasedOnDiskState', + ) + }, []) +} + +export function useMoreMenu() { + const moreMenu = usePopover( + () => { + const triggerBounds = moreMenuTriggerRef.current!.getBoundingClientRect() + return { + debugName: 'More Menu', + + constraints: { + maxX: triggerBounds.right, + maxY: 8, + // MVP: Don't render the more menu all the way to the left + // when it doesn't fit on the screen height + // See https://linear.app/theatre/issue/P-178/bug-broken-updater-ui-in-simple-html-page + // 1/10 There's a better way to solve this. + // 1/10 Perhaps consider separate constraint like "rightSideMinX" & for future: "bottomSideMinY" + // 2/10 Or, consider constraints being a function of the dimensions of the box => constraints. + minX: triggerBounds.left - 140, + minY: 8, + }, + verticalGap: 2, + } + }, + () => { + return + }, + ) + const moreMenuTriggerRef = useRef(null) + return {moreMenu, moreMenuTriggerRef} +} + +export function useShouldShowUpdatesBadge(): boolean { + const hasUpdates = + useVal( + getStudio().ahistoricAtom.pointer.updateChecker.result.hasUpdates, + ) === true + + return useMemo(() => { + if (window.__IS_VISUAL_REGRESSION_TESTING) { + if (!showedVisualTestingWarning) { + showedVisualTestingWarning = true + console.warn( + "Visual regression testing enabled, so we're showing the updates badge unconditionally", + ) + } + } + if (hasUpdates || window.__IS_VISUAL_REGRESSION_TESTING) { + return true + } + + return hasUpdates + }, [hasUpdates]) +} diff --git a/theatre/studio/src/uiComponents/ExternalLink.tsx b/theatre/studio/src/uiComponents/ExternalLink.tsx new file mode 100644 index 0000000000..2f663282be --- /dev/null +++ b/theatre/studio/src/uiComponents/ExternalLink.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import {RxExternalLink} from 'react-icons/rx' +import styled from 'styled-components' + +const A = styled.a` + text-decoration: none; + border-bottom: 1px solid #888; + position: relative; + display: inline-block; + margin-left: 0.4em; + + &:hover, + &:active { + border-color: #ccc; + } +` + +const IconContainer = styled.span` + padding-right: 0.2em; + fotn-size: 0.8em; + position: relative; + top: 2px; +` + +const ExternalLink = React.forwardRef< + HTMLAnchorElement, + React.ComponentProps +>(({children, ...props}, ref) => { + return ( + + {/* */} + + + + {children} + + ) +}) + +export default ExternalLink diff --git a/theatre/studio/src/uiComponents/Popover/BasicPopover.tsx b/theatre/studio/src/uiComponents/Popover/BasicPopover.tsx index e4a49c26fc..567c9fa642 100644 --- a/theatre/studio/src/uiComponents/Popover/BasicPopover.tsx +++ b/theatre/studio/src/uiComponents/Popover/BasicPopover.tsx @@ -37,6 +37,7 @@ const BasicPopover: React.FC<{ className?: string showPopoverEdgeTriangle?: boolean children: React.ReactNode + ref?: React.Ref }> = React.forwardRef( ( { diff --git a/theatre/studio/src/uiComponents/Popover/PopoverPositioner.tsx b/theatre/studio/src/uiComponents/Popover/PopoverPositioner.tsx index e513cce1eb..f19b45a7f4 100644 --- a/theatre/studio/src/uiComponents/Popover/PopoverPositioner.tsx +++ b/theatre/studio/src/uiComponents/Popover/PopoverPositioner.tsx @@ -18,8 +18,8 @@ export type AbsolutePlacementBoxConstraints = { maxY?: number } -const PopoverPositioner: React.FC<{ - target: HTMLElement | SVGElement | Element +export type PopoverPositionerProps = { + target: Element | React.MutableRefObject onClickOutside?: (e: MouseEvent) => void children: () => React.ReactElement onPointerOutside?: { @@ -29,7 +29,9 @@ const PopoverPositioner: React.FC<{ verticalPlacement?: 'top' | 'bottom' | 'overlay' verticalGap?: number // Has no effect if verticalPlacement === 'overlay' constraints?: AbsolutePlacementBoxConstraints -}> = (props) => { +} + +const PopoverPositioner: React.FC = (props) => { const originalElement = props.children() const [ref, container] = useRefAndState(null) const style: Record = originalElement.props.style diff --git a/theatre/studio/src/uiComponents/Popover/SimplePopover.tsx b/theatre/studio/src/uiComponents/Popover/SimplePopover.tsx new file mode 100644 index 0000000000..620e7f4506 --- /dev/null +++ b/theatre/studio/src/uiComponents/Popover/SimplePopover.tsx @@ -0,0 +1,39 @@ +import React, {useContext} from 'react' +import BasicPopover from './BasicPopover' +import {mergeRefs} from 'react-merge-refs' +import {createPortal} from 'react-dom' +import {PortalContext} from 'reakit' +import type {PopoverPositionerProps} from './PopoverPositioner' +import PopoverPositioner from './PopoverPositioner' + +type Props = { + className?: string + children: React.ReactNode + isOpen?: boolean +} & Omit + +const SimplePopover = React.forwardRef<{}, Props>((props, ref) => { + const portalLayer = useContext(PortalContext) + + if (!portalLayer) { + return <> + } + + return props.isOpen !== false ? ( + createPortal( + } + target={props.target} + onClickOutside={props.onClickOutside} + onPointerOutside={props.onPointerOutside} + constraints={props.constraints} + verticalGap={props.verticalGap} + />, + portalLayer!, + ) + ) : ( + <> + ) +}) + +export default SimplePopover diff --git a/theatre/studio/src/uiComponents/Popover/usePopover.tsx b/theatre/studio/src/uiComponents/Popover/usePopover.tsx index 27197b6677..9f687c5a0e 100644 --- a/theatre/studio/src/uiComponents/Popover/usePopover.tsx +++ b/theatre/studio/src/uiComponents/Popover/usePopover.tsx @@ -8,7 +8,11 @@ import PopoverPositioner from './PopoverPositioner' import {contextMenuShownContext} from '@theatre/studio/panels/DetailPanel/DetailPanel' export type OpenFn = ( - e: React.MouseEvent | MouseEvent | {clientX: number; clientY: number}, + e: + | React.MouseEvent + | MouseEvent + | {clientX: number; clientY: number} + | undefined, target: HTMLElement | SVGElement | Element, ) => void type CloseFn = (reason: string) => void @@ -60,6 +64,9 @@ export interface IPopover { isOpen: boolean } +/** + * @deprecated Use useChordial() instead. + */ export default function usePopover( opts: Opts | (() => Opts), render: () => React.ReactElement, @@ -96,7 +103,7 @@ export default function usePopover( stateRef.current = { isOpen: true, - clickPoint: {clientX: e.clientX, clientY: e.clientY}, + clickPoint: {clientX: e?.clientX ?? 0, clientY: e?.clientY ?? 0}, target, opts, onClickOutside: onClickOutside, @@ -179,7 +186,7 @@ export default function usePopover( * behaviors for parenting popovers. */ function useAutoCloseLockState(options: { - state: State + state: {isOpen: boolean} _debug: (message: string, args?: object) => void }) { const parentLock = useContext(PopoverAutoCloseLock) diff --git a/theatre/studio/src/uiComponents/chordial/ChordialOverlay.tsx b/theatre/studio/src/uiComponents/chordial/ChordialOverlay.tsx index 1f813bad7f..b2a377e5af 100644 --- a/theatre/studio/src/uiComponents/chordial/ChordialOverlay.tsx +++ b/theatre/studio/src/uiComponents/chordial/ChordialOverlay.tsx @@ -3,6 +3,7 @@ import {TooltipOverlay} from './TooltipOverlay' import {ContextOverlay} from './ContextOverlay' import {createPortal} from 'react-dom' import {PortalContext} from 'reakit' +import {PopoverOverlay} from './PopoverOverlay' export const ChordialOverlay = () => { const portalLayer = useContext(PortalContext) @@ -13,6 +14,7 @@ export const ChordialOverlay = () => { <> + , portalLayer, ) diff --git a/theatre/studio/src/uiComponents/chordial/PopoverOverlay.tsx b/theatre/studio/src/uiComponents/chordial/PopoverOverlay.tsx new file mode 100644 index 0000000000..9ae56d5ee0 --- /dev/null +++ b/theatre/studio/src/uiComponents/chordial/PopoverOverlay.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import {usePrism} from '@theatre/react' +import type {ChodrialElement, InvokeTypePopover} from './chordialInternals' +import {val} from '@theatre/dataverse' +import {popoverActor} from './popoverActor' +import PopoverPositioner from '@theatre/studio/uiComponents/Popover/PopoverPositioner' +import {usePointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing' + +export const PopoverOverlay: React.FC<{}> = () => { + const s = usePrism((): + | undefined + | ({ + originalTriggerEvent: MouseEvent | undefined + element: ChodrialElement + domEl: Element + } & InvokeTypePopover) => { + const status = val(popoverActor.pointer) + if (!status) return undefined + + const domEl = status.element.target + + if (!(domEl instanceof Element)) { + return undefined + } + + const optsFn = val(status.element.atom.pointer.optsFn) + const opts = optsFn() + const {invoke} = opts + if (invoke && typeof invoke !== 'function' && invoke.type === 'popover') { + return { + ...invoke, + domEl, + originalTriggerEvent: status.originalTriggerEvent, + element: status.element, + } + } else { + return undefined + } + }, []) + + const {isPointerBeingCaptured} = usePointerCapturing(`PopoverOverlay`) + + if (!s) return null + + const close = () => { + popoverActor.send({type: 'close', el: s.element}) + } + + const onPointerOutside = + s.closeWhenPointerIsDistant === false + ? undefined + : { + threshold: s.pointerDistanceThreshold ?? 100, + callback: () => { + // if (lock.childHasFocusRef.current) return + // this is a bit weird, because when you stop capturing, then the popover can close on you... + // TODO: Better fixes? + if (isPointerBeingCaptured()) return + close() + }, + } + + return ( + s.render({close})} + target={s.domEl} + onClickOutside={close} + onPointerOutside={onPointerOutside} + constraints={s.constraints} + verticalGap={s.verticalGap} + /> + ) +} diff --git a/theatre/studio/src/uiComponents/chordial/chordialInternals.ts b/theatre/studio/src/uiComponents/chordial/chordialInternals.ts index 376edbb3b3..a635005f7e 100644 --- a/theatre/studio/src/uiComponents/chordial/chordialInternals.ts +++ b/theatre/studio/src/uiComponents/chordial/chordialInternals.ts @@ -3,6 +3,32 @@ import type {$IntentionalAny} from '@theatre/utils/types' import {useEffect, type ElementType, type MutableRefObject} from 'react' import type {DragOpts} from '@theatre/studio/uiComponents/useDrag' import type React from 'react' +import type {AbsolutePlacementBoxConstraints} from '@theatre/studio/uiComponents/Popover/PopoverPositioner' + +export type InvokeTypePopover = { + type: 'popover' + render: (props: {close: () => void}) => React.ReactElement + closeWhenPointerIsDistant?: boolean + pointerDistanceThreshold?: number + closeOnClickOutside?: boolean + constraints?: AbsolutePlacementBoxConstraints + verticalGap?: number +} + +export type InvokeType = + | InvokeTypePopover + | (( + e: + | { + type: 'MouseEvent' + event: MouseEvent + } + | { + type: 'KeyboardEvent' + event: KeyboardEvent + } + | undefined, + ) => void) export type ChordialOpts = { // shown on the tooltip @@ -10,23 +36,20 @@ export type ChordialOpts = { // shown as the top item in the menu menuTitle?: string | React.ReactNode items: Array - invoke?: ( - e: - | {type: 'MouseEvent'; event: MouseEvent} - | {type: 'KeyboardEvent'; event: KeyboardEvent} - | undefined, - ) => void + invoke?: InvokeType drag?: DragOpts } -export type ContextMenuItem = { - type: 'normal' - label: string | ElementType - callback?: (e: React.MouseEvent) => void - focus?: () => void - enabled?: boolean - key?: string -} +export type ContextMenuItem = + | { + type: 'normal' + label: string | ElementType + callback?: (e: React.MouseEvent) => void + focus?: () => void + enabled?: boolean + key?: string + } + | {type: 'separator'} export type ChordialOptsFn = () => ChordialOpts diff --git a/theatre/studio/src/uiComponents/chordial/gestureActor.ts b/theatre/studio/src/uiComponents/chordial/gestureActor.ts index 79f902ae97..204d7506fc 100644 --- a/theatre/studio/src/uiComponents/chordial/gestureActor.ts +++ b/theatre/studio/src/uiComponents/chordial/gestureActor.ts @@ -8,8 +8,26 @@ import { import {isSafari} from '@theatre/studio/uiComponents/isSafari' import type {CapturedPointer} from '@theatre/studio/UIRoot/PointerCapturing' import {createPointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing' -import type {ChodrialElement, ChordialOpts} from './chordialInternals' +import type { + ChodrialElement, + ChordialOpts, + InvokeType, +} from './chordialInternals' import {findChodrialByDomNode} from './chordialInternals' +import {popoverActor} from './popoverActor' + +function handleInvoke( + invoke: undefined | InvokeType, + el: ChodrialElement, + mouseEvent: MouseEvent, +) { + if (!invoke) return + if (typeof invoke === 'function') { + invoke({type: 'MouseEvent', event: mouseEvent}) + } else if (invoke.type === 'popover') { + popoverActor.send({type: 'open', el, triggerEvent: mouseEvent}) + } +} export const gestureActor = basicFSM< | {type: 'mousedown'; mouseEvent: MouseEvent} @@ -26,8 +44,7 @@ export const gestureActor = basicFSM< const el = findChodrialByDomNode(e.mouseEvent.target) if (!el) return const {invoke} = el.atom.get().optsFn() - if (!invoke) return - invoke({type: 'MouseEvent', event: e.mouseEvent}) + handleInvoke(invoke, el, e.mouseEvent) } break case 'mousedown': @@ -83,6 +100,7 @@ export const gestureActor = basicFSM< } function handleMouseup( + el: ChodrialElement, opts: ChordialOpts, dragOpts: DragOpts, e: MouseEvent, @@ -104,7 +122,8 @@ export const gestureActor = basicFSM< if (!dragHappened) { handlers.onClick?.(e) - opts.invoke?.({type: 'MouseEvent', event: e}) + handleInvoke(opts.invoke, el, e) + // opts.invoke?.({type: 'MouseEvent', event: e}) } idle() } @@ -122,6 +141,7 @@ export const gestureActor = basicFSM< switch (e.mouseEvent.type) { case 'mouseup': handleMouseup( + el, opts, dragOpts, e.mouseEvent, @@ -190,6 +210,7 @@ export const gestureActor = basicFSM< switch (e.mouseEvent.type) { case 'mouseup': handleMouseup( + el, opts, dragOpts, e.mouseEvent, diff --git a/theatre/studio/src/uiComponents/chordial/popoverActor.ts b/theatre/studio/src/uiComponents/chordial/popoverActor.ts new file mode 100644 index 0000000000..f95a4407e1 --- /dev/null +++ b/theatre/studio/src/uiComponents/chordial/popoverActor.ts @@ -0,0 +1,69 @@ +import {basicFSM} from '@theatre/utils/basicFSM' +import type {ChodrialElement, InvokeTypePopover} from './chordialInternals' +import {prism, val} from '@theatre/dataverse' + +export const popoverActor = basicFSM< + | {type: 'open'; el: ChodrialElement; triggerEvent: MouseEvent | undefined} + | {type: 'close'; el: ChodrialElement}, + | ({ + element: ChodrialElement + originalTriggerEvent: MouseEvent | undefined + } & InvokeTypePopover) + | undefined +>((transition) => { + function idle() { + transition('idle', undefined, (e) => { + switch (e.type) { + case 'open': + const {el} = e + const {invoke} = el.atom.get().optsFn() + if ( + invoke && + typeof invoke !== 'function' && + invoke.type === 'popover' + ) { + active(el, invoke, e.triggerEvent) + } + break + case 'close': + break + } + }) + } + + function active( + originalEl: ChodrialElement, + invoke: InvokeTypePopover, + triggerEvent: MouseEvent | undefined, + ) { + const activationTime = Date.now() + transition( + 'active', + {element: originalEl, originalTriggerEvent: triggerEvent, ...invoke}, + (e) => { + switch (e.type) { + case 'open': + const {el} = e + const {invoke} = el.atom.get().optsFn() + if ( + invoke && + typeof invoke !== 'function' && + invoke.type === 'popover' + ) { + active(el, invoke, e.triggerEvent) + } + break + case 'close': + if (e.el === originalEl) { + idle() + } + break + } + }, + ) + } + + idle() +})() + +export const popoverStatus = prism(() => val(popoverActor.pointer)) diff --git a/theatre/studio/src/uiComponents/form/BasicSelect.tsx b/theatre/studio/src/uiComponents/form/BasicSelect.tsx index abfec78db1..81ae801ef9 100644 --- a/theatre/studio/src/uiComponents/form/BasicSelect.tsx +++ b/theatre/studio/src/uiComponents/form/BasicSelect.tsx @@ -1,6 +1,6 @@ import React, {useCallback} from 'react' import styled from 'styled-components' -import {CgSelect} from 'react-icons/all' +import {CgSelect} from 'react-icons/cg' const Container = styled.div` width: 100%; diff --git a/theatre/studio/src/uiComponents/icons/DropdownChevron.tsx b/theatre/studio/src/uiComponents/icons/DropdownChevron.tsx new file mode 100644 index 0000000000..7ae3f0c721 --- /dev/null +++ b/theatre/studio/src/uiComponents/icons/DropdownChevron.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import styled from 'styled-components' + +/** + * A chevron icon specifically for dropdowns and elements that open a menu. + * If you want the chevron to shift down on hover, set `--chevron-down: 1` on the parent element like: + * + * ```tsx + * const Container = styled.div` + * &:hover { + * --chevron-down: 1; + * } + * ` + * ``` + */ +const DropdownChevron = React.forwardRef( + function DropdownChevron(props, ref) { + return ( + + {icon} + + ) + }, +) + +const Container = styled.div` + color: #aaaaaa; + transition: all 0.12s; + + transform: translateY(calc(2px * var(--chevron-down, 0))); +` + +const icon = ( + + + +) + +export default DropdownChevron diff --git a/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/BaseMenu.tsx b/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/BaseMenu.tsx index 74eb9c6ae7..5ec1046ced 100644 --- a/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/BaseMenu.tsx +++ b/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/BaseMenu.tsx @@ -14,8 +14,11 @@ const MenuContainer = styled.ul` position: absolute; min-width: ${minWidth}px; z-index: 10000; - background: ${transparentize(0.8, '#000000')}; - backdrop-filter: blur(8px) saturate(300%) contrast(65%) brightness(70%); + /* background: ${transparentize(0.4, '#000000')}; + backdrop-filter: blur(8px) saturate(300%) contrast(65%) brightness(70%); */ + + background: rgb(45 55 66 / 75%); + backdrop-filter: blur(8px) brightness(70%); color: white; border: 0.5px solid #6262622c; box-sizing: border-box; @@ -49,12 +52,15 @@ const MenuTitle = styled.div` } */ ` -type MenuItem = { - label: string | ElementType - callback?: (e: React.MouseEvent) => void - enabled?: boolean - // subs?: Item[] -} +type MenuItem = + | { + type?: 'normal' + label: string | ElementType + callback?: (e: React.MouseEvent) => void + enabled?: boolean + // subs?: Item[] + } + | {type: 'separator'} const BaseMenu: React.FC<{ items: MenuItem[] @@ -67,21 +73,31 @@ const BaseMenu: React.FC<{ {SHOW_OPTIONAL_MENU_TITLE && props.displayName ? ( {props.displayName} ) : null} - {props.items.map((item, i) => ( - { - if (item.callback) { - item.callback(e) - } - props.onRequestClose() - }} - /> - ))} + {props.items.map((item, i) => + item.type === 'separator' ? ( + + ) : ( + { + if (item.callback) { + item.callback(e) + } + props.onRequestClose() + }} + /> + ), + )} ) }) +const Separator = styled.div` + height: 1px; + margin: 2px 8px; + background: #6262622c; +` + export default BaseMenu diff --git a/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx b/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx index dfc0826d7e..fc2f87fabf 100644 --- a/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx +++ b/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx @@ -8,7 +8,6 @@ import {height as itemHeight} from './Item' import {PortalContext} from 'reakit' import useOnKeyDown from '@theatre/studio/uiComponents/useOnKeyDown' import BaseMenu from './BaseMenu' -import type {$IntentionalAny} from '@theatre/utils/types' import type {ContextMenuItem} from '@theatre/studio/uiComponents/chordial/chordialInternals' /** @@ -27,7 +26,7 @@ export type IContextMenuItemsValue = export type ContextMenuProps = { items: IContextMenuItemsValue displayName?: React.ReactNode - clickPoint: { + clickPoint?: { clientX: number clientY: number } @@ -36,24 +35,6 @@ export type ContextMenuProps = { closeOnPointerLeave?: boolean } -/** - * Useful helper in development to prevent the context menu from auto-closing, - * so its easier to inspect the DOM / change the styles, etc. - * - * Call window.$disableAutoCloseContextMenu() in the console to disable auto-close - */ -const shouldAutoCloseByDefault = - process.env.NODE_ENV === 'development' - ? (): boolean => - (window as $IntentionalAny).__autoCloseContextMenuByDefault ?? true - : (): boolean => true - -if (process.env.NODE_ENV === 'development') { - ;(window as $IntentionalAny).$disableAutoCloseContextMenu = () => { - ;(window as $IntentionalAny).__autoCloseContextMenuByDefault = false - } -} - /** * TODO let's make sure that triggering a context menu would close * the other open context menu (if one _is_ open). @@ -66,6 +47,14 @@ const ContextMenu: React.FC = (props) => { useLayoutEffect(() => { if (!rect || !container) return + const windowGap = 10 + const windowEdges = { + left: windowGap, + top: windowGap, + right: windowSize.width - windowGap, + bottom: windowSize.height - windowGap, + } + const preferredAnchorPoint = { left: rect.width / 2, // if there is a displayName, make sure to move the context menu up by one item, @@ -73,21 +62,23 @@ const ContextMenu: React.FC = (props) => { top: itemHeight / 2 + (props.displayName ? itemHeight : 0), } + const clickPoint = props.clickPoint ?? {clientX: 0, clientY: 0} + const pos = { - left: props.clickPoint.clientX - preferredAnchorPoint.left, - top: props.clickPoint.clientY - preferredAnchorPoint.top, + left: clickPoint.clientX - preferredAnchorPoint.left, + top: clickPoint.clientY - preferredAnchorPoint.top, } - if (pos.left < 0) { - pos.left = 0 - } else if (pos.left + rect.width > windowSize.width) { - pos.left = windowSize.width - rect.width + if (pos.left < windowEdges.left) { + pos.left = windowEdges.left + } else if (pos.left + rect.width > windowEdges.right) { + pos.left = windowEdges.right - rect.width } - if (pos.top < 0) { - pos.top = 0 - } else if (pos.top + rect.height > windowSize.height) { - pos.top = windowSize.height - rect.height + if (pos.top < windowEdges.top) { + pos.top = windowEdges.top + } else if (pos.top + rect.height > windowEdges.bottom) { + pos.top = windowEdges.bottom - rect.height } container.style.left = pos.left + 'px' @@ -100,8 +91,7 @@ const ContextMenu: React.FC = (props) => { e.clientY < pos.top - pointerDistanceThreshold || e.clientY > pos.top + rect.height + pointerDistanceThreshold ) { - if (props.closeOnPointerLeave !== false && shouldAutoCloseByDefault()) - props.onRequestClose() + if (props.closeOnPointerLeave !== false) props.onRequestClose() } } diff --git a/theatre/studio/src/uiComponents/simpleContextMenu/useContextMenu.tsx b/theatre/studio/src/uiComponents/simpleContextMenu/useContextMenu.tsx index ab2bec22d2..46f377b269 100644 --- a/theatre/studio/src/uiComponents/simpleContextMenu/useContextMenu.tsx +++ b/theatre/studio/src/uiComponents/simpleContextMenu/useContextMenu.tsx @@ -1,51 +1,30 @@ -import type {VoidFn} from '@theatre/utils/types' -import React, {useContext, useEffect} from 'react' -import ContextMenu from './ContextMenu/ContextMenu' -import type {IContextMenuItemsValue} from './ContextMenu/ContextMenu' -import useRequestContextMenu from './useRequestContextMenu' -import type {IRequestContextMenuOptions} from './useRequestContextMenu' -import {contextMenuShownContext} from '@theatre/studio/panels/DetailPanel/DetailPanel' -import {closeAllTooltips} from '@theatre/studio/uiComponents/Popover/useTooltip' - -// re-exports -export type {IContextMenuItemsValue, IRequestContextMenuOptions} - -const emptyNode = <> +import type {$FixMe, VoidFn} from '@theatre/utils/types' +import type React from 'react' +import {useEffect} from 'react' +import useMenu from './useMenu' export default function useContextMenu( target: HTMLElement | SVGElement | null, - opts: IRequestContextMenuOptions & { - menuItems: IContextMenuItemsValue - displayName?: string - onOpen?: () => void - }, + opts: Parameters[0], ): [node: React.ReactNode, close: VoidFn, isOpen: boolean] { - const [status, close] = useRequestContextMenu(target, opts) - - // TODO: this lock is now exported from the detail panel, do refactor it when you get the chance - const [, addContextMenu] = useContext(contextMenuShownContext) + const [node, open, close, isOpen] = useMenu(opts) useEffect(() => { - let removeContextMenu: () => void | undefined - if (status.isOpen) { - closeAllTooltips() - opts.onOpen?.() - removeContextMenu = addContextMenu() + if (!target || opts.disabled === true) { + close() + return } - return () => removeContextMenu?.() - }, [status.isOpen, opts.onOpen]) - - const node = !status.isOpen ? ( - emptyNode - ) : ( - - ) + const onTrigger = (event: MouseEvent) => { + open(event) + event.preventDefault() + event.stopPropagation() + } + target.addEventListener('contextmenu', onTrigger as $FixMe) + return () => { + target.removeEventListener('contextmenu', onTrigger as $FixMe) + } + }, [target, opts.disabled]) - return [node, close, status.isOpen] + return [node, close, isOpen] } diff --git a/theatre/studio/src/uiComponents/simpleContextMenu/useMenu.tsx b/theatre/studio/src/uiComponents/simpleContextMenu/useMenu.tsx new file mode 100644 index 0000000000..d4d9ef77b2 --- /dev/null +++ b/theatre/studio/src/uiComponents/simpleContextMenu/useMenu.tsx @@ -0,0 +1,61 @@ +import type {VoidFn} from '@theatre/utils/types' +import React, {useContext, useEffect, useState} from 'react' +import ContextMenu from './ContextMenu/ContextMenu' +import type {ContextMenuProps} from './ContextMenu/ContextMenu' +import {contextMenuShownContext} from '@theatre/studio/panels/DetailPanel/DetailPanel' +import {closeAllTooltips} from '@theatre/studio/uiComponents/Popover/useTooltip' + +const emptyNode = <> + +type IState = + | {isOpen: true; event: undefined | Pick} + | {isOpen: false} + +export default function useMenu( + _opts: { + disabled?: boolean + displayName?: string + onOpen?: () => void + } & Omit, +): [ + node: React.ReactNode, + open: (ev: undefined | Pick) => void, + close: VoidFn, + isOpen: boolean, +] { + const {onOpen, ...contextMenuProps} = _opts + + const [state, setState] = useState({isOpen: false}) + + const close = () => setState({isOpen: false}) + + // TODO: this lock is now exported from the detail panel, do refactor it when you get the chance + const [, addContextMenu] = useContext(contextMenuShownContext) + + useEffect(() => { + let removeContextMenu: () => void | undefined + if (state.isOpen) { + closeAllTooltips() + onOpen?.() + removeContextMenu = addContextMenu() + } + + return () => removeContextMenu?.() + }, [state.isOpen, onOpen]) + + const node = !state.isOpen ? ( + emptyNode + ) : ( + + ) + + const open = (ev: undefined | Pick) => { + setState({isOpen: true, event: ev}) + } + + return [node, open, close, state.isOpen] +} diff --git a/theatre/studio/src/uiComponents/simpleContextMenu/useRequestContextMenu.ts b/theatre/studio/src/uiComponents/simpleContextMenu/useRequestContextMenu.ts deleted file mode 100644 index 5f7282ea62..0000000000 --- a/theatre/studio/src/uiComponents/simpleContextMenu/useRequestContextMenu.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type {$FixMe} from '@theatre/utils/types' -import {useCallback, useEffect, useState} from 'react' - -type IState = {isOpen: true; event: MouseEvent} | {isOpen: false} - -type CloseMenuFn = () => void - -export type IRequestContextMenuOptions = { - disabled?: boolean -} - -const useRequestContextMenu = ( - target: HTMLElement | SVGElement | null, - opts: IRequestContextMenuOptions, -): [state: IState, close: CloseMenuFn] => { - const [state, setState] = useState({isOpen: false}) - const close = useCallback(() => setState({isOpen: false}), []) - - useEffect(() => { - if (!target || opts.disabled === true) { - setState({isOpen: false}) - return - } - - const onTrigger = (event: MouseEvent) => { - setState({isOpen: true, event}) - event.preventDefault() - event.stopPropagation() - } - target.addEventListener('contextmenu', onTrigger as $FixMe) - return () => { - target.removeEventListener('contextmenu', onTrigger as $FixMe) - } - }, [target, opts.disabled]) - - return [state, close] -} - -export default useRequestContextMenu diff --git a/theatre/studio/src/uiComponents/toolbar/ToolbarButton.tsx b/theatre/studio/src/uiComponents/toolbar/ToolbarButton.tsx new file mode 100644 index 0000000000..15cf08e082 --- /dev/null +++ b/theatre/studio/src/uiComponents/toolbar/ToolbarButton.tsx @@ -0,0 +1,57 @@ +import styled from 'styled-components' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' +import React from 'react' + +export const Container = styled.button<{disabled?: boolean; primary?: boolean}>` + ${pointerEventsAutoInNormalMode}; + position: relative; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + height: 32px; + outline: none; + padding: 0 8px; + + color: ${({disabled, primary}) => + disabled === true ? '#919191' : primary === true ? 'white' : '#a8a8a9'}; + + background: ${({disabled, primary}) => + disabled === true + ? 'rgba(64, 67, 71, 0.8)' + : primary === true + ? 'rgb(41 110 120 / 60%)' + : 'rgba(40, 43, 47, 0.8)'}; + + backdrop-filter: blur(14px); + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 3px; + box-shadow: 0px 4px 4px -1px rgba(0, 0, 0, 0.48); + + svg { + display: block; + } + + &:hover { + background: ${({disabled, primary}) => + disabled === true + ? 'rgba(64, 67, 71, 0.8)' + : primary === true + ? 'rgba(50, 155, 169, 0.80)' + : 'rgba(59, 63, 69, 0.8)'}; + } + + &:active { + background: rgba(82, 88, 96, 0.8); + } +` + +const ToolbarButton = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>((props, ref) => { + return +}) + +export default ToolbarButton diff --git a/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx b/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx index 5df7b0a00e..c50f6b733d 100644 --- a/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx +++ b/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx @@ -24,7 +24,7 @@ export const Container = styled.button` backdrop-filter: blur(14px); border: none; border-bottom: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 2px; + border-radius: 3px; svg { display: block; diff --git a/theatre/studio/src/uiComponents/useBoundingClientRect.ts b/theatre/studio/src/uiComponents/useBoundingClientRect.ts index d9eb0a0a90..302a73d9e9 100644 --- a/theatre/studio/src/uiComponents/useBoundingClientRect.ts +++ b/theatre/studio/src/uiComponents/useBoundingClientRect.ts @@ -1,13 +1,17 @@ import {useLayoutEffect, useState} from 'react' export default function useBoundingClientRect( - node: Element | null | undefined, + node: Element | React.MutableRefObject | null | undefined, ): null | DOMRect { const [bounds, set] = useState(null) useLayoutEffect(() => { if (node) { - set(node.getBoundingClientRect()) + if (node instanceof Element) { + set(node.getBoundingClientRect()) + } else if (node.current instanceof Element) { + set(node.current.getBoundingClientRect()) + } } return () => { diff --git a/theatre/studio/src/uiComponents/useOnClickOutside.ts b/theatre/studio/src/uiComponents/useOnClickOutside.ts index dcf0f247d5..0bff7cde61 100644 --- a/theatre/studio/src/uiComponents/useOnClickOutside.ts +++ b/theatre/studio/src/uiComponents/useOnClickOutside.ts @@ -2,18 +2,24 @@ import type {$IntentionalAny} from '@theatre/utils/types' import {useEffect} from 'react' export default function useOnClickOutside( - container: Element | null | (Element | null)[], + container: + | Element + | null + | React.MutableRefObject + | (Element | null | React.MutableRefObject)[], onOutside: (e: MouseEvent) => void, enabled?: boolean, // Can be used e.g. to prevent unexpected closing-reopening when clicking on a // popover's trigger. ) { useEffect(() => { - if (!container || enabled === false) return + let containers: Array = ( + Array.isArray(container) ? container : [container] + ) + .map((el) => (!el ? null : el instanceof Element ? el : el.current)) + .filter((el) => !!el) as Element[] - const containers = Array.isArray(container) - ? (container.filter((container) => container) as Element[]) - : [container] + if (containers.length === 0) return const onMouseDown = (e: MouseEvent) => { if ( diff --git a/yarn.lock b/yarn.lock index 26f1b8cee1..4e0128dd9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9302,6 +9302,7 @@ __metadata: next: latest next-auth: ^4.23.2 npm-run-all: ^4.1.5 + oauth4webapi: ^2.4.0 pg: ^8.11.2 postcss: ^8.4.31 prisma: ^4.12.0 @@ -26612,6 +26613,13 @@ fsevents@^1.2.7: languageName: node linkType: hard +"oauth4webapi@npm:^2.4.0": + version: 2.4.0 + resolution: "oauth4webapi@npm:2.4.0" + checksum: 9e6d5be3966013aa9dd61781032a6bd07a63166a9819f2fc0d622d33b23221ea39ae25334a4bde9eba4623e576972d367b196e3b5d3facff75002125c510b672 + languageName: node + linkType: hard + "oauth@npm:^0.9.15": version: 0.9.15 resolution: "oauth@npm:0.9.15" @@ -29951,12 +29959,12 @@ fsevents@^1.2.7: languageName: node linkType: hard -"prettier@npm:^3.0.2": - version: 3.0.2 - resolution: "prettier@npm:3.0.2" +"prettier@npm:^3.1.1": + version: 3.1.1 + resolution: "prettier@npm:3.1.1" bin: prettier: bin/prettier.cjs - checksum: 118b59ddb6c80abe2315ab6d0f4dd1b253be5cfdb20622fa5b65bb1573dcd362e6dd3dcf2711dd3ebfe64aecf7bdc75de8a69dc2422dcd35bdde7610586b677a + checksum: e386855e3a1af86a748e16953f168be555ce66d6233f4ba54eb6449b88eb0c6b2ca79441b11eae6d28a7f9a5c96440ce50864b9d5f6356d331d39d6bb66c648e languageName: node linkType: hard @@ -30628,6 +30636,15 @@ fsevents@^1.2.7: languageName: node linkType: hard +"react-icons@npm:^4.12.0": + version: 4.12.0 + resolution: "react-icons@npm:4.12.0" + peerDependencies: + react: "*" + checksum: db82a141117edcd884ade4229f0294b2ce15d82f68e0533294db07765d6dce00b129cf504338ec7081ce364fe899b296cb7752554ea08665b1d6bad811134e79 + languageName: node + linkType: hard + "react-is@npm:^16.13.1, react-is@npm:^16.7.0, react-is@npm:^16.8.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -34440,7 +34457,7 @@ fsevents@^1.2.7: jsonc-parser: ^3.1.0 lint-staged: ^13.0.3 node-gyp: ^9.1.0 - prettier: ^3.0.2 + prettier: ^3.1.1 sade: ^1.8.1 typescript: 5.1.6 yaml: ^2.3.1 @@ -34506,7 +34523,7 @@ fsevents@^1.2.7: react-dom: ^18.2.0 react-error-boundary: ^3.1.3 react-hot-toast: ^2.4.0 - react-icons: ^4.2.0 + react-icons: ^4.12.0 react-is: ^17.0.2 react-merge-refs: ^2.0.2 react-shadow: ^20.4.0 @@ -34543,6 +34560,7 @@ fsevents@^1.2.7: esbuild-register: ^2.5.0 lodash-es: ^4.17.21 npm-run-all: ^4.1.5 + oauth4webapi: ^2.4.0 typescript: 5.1.6 peerDependencies: react: "*"