Fix various issues on the Dashboard (#10256)

- Fix React DevTools not working in Firefox
- Fix selection of asset names when editing them, not working at all in Firefox
- Convert tick/cross buttons when editing assets, and the "plus" and "reload" buttons on the "shared with" column, "labels" column, and keyboard shortcuts table, to match more with the rest of the design
- Update clip path when the container resizes, so that the icons for hidden columns never overlap the actual table header
- Fix #10184
- Fix renames being committed even when cancelling
- Fix duplicate name detection - previously, all asset types only checked folders with the same name, not assets with the same name
- I'm not 100% sure this is the correct behavior still
- Stop using `kbd` (`aria.Keyboard`) to display keyboard shortcuts, since they should not be displayed in a monospace font.
- Fix "plus" and "reload" buttons going past the right side of their parent table cell
- Limit length of `PermissionDisplay` - if the username of a user with permission is too long, it uses a tooltip instead
- Update the username dynamically for all permissions owned by self, when changing username in the settings.
- This avoids having to fully invalidate the directory tree every time the username changes, given that nothing changes about the assets' metadata themselves.
- Cache children in the Drive tree
- This avoids loading spinners when closing a folder and immediately reopening it.
- Note that children are still re-fetched on reopen to ensure freshness

# Important Notes
- This MAY be split into multiple smaller PRs. However, I think it's better to QA as a single PR, to avoid duplicating work checking behavior that may be changed by a sibling PR (assuming the PR was split into multiple).
This commit is contained in:
somebody1234 2024-06-21 04:30:24 +10:00 committed by GitHub
parent 83ec24da59
commit b5641aa3bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 1217 additions and 989 deletions

View File

@ -20,6 +20,7 @@ import tsEslintParser from '@typescript-eslint/parser'
// === Constants ===
// =================
const DEBUG_STATEMENTS_MESSAGE = 'Avoid leaving debugging statements when committing code'
const DIR_NAME = path.dirname(url.fileURLToPath(import.meta.url))
const NAME = 'enso'
/** An explicit whitelist of CommonJS modules, which do not support namespace imports.
@ -204,7 +205,7 @@ const RESTRICTED_SYNTAXES = [
{
selector: `JSXAttribute[name.name=/^(?:className)$/] TemplateLiteral`,
message:
'Use `tv` from `tailwind-variants` or `twMerge` from `tailwind-merge` instead of template strings for classes',
'Use `tv` from `#/utilities/tailwindVariants` or `twMerge` from `tailwind-merge` instead of template strings for classes',
},
{
selector: 'JSXOpeningElement[name.name=button] > JSXIdentifier',
@ -283,11 +284,18 @@ export default [
'no-constant-condition': ['error', { checkLoops: false }],
'no-restricted-syntax': ['error', ...RESTRICTED_SYNTAXES],
'prefer-const': 'error',
'react/forbid-elements': [
'error',
{ forbid: [{ element: 'Debug', message: DEBUG_STATEMENTS_MESSAGE }] },
],
// Not relevant because TypeScript checks types.
'react/prop-types': 'off',
'react/self-closing-comp': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
'react-hooks/exhaustive-deps': [
'error',
{ additionalHooks: 'useOnScroll|useStickyTableHeaderOnScroll' },
],
'react/jsx-pascal-case': ['error', { allowNamespace: true }],
// Prefer `interface` over `type`.
'@typescript-eslint/consistent-type-definitions': 'error',
@ -473,26 +481,11 @@ export default [
property: 'useNavigate',
message: 'Use `hooks.useNavigate` instead.',
},
{
object: 'console',
message: 'Avoid leaving debugging statements when committing code',
},
{
property: 'useDebugState',
message: 'Avoid leaving debugging statements when committing code',
},
{
property: 'useDebugEffect',
message: 'Avoid leaving debugging statements when committing code',
},
{
property: 'useDebugMemo',
message: 'Avoid leaving debugging statements when committing code',
},
{
property: 'useDebugCallback',
message: 'Avoid leaving debugging statements when committing code',
},
{ object: 'console', message: DEBUG_STATEMENTS_MESSAGE },
{ property: 'useDebugState', message: DEBUG_STATEMENTS_MESSAGE },
{ property: 'useDebugEffect', message: DEBUG_STATEMENTS_MESSAGE },
{ property: 'useDebugMemo', message: DEBUG_STATEMENTS_MESSAGE },
{ property: 'useDebugCallback', message: DEBUG_STATEMENTS_MESSAGE },
],
},
},

View File

@ -1,5 +1,4 @@
<svg height="16" width="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.8 6.2L6.6 8 4.8 9.8A.5.5 0 1 0 6.2 11.2L8 9.4 9.8 11.2A.5.5 0 1 0 11.2 9.8L9.4 8 11.2 6.2A.5.5 0 1 0 9.8 4.8L8 6.6 6.2 4.8A.5.5 0 1 0 4.8 6.2Z"
fill="black" />
<rect x="7" y="2" width="2" height="12" fill="black" transform="rotate(45 8 8)" />
<rect x="2" y="7" width="12" height="2" fill="black" transform="rotate(45 8 8)" />
</svg>

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 264 B

View File

@ -1,7 +1,4 @@
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="12" fill="#3e515fe5" fill-opacity="0.1" />
<g opacity="0.66">
<rect x="11" y="6" width="2" height="12" fill="#3e515fe5" />
<rect x="6" y="11" width="12" height="2" fill="#3e515fe5" />
</g>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="2" width="2" height="12" fill="black" />
<rect x="2" y="7" width="12" height="2" fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 346 B

After

Width:  |  Height:  |  Size: 222 B

View File

@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="1 1 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.39065 7.79933L2.32999 8.85999L4.80486 11.3349L5.86552 10.2742L3.39065 7.79933Z" fill="black" />
<path d="M6.22487 7.79512L3.75 10.27L4.81066 11.3306L7.28553 8.85578L6.22487 7.79512Z" fill="black" />
<path
d="M4 9C4 8.01109 4.29325 7.04439 4.84265 6.22215C5.39206 5.3999 6.17295 4.75904 7.08658 4.3806C8.00021 4.00216 9.00555 3.90315 9.97545 4.09607C10.9454 4.289 11.8363 4.7652 12.5355 5.46447C13.2348 6.16373 13.711 7.05464 13.9039 8.02455C14.0969 8.99445 13.9978 9.99979 13.6194 10.9134C13.241 11.827 12.6001 12.6079 11.7778 13.1573C10.9556 13.7068 9.9889 14 9 14L9 12.5C9.69223 12.5 10.3689 12.2947 10.9445 11.9101C11.5201 11.5256 11.9687 10.9789 12.2336 10.3394C12.4985 9.69985 12.5678 8.99612 12.4327 8.31718C12.2977 7.63825 11.9644 7.01461 11.4749 6.52513C10.9854 6.03564 10.3618 5.7023 9.68282 5.56725C9.00388 5.4322 8.30015 5.50151 7.66061 5.76642C7.02107 6.03133 6.47444 6.47993 6.08986 7.0555C5.70527 7.63108 5.5 8.30777 5.5 9L4 9Z"
fill="black" />
<rect x="4" y="9" width="2" height="1" fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,20 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_22795_32405)">
<path
d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18Z"
fill="#3E515F" fill-opacity="0.0898039" />
<g opacity="0.6">
<path d="M3.39065 7.79933L2.32999 8.85999L4.80486 11.3349L5.86552 10.2742L3.39065 7.79933Z" fill="#3E515F" />
<path d="M6.22487 7.79512L3.75 10.27L4.81066 11.3306L7.28553 8.85578L6.22487 7.79512Z" fill="#3E515F" />
<path
d="M4 9C4 8.01109 4.29325 7.04439 4.84265 6.22215C5.39206 5.3999 6.17295 4.75904 7.08658 4.3806C8.00021 4.00216 9.00555 3.90315 9.97545 4.09607C10.9454 4.289 11.8363 4.7652 12.5355 5.46447C13.2348 6.16373 13.711 7.05464 13.9039 8.02455C14.0969 8.99445 13.9978 9.99979 13.6194 10.9134C13.241 11.827 12.6001 12.6079 11.7778 13.1573C10.9556 13.7068 9.9889 14 9 14L9 12.5C9.69223 12.5 10.3689 12.2947 10.9445 11.9101C11.5201 11.5256 11.9687 10.9789 12.2336 10.3394C12.4985 9.69985 12.5678 8.99612 12.4327 8.31718C12.2977 7.63825 11.9644 7.01461 11.4749 6.52513C10.9854 6.03564 10.3618 5.7023 9.68282 5.56725C9.00388 5.4322 8.30015 5.50151 7.66061 5.76642C7.02107 6.03133 6.47444 6.47993 6.08986 7.0555C5.70527 7.63108 5.5 8.30777 5.5 9L4 9Z"
fill="#3E515F" />
<rect x="4" y="9" width="2" height="1" fill="#3E515F" />
</g>
</g>
<defs>
<clipPath id="clip0_22795_32405">
<rect width="18" height="18" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,4 +1,4 @@
<svg height="16" width="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M3.4 9.2L5.7 11.5A1 1 0 0 0 7.3 11.5L12.6 6.2A.5.5 0 1 0 11.2 4.8L6.5 9.5 4.8 7.8A.5.5 0 1 0 3.4 9.2"
fill="black" />
<rect x="8" y="2" width="2" height="11" fill="black" transform="rotate(45 8 8)" />
<rect x="4" y="11" width="6" height="2" fill="black" transform="rotate(45 8 8)" />
</svg>

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 264 B

View File

@ -3,6 +3,13 @@
The dashboard is the entrypoint into the application. It includes project
management, project sharing, and user accounts and authentication.
## Further documentation
Further documentation is provided in the `docs/` folder:
- [Browser-specific behavior](./docs/browser_specific_behavior.md) details
behavior that is inconsistent between browsers and needs to be worked around.
## Folder structure
- `mock/`: Overrides for specific files in `src/` when running Playwright tests.

View File

@ -0,0 +1,65 @@
# Browser-specific behavior
This document details behavior that is inconsistent between browsers and needs
to be worked around.
## List of inconsistent behaviors
### Drag event missing coordinates
Firefox sets `MouseEvent.pageX` and `MouseEvent.pageY` to `0` for `drag`
events.
#### Fix
Pass the `drag` event handlers to `dragover` event as well, and wrap all `drag`
event handlers in:
````ts
if (event.pageX !== 0 || event.pageY !== 0) {
// original body here
}
```
#### Affected files
- [`DragModal.tsx`](../src/modals/DragModal.tsx)
### Drag event propagation in text inputs
Text selection in text inputs DO NOT WORK on Firefox, when the text input is a
child of an element with `draggable="true"`.
See [Firefox bug 800050].
To solve this problem, use `useDraggable` from
[`dragAndDropHooks.ts`] on ALL elements that MAY contain a text input.
[Firefox bug 800050]: https://bugzilla.mozilla.org/show_bug.cgi?id=800050
#### Fix
Merge `useDraggable` from [`dragAndDropHooks.ts`] on ALL elements that MAY
contain a text input.
It is recommended to use `aria.mergeProps` to combine these props with existing
props.
```tsx
import * as dragAndDropHooks from "#/hooks/dragAndDropHooks.ts";
const draggableProps = dragAndDropHooks.useDraggable();
return <div {...draggableProps}></div>;
````
[`draggableHooks.ts`]: ../src/hooks/dragAndDropHooks.ts
#### Affected browsers
- Firefox (all versions)
#### Affected files
- [`EditableSpan.tsx`](../src/components/EditableSpan.tsx) - the text inputs
that are affected
- [`AssetRow.tsx`](../src/components/dashboard/AssetRow.tsx) - fixes text
selection in `EditableSpan.tsx`

View File

@ -142,12 +142,12 @@ export function locateLabelsPanelLabels(page: test.Page) {
/** Find a tick button (if any) on the current page. */
export function locateEditingTick(page: test.Locator | test.Page) {
return page.getByAltText('Confirm Edit')
return page.getByLabel('Confirm Edit')
}
/** Find a cross button (if any) on the current page. */
export function locateEditingCross(page: test.Locator | test.Page) {
return page.getByAltText('Cancel Edit')
return page.getByLabel('Cancel Edit')
}
/** Find labels in the "Labels" column of the assets table (if any) on the current page. */

View File

@ -55,7 +55,8 @@ test.test('asset panel contents', ({ page }) =>
permission: permissions.PermissionAction.own,
user: {
organizationId: defaultOrganizationId,
userId: defaultUserId,
// Using the default ID causes the asset to have a dynamic username.
userId: backend.UserId(defaultUserId + '2'),
name: USERNAME,
email: backend.EmailAddress(EMAIL),
},

View File

@ -8,85 +8,80 @@ test.test.beforeEach(actions.mockAllAndLogin)
test.test('edit name', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
const mod = await actions.modModifier(page)
const row = assetRows.nth(0)
const newName = 'foo bar baz'
await actions.locateNewFolderIcon(page).click()
await actions.locateAssetRowName(assetRows.nth(0)).click({ modifiers: [mod] })
await actions.locateAssetRowName(assetRows.nth(0)).fill(newName)
await actions.locateEditingTick(assetRows.nth(0)).click()
await test.expect(assetRows).toHaveCount(1)
await test.expect(assetRows.nth(0)).toBeVisible()
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + newName))
await actions.locateAssetRowName(row).click({ modifiers: [mod] })
await actions.locateAssetRowName(row).fill(newName)
await actions.locateEditingTick(row).click()
await test.expect(row).toHaveText(new RegExp('^' + newName))
})
test.test('edit name (keyboard)', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
const row = assetRows.nth(0)
const newName = 'foo bar baz quux'
await actions.locateNewFolderIcon(page).click()
await actions.locateAssetRowName(assetRows.nth(0)).click()
await actions.locateAssetRowName(row).click()
await actions.press(page, 'Mod+R')
await actions.locateAssetRowName(assetRows.nth(0)).fill(newName)
await actions.locateAssetRowName(assetRows.nth(0)).press('Enter')
await test.expect(assetRows).toHaveCount(1)
await test.expect(assetRows.nth(0)).toBeVisible()
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + newName))
await actions.locateAssetRowName(row).fill(newName)
await actions.locateAssetRowName(row).press('Enter')
await test.expect(row).toHaveText(new RegExp('^' + newName))
})
test.test('cancel editing name', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
const mod = await actions.modModifier(page)
const row = assetRows.nth(0)
const newName = 'foo bar baz'
await actions.locateNewFolderIcon(page).click()
const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? ''
await actions.locateAssetRowName(assetRows.nth(0)).click({ modifiers: [mod] })
await actions.locateAssetRowName(assetRows.nth(0)).fill(newName)
await actions.locateEditingCross(assetRows.nth(0)).click()
await test.expect(assetRows).toHaveCount(1)
await test.expect(assetRows.nth(0)).toBeVisible()
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName))
const oldName = (await actions.locateAssetRowName(row).textContent()) ?? ''
await actions.locateAssetRowName(row).click({ modifiers: [mod] })
await actions.locateAssetRowName(row).fill(newName)
await actions.locateEditingCross(row).click()
await test.expect(row).toHaveText(new RegExp('^' + oldName))
})
test.test('cancel editing name (keyboard)', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
const row = assetRows.nth(0)
const newName = 'foo bar baz quux'
await actions.locateNewFolderIcon(page).click()
const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? ''
await actions.locateAssetRowName(assetRows.nth(0)).click()
const oldName = (await actions.locateAssetRowName(row).textContent()) ?? ''
await actions.locateAssetRowName(row).click()
await actions.press(page, 'Mod+R')
await actions.locateAssetRowName(assetRows.nth(0)).fill(newName)
await actions.locateAssetRowName(assetRows.nth(0)).press('Escape')
await test.expect(assetRows).toHaveCount(1)
await test.expect(assetRows.nth(0)).toBeVisible()
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName))
await actions.locateAssetRowName(row).fill(newName)
await actions.locateAssetRowName(row).press('Escape')
await test.expect(row).toHaveText(new RegExp('^' + oldName))
})
test.test('change to blank name', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
const mod = await actions.modModifier(page)
const row = assetRows.nth(0)
await actions.locateNewFolderIcon(page).click()
const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? ''
await actions.locateAssetRowName(assetRows.nth(0)).click({ modifiers: [mod] })
await actions.locateAssetRowName(assetRows.nth(0)).fill('')
await actions.locateEditingTick(assetRows.nth(0)).click()
await test.expect(assetRows).toHaveCount(1)
await test.expect(assetRows.nth(0)).toBeVisible()
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName))
const oldName = (await actions.locateAssetRowName(row).textContent()) ?? ''
await actions.locateAssetRowName(row).click({ modifiers: [mod] })
await actions.locateAssetRowName(row).fill('')
await test.expect(actions.locateEditingTick(row)).not.toBeVisible()
await actions.locateEditingCross(row).click()
await test.expect(row).toHaveText(new RegExp('^' + oldName))
})
test.test('change to blank name (keyboard)', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
const row = assetRows.nth(0)
await actions.locateNewFolderIcon(page).click()
const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? ''
await actions.locateAssetRowName(assetRows.nth(0)).click()
const oldName = (await actions.locateAssetRowName(row).textContent()) ?? ''
await actions.locateAssetRowName(row).click()
await actions.press(page, 'Mod+R')
await actions.locateAssetRowName(assetRows.nth(0)).fill('')
await actions.locateAssetRowName(assetRows.nth(0)).press('Enter')
await test.expect(assetRows).toHaveCount(1)
await test.expect(assetRows.nth(0)).toBeVisible()
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName))
await actions.locateAssetRowName(row).fill('')
await actions.locateAssetRowName(row).press('Enter')
await test.expect(row).toHaveText(new RegExp('^' + oldName))
})

View File

@ -58,141 +58,3 @@ test.test('drag labels onto multiple rows', async ({ page }) => {
await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible()
})
test.test('drag (recursive)', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const labels = actions.locateLabelsPanelLabels(page)
const label = 'bbbb'
api.addLabel('aaaa', backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel(label, backend.COLORS[1]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('cccc', backend.COLORS[2]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('dddd', backend.COLORS[3]!)
const assetsWithLabel = new Set<string>()
const shouldHaveLabel = <T extends backend.AnyAsset>(asset: T) => {
assetsWithLabel.add(asset.title)
return asset
}
const directory1 = shouldHaveLabel(api.addDirectory('foo'))
const directory2 = shouldHaveLabel(api.addDirectory('bar'))
shouldHaveLabel(api.addFile('baz', { parentId: directory1.id }))
shouldHaveLabel(api.addSecret('quux', { parentId: directory1.id }))
const directory3 = api.addDirectory('directory 3')
const directory5 = shouldHaveLabel(api.addDirectory('directory 5', { parentId: directory3.id }))
api.addFile('file 1', { parentId: directory3.id })
api.addProject('file 2', { parentId: directory3.id })
api.addFile('another file')
const directory4 = shouldHaveLabel(api.addDirectory('blargle', { parentId: directory2.id }))
shouldHaveLabel(api.addProject('abcd', { parentId: directory2.id }))
shouldHaveLabel(api.addProject('efgh', { parentId: directory2.id }))
shouldHaveLabel(api.addFile('ijkl', { parentId: directory4.id }))
shouldHaveLabel(api.addProject('mnop', { parentId: directory4.id }))
shouldHaveLabel(api.addSecret('secret 1', { parentId: directory5.id }))
shouldHaveLabel(api.addFile('yet another file', { parentId: directory5.id }))
await actions.login({ page })
let didExpandRows = false
do {
didExpandRows = false
const directories = await actions.locateExpandableDirectories(page).all()
// If going through the directories in forward order, the positions change when
// one directory is expanded, making the double click happend on the wrong row
// for all directories after it.
for (const directory of directories.reverse()) {
didExpandRows = true
await directory.dblclick()
}
} while (didExpandRows)
await page.keyboard.down(await actions.modModifier(page))
const directory1Row = assetRows.filter({ hasText: directory1.title })
await directory1Row.click()
await actions.clickAssetRow(assetRows.filter({ hasText: directory2.title }))
await actions.clickAssetRow(assetRows.filter({ hasText: directory5.title }))
await labels.nth(1).dragTo(directory1Row)
await page.keyboard.up(await actions.modModifier(page))
for (const row of await actions.locateAssetRows(page).all()) {
const name = await actions.locateAssetName(row).innerText()
const labelElement = actions.locateAssetLabels(row).getByText(label)
if (assetsWithLabel.has(name)) {
await test.expect(labelElement).toBeVisible()
} else {
await test.expect(labelElement).not.toBeVisible()
}
}
})
test.test('drag (inverted, recursive)', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const labels = actions.locateLabelsPanelLabels(page)
const label = 'bbbb'
api.addLabel('aaaa', backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const backendLabel = api.addLabel(label, backend.COLORS[1]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('cccc', backend.COLORS[2]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('dddd', backend.COLORS[3]!)
const assetsWithoutLabel = new Set<string>()
const shouldNotHaveLabel = <T extends backend.AnyAsset>(asset: T) => {
assetsWithoutLabel.add(asset.title)
return asset
}
const directory1 = shouldNotHaveLabel(api.addDirectory('foo'))
const directory2 = shouldNotHaveLabel(api.addDirectory('bar'))
shouldNotHaveLabel(api.addFile('baz', { parentId: directory1.id }))
shouldNotHaveLabel(api.addSecret('quux', { parentId: directory1.id }))
const directory3 = api.addDirectory('directory 3')
const directory5 = shouldNotHaveLabel(
api.addDirectory('directory 5', { parentId: directory3.id })
)
api.addFile('file 1', { parentId: directory3.id })
api.addProject('file 2', { parentId: directory3.id })
api.addFile('another file')
const directory4 = shouldNotHaveLabel(api.addDirectory('blargle', { parentId: directory2.id }))
shouldNotHaveLabel(api.addProject('abcd', { parentId: directory2.id }))
shouldNotHaveLabel(api.addProject('efgh', { parentId: directory2.id }))
shouldNotHaveLabel(api.addFile('ijkl', { parentId: directory4.id }))
shouldNotHaveLabel(api.addProject('mnop', { parentId: directory4.id }))
shouldNotHaveLabel(api.addSecret('secret 1', { parentId: directory5.id }))
shouldNotHaveLabel(api.addFile('yet another file', { parentId: directory5.id }))
api.setLabels(api.rootDirectoryId, [backendLabel.value])
await actions.login({ page })
/** The default position (the center) cannot be clicked on as it lands exactly on a label -
* which has its own mouse action. It also cannot be too far left, otherwise it triggers
* edit mode for the name. */
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const somewhereInRow = { x: 300, y: 16 }
let didExpandRows = false
do {
didExpandRows = false
const directories = await actions.locateExpandableDirectories(page).all()
// If going through the directories in forward order, the positions change when
// one directory is expanded, making the double click happend on the wrong row
// for all directories after it.
for (const directory of directories.reverse()) {
didExpandRows = true
await directory.dblclick({ position: somewhereInRow })
}
} while (didExpandRows)
await page.keyboard.down(await actions.modModifier(page))
const directory1Row = assetRows.filter({ hasText: directory1.title })
await directory1Row.click({ position: somewhereInRow })
await assetRows.filter({ hasText: directory2.title }).click({ position: somewhereInRow })
await assetRows.filter({ hasText: directory5.title }).click({ position: somewhereInRow })
await labels.nth(1).dragTo(directory1Row)
await page.keyboard.up(await actions.modModifier(page))
for (const row of await actions.locateAssetRows(page).all()) {
const name = await actions.locateAssetName(row).innerText()
const labelElement = actions.locateAssetLabels(row).getByText(label)
if (assetsWithoutLabel.has(name)) {
await test.expect(labelElement).not.toBeVisible()
} else {
await test.expect(labelElement).toBeVisible()
}
}
})

View File

@ -16,14 +16,15 @@
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self';
frame-src 'self' data: https://js.stripe.com;
script-src 'self' 'unsafe-eval' data: https://*;
style-src 'self' 'unsafe-inline' data: https://*;
connect-src 'self' data: ws://localhost:* ws://127.0.0.1:* http://localhost:* https://* wss://*;
worker-src 'self' blob:;
img-src 'self' blob: data: https://*;
font-src 'self' data: https://*"
default-src 'self';
frame-src 'self' data: https://js.stripe.com;
script-src 'self' 'unsafe-eval' data: https://*;
script-src-elem 'self' 'unsafe-inline' https://*;
style-src 'self' 'unsafe-inline' data: https://*;
connect-src 'self' data: ws://localhost:* ws://127.0.0.1:* http://localhost:* https://* wss://*;
worker-src 'self' blob:;
img-src 'self' blob: data: https://*;
font-src 'self' data: https://*"
/>
<meta
name="viewport"

View File

@ -253,7 +253,7 @@ function AppRouter(props: AppRouterProps) {
}
}
}
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
}, [localStorage, inputBindingsRaw])
React.useEffect(() => {
if (remoteBackend) {
@ -315,14 +315,14 @@ function AppRouter(props: AppRouterProps) {
return inputBindingsRaw.unregister.bind(inputBindingsRaw)
},
}
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
}, [localStorage, inputBindingsRaw])
const mainPageUrl = getMainPageUrl()
const authService = React.useMemo(() => {
const authConfig = { navigate, ...props }
return authServiceModule.initAuthService(authConfig)
}, [props, /* should never change */ navigate])
}, [props, navigate])
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
const refreshUserSession =
@ -335,7 +335,7 @@ function AppRouter(props: AppRouterProps) {
setModal(<AboutModal />)
})
}
}, [/* should never change */ setModal])
}, [setModal])
React.useEffect(() => {
const onKeyDown = navigator2D.onKeyDown.bind(navigator2D)
@ -366,15 +366,15 @@ function AppRouter(props: AppRouterProps) {
app.contains(selection.anchorNode) &&
selection.focusNode != null &&
app.contains(selection.focusNode)
if (selection != null && !appContainsSelection) {
selection.removeAllRanges()
if (!appContainsSelection) {
selection?.removeAllRanges()
}
}
}
const onSelectStart = () => {
isClick = false
}
document.addEventListener('mousedown', onMouseDown)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('selectstart', onSelectStart)

View File

@ -179,7 +179,8 @@ export const BUTTON_STYLES = twv.tv({
ghost:
'text-primary hover:text-primary/80 hover:bg-white focus-visible:text-primary/80 focus-visible:bg-white',
submit: 'bg-invite text-white opacity-80 hover:opacity-100 focus-visible:outline-offset-2',
outline: 'border-primary/40 text-primary hover:border-primary focus-visible:outline-offset-2',
outline:
'border-primary/40 text-primary hover:border-primary focus-visible:outline-offset-2 hover:bg-primary/10',
bar: 'rounded-full border-0.5 border-primary/20 transition-colors hover:bg-primary/10',
},
iconPosition: {

View File

@ -66,16 +66,16 @@ export const TEXT_STYLE = twv.tv({
},
truncate: {
/* eslint-disable @typescript-eslint/naming-convention */
'1': 'truncate ellipsis w-full',
'2': 'line-clamp-2 ellipsis w-full',
'3': 'line-clamp-3 ellipsis w-full',
'4': 'line-clamp-4 ellipsis w-full',
'5': 'line-clamp-5 ellipsis w-full',
'6': 'line-clamp-6 ellipsis w-full',
'7': 'line-clamp-7 ellipsis w-full',
'8': 'line-clamp-8 ellipsis w-full',
'9': 'line-clamp-9 ellipsis w-full',
custom: 'line-clamp-[var(--line-clamp)] ellipsis w-full',
'1': 'truncate ellipsis',
'2': 'line-clamp-2 ellipsis',
'3': 'line-clamp-3 ellipsis',
'4': 'line-clamp-4 ellipsis',
'5': 'line-clamp-5 ellipsis',
'6': 'line-clamp-6 ellipsis',
'7': 'line-clamp-7 ellipsis',
'8': 'line-clamp-8 ellipsis',
'9': 'line-clamp-9 ellipsis',
custom: 'line-clamp-[var(--line-clamp)] ellipsis',
/* eslint-enable @typescript-eslint/naming-convention */
},
monospace: { true: 'font-mono' },

View File

@ -11,7 +11,7 @@ import * as text from '../Text'
// =================
export const TOOLTIP_STYLES = twv.tv({
base: 'group flex justify-center items-center text-center text-balance',
base: 'group flex justify-center items-center text-center text-balance break-words',
variants: {
variant: {
custom: '',

View File

@ -4,18 +4,17 @@ import * as React from 'react'
import CrossIcon from 'enso-assets/cross.svg'
import TickIcon from 'enso-assets/tick.svg'
import * as eventCalback from '#/hooks/eventCallbackHooks'
import * as eventCallback from '#/hooks/eventCallbackHooks'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import * as eventModule from '#/utilities/event'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// ====================
// === EditableSpan ===
@ -36,19 +35,18 @@ export interface EditableSpanProps {
/** A `<span>` that can turn into an `<input type="text">`. */
export default function EditableSpan(props: EditableSpanProps) {
const { className, editable = false, children } = props
const { className = '', editable = false, children } = props
const { checkSubmittable, onSubmit, onCancel, inputPattern, inputTitle } = props
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const [isSubmittable, setIsSubmittable] = React.useState(false)
const inputRef = React.useRef<HTMLInputElement>(null)
const inputRef = React.useRef<HTMLInputElement | null>(null)
const cancelledRef = React.useRef(false)
const checkSubmittableRef = React.useRef(checkSubmittable)
checkSubmittableRef.current = checkSubmittable
// Making sure that the event callback is stable.
// to prevent the effect from re-running.
const onCancelEventCallback = eventCalback.useEventCallback(onCancel)
// Make sure that the event callback is stable to prevent the effect from re-running.
const onCancelEventCallback = eventCallback.useEventCallback(onCancel)
React.useEffect(() => {
if (editable) {
@ -68,7 +66,7 @@ export default function EditableSpan(props: EditableSpanProps) {
} else {
return
}
}, [editable, /* should never change */ inputBindings, onCancelEventCallback])
}, [editable, inputBindings, onCancelEventCallback])
React.useEffect(() => {
cancelledRef.current = false
@ -77,33 +75,43 @@ export default function EditableSpan(props: EditableSpanProps) {
if (editable) {
return (
<form
className="flex grow"
className="flex grow gap-1.5"
onBlur={event => {
const currentTarget = event.currentTarget
if (!currentTarget.contains(event.relatedTarget)) {
// This must run AFTER the cancel button's event handler runs.
setTimeout(() => {
if (!cancelledRef.current) {
currentTarget.requestSubmit()
}
})
}
}}
onSubmit={event => {
event.preventDefault()
if (isSubmittable) {
if (inputRef.current != null) {
if (inputRef.current != null) {
if (isSubmittable) {
onSubmit(inputRef.current.value)
} else {
onCancel()
}
}
}}
>
<aria.Input
data-testid={props['data-testid']}
className={className ?? ''}
ref={inputRef}
className={tailwindMerge.twMerge('rounded-lg', className)}
ref={element => {
inputRef.current = element
if (element) {
element.style.width = '0'
element.style.width = `${element.scrollWidth}px`
}
}}
autoFocus
type="text"
size={1}
defaultValue={children}
onBlur={event => {
const currentTarget = event.currentTarget
// This must run AFTER the cancel button's event handler runs.
setTimeout(() => {
if (!cancelledRef.current) {
currentTarget.form?.requestSubmit()
}
})
}}
onContextMenu={event => {
event.stopPropagation()
}}
@ -111,6 +119,10 @@ export default function EditableSpan(props: EditableSpanProps) {
if (event.key !== 'Escape') {
event.stopPropagation()
}
if (event.target instanceof HTMLElement) {
event.target.style.width = '0'
event.target.style.width = `${event.target.scrollWidth}px`
}
}}
{...(inputPattern == null ? {} : { pattern: inputPattern })}
{...(inputTitle == null ? {} : { title: inputTitle })}
@ -122,21 +134,21 @@ export default function EditableSpan(props: EditableSpanProps) {
},
})}
/>
{isSubmittable && (
<ariaComponents.ButtonGroup gap="xsmall" className="grow-0 items-center">
{isSubmittable && (
<ariaComponents.Button
size="icon"
variant="ghost"
icon={TickIcon}
aria-label={getText('confirmEdit')}
onPress={eventModule.submitForm}
/>
)}
<ariaComponents.Button
size="custom"
variant="custom"
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
onPress={eventModule.submitForm}
>
<SvgMask src={TickIcon} alt={getText('confirmEdit')} className="size-4" />
</ariaComponents.Button>
)}
<FocusRing>
<ariaComponents.Button
size="custom"
variant="custom"
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
size="icon"
variant="ghost"
icon={CrossIcon}
aria-label={getText('cancelEdit')}
onPress={() => {
cancelledRef.current = true
onCancel()
@ -144,10 +156,8 @@ export default function EditableSpan(props: EditableSpanProps) {
cancelledRef.current = false
})
}}
>
<SvgMask src={CrossIcon} alt={getText('cancelEdit')} className="size-4" />
</ariaComponents.Button>
</FocusRing>
/>
</ariaComponents.ButtonGroup>
</form>
)
} else {

View File

@ -35,30 +35,17 @@ export interface JSONSchemaInputProps {
/** A dynamic wizard for creating an arbitrary type of Datalink. */
export default function JSONSchemaInput(props: JSONSchemaInputProps) {
const { dropdownTitle, readOnly = false, defs, schema, path, getValidator } = props
const { value: valueRaw, setValue: setValueRaw } = props
const { value, setValue } = props
// The functionality for inputting `enso-secret`s SHOULD be injected using a plugin,
// but it is more convenient to avoid having plugin infrastructure.
const remoteBackend = backendProvider.useRemoteBackend()
const { getText } = textProvider.useText()
const [value, setValue] = React.useState(valueRaw)
const [autocompleteText, setAutocompleteText] = React.useState(() =>
typeof value === 'string' ? value : null
)
const [selectedChildIndex, setSelectedChildIndex] = React.useState<number | null>(null)
const [autocompleteItems, setAutocompleteItems] = React.useState<string[] | null>(null)
React.useEffect(() => {
setValue(valueRaw)
// `initializing` is not a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [valueRaw])
React.useEffect(() => {
setValueRaw(value)
// `setStateRaw` is a callback, not a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
// NOTE: `enum` schemas omitted for now as they are not yet used.
if ('const' in schema) {
// This value cannot change.
@ -221,48 +208,45 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
: {})}
>
<FocusArea active={isOptional} direction="horizontal">
{innerProps => (
<ariaComponents.Button
size="custom"
variant="custom"
isDisabled={!isOptional}
className={tailwindMerge.twMerge(
'text inline-block w-json-schema-object-key whitespace-nowrap rounded-full px-button-x text-left',
isOptional && 'hover:bg-hover-bg'
)}
onPress={() => {
if (isOptional) {
setValue(oldValue => {
if (oldValue != null && key in oldValue) {
// This is SAFE, as `value` is an untyped object.
// The removed key is intentionally unused.
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
const { [key]: removed, ...newValue } = oldValue as Record<
string,
NonNullable<unknown> | null
>
return newValue
} else {
return {
...oldValue,
[key]: jsonSchema.constantValue(defs, childSchema, true)[0],
}
}
})
}
}}
{...innerProps}
>
<aria.Text
{innerProps => {
const isPresent = value != null && key in value
return (
<ariaComponents.Button
size="custom"
variant="custom"
isDisabled={!isOptional}
isActive={!isOptional || isPresent}
className={tailwindMerge.twMerge(
'selectable',
value != null && key in value && 'active'
'text inline-block w-json-schema-object-key whitespace-nowrap rounded-full px-button-x text-left',
isOptional && 'hover:bg-hover-bg'
)}
onPress={() => {
if (isOptional) {
setValue(oldValue => {
if (oldValue != null && key in oldValue) {
// This is SAFE, as `value` is an untyped object.
// The removed key is intentionally unused.
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
const { [key]: removed, ...newValue } = oldValue as Record<
string,
NonNullable<unknown> | null
>
return newValue
} else {
return {
...oldValue,
[key]: jsonSchema.constantValue(defs, childSchema, true)[0],
}
}
})
}
}}
{...innerProps}
>
{'title' in childSchema ? String(childSchema.title) : key}
</aria.Text>
</ariaComponents.Button>
)}
</ariaComponents.Button>
)
}}
</FocusArea>
{value != null && key in value && (
<JSONSchemaInput

View File

@ -42,7 +42,7 @@ export default function Page(props: PageProps) {
return () => {
document.removeEventListener('click', onClick)
}
}, [/* should never change */ unsetModal])
}, [unsetModal])
return (
<>

View File

@ -11,6 +11,14 @@ import * as eventModule from '#/utilities/event'
import type * as geometry from '#/utilities/geometry'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// =================
// === Constants ===
// =================
/** Controls the speed of animation of the {@link SelectionBrush} when the
* mouse is released and the selection brush collapses back to zero size. */
const ANIMATION_TIME_HORIZON = 60
// ======================
// === SelectionBrush ===
// ======================
@ -18,6 +26,7 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
/** Props for a {@link SelectionBrush}. */
export interface SelectionBrushProps {
readonly targetRef: React.RefObject<HTMLElement>
readonly margin?: number
readonly onDrag: (rectangle: geometry.DetailedRectangle, event: MouseEvent) => void
readonly onDragEnd: (event: MouseEvent) => void
readonly onDragCancel: () => void
@ -25,37 +34,30 @@ export interface SelectionBrushProps {
/** A selection brush to indicate the area being selected by the mouse drag action. */
export default function SelectionBrush(props: SelectionBrushProps) {
const { onDrag, onDragEnd, onDragCancel, targetRef } = props
const { targetRef, margin = 0, onDrag, onDragEnd, onDragCancel } = props
const { modalRef } = modalProvider.useModalRef()
const isMouseDownRef = React.useRef(false)
const didMoveWhileDraggingRef = React.useRef(false)
const onDragRef = React.useRef(onDrag)
onDragRef.current = onDrag
const onDragEndRef = React.useRef(onDragEnd)
onDragEndRef.current = onDragEnd
const onDragCancelRef = React.useRef(onDragCancel)
onDragCancelRef.current = onDragCancel
const lastMouseEvent = React.useRef<MouseEvent | null>(null)
const parentBounds = React.useRef<DOMRect | null>(null)
const [anchor, setAnchor] = React.useState<geometry.Coordinate2D | null>(null)
// This will be `null` if `anchor` is `null`.
const [position, setPosition] = React.useState<geometry.Coordinate2D | null>(null)
const [lastSetAnchor, setLastSetAnchor] = React.useState<geometry.Coordinate2D | null>(null)
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const anchorAnimFactor = animationHooks.useApproach(anchor != null ? 1 : 0, 60)
const anchorAnimFactor = animationHooks.useApproach(
anchor != null ? 1 : 0,
ANIMATION_TIME_HORIZON
)
const hidden =
anchor == null ||
position == null ||
(anchor.left === position.left && anchor.top === position.top)
React.useEffect(() => {
onDragRef.current = onDrag
}, [onDrag])
React.useEffect(() => {
onDragEndRef.current = onDragEnd
}, [onDragEnd])
React.useEffect(() => {
onDragCancelRef.current = onDragCancel
}, [onDragCancel])
React.useEffect(() => {
if (anchor != null) {
anchorAnimFactor.skip()
@ -63,77 +65,95 @@ export default function SelectionBrush(props: SelectionBrushProps) {
}, [anchorAnimFactor, anchor])
React.useEffect(() => {
if (targetRef.current != null) {
const target = targetRef.current
const onMouseDown = (event: MouseEvent) => {
if (
modalRef.current == null &&
!eventModule.isElementTextInput(event.target) &&
!(event.target instanceof HTMLButtonElement) &&
!(event.target instanceof HTMLAnchorElement)
) {
isMouseDownRef.current = true
didMoveWhileDraggingRef.current = false
lastMouseEvent.current = event
const newAnchor = { left: event.pageX, top: event.pageY }
setAnchor(newAnchor)
setLastSetAnchor(newAnchor)
setPosition(newAnchor)
}
const target = targetRef.current ?? document.body
const isEventInBounds = (event: MouseEvent, parent?: HTMLElement | null) => {
if (parent == null) {
return true
} else {
parentBounds.current = parent.getBoundingClientRect()
return eventModule.isElementInBounds(event, parentBounds.current, margin)
}
const onMouseUp = (event: MouseEvent) => {
if (didMoveWhileDraggingRef.current) {
onDragEndRef.current(event)
}
// The `setTimeout` is required, otherwise the values are changed before the `onClick` handler
// is executed.
window.setTimeout(() => {
isMouseDownRef.current = false
didMoveWhileDraggingRef.current = false
})
}
const onMouseDown = (event: MouseEvent) => {
if (
modalRef.current == null &&
!eventModule.isElementTextInput(event.target) &&
!(event.target instanceof HTMLButtonElement) &&
!(event.target instanceof HTMLAnchorElement) &&
isEventInBounds(event, targetRef.current)
) {
isMouseDownRef.current = true
didMoveWhileDraggingRef.current = false
lastMouseEvent.current = event
const newAnchor = { left: event.pageX, top: event.pageY }
setAnchor(newAnchor)
setLastSetAnchor(newAnchor)
setPosition(newAnchor)
}
}
const onMouseUp = (event: MouseEvent) => {
if (didMoveWhileDraggingRef.current) {
onDragEndRef.current(event)
}
// The `setTimeout` is required, otherwise the values are changed before the `onClick` handler
// is executed.
window.setTimeout(() => {
isMouseDownRef.current = false
didMoveWhileDraggingRef.current = false
})
setAnchor(null)
}
const onMouseMove = (event: MouseEvent) => {
if (!(event.buttons & 1)) {
isMouseDownRef.current = false
}
if (isMouseDownRef.current) {
// Left click is being held.
didMoveWhileDraggingRef.current = true
lastMouseEvent.current = event
const positionLeft =
parentBounds.current == null
? event.pageX
: Math.max(
parentBounds.current.left - margin,
Math.min(parentBounds.current.right + margin, event.pageX)
)
const positionTop =
parentBounds.current == null
? event.pageY
: Math.max(
parentBounds.current.top - margin,
Math.min(parentBounds.current.bottom + margin, event.pageY)
)
setPosition({ left: positionLeft, top: positionTop })
}
}
const onClick = (event: MouseEvent) => {
if (isMouseDownRef.current && didMoveWhileDraggingRef.current) {
event.stopImmediatePropagation()
}
}
const onDragStart = () => {
if (isMouseDownRef.current) {
isMouseDownRef.current = false
onDragCancelRef.current()
setAnchor(null)
}
const onMouseMove = (event: MouseEvent) => {
if (!(event.buttons & 1)) {
isMouseDownRef.current = false
}
if (isMouseDownRef.current) {
// Left click is being held.
didMoveWhileDraggingRef.current = true
lastMouseEvent.current = event
setPosition({ left: event.pageX, top: event.pageY })
}
}
const onClick = (event: MouseEvent) => {
if (isMouseDownRef.current && didMoveWhileDraggingRef.current) {
event.stopImmediatePropagation()
}
}
const onDragStart = () => {
if (isMouseDownRef.current) {
isMouseDownRef.current = false
onDragCancelRef.current()
setAnchor(null)
}
}
target.addEventListener('mousedown', onMouseDown)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('dragstart', onDragStart, { capture: true })
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('click', onClick, { capture: true })
return () => {
target.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('dragstart', onDragStart, { capture: true })
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('click', onClick, { capture: true })
}
} else {
return () => {}
}
}, [/* should never change */ modalRef, targetRef])
target.addEventListener('mousedown', onMouseDown)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('dragstart', onDragStart, { capture: true })
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('click', onClick, { capture: true })
return () => {
target.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('dragstart', onDragStart, { capture: true })
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('click', onClick, { capture: true })
}
}, [margin, targetRef, modalRef])
const rectangle = React.useMemo(() => {
if (position != null && lastSetAnchor != null) {
@ -163,10 +183,8 @@ export default function SelectionBrush(props: SelectionBrushProps) {
React.useEffect(() => {
if (selectionRectangle != null && lastMouseEvent.current != null) {
onDrag(selectionRectangle, lastMouseEvent.current)
onDragRef.current(selectionRectangle, lastMouseEvent.current)
}
// `onChange` is a callback, not a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectionRectangle])
const brushStyle =

View File

@ -44,7 +44,7 @@ export default function SvgMask(props: SvgMaskProps) {
...(invert ? { WebkitMaskComposite: 'exclude, exclude' } : {}),
/* eslint-enable @typescript-eslint/naming-convention */
}}
className={tailwindMerge.twMerge('inline-block h-max w-max', className)}
className={tailwindMerge.twMerge('inline-block size-max', className)}
>
{/* This is required for this component to have the right size. */}
<img alt={alt} src={src} className="pointer-events-none opacity-0" draggable={false} />

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import BlankIcon from 'enso-assets/blank.svg'
import * as backendHooks from '#/hooks/backendHooks'
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
import * as eventHooks from '#/hooks/eventHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -99,6 +100,7 @@ export default function AssetRow(props: AssetRowProps) {
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId } = state
const draggableProps = dragAndDropHooks.useDraggable()
const { user } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
@ -156,7 +158,7 @@ export default function AssetRow(props: AssetRowProps) {
if (selected && insertionVisibility !== Visibility.visible) {
setSelected(false)
}
}, [selected, insertionVisibility, /* should never change */ setSelected])
}, [selected, insertionVisibility, setSelected])
React.useEffect(() => {
if (isKeyboardSelected) {
@ -201,10 +203,10 @@ export default function AssetRow(props: AssetRowProps) {
asset,
item.key,
toastAndLog,
/* should never change */ copyAssetMutate,
/* should never change */ nodeMap,
/* should never change */ setAsset,
/* should never change */ dispatchAssetListEvent,
copyAssetMutate,
nodeMap,
setAsset,
dispatchAssetListEvent,
]
)
@ -316,9 +318,9 @@ export default function AssetRow(props: AssetRowProps) {
item.directoryKey,
item.key,
toastAndLog,
/* should never change */ updateAssetMutate,
/* should never change */ setAsset,
/* should never change */ dispatchAssetListEvent,
updateAssetMutate,
setAsset,
dispatchAssetListEvent,
]
)
@ -327,13 +329,7 @@ export default function AssetRow(props: AssetRowProps) {
setAssetPanelProps({ backend, item, setItem })
setIsAssetPanelTemporarilyVisible(false)
}
}, [
item,
isSoleSelected,
/* should never change */ backend,
/* should never change */ setAssetPanelProps,
/* should never change */ setIsAssetPanelTemporarilyVisible,
])
}, [item, isSoleSelected, backend, setAssetPanelProps, setIsAssetPanelTemporarilyVisible])
const doDelete = React.useCallback(
async (forever = false) => {
@ -377,14 +373,14 @@ export default function AssetRow(props: AssetRowProps) {
}
},
[
backend.type,
backend,
dispatchAssetListEvent,
asset,
/* should never change */ openProjectMutate,
/* should never change */ closeProjectMutate,
/* should never change */ deleteAssetMutate,
/* should never change */ item.key,
/* should never change */ toastAndLog,
openProjectMutate,
closeProjectMutate,
deleteAssetMutate,
item.key,
toastAndLog,
]
)
@ -398,13 +394,7 @@ export default function AssetRow(props: AssetRowProps) {
setInsertionVisibility(Visibility.visible)
toastAndLog('restoreAssetError', error, asset.title)
}
}, [
dispatchAssetListEvent,
asset,
toastAndLog,
/* should never change */ undoDeleteAssetMutate,
/* should never change */ item.key,
])
}, [dispatchAssetListEvent, asset, toastAndLog, undoDeleteAssetMutate, item.key])
const doTriggerDescriptionEdit = React.useCallback(() => {
setModal(
@ -723,20 +713,12 @@ export default function AssetRow(props: AssetRowProps) {
case backendModule.AssetType.file:
case backendModule.AssetType.datalink:
case backendModule.AssetType.secret: {
const innerProps: AssetRowInnerProps = {
key,
item,
setItem,
state,
rowState,
setRowState,
}
const innerProps: AssetRowInnerProps = { key, item, setItem, state, rowState, setRowState }
return (
<>
{!hidden && (
<FocusRing>
<tr
draggable
tabIndex={0}
ref={element => {
rootRef.current = element
@ -761,6 +743,7 @@ export default function AssetRow(props: AssetRowProps) {
visibility,
(isDraggedOver || selected) && 'selected'
)}
{...draggableProps}
onClick={event => {
unsetModal()
onClick(innerProps, event)
@ -825,7 +808,6 @@ export default function AssetRow(props: AssetRowProps) {
if (state.category === Category.trash) {
event.dataTransfer.dropEffect = 'none'
}
props.onDragOver?.(event)
onDragOver(event)
}}

View File

@ -27,6 +27,7 @@ import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
import * as string from '#/utilities/string'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import * as validation from '#/utilities/validation'
import Visibility from '#/utilities/Visibility'
// =====================
@ -52,6 +53,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
}
const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const isExpanded = item.children != null && item.isExpanded
const createDirectoryMutation = backendHooks.useBackendMutation(backend, 'createDirectory')
const updateDirectoryMutation = backendHooks.useBackendMutation(backend, 'updateDirectory')
@ -169,11 +171,11 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
icon={FolderArrowIcon}
size="icon"
variant="custom"
aria-label={item.children == null ? getText('expand') : getText('collapse')}
aria-label={isExpanded ? getText('collapse') : getText('expand')}
tooltipPlacement="left"
className={tailwindMerge.twMerge(
'm-0 hidden cursor-pointer border-0 transition-transform duration-arrow group-hover:m-name-column-icon group-hover:inline-block',
item.children != null && 'rotate-90'
isExpanded && 'rotate-90'
)}
onPress={() => {
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
@ -188,16 +190,8 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer'
)}
checkSubmittable={newTitle =>
newTitle !== item.item.title &&
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
child =>
// All siblings,
child.key === item.key ||
// that are directories,
!backendModule.assetIsDirectory(child.item) ||
// must have a different name.
child.item.title !== newTitle
)
validation.DIRECTORY_NAME_REGEX.test(newTitle) &&
item.isNewTitleValid(newTitle, nodeMap.current.get(item.directoryKey)?.children)
}
onSubmit={doRename}
onCancel={() => {

View File

@ -175,16 +175,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
editable={rowState.isEditingName}
className="text grow bg-transparent font-naming"
checkSubmittable={newTitle =>
newTitle !== item.item.title &&
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
child =>
// All siblings,
child.key === item.key ||
// that are not directories,
backendModule.assetIsDirectory(child.item) ||
// must have a different name.
child.item.title !== newTitle
)
item.isNewTitleValid(newTitle, nodeMap.current.get(item.directoryKey)?.children)
}
onSubmit={doRename}
onCancel={() => {

View File

@ -16,6 +16,7 @@ import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import SvgMask from '#/components/SvgMask'
import * as inputBindingsModule from '#/utilities/inputBindings'
@ -26,9 +27,9 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
// ========================
/** The size (both width and height) of key icons. */
const ICON_SIZE_PX = 13
const ICON_SIZE_PX = '1.5cap'
const ICON_STYLE = { width: ICON_SIZE_PX, height: ICON_SIZE_PX }
const ICON_STYLE = { width: ICON_SIZE_PX, height: ICON_SIZE_PX, marginTop: '0.1cap' }
/** Props for values of {@link MODIFIER_JSX}. */
interface InternalModifierProps {
@ -124,24 +125,24 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) {
.sort(inputBindingsModule.compareModifiers)
.map(inputBindingsModule.toModifierKey)
return (
<aria.Text
<div
className={tailwindMerge.twMerge(
'flex h-text items-center',
'flex items-center',
detect.isOnMacOS() ? 'gap-modifiers-macos' : 'gap-modifiers'
)}
>
{modifiers.map(
modifier =>
MODIFIER_JSX[detect.platform()][modifier]?.({ getText }) ?? (
<aria.Text key={modifier} className="text">
<ariaComponents.Text key={modifier}>
{getText(MODIFIER_TO_TEXT_ID[modifier])}
</aria.Text>
</ariaComponents.Text>
)
)}
<aria.Text className="text">
<ariaComponents.Text>
{shortcut.key === ' ' ? 'Space' : KEY_CHARACTER[shortcut.key] ?? shortcut.key}
</aria.Text>
</aria.Text>
</ariaComponents.Text>
</div>
)
}
}

View File

@ -6,6 +6,7 @@ import * as focusHooks from '#/hooks/focusHooks'
import * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
import type * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import FocusRing from '#/components/styled/FocusRing'
import * as backend from '#/services/Backend'
@ -38,7 +39,8 @@ interface InternalLabelProps extends Readonly<React.PropsWithChildren> {
/** An label that can be applied to an asset. */
export default function Label(props: InternalLabelProps) {
const { active = false, isDisabled = false, color, negated = false, draggable, title } = props
const { className = 'text-tag-text', children, onPress, onDragStart, onContextMenu } = props
const { className = 'text-tag-text', onPress, onDragStart, onContextMenu } = props
const { children: childrenRaw } = props
const focusDirection = focusDirectionProvider.useFocusDirection()
const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection)
const textClass = /\btext-/.test(className)
@ -47,6 +49,15 @@ export default function Label(props: InternalLabelProps) {
? 'text-tag-text'
: 'text-primary'
const children =
typeof childrenRaw !== 'string' ? (
childrenRaw
) : (
<ariaComponents.Text truncate="1" className="max-w-24 text-inherit">
{childrenRaw}
</ariaComponents.Text>
)
return (
<FocusRing within placement="after">
<div

View File

@ -20,9 +20,18 @@ export interface PermissionDisplayProps extends Readonly<React.PropsWithChildren
/** Colored border around icons and text indicating permissions. */
export default function PermissionDisplay(props: PermissionDisplayProps) {
const { action, className, onPress, children } = props
const { action, className, onPress, children: childrenRaw } = props
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]
const children =
typeof childrenRaw !== 'string' ? (
childrenRaw
) : (
<ariaComponents.Text truncate="1" className="max-w-24 text-inherit">
{childrenRaw}
</ariaComponents.Text>
)
switch (permission.type) {
case permissionsModule.Permission.owner:
case permissionsModule.Permission.admin:

View File

@ -202,14 +202,14 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
default: {
permissionDisplay = (
<ariaComponents.Button
size="xxsmall"
size="xsmall"
variant="custom"
ref={permissionSelectorButtonRef}
isDisabled={isDisabled}
isActive={!isDisabled || !isInput}
{...(isDisabled && error != null ? { title: error } : {})}
className={tailwindMerge.twMerge(
'h-6 w-[121px] rounded-full',
'w-[121px] rounded-full border-0 py-0',
permissions.PERMISSION_CLASS_NAME[permission.type]
)}
onPress={doShowPermissionTypeSelector}

View File

@ -108,7 +108,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
return object.merge(oldItem, { projectState: newProjectState })
})
},
[/* should never change */ user, /* should never change */ setItem]
[user, setItem]
)
const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial)
const [shouldOpenWhenReady, setShouldOpenWhenReady] = React.useState(false)
@ -116,6 +116,8 @@ export default function ProjectIcon(props: ProjectIconProps) {
item.projectState.executeAsync ?? false
)
const [shouldSwitchPage, setShouldSwitchPage] = React.useState(false)
const doOpenEditorRef = React.useRef(doOpenEditor)
doOpenEditorRef.current = doOpenEditor
const toastId: toast.Id = React.useId()
const isOpening =
backendModule.IS_OPENING[item.projectState.type] &&
@ -163,10 +165,10 @@ export default function ProjectIcon(props: ProjectIconProps) {
item,
session,
toastAndLog,
/* should never change */ openProjectMutate,
/* should never change */ getProjectDetailsMutate,
/* should never change */ setState,
/* should never change */ setItem,
openProjectMutate,
getProjectDetailsMutate,
setState,
setItem,
]
)
@ -231,10 +233,16 @@ export default function ProjectIcon(props: ProjectIconProps) {
}
}
} else {
setShouldOpenWhenReady(!event.runInBackground)
setShouldSwitchPage(event.shouldAutomaticallySwitchPage)
setIsRunningInBackground(event.runInBackground)
void openProject(event.runInBackground)
if (backendModule.IS_OPENING_OR_OPENED[state]) {
if (!isRunningInBackground) {
doOpenEditor(true)
}
} else {
setShouldOpenWhenReady(!event.runInBackground)
setShouldSwitchPage(event.shouldAutomaticallySwitchPage)
setIsRunningInBackground(event.runInBackground)
void openProject(event.runInBackground)
}
}
break
}
@ -257,12 +265,10 @@ export default function ProjectIcon(props: ProjectIconProps) {
React.useEffect(() => {
if (state === backendModule.ProjectState.opened) {
if (shouldOpenWhenReady) {
doOpenEditor(shouldSwitchPage)
doOpenEditorRef.current(shouldSwitchPage)
setShouldOpenWhenReady(false)
}
}
// `doOpenEditor` is a callback, not a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldOpenWhenReady, shouldSwitchPage, state])
const closeProject = async () => {
@ -284,11 +290,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
return (
<ariaComponents.Button
size="custom"
variant="custom"
variant="icon"
icon={PlayIcon}
aria-label={getText('openInEditor')}
tooltipPlacement="left"
className="size-project-icon border-0"
className="h-6 border-0"
onPress={() => {
dispatchAssetEvent({
type: AssetEventType.openProject,
@ -307,17 +313,14 @@ export default function ProjectIcon(props: ProjectIconProps) {
<div className="relative flex">
<ariaComponents.Button
size="custom"
variant="custom"
variant="icon"
isDisabled={isOtherUserUsingProject}
isActive={!isOtherUserUsingProject}
icon={StopIcon}
aria-label={getText('stopExecution')}
tooltipPlacement="left"
{...(isOtherUserUsingProject ? { title: getText('otherUserIsUsingProjectError') } : {})}
className={tailwindMerge.twMerge(
'size-project-icon border-0',
isRunningInBackground && 'text-green'
)}
className={tailwindMerge.twMerge('h-6 border-0', isRunningInBackground && 'text-green')}
onPress={closeProject}
/>
<Spinner
@ -335,7 +338,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
<div className="relative flex">
<ariaComponents.Button
size="custom"
variant="custom"
variant="icon"
isDisabled={isOtherUserUsingProject}
isActive={!isOtherUserUsingProject}
icon={StopIcon}
@ -345,7 +348,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
? { title: getText('otherUserIsUsingProjectError') }
: {})}
className={tailwindMerge.twMerge(
'size-project-icon border-0',
'h-6 border-0',
isRunningInBackground && 'text-green'
)}
onPress={closeProject}
@ -361,11 +364,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
{!isOtherUserUsingProject && !isRunningInBackground && (
<ariaComponents.Button
size="custom"
variant="custom"
className="size-project-icon border-0"
variant="icon"
icon={ArrowUpIcon}
aria-label={getText('openInEditor')}
tooltipPlacement="right"
className="h-6 border-0"
onPress={() => {
doOpenEditor(true)
}}

View File

@ -270,22 +270,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
)
const handleClick = inputBindings.handler({
open: () => {
dispatchAssetEvent({
type: AssetEventType.openProject,
id: asset.id,
shouldAutomaticallySwitchPage: true,
runInBackground: false,
})
},
run: () => {
dispatchAssetEvent({
type: AssetEventType.openProject,
id: asset.id,
shouldAutomaticallySwitchPage: false,
runInBackground: true,
})
},
editName: () => {
setIsEditing(true)
},
@ -314,6 +298,13 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
selectedKeys.current.size === 1
) {
setIsEditing(true)
} else if (eventModule.isDoubleClick(event)) {
dispatchAssetEvent({
type: AssetEventType.openProject,
id: asset.id,
shouldAutomaticallySwitchPage: true,
runInBackground: false,
})
}
}}
>
@ -346,16 +337,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
rowState.isEditingName && 'cursor-text'
)}
checkSubmittable={newTitle =>
newTitle !== item.item.title &&
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
child =>
// All siblings,
child.key === item.key ||
// that are not directories,
backendModule.assetIsDirectory(child.item) ||
// must have a different name.
child.item.title !== newTitle
)
item.isNewTitleValid(newTitle, nodeMap.current.get(item.directoryKey)?.children)
}
onSubmit={doRename}
onCancel={() => {

View File

@ -62,7 +62,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
})
)
},
[/* should never change */ setItem]
[setItem]
)
return (
@ -141,10 +141,11 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
))}
{managesThisAsset && (
<ariaComponents.Button
size="custom"
variant="custom"
ref={plusButtonRef}
className="shrink-0 rounded-full opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
size="icon"
variant="ghost"
showIconOnHover
icon={Plus2Icon}
onPress={() => {
setModal(
<ManageLabelsModal
@ -156,9 +157,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
/>
)
}}
>
<img className="size-plus-icon" src={Plus2Icon} />
</ariaComponents.Button>
/>
)}
</div>
)

View File

@ -70,7 +70,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
})
)
},
[/* should never change */ setItem]
[setItem]
)
return (
@ -108,10 +108,11 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
)}
{managesThisAsset && !isUnderPaywall && (
<ariaComponents.Button
variant="icon"
size="xsmall"
ref={plusButtonRef}
size="icon"
variant="ghost"
icon={Plus2Icon}
className="opacity-0 group-hover:opacity-100"
showIconOnHover
onPress={() => {
setModal(
<ManagePermissionsModal
@ -122,10 +123,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
self={self}
eventTarget={plusButtonRef.current}
doRemoveSelf={() => {
dispatchAssetEvent({
type: AssetEventType.removeSelf,
id: asset.id,
})
dispatchAssetEvent({ type: AssetEventType.removeSelf, id: asset.id })
}}
/>
)

View File

@ -49,25 +49,28 @@ export function useDebugState<T>(
// === useMonitorDependencies ===
/** A helper function to log the old and new values of changed dependencies. */
function useMonitorDependencies(
export function useMonitorDependencies(
dependencies: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[]
dependencyDescriptions?: readonly string[],
active = true
) {
const oldDependenciesRef = React.useRef(dependencies)
const indicesOfChangedDependencies = dependencies.flatMap((dep, i) =>
Object.is(dep, oldDependenciesRef.current[i]) ? [] : [i]
)
if (indicesOfChangedDependencies.length !== 0) {
const descriptionText = description == null ? '' : `for '${description}'`
console.group(`dependencies changed${descriptionText}`)
for (const i of indicesOfChangedDependencies) {
console.group(dependencyDescriptions?.[i] ?? `dependency #${i + 1}`)
console.log('old value:', oldDependenciesRef.current[i])
console.log('new value:', dependencies[i])
if (active) {
const indicesOfChangedDependencies = dependencies.flatMap((dep, i) =>
Object.is(dep, oldDependenciesRef.current[i]) ? [] : [i]
)
if (indicesOfChangedDependencies.length !== 0) {
const descriptionText = description == null ? '' : ` for '${description}'`
console.group(`dependencies changed${descriptionText}`)
for (const i of indicesOfChangedDependencies) {
console.group(dependencyDescriptions?.[i] ?? `dependency #${i + 1}`)
console.log('old value:', oldDependenciesRef.current[i])
console.log('new value:', dependencies[i])
console.groupEnd()
}
console.groupEnd()
}
console.groupEnd()
}
oldDependenciesRef.current = dependencies
}

View File

@ -0,0 +1,26 @@
/** @file Hooks related to the HTML5 Drag and Drop API. */
import * as React from 'react'
import * as eventModule from '#/utilities/event'
/** Whether an element is actually draggable. This should be used on ALL
* elements that are parents of text inputs.
*
* This is required to work around a Firefox bug:
* https://bugzilla.mozilla.org/show_bug.cgi?id=800050
* @returns An object that should be merged into the element's props. */
export function useDraggable() {
const [isDraggable, setIsDraggable] = React.useState(true)
return {
draggable: isDraggable,
onFocus: event => {
if (eventModule.isElementTextInput(event.target)) {
setIsDraggable(false)
}
},
onBlur: () => {
setIsDraggable(true)
},
} satisfies Partial<React.HTMLAttributes<HTMLElement>>
}

View File

@ -0,0 +1,90 @@
/** @file Track changes in intersection ratio between an element and one of its ancestors. */
import * as React from 'react'
// ============================
// === useIntersectionRatio ===
// ============================
export function useIntersectionRatio(
rootRef: Readonly<React.MutableRefObject<HTMLDivElement | null>> | null,
targetRef: Readonly<React.MutableRefObject<HTMLElement | SVGElement | null>>,
threshold: number[] | number
): number
export function useIntersectionRatio<T>(
rootRef: Readonly<React.MutableRefObject<HTMLDivElement | null>> | null,
targetRef: Readonly<React.MutableRefObject<HTMLElement | SVGElement | null>>,
threshold: number[] | number,
// Undefined MUST be excluded due to how the fallback value works when a `transform` function
// is not passed in.
// eslint-disable-next-line no-restricted-syntax
transform: (ratio: number) => Exclude<T, undefined>,
// eslint-disable-next-line no-restricted-syntax
initialValue: Exclude<T, undefined>
): T
/** Track changes in intersection ratio between an element and one of its ancestors.
*
* Note that if `threshold` is an array, it MUST be memoized.
* Similarly, `rootRef` and `targetRef` MUST be stable across renders. */
export function useIntersectionRatio<T>(
rootRef: Readonly<React.MutableRefObject<HTMLDivElement | null>> | null,
targetRef: Readonly<React.MutableRefObject<HTMLElement | SVGElement | null>>,
threshold: number[] | number,
transform?: (ratio: number) => T,
initialValue?: T
) {
// `initialValue` is guaranteed to be the right type by the overloads.
// eslint-disable-next-line no-restricted-syntax
const [value, setValue] = React.useState((initialValue === undefined ? 0 : initialValue) as T)
// eslint-disable-next-line no-restricted-syntax
const transformRef = React.useRef(transform ?? ((ratio: number) => ratio as never))
if (transform) {
transformRef.current = transform
}
React.useEffect(() => {
const root = rootRef?.current ?? document.body
const mainDropzone = targetRef.current
if (mainDropzone != null) {
const intersectionObserver = new IntersectionObserver(
entries => {
for (const entry of entries) {
setValue(transformRef.current(entry.intersectionRatio))
}
},
{ root, threshold }
)
intersectionObserver.observe(mainDropzone)
const recomputeIntersectionRatio = () => {
const rootRect = root.getBoundingClientRect()
const dropzoneRect = mainDropzone.getBoundingClientRect()
const intersectionX = Math.max(rootRect.x, dropzoneRect.x)
const intersectionY = Math.max(rootRect.y, dropzoneRect.y)
const intersectionRect = new DOMRect(
intersectionX,
intersectionY,
Math.min(rootRect.right, dropzoneRect.right) - intersectionX,
Math.min(rootRect.bottom, dropzoneRect.bottom) - intersectionY
)
const dropzoneArea = dropzoneRect.width * dropzoneRect.height
const intersectionArea = intersectionRect.width * intersectionRect.height
const intersectionRatio = Math.max(0, dropzoneArea / intersectionArea)
setValue(transformRef.current(intersectionRatio))
}
recomputeIntersectionRatio()
const resizeObserver = new ResizeObserver(() => {
recomputeIntersectionRatio()
})
resizeObserver.observe(root)
return () => {
intersectionObserver.disconnect()
resizeObserver.disconnect()
}
} else {
return
}
}, [targetRef, rootRef, threshold])
return value
}

View File

@ -33,7 +33,7 @@ export function useNavigate() {
originalNavigate(...(args as [never, never?]))
}
},
[/* should never change */ goOffline, /* should never change */ originalNavigate]
[goOffline, originalNavigate]
)
return navigate

View File

@ -1,51 +1,17 @@
/** @file Execute a function on scroll. */
import * as React from 'react'
// ===================
// === useOnScroll ===
// ===================
/** Execute a function on scroll. */
export function useOnScroll(callback: () => void, dependencies: React.DependencyList = []) {
const callbackRef = React.useRef(callback)
callbackRef.current = callback
const updateClipPathRef = React.useRef(() => {})
const onScroll = React.useMemo(() => {
let isClipPathUpdateQueued = false
const updateClipPath = () => {
isClipPathUpdateQueued = false
callbackRef.current()
}
updateClipPathRef.current = updateClipPath
updateClipPath()
return () => {
if (!isClipPathUpdateQueued) {
isClipPathUpdateQueued = true
requestAnimationFrame(updateClipPath)
}
}
}, [])
React.useLayoutEffect(() => {
updateClipPathRef.current()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies)
React.useEffect(() => {
window.addEventListener('resize', onScroll)
return () => {
window.removeEventListener('resize', onScroll)
}
}, [onScroll])
return onScroll
}
import useOnScroll from '#/hooks/useOnScroll'
// ====================================
// === useStickyTableHeaderOnScroll ===
// ====================================
/** Options for the {@link useStickyTableHeaderOnScroll} hook. */
interface UseStickyTableHeaderOnScrollOptions {
readonly trackShadowClass?: boolean
}
/** Properly clip the table body to avoid the table header on scroll.
* This is required to prevent the table body from overlapping the table header,
* because the table header is transparent.
@ -57,8 +23,9 @@ export function useOnScroll(callback: () => void, dependencies: React.Dependency
export function useStickyTableHeaderOnScroll(
rootRef: React.MutableRefObject<HTMLDivElement | null>,
bodyRef: React.RefObject<HTMLTableSectionElement>,
trackShadowClass = false
options: UseStickyTableHeaderOnScrollOptions = {}
) {
const { trackShadowClass = false } = options
const trackShadowClassRef = React.useRef(trackShadowClass)
trackShadowClassRef.current = trackShadowClass
const [shadowClassName, setShadowClass] = React.useState('')
@ -79,6 +46,6 @@ export function useStickyTableHeaderOnScroll(
setShadowClass(newShadowClass)
}
}
})
}, [bodyRef, rootRef])
return { onScroll, shadowClassName }
}

View File

@ -38,6 +38,6 @@ export function useSetAsset<T extends backend.AnyAsset>(
return ret
})
},
[/* should never change */ setNode]
[setNode]
)
}

View File

@ -50,6 +50,6 @@ export function useToastAndLog() {
logger.error(message)
return id
},
[getText, /* should never change */ logger]
[getText, logger]
)
}

View File

@ -0,0 +1,44 @@
/** @file Execute a function on scroll. */
import * as React from 'react'
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
// ===================
// === useOnScroll ===
// ===================
/** Execute a function on scroll. */
export default function useOnScroll(callback: () => void, dependencies: React.DependencyList) {
const callbackTrampoline = eventCallbackHooks.useEventCallback(callback)
const updateClipPathRef = React.useRef(() => {})
const onScroll = React.useMemo(() => {
let isClipPathUpdateQueued = false
const updateClipPath = () => {
isClipPathUpdateQueued = false
callbackTrampoline()
}
updateClipPathRef.current = updateClipPath
updateClipPath()
return () => {
if (!isClipPathUpdateQueued) {
isClipPathUpdateQueued = true
requestAnimationFrame(updateClipPath)
}
}
}, [callbackTrampoline])
React.useLayoutEffect(() => {
updateClipPathRef.current()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies)
React.useEffect(() => {
window.addEventListener('resize', onScroll)
return () => {
window.removeEventListener('resize', onScroll)
}
}, [onScroll])
return onScroll
}

View File

@ -75,6 +75,8 @@ export default function AssetPanel(props: AssetPanelProps) {
const { getText } = textProvider.useText()
const { localStorage } = localStorageProvider.useLocalStorage()
const [initialized, setInitialized] = React.useState(false)
const initializedRef = React.useRef(initialized)
initializedRef.current = initialized
const [tab, setTab] = React.useState(() => {
const savedTab = localStorage.get('assetPanelTab') ?? AssetPanelTab.properties
if (
@ -91,12 +93,10 @@ export default function AssetPanel(props: AssetPanelProps) {
React.useEffect(() => {
// This prevents secrets and directories always setting the tab to `properties`
// (because they do not support the `versions` tab).
if (initialized) {
if (initializedRef.current) {
localStorage.set('assetPanelTab', tab)
}
// `initialized` is NOT a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, /* should never change */ localStorage])
}, [tab, localStorage])
React.useEffect(() => {
setInitialized(true)

View File

@ -70,7 +70,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
setItemInner(valueOrUpdater)
setItemRaw(valueOrUpdater)
},
[/* should never change */ setItemRaw]
[setItemRaw]
)
const labels = backendHooks.useBackendListTags(backend) ?? []
const self = item.item.permissions?.find(
@ -87,6 +87,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
const getDatalinkMutation = backendHooks.useBackendMutation(backend, 'getDatalink')
const updateAssetMutation = backendHooks.useBackendMutation(backend, 'updateAsset')
const getDatalinkMutate = getDatalinkMutation.mutateAsync
React.useEffect(() => {
setDescription(item.item.description ?? '')
@ -95,13 +96,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
React.useEffect(() => {
void (async () => {
if (item.item.type === backendModule.AssetType.datalink) {
const value = await getDatalinkMutation.mutateAsync([item.item.id, item.item.title])
const value = await getDatalinkMutate([item.item.id, item.item.title])
setDatalinkValue(value)
setEditedDatalinkValue(value)
setIsDatalinkFetched(true)
}
})()
}, [backend, item.item, getDatalinkMutation])
}, [backend, item.item, getDatalinkMutate])
const doEditDescription = async () => {
setIsEditingDescription(false)

View File

@ -260,7 +260,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
root?.removeEventListener('keydown', onSearchKeyDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [setQuery, /* should never change */ modalRef])
}, [setQuery, modalRef])
// Reset `querySource` after all other effects have run.
React.useEffect(() => {
@ -271,7 +271,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
baseQuery.current = query
querySource.current = QuerySource.external
}
}, [query, /* should never change */ setQuery])
}, [query, setQuery])
return (
<FocusArea direction="horizontal">

View File

@ -10,8 +10,9 @@ import * as mimeTypes from '#/data/mimeTypes'
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
import * as backendHooks from '#/hooks/backendHooks'
import * as eventHooks from '#/hooks/eventHooks'
import * as scrollHooks from '#/hooks/scrollHooks'
import * as intersectionHooks from '#/hooks/intersectionHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import useOnScroll from '#/hooks/useOnScroll'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
@ -99,6 +100,9 @@ LocalStorage.registerKey('enabledColumns', {
// === Constants ===
// =================
/** If the ratio of intersection between the main dropzone that should be visible, and the
* scrollable container, is below this value, then the backup dropzone will be shown. */
const MINIMUM_DROPZONE_INTERSECTION_RATIO = 0.5
/** If the drag pointer is less than this distance away from the top or bottom of the
* scroll container, then the scroll container automatically scrolls upwards if the cursor is near
* the top of the scroll container, or downwards if the cursor is near the bottom. */
@ -382,7 +386,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const { doOpenEditor: doOpenEditorRaw, doCloseEditor: doCloseEditorRaw } = props
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
const { user, accessToken } = authProvider.useNonPartialUserSession()
const { user } = authProvider.useNonPartialUserSession()
const backend = backendProvider.useBackend(category)
const labels = backendHooks.useBackendListTags(backend)
const { setModal, unsetModal } = modalProvider.useSetModal()
@ -392,6 +396,8 @@ export default function AssetsTable(props: AssetsTableProps) {
const navigator2D = navigator2DProvider.useNavigator2D()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [initialized, setInitialized] = React.useState(false)
const initializedRef = React.useRef(initialized)
initializedRef.current = initialized
const [isLoading, setIsLoading] = React.useState(true)
const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS)
const [sortInfo, setSortInfo] =
@ -418,12 +424,17 @@ export default function AssetsTable(props: AssetsTableProps) {
-1
)
})
const [isDropzoneVisible, setIsDropzoneVisible] = React.useState(false)
const [isDraggingFiles, setIsDraggingFiles] = React.useState(false)
const [droppedFilesCount, setDroppedFilesCount] = React.useState(0)
const isCloud = backend.type === backendModule.BackendType.remote
/** Events sent when the asset list was still loading. */
const queuedAssetListEventsRef = React.useRef<assetListEvent.AssetListEvent[]>([])
const rootRef = React.useRef<HTMLDivElement | null>(null)
const cleanupRootRef = React.useRef(() => {})
const mainDropzoneRef = React.useRef<HTMLButtonElement | null>(null)
const lastSelectedIdsRef = React.useRef<
backendModule.AssetId | ReadonlySet<backendModule.AssetId> | null
>(null)
const headerRowRef = React.useRef<HTMLTableRowElement>(null)
const assetTreeRef = React.useRef<assetTreeNode.AnyAssetTreeNode>(assetTree)
const pasteDataRef = React.useRef<pasteDataModule.PasteData<
@ -605,6 +616,14 @@ export default function AssetsTable(props: AssetsTableProps) {
[displayItems, visibilities]
)
const isMainDropzoneVisible = intersectionHooks.useIntersectionRatio(
rootRef,
mainDropzoneRef,
MINIMUM_DROPZONE_INTERSECTION_RATIO,
ratio => ratio >= MINIMUM_DROPZONE_INTERSECTION_RATIO,
true
)
const updateSecretMutation = backendHooks.useBackendMutation(backend, 'updateSecret')
React.useEffect(() => {
@ -823,7 +842,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}
}
}, [isCloud, assetTree, query, visibilities, labels, /* should never change */ setSuggestions])
}, [isCloud, assetTree, query, visibilities, labels, setSuggestions])
React.useEffect(() => {
setIsLoading(true)
@ -854,11 +873,7 @@ export default function AssetsTable(props: AssetsTableProps) {
},
})
}
}, [
hidden,
/* should never change */ inputBindings,
/* should never change */ dispatchAssetEvent,
])
}, [hidden, inputBindings, dispatchAssetEvent])
React.useEffect(() => {
if (isLoading) {
@ -916,7 +931,7 @@ export default function AssetsTable(props: AssetsTableProps) {
)
}
},
[isCloud, /* should never change */ setCanDownload]
[isCloud, setCanDownload]
)
const clearSelectedKeys = React.useCallback(() => {
@ -940,7 +955,9 @@ export default function AssetsTable(props: AssetsTableProps) {
newAssets.map(asset =>
AssetTreeNode.fromAsset(asset, rootDirectory.id, rootDirectory.id, 0)
),
-1
-1,
rootDirectory.id,
true
)
setAssetTree(newRootNode)
// The project name here might also be a string with project id, e.g.
@ -978,20 +995,15 @@ export default function AssetsTable(props: AssetsTableProps) {
return null
})
},
[
rootDirectoryId,
toastAndLog,
/* should never change */ setNameOfProjectToImmediatelyOpen,
/* should never change */ dispatchAssetEvent,
]
[rootDirectoryId, toastAndLog, setNameOfProjectToImmediatelyOpen, dispatchAssetEvent]
)
const overwriteNodesRef = React.useRef(overwriteNodes)
overwriteNodesRef.current = overwriteNodes
React.useEffect(() => {
if (initialized) {
overwriteNodes([])
if (initializedRef.current) {
overwriteNodesRef.current([])
}
// `overwriteAssets` is a callback, not a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [backend, category])
asyncEffectHooks.useAsyncEffect(
@ -1026,7 +1038,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}
},
[category, accessToken, user, backend, setSelectedKeys]
[category, backend, setSelectedKeys]
)
React.useEffect(() => {
@ -1034,24 +1046,20 @@ export default function AssetsTable(props: AssetsTableProps) {
if (savedEnabledColumns != null) {
setEnabledColumns(new Set(savedEnabledColumns))
}
}, [/* should never change */ localStorage])
}, [localStorage])
React.useEffect(() => {
if (initialized) {
localStorage.set('enabledColumns', [...enabledColumns])
}
}, [enabledColumns, initialized, /* should never change */ localStorage])
}, [enabledColumns, initialized, localStorage])
React.useEffect(() => {
if (selectedKeysRef.current.size !== 1) {
setAssetPanelProps(null)
setIsAssetPanelTemporarilyVisible(false)
}
}, [
selectedKeysRef.current.size,
/* should never change */ setAssetPanelProps,
/* should never change */ setIsAssetPanelTemporarilyVisible,
])
}, [selectedKeysRef.current.size, setAssetPanelProps, setIsAssetPanelTemporarilyVisible])
const directoryListAbortControllersRef = React.useRef(
new Map<backendModule.DirectoryId, AbortController>()
@ -1064,7 +1072,7 @@ export default function AssetsTable(props: AssetsTableProps) {
override?: boolean
) => {
const directory = nodeMapRef.current.get(key)
const isExpanded = directory?.children != null
const isExpanded = directory?.children != null && directory.isExpanded
const shouldExpand = override ?? !isExpanded
if (shouldExpand === isExpanded) {
// This is fine, as this is near the top of a very long function.
@ -1078,23 +1086,26 @@ export default function AssetsTable(props: AssetsTableProps) {
directoryListAbortControllersRef.current.delete(directoryId)
}
setAssetTree(oldAssetTree =>
oldAssetTree.map(item => (item.key !== key ? item : item.with({ children: null })))
oldAssetTree.map(item => (item.key !== key ? item : item.with({ isExpanded: false })))
)
} else {
setAssetTree(oldAssetTree =>
oldAssetTree.map(item =>
item.key !== key
? item
: item.with({
children: [
AssetTreeNode.fromAsset(
backendModule.createSpecialLoadingAsset(directoryId),
key,
directoryId,
item.depth + 1
),
],
})
: item.children != null
? item.with({ isExpanded: true })
: item.with({
isExpanded: true,
children: [
AssetTreeNode.fromAsset(
backendModule.createSpecialLoadingAsset(directoryId),
key,
directoryId,
item.depth + 1
),
],
})
)
)
void (async () => {
@ -1818,7 +1829,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const doCopy = React.useCallback(() => {
unsetModal()
setPasteData({ type: PasteType.copy, data: selectedKeysRef.current })
}, [/* should never change */ unsetModal])
}, [unsetModal])
const doCut = React.useCallback(() => {
unsetModal()
@ -1828,12 +1839,7 @@ export default function AssetsTable(props: AssetsTableProps) {
setPasteData({ type: PasteType.move, data: selectedKeysRef.current })
dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeysRef.current })
setSelectedKeys(new Set())
}, [
pasteData,
setSelectedKeys,
/* should never change */ unsetModal,
/* should never change */ dispatchAssetEvent,
])
}, [pasteData, setSelectedKeys, unsetModal, dispatchAssetEvent])
const doPaste = React.useCallback(
(newParentKey: backendModule.DirectoryId, newParentId: backendModule.DirectoryId) => {
@ -1865,13 +1871,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}
},
[
pasteData,
doToggleDirectoryExpansion,
/* should never change */ unsetModal,
/* should never change */ dispatchAssetEvent,
/* should never change */ dispatchAssetListEvent,
]
[pasteData, doToggleDirectoryExpansion, unsetModal, dispatchAssetEvent, dispatchAssetListEvent]
)
const hideColumn = React.useCallback((column: columnUtils.Column) => {
@ -1906,9 +1906,9 @@ export default function AssetsTable(props: AssetsTableProps) {
doCopy,
doCut,
doPaste,
/* should never change */ clearSelectedKeys,
/* should never change */ dispatchAssetEvent,
/* should never change */ dispatchAssetListEvent,
clearSelectedKeys,
dispatchAssetEvent,
dispatchAssetListEvent,
]
)
@ -1918,12 +1918,17 @@ export default function AssetsTable(props: AssetsTableProps) {
if (filtered != null && filtered.length > 0) {
event.preventDefault()
} else if (event.dataTransfer.types.includes('Files')) {
setIsDropzoneVisible(true)
setDroppedFilesCount(event.dataTransfer.items.length)
event.preventDefault()
}
}
const updateIsDraggingFiles = (event: React.DragEvent<Element>) => {
if (event.dataTransfer.types.includes('Files')) {
setIsDraggingFiles(true)
setDroppedFilesCount(event.dataTransfer.items.length)
}
}
const state = React.useMemo<AssetsTableState>(
// The type MUST be here to trigger excess property errors at typecheck time.
() => ({
@ -1969,19 +1974,19 @@ export default function AssetsTable(props: AssetsTableProps) {
doCopy,
doCut,
doPaste,
/* should never change */ hideColumn,
/* should never change */ setAssetPanelProps,
/* should never change */ setIsAssetPanelTemporarilyVisible,
/* should never change */ setProjectStartupInfo,
/* should never change */ setQuery,
/* should never change */ dispatchAssetEvent,
/* should never change */ dispatchAssetListEvent,
hideColumn,
setAssetPanelProps,
setIsAssetPanelTemporarilyVisible,
setQuery,
setProjectStartupInfo,
dispatchAssetEvent,
dispatchAssetListEvent,
]
)
// This is required to prevent the table body from overlapping the table header, because
// the table header is transparent.
const onScroll = scrollHooks.useOnScroll(() => {
const updateClipPath = useOnScroll(() => {
if (bodyRef.current != null && rootRef.current != null) {
bodyRef.current.style.clipPath = `inset(${rootRef.current.scrollTop}px 0 0 0)`
}
@ -1996,7 +2001,12 @@ export default function AssetsTable(props: AssetsTableProps) {
const rightOffset = rootRef.current.clientWidth + rootRef.current.scrollLeft - shrinkBy
headerRowRef.current.style.clipPath = `polygon(0 0, ${rightOffset}px 0, ${rightOffset}px 100%, 0 100%)`
}
}, [enabledColumns.size])
}, [backend.type, enabledColumns.size])
const updateClipPathObserver = React.useMemo(
() => new ResizeObserver(updateClipPath),
[updateClipPath]
)
React.useEffect(
() =>
@ -2015,11 +2025,7 @@ export default function AssetsTable(props: AssetsTableProps) {
},
false
),
[
setSelectedKeys,
/* should never change */ inputBindings,
/* should never change */ setMostRecentlySelectedIndex,
]
[setSelectedKeys, inputBindings, setMostRecentlySelectedIndex]
)
React.useEffect(() => {
@ -2076,7 +2082,7 @@ export default function AssetsTable(props: AssetsTableProps) {
})(event, false)
return result
},
[/* should never change */ inputBindings]
[inputBindings]
)
// Only non-`null` when it is different to`selectedKeys`.
@ -2167,7 +2173,7 @@ export default function AssetsTable(props: AssetsTableProps) {
setVisuallySelectedKeysOverride(null)
dragSelectionRangeRef.current = null
},
[displayItems, calculateNewKeys, /* should never change */ setSelectedKeys]
[displayItems, calculateNewKeys, setSelectedKeys]
)
const onSelectionDragCancel = React.useCallback(() => {
@ -2197,12 +2203,7 @@ export default function AssetsTable(props: AssetsTableProps) {
selectionStartIndexRef.current = null
}
},
[
visibleItems,
calculateNewKeys,
/* should never change */ setSelectedKeys,
/* should never change */ setMostRecentlySelectedIndex,
]
[visibleItems, calculateNewKeys, setSelectedKeys, setMostRecentlySelectedIndex]
)
const columns = columnUtils.getColumnList(backend.type, enabledColumns)
@ -2290,7 +2291,7 @@ export default function AssetsTable(props: AssetsTableProps) {
<DragModal
event={event}
className="flex flex-col rounded-default bg-selected-frame backdrop-blur-default"
doCleanup={() => {
onDragEnd={() => {
drag.ASSET_ROWS.unbind(payload)
}}
>
@ -2319,41 +2320,38 @@ export default function AssetsTable(props: AssetsTableProps) {
if (payload != null) {
event.preventDefault()
event.stopPropagation()
const ids = new Set(
selectedKeysRef.current.has(key) ? selectedKeysRef.current : [key]
)
// Expand ids to include ids of children as well.
for (const node of assetTree.preorderTraversal()) {
if (ids.has(node.key) && node.children != null) {
for (const child of node.children) {
ids.add(child.key)
}
}
}
let labelsPresent = 0
for (const selectedKey of ids) {
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
if (nodeLabels != null) {
for (const label of nodeLabels) {
if (payload.has(label)) {
labelsPresent += 1
const idsReference = selectedKeysRef.current.has(key) ? selectedKeysRef.current : key
// This optimization is required in order to avoid severe lag on Firefox.
if (idsReference !== lastSelectedIdsRef.current) {
lastSelectedIdsRef.current = idsReference
const ids =
typeof idsReference === 'string' ? new Set([idsReference]) : idsReference
let labelsPresent = 0
for (const selectedKey of ids) {
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
if (nodeLabels != null) {
for (const label of nodeLabels) {
if (payload.has(label)) {
labelsPresent += 1
}
}
}
}
}
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
window.setTimeout(() => {
dispatchAssetEvent({
type: shouldAdd
? AssetEventType.temporarilyAddLabels
: AssetEventType.temporarilyRemoveLabels,
ids,
labelNames: payload,
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
window.setTimeout(() => {
dispatchAssetEvent({
type: shouldAdd
? AssetEventType.temporarilyAddLabels
: AssetEventType.temporarilyRemoveLabels,
ids,
labelNames: payload,
})
})
})
}
}
}}
onDragEnd={() => {
lastSelectedIdsRef.current = null
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids: selectedKeysRef.current,
@ -2362,14 +2360,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}}
onDrop={event => {
const ids = new Set(selectedKeysRef.current.has(key) ? selectedKeysRef.current : [key])
// Expand ids to include ids of descendants as well.
for (const node of assetTree.preorderTraversal()) {
if (ids.has(node.key) && node.children != null) {
for (const child of node.children) {
ids.add(child.key)
}
}
}
const payload = drag.LABELS.lookup(event)
if (payload != null) {
event.preventDefault()
@ -2404,7 +2394,7 @@ export default function AssetsTable(props: AssetsTableProps) {
})
)
const dropzoneText = isDropzoneVisible
const dropzoneText = isDraggingFiles
? droppedFilesCount === 1
? getText('assetsDropFileDescription')
: getText('assetsDropFilesDescription', droppedFilesCount)
@ -2434,7 +2424,6 @@ export default function AssetsTable(props: AssetsTableProps) {
/>
)
}}
onDragEnter={onDropzoneDragOver}
onDragLeave={event => {
const payload = drag.LABELS.lookup(event)
if (
@ -2442,6 +2431,7 @@ export default function AssetsTable(props: AssetsTableProps) {
event.relatedTarget instanceof Node &&
!event.currentTarget.contains(event.relatedTarget)
) {
lastSelectedIdsRef.current = null
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids: selectedKeysRef.current,
@ -2480,12 +2470,19 @@ export default function AssetsTable(props: AssetsTableProps) {
</tbody>
</table>
<div
data-testid="root-directory-dropzone"
className={tailwindMerge.twMerge(
'sticky left-0 grid max-w-container grow place-items-center',
category !== Category.cloud && category !== Category.local && 'hidden'
)}
onDragEnter={onDropzoneDragOver}
onDragOver={onDropzoneDragOver}
onDragLeave={event => {
lastSelectedIdsRef.current = null
if (event.currentTarget === event.target) {
setIsDraggingFiles(false)
}
}}
onDrop={event => {
const payload = drag.ASSET_ROWS.lookup(event)
const filtered = payload?.filter(item => item.asset.parentId !== rootDirectoryId)
@ -2517,6 +2514,7 @@ export default function AssetsTable(props: AssetsTableProps) {
>
<FocusRing>
<aria.Button
ref={mainDropzoneRef}
className="my-20 flex flex-col items-center gap-3 text-primary/30 transition-colors duration-200 hover:text-primary/50"
onPress={() => {}}
>
@ -2535,10 +2533,21 @@ export default function AssetsTable(props: AssetsTableProps) {
{innerProps => (
<div
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(innerProps, {
ref: rootRef,
ref: value => {
rootRef.current = value
cleanupRootRef.current()
if (value) {
updateClipPathObserver.observe(value)
cleanupRootRef.current = () => {
updateClipPathObserver.unobserve(value)
}
} else {
cleanupRootRef.current = () => {}
}
},
className: 'flex-1 overflow-auto container-size w-full h-full',
onKeyDown,
onScroll,
onScroll: updateClipPath,
onBlur: event => {
if (
event.relatedTarget instanceof HTMLElement &&
@ -2547,12 +2556,24 @@ export default function AssetsTable(props: AssetsTableProps) {
setKeyboardSelectedIndex(null)
}
},
onDragEnter: updateIsDraggingFiles,
onDragOver: updateIsDraggingFiles,
onDragLeave: event => {
if (
!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget)
) {
lastSelectedIdsRef.current = null
setIsDraggingFiles(false)
}
},
})}
>
{!hidden && hiddenContextMenu}
{!hidden && (
<SelectionBrush
targetRef={rootRef}
margin={8}
onDrag={onSelectionDrag}
onDragEnd={onSelectionDragEnd}
onDragCancel={onSelectionDragCancel}
@ -2605,38 +2626,31 @@ export default function AssetsTable(props: AssetsTableProps) {
</div>
)}
</FocusArea>
<div className="pointer-events-none absolute inset-0">
<div
data-testid="root-directory-dropzone"
onDragEnter={onDropzoneDragOver}
onDragOver={onDropzoneDragOver}
onDragLeave={event => {
if (event.currentTarget === event.target) {
setIsDropzoneVisible(false)
}
}}
onDrop={event => {
setIsDropzoneVisible(false)
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.stopPropagation()
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: rootDirectoryId,
parentId: rootDirectoryId,
files: Array.from(event.dataTransfer.files),
})
}
}}
className={tailwindMerge.twMerge(
'pointer-events-none sticky left-0 top-0 flex h-full w-full flex-col items-center justify-center gap-3 rounded-default bg-selected-frame text-primary/50 opacity-0 backdrop-blur-3xl transition-all',
isDropzoneVisible && 'pointer-events-auto opacity-100'
)}
>
<SvgMask src={DropFilesImage} className="size-[186px]" />
{dropzoneText}
{isDraggingFiles && !isMainDropzoneVisible && (
<div className="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2">
<div
className="flex items-center justify-center gap-3 rounded-default bg-selected-frame px-8 py-6 text-primary/50 backdrop-blur-3xl transition-all"
onDragEnter={onDropzoneDragOver}
onDragOver={onDropzoneDragOver}
onDrop={event => {
setIsDraggingFiles(false)
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.stopPropagation()
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: rootDirectoryId,
parentId: rootDirectoryId,
files: Array.from(event.dataTransfer.files),
})
}
}}
>
<SvgMask src={DropFilesImage} className="size-8" />
{dropzoneText}
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -209,7 +209,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
React.useEffect(() => {
localStorage.set('driveCategory', category)
}, [category, /* should never change */ localStorage])
}, [category, localStorage])
return (
<FocusArea direction="vertical">

View File

@ -465,7 +465,7 @@ export default function Chat(props: ChatProps) {
} else {
return
}
}, [isOpen, /* should never change */ endpoint])
}, [isOpen, endpoint])
React.useLayoutEffect(() => {
const element = messagesRef.current
@ -630,7 +630,7 @@ export default function Chat(props: ChatProps) {
})
}
},
[threads, toastAndLog, /* should never change */ sendMessage]
[threads, toastAndLog, sendMessage]
)
const sendCurrentMessage = React.useCallback(
@ -680,14 +680,7 @@ export default function Chat(props: ChatProps) {
}
}
},
[
threads,
threadId,
threadTitle,
shouldIgnoreMessageLimit,
getText,
/* should never change */ sendMessage,
]
[threads, threadId, threadTitle, shouldIgnoreMessageLimit, getText, sendMessage]
)
const upgradeToPro = () => {

View File

@ -161,18 +161,12 @@ export default function Drive(props: DriveProps) {
})
}
},
[
isCloud,
rootDirectoryId,
sessionType,
toastAndLog,
/* should never change */ dispatchAssetListEvent,
]
[isCloud, rootDirectoryId, sessionType, toastAndLog, dispatchAssetListEvent]
)
const doEmptyTrash = React.useCallback(() => {
dispatchAssetListEvent({ type: AssetListEventType.emptyTrash })
}, [/* should never change */ dispatchAssetListEvent])
}, [dispatchAssetListEvent])
const doCreateProject = React.useCallback(
(templateId: string | null = null, templateName: string | null = null) => {
@ -185,7 +179,7 @@ export default function Drive(props: DriveProps) {
preferredName: templateName,
})
},
[rootDirectoryId, /* should never change */ dispatchAssetListEvent]
[rootDirectoryId, dispatchAssetListEvent]
)
const doCreateDirectory = React.useCallback(() => {
@ -194,7 +188,7 @@ export default function Drive(props: DriveProps) {
parentKey: targetDirectoryNodeRef.current?.key ?? rootDirectoryId,
parentId: targetDirectoryNodeRef.current?.item.id ?? rootDirectoryId,
})
}, [rootDirectoryId, /* should never change */ dispatchAssetListEvent])
}, [rootDirectoryId, dispatchAssetListEvent])
const doCreateSecret = React.useCallback(
(name: string, value: string) => {
@ -206,7 +200,7 @@ export default function Drive(props: DriveProps) {
value,
})
},
[rootDirectoryId, /* should never change */ dispatchAssetListEvent]
[rootDirectoryId, dispatchAssetListEvent]
)
const doCreateDatalink = React.useCallback(
@ -219,7 +213,7 @@ export default function Drive(props: DriveProps) {
value,
})
},
[rootDirectoryId, /* should never change */ dispatchAssetListEvent]
[rootDirectoryId, dispatchAssetListEvent]
)
switch (status) {

View File

@ -88,7 +88,7 @@ export default function DriveBar(props: DriveBarProps) {
uploadFilesRef.current?.click()
},
})
}, [isCloud, doCreateDirectory, doCreateProject, /* should never change */ inputBindings])
}, [isCloud, doCreateDirectory, doCreateProject, inputBindings])
const searchBar = (
<AssetSearchBar

View File

@ -95,7 +95,7 @@ export default function Labels(props: LabelsProps) {
setModal(
<DragModal
event={event}
doCleanup={() => {
onDragEnd={() => {
drag.LABELS.unbind(payload)
}}
>

View File

@ -79,10 +79,10 @@ export default function PageSwitcher(props: PageSwitcherProps) {
return (element: HTMLDivElement | null) => {
const backgroundElement = backgroundRef.current
if (backgroundElement != null) {
selectedTabRef.current = element
if (element == null) {
backgroundElement.style.clipPath = ''
} else {
selectedTabRef.current = element
const bounds = element.getBoundingClientRect()
const rootBounds = backgroundElement.getBoundingClientRect()
const tabLeft = bounds.left - rootBounds.left
@ -120,6 +120,12 @@ export default function PageSwitcher(props: PageSwitcherProps) {
}
}
React.useEffect(() => {
if (visiblePageData.every(pageData => page !== pageData.page)) {
updateClipPath(null)
}
}, [page, updateClipPath, visiblePageData])
return (
<div className="relative flex grow">
<div

View File

@ -18,6 +18,7 @@ import UserGroupsSettingsTab from '#/layouts/Settings/UserGroupsSettingsTab'
import SettingsSidebar from '#/layouts/SettingsSidebar'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as loader from '#/components/Loader'
import * as portal from '#/components/Portal'
@ -95,7 +96,7 @@ export default function Settings(props: SettingsProps) {
return (
<div className="mt-4 flex flex-1 flex-col gap-settings-header overflow-hidden px-page-x">
<aria.Heading level={1} className="flex h-heading px-heading-x text-xl font-bold">
<aria.Heading level={1} className="flex items-center px-heading-x">
<aria.MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}>
<Button image={BurgerMenuIcon} buttonClassName="mr-3 sm:hidden" onPress={() => {}} />
<aria.Popover UNSTABLE_portalContainer={root}>
@ -111,27 +112,37 @@ export default function Settings(props: SettingsProps) {
/>
</aria.Popover>
</aria.MenuTrigger>
<aria.Text className="py-heading-y">{getText('settingsFor')}</aria.Text>
{/* This UI element does not appear anywhere else. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<div className="ml-[0.625rem] h-[2.25rem] rounded-full bg-frame px-[0.5625rem] pb-[0.3125rem] pt-[0.125rem] leading-snug">
<ariaComponents.Text.Heading>
<span>{getText('settingsFor')}</span>
</ariaComponents.Text.Heading>
<ariaComponents.Text
variant="h1"
truncate="1"
className="ml-2.5 max-w-lg rounded-full bg-frame px-2.5"
aria-hidden
>
{settingsTab !== SettingsTab.organization &&
settingsTab !== SettingsTab.members &&
settingsTab !== SettingsTab.userGroups
? user?.name ?? 'your account'
: organization?.name ?? 'your organization'}
</div>
</ariaComponents.Text>
</aria.Heading>
<div className="flex flex-1 gap-settings overflow-hidden">
<SettingsSidebar
hasBackend={backend != null}
isUserInOrganization={isUserInOrganization}
settingsTab={settingsTab}
setSettingsTab={setSettingsTab}
/>
<div className="mt-8 flex flex-1 gap-6 overflow-hidden pr-0.5">
<aside className="flex h-full flex-col overflow-y-auto overflow-x-hidden pb-12">
<SettingsSidebar
hasBackend={backend != null}
isUserInOrganization={isUserInOrganization}
settingsTab={settingsTab}
setSettingsTab={setSettingsTab}
/>
</aside>
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader size="medium" minHeight="h64" />}>
{content}
<main className="h-full w-full flex-shrink-0 flex-grow basis-0 overflow-y-auto overflow-x-hidden pb-12 pl-1.5 pr-3">
<div className="w-full max-w-[840px]">{content}</div>
</main>
</React.Suspense>
</errorBoundary.ErrorBoundary>
</div>

View File

@ -7,7 +7,6 @@ import * as inputBindingsManager from '#/providers/InputBindingsProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
@ -34,7 +33,6 @@ export default function KeyboardShortcutsSettingsTabBar(
return (
<HorizontalMenuBar>
<ariaComponents.Button
size="custom"
variant="bar"
onPress={() => {
setModal(
@ -52,9 +50,7 @@ export default function KeyboardShortcutsSettingsTabBar(
)
}}
>
<aria.Text className="text whitespace-nowrap font-semibold">
{getText('resetAll')}
</aria.Text>
{getText('resetAll')}
</ariaComponents.Button>
</HorizontalMenuBar>
)

View File

@ -4,7 +4,7 @@ import * as React from 'react'
import BlankIcon from 'enso-assets/blank.svg'
import CrossIcon from 'enso-assets/cross.svg'
import Plus2Icon from 'enso-assets/plus2.svg'
import ReloadInCircleIcon from 'enso-assets/reload_in_circle.svg'
import ReloadIcon from 'enso-assets/reload.svg'
import type * as refreshHooks from '#/hooks/refreshHooks'
import * as scrollHooks from '#/hooks/scrollHooks'
@ -99,7 +99,7 @@ export default function KeyboardShortcutsTable(props: KeyboardShortcutsTableProp
{/* I don't know why this padding is needed,
* given that this is a flex container. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<div className="flex gap-buttons pr-4">
<div className="flex items-center gap-buttons pr-4">
{info.bindings.map((binding, j) => (
<div
key={j}
@ -107,23 +107,27 @@ export default function KeyboardShortcutsTable(props: KeyboardShortcutsTableProp
>
<KeyboardShortcut shortcut={binding} />
<ariaComponents.Button
size="custom"
variant="custom"
className="flex rounded-full transition-colors hover:bg-hover-bg focus:bg-hover-bg"
variant="ghost"
size="icon"
aria-label={getText('removeShortcut')}
tooltipPlacement="top left"
icon={CrossIcon}
showIconOnHover
onPress={() => {
inputBindings.delete(action, binding)
doRefresh()
}}
>
<SvgMask src={CrossIcon} className="size-4" />
</ariaComponents.Button>
/>
</div>
))}
<div className="gap-keyboard-shortcuts-buttons flex shrink-0">
<div className="ml-auto flex items-center gap-2">
<ariaComponents.Button
size="custom"
variant="custom"
className="focus-default my-auto flex rounded-full"
variant="ghost"
size="icon"
aria-label={getText('addShortcut')}
tooltipPlacement="top left"
icon={Plus2Icon}
showIconOnHover
onPress={() => {
setModal(
<CaptureKeyboardShortcutModal
@ -136,20 +140,19 @@ export default function KeyboardShortcutsTable(props: KeyboardShortcutsTableProp
/>
)
}}
>
<img className="size-plus-icon" src={Plus2Icon} />
</ariaComponents.Button>
/>
<ariaComponents.Button
size="custom"
variant="custom"
className="my-auto flex rounded-full"
variant="ghost"
size="icon"
aria-label={getText('resetShortcut')}
tooltipPlacement="top left"
icon={ReloadIcon}
showIconOnHover
onPress={() => {
inputBindings.reset(action)
doRefresh()
}}
>
<img className="size-plus-icon" src={ReloadInCircleIcon} />
</ariaComponents.Button>
/>
</div>
</div>
</div>

View File

@ -63,7 +63,7 @@ export default function MembersTable(props: MembersTableProps) {
const { onScroll, shadowClassName } = scrollHooks.useStickyTableHeaderOnScroll(
scrollContainerRef,
bodyRef,
true
{ trackShadowClass: true }
)
const { dragAndDropHooks } = aria.useDragAndDrop({

View File

@ -61,7 +61,7 @@ export default function UserAccountSettingsSection(props: UserAccountSettingsSec
<aria.Label className="text my-auto w-user-account-settings-label">
{getText('name')}
</aria.Label>
<SettingsInput ref={nameRef} type="text" onSubmit={doUpdateName} />
<SettingsInput key={user?.name ?? ''} ref={nameRef} type="text" onSubmit={doUpdateName} />
</aria.TextField>
<div className="flex h-row gap-settings-entry">
<aria.Text className="text my-auto w-user-account-settings-label">

View File

@ -20,7 +20,6 @@ import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as paywallComponents from '#/components/Paywall'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
import SettingsSection from '#/components/styled/settings/SettingsSection'
import NewUserGroupModal from '#/modals/NewUserGroupModal'
@ -67,7 +66,7 @@ function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps) {
const shouldDisplayPaywall = isUnderPaywall ? userGroupsLeft <= 0 : false
const { onScroll: onUserGroupsTableScroll, shadowClassName } =
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, true)
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, { trackShadowClass: true })
const { dragAndDropHooks } = aria.useDragAndDrop({
getDropOperation: (target, types, allowedOperations) =>
@ -137,43 +136,40 @@ function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps) {
<div className="flex h min-h-full flex-1 flex-col gap-settings-section overflow-hidden lg:h-auto lg:flex-row">
<div className="flex h-3/5 w-settings-main-section max-w-full flex-col gap-settings-subsection lg:h-[unset] lg:min-w">
<SettingsSection noFocusArea title={getText('userGroups')} className="overflow-hidden">
<HorizontalMenuBar>
<div className="flex items-center gap-2">
{shouldDisplayPaywall && (
<paywallComponents.PaywallDialogButton
feature="userGroupsFull"
variant="bar"
size="medium"
rounded="full"
iconPosition="end"
tooltip={getText('userGroupsPaywallMessage')}
>
{getText('newUserGroup')}
</paywallComponents.PaywallDialogButton>
)}
{!shouldDisplayPaywall && (
<ariaComponents.Button
size="medium"
variant="bar"
onPress={event => {
const rect = event.target.getBoundingClientRect()
const position = { pageX: rect.left, pageY: rect.top }
setModal(<NewUserGroupModal backend={backend} event={position} />)
}}
>
{getText('newUserGroup')}
</ariaComponents.Button>
)}
<div className="flex items-center gap-2">
{shouldDisplayPaywall ? (
<paywallComponents.PaywallDialogButton
feature="userGroupsFull"
variant="bar"
size="medium"
rounded="full"
iconPosition="end"
tooltip={getText('userGroupsPaywallMessage')}
>
{getText('newUserGroup')}
</paywallComponents.PaywallDialogButton>
) : (
<ariaComponents.Button
size="medium"
variant="bar"
onPress={event => {
const rect = event.target.getBoundingClientRect()
const position = { pageX: rect.left, pageY: rect.top }
setModal(<NewUserGroupModal backend={backend} event={position} />)
}}
>
{getText('newUserGroup')}
</ariaComponents.Button>
)}
{isUnderPaywall && (
<span className="text-xs">
{userGroupsLeft <= 0
? getText('userGroupsPaywallMessage')
: getText('userGroupsLimitMessage', userGroupsLeft)}
</span>
)}
</div>
</HorizontalMenuBar>
{isUnderPaywall && (
<span className="text-xs">
{userGroupsLeft <= 0
? getText('userGroupsPaywallMessage')
: getText('userGroupsLimitMessage', userGroupsLeft)}
</span>
)}
</div>
<div
ref={rootRef}
className={tailwindMerge.twMerge(

View File

@ -8,14 +8,14 @@
import * as React from 'react'
import * as twv from 'tailwind-variants'
import * as billingHooks from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider'
import * as paywallComponents from '#/components/Paywall'
import * as twv from '#/utilities/tailwindVariants'
/**
* Props for the `withPaywall` HOC.
*/

View File

@ -23,7 +23,7 @@ export interface DragModalProps
extends Readonly<React.PropsWithChildren>,
Readonly<JSX.IntrinsicElements['div']> {
readonly event: React.DragEvent
readonly doCleanup: () => void
readonly onDragEnd: () => void
readonly offsetPx?: number
readonly offsetXPx?: number
readonly offsetYPx?: number
@ -39,22 +39,24 @@ export default function DragModal(props: DragModalProps) {
children,
style,
className,
doCleanup,
onDragEnd: onDragEndRaw,
...passthrough
} = props
const { unsetModal } = modalProvider.useSetModal()
const [left, setLeft] = React.useState(event.pageX - (offsetPx ?? offsetXPx))
const [top, setTop] = React.useState(event.pageY - (offsetPx ?? offsetYPx))
const onDragEndRef = React.useRef(onDragEndRaw)
onDragEndRef.current = onDragEndRaw
React.useEffect(() => {
const onDrag = (moveEvent: MouseEvent) => {
if (moveEvent.pageX !== 0 || moveEvent.pageY !== 0) {
setLeft(moveEvent.pageX - (offsetPx ?? offsetXPx))
setTop(moveEvent.pageY - (offsetPx ?? offsetYPx))
const onDrag = (dragEvent: MouseEvent) => {
if (dragEvent.pageX !== 0 || dragEvent.pageY !== 0) {
setLeft(dragEvent.pageX - (offsetPx ?? offsetXPx))
setTop(dragEvent.pageY - (offsetPx ?? offsetYPx))
}
}
const onDragEnd = () => {
doCleanup()
onDragEndRef.current()
unsetModal()
}
// Update position (non-FF)
@ -67,14 +69,7 @@ export default function DragModal(props: DragModalProps) {
document.removeEventListener('dragover', onDrag, { capture: true })
document.removeEventListener('dragend', onDragEnd, { capture: true })
}
// `doCleanup` is a callback, not a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
/* should never change */ offsetPx,
/* should never change */ offsetXPx,
/* should never change */ offsetYPx,
/* should never change */ unsetModal,
])
}, [offsetPx, offsetXPx, offsetYPx, unsetModal])
return (
<Modal className="pointer-events-none absolute size-full overflow-hidden">

View File

@ -54,12 +54,12 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
{
queryKey: ['listInvitations'],
queryFn: async () => backend.listInvitations(),
select: (invitations: backendModule.Invitation[]) => invitations.length,
select: (invitations: readonly backendModule.Invitation[]) => invitations.length,
},
{
queryKey: ['listUsers'],
queryFn: async () => backend.listUsers(),
select: (users: backendModule.User[]) => users.length,
select: (users: readonly backendModule.User[]) => users.length,
},
],
})

View File

@ -90,7 +90,7 @@ export default function ManageLabelsModal<
} as Partial<Asset>)
)
},
[/* should never change */ setItem]
[setItem]
)
const doToggleLabel = async (name: backendModule.LabelName) => {

View File

@ -129,7 +129,7 @@ export default function ManagePermissionsModal<
// This is SAFE, as the type of asset is not being changed.
// eslint-disable-next-line no-restricted-syntax
setItem(object.merger({ permissions } as Partial<Asset>))
}, [permissions, /* should never change */ setItem])
}, [permissions, setItem])
if (backend.type === backendModule.BackendType.local) {
// This should never happen - the local backend does not have the "shared with" column,

View File

@ -54,7 +54,7 @@ export default function ResetPassword() {
toastAndLog('missingVerificationCodeError')
navigate(appUtils.LOGIN_PATH)
}
}, [email, navigate, verificationCode, getText, /* should never change */ toastAndLog])
}, [email, navigate, verificationCode, getText, toastAndLog])
const doSubmit = () => {
if (newPassword !== newPasswordConfirm) {

View File

@ -125,6 +125,8 @@ export default function Dashboard(props: DashboardProps) {
const { localStorage } = localStorageProvider.useLocalStorage()
const inputBindings = inputBindingsProvider.useInputBindings()
const [initialized, setInitialized] = React.useState(false)
const initializedRef = React.useRef(initialized)
initializedRef.current = initialized
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
// These pages MUST be ROUTER PAGES.
@ -245,22 +247,20 @@ export default function Dashboard(props: DashboardProps) {
})
React.useEffect(() => {
if (initialized) {
if (initializedRef.current) {
if (projectStartupInfo != null) {
localStorage.set('projectStartupInfo', projectStartupInfo)
} else {
localStorage.delete('projectStartupInfo')
}
}
// `initialized` is NOT a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectStartupInfo, /* should never change */ localStorage])
}, [projectStartupInfo, localStorage])
React.useEffect(() => {
if (page !== pageSwitcher.Page.settings) {
localStorage.set('page', page)
}
}, [page, /* should never change */ localStorage])
}, [page, localStorage])
React.useEffect(
() =>
@ -345,7 +345,7 @@ export default function Dashboard(props: DashboardProps) {
dispatchAssetListEvent({ type: AssetListEventType.removeSelf, id })
setProjectStartupInfo(null)
}
}, [projectStartupInfo?.projectAsset, /* should never change */ dispatchAssetListEvent])
}, [projectStartupInfo?.projectAsset, dispatchAssetListEvent])
const onSignOut = React.useCallback(() => {
if (page === pageSwitcher.Page.editor) {

View File

@ -171,7 +171,11 @@ export default function AuthProvider(props: AuthProviderProps) {
const navigate = router.useNavigate()
const [forceOfflineMode, setForceOfflineMode] = React.useState(shouldStartInOfflineMode)
const [initialized, setInitialized] = React.useState(false)
const initializedRef = React.useRef(initialized)
initializedRef.current = initialized
const [userSession, setUserSession] = React.useState<UserSession | null>(null)
const userSessionRef = React.useRef(userSession)
userSessionRef.current = userSession
const toastId = React.useId()
const setUser = React.useCallback((valueOrUpdater: React.SetStateAction<backendModule.User>) => {
@ -205,7 +209,7 @@ export default function AuthProvider(props: AuthProviderProps) {
navigate(appUtils.DASHBOARD_PATH)
return Promise.resolve(true)
},
[goOfflineInternal, /* should never change */ navigate]
[goOfflineInternal, navigate]
)
// This component cannot use `useGtagEvent` because `useGtagEvent` depends on the React Context
@ -236,7 +240,7 @@ export default function AuthProvider(props: AuthProviderProps) {
if (!navigator.onLine) {
void goOffline()
}
}, [/* should never change */ goOffline])
}, [goOffline])
React.useEffect(() => {
if (authService == null) {
@ -244,7 +248,7 @@ export default function AuthProvider(props: AuthProviderProps) {
goOfflineInternal()
navigate(appUtils.DASHBOARD_PATH)
}
}, [authService, navigate, /* should never change */ goOfflineInternal])
}, [authService, navigate, goOfflineInternal])
React.useEffect(
() =>
@ -253,7 +257,7 @@ export default function AuthProvider(props: AuthProviderProps) {
void goOffline()
}
}),
[onSessionError, /* should never change */ goOffline]
[onSessionError, goOffline]
)
/** Fetch the JWT access token from the session via the AWS Amplify library.
@ -268,7 +272,7 @@ export default function AuthProvider(props: AuthProviderProps) {
setForceOfflineMode(false)
} else if (session == null) {
setInitialized(true)
if (!initialized) {
if (!initializedRef.current) {
sentry.setUser(null)
setUserSession(null)
}
@ -277,7 +281,11 @@ export default function AuthProvider(props: AuthProviderProps) {
const backend = new RemoteBackend(client, logger, getText)
// The backend MUST be the remote backend before login is finished.
// This is because the "set username" flow requires the remote backend.
if (!initialized || userSession == null || userSession.type === UserSessionType.offline) {
if (
!initializedRef.current ||
userSessionRef.current == null ||
userSessionRef.current.type === UserSessionType.offline
) {
setRemoteBackend(backend)
}
gtagEvent('cloud_open')
@ -360,18 +368,17 @@ export default function AuthProvider(props: AuthProviderProps) {
logger.error(error)
}
})
// `userSession` MUST NOT be a dependency as `setUserSession` is called every time
// by this effect. Because it is an object literal, it will never be equal to the previous
// value.
// `initialized` MUST NOT be a dependency as it breaks offline mode.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
cognito,
logger,
onAuthenticated,
session,
/* should never change */ setRemoteBackend,
/* should never change */ goOfflineInternal,
goOfflineInternal,
forceOfflineMode,
getText,
gtagEvent,
setRemoteBackend,
goOffline,
])
/** Wrap a function returning a {@link Promise} to display a loading toast notification

View File

@ -95,6 +95,6 @@ export function useSetModal() {
const updateModal: (updater: (modal: Modal | null) => Modal | null) => void = setModalRaw
const unsetModal = React.useCallback(() => {
setModalRaw(null)
}, [/* should never change */ setModalRaw])
}, [setModalRaw])
return { setModal, updateModal, unsetModal } as const
}

View File

@ -1260,7 +1260,7 @@ export default abstract class Backend {
/** Return the ID of the root directory, if known. */
abstract rootDirectoryId(user: User | null): DirectoryId | null
/** Return a list of all users in the same organization. */
abstract listUsers(): Promise<User[]>
abstract listUsers(): Promise<readonly User[]>
/** Set the username of the current user. */
abstract createUser(body: CreateUserRequestBody): Promise<User>
/** Change the username of the current user. */

View File

@ -118,6 +118,7 @@ interface RemoteBackendPostOptions {
export default class RemoteBackend extends Backend {
readonly type = backend.BackendType.remote
private defaultVersions: Partial<Record<backend.VersionType, DefaultVersionInfo>> = {}
private user: object.Mutable<backend.User> | null = null
/** Create a new instance of the {@link RemoteBackend} API client.
* @throws An error if the `Authorization` header is not set on the given `client`. */
@ -176,7 +177,7 @@ export default class RemoteBackend extends Backend {
}
/** Return a list of all users in the same organization. */
override async listUsers(): Promise<backend.User[]> {
override async listUsers(): Promise<readonly backend.User[]> {
const path = remoteBackendPaths.LIST_USERS_PATH
const response = await this.get<ListUsersResponseBody>(path)
if (!responseIsSuccessful(response)) {
@ -206,6 +207,9 @@ export default class RemoteBackend extends Backend {
? await this.throw(response, 'updateUsernameBackendError')
: await this.throw(response, 'updateUserBackendError')
} else {
if (this.user != null && body.username != null) {
this.user.name = body.username
}
return
}
}
@ -389,7 +393,9 @@ export default class RemoteBackend extends Backend {
if (!responseIsSuccessful(response)) {
return null
} else {
return await response.json()
const user = await response.json()
this.user = { ...user }
return user
}
}
@ -440,6 +446,7 @@ export default class RemoteBackend extends Backend {
permissions: [...(asset.permissions ?? [])].sort(backend.compareAssetPermissions),
})
)
.map(asset => this.dynamicAssetUser(asset))
}
}
@ -1082,7 +1089,7 @@ export default class RemoteBackend extends Backend {
}
/** Get the default version given the type of version (IDE or backend). */
protected async getDefaultVersion(versionType: backend.VersionType) {
private async getDefaultVersion(versionType: backend.VersionType) {
const cached = this.defaultVersions[versionType]
const nowEpochMs = Number(new Date())
if (cached != null && nowEpochMs - cached.lastUpdatedEpochMs < ONE_DAY_MS) {
@ -1099,6 +1106,29 @@ export default class RemoteBackend extends Backend {
}
}
/** Replaces the `user` of all permissions for the current user on an asset, so that they always
* return the up-to-date user. */
private dynamicAssetUser<Asset extends backend.AnyAsset>(asset: Asset) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this
let foundSelfPermission = (() => false)()
const permissions = asset.permissions?.map(permission => {
if (!('user' in permission) || permission.user.userId !== this.user?.userId) {
return permission
} else {
foundSelfPermission = true
return {
...permission,
/** Return a dynamic reference to the current user. */
get user() {
return self.user
},
}
}
})
return !foundSelfPermission ? asset : { ...asset, permissions }
}
/** Send an HTTP GET request to the given path. */
private get<T = void>(path: string) {
return this.client.get<T>(`${process.env.ENSO_CLOUD_API_URL}/${path}`)

View File

@ -421,14 +421,12 @@
:where(body:not(.vibrancy)) {
&::before {
content: "";
position: fixed;
inset: 0 -16vw -16vh 0;
z-index: -1;
background: url("enso-assets/background.jpg");
background-size: cover;
pointer-events: none;
@apply fixed bg-cover pointer-events-none;
}
& > * {
@ -441,9 +439,7 @@
}
:where(.enso-dashboard) {
position: absolute;
inset: 0;
overflow: hidden;
@apply absolute inset-0 overflow-hidden;
}
/* These styles MUST still be copied
@ -454,9 +450,10 @@
-moz-tab-size: 4;
tab-size: 4;
font-family: "Enso Prose", "Enso", "M PLUS 1", "Roboto Light", sans-serif;
font-weight: 500;
font-feature-settings: normal;
@apply font-medium;
kbd {
font-family: "Enso Prose", "Enso", "M PLUS 1", "Roboto Light", sans-serif;
}

View File

@ -330,6 +330,9 @@
"onDateX": "on $0",
"xUsersAndGroupsSelected": "$0 users and groups selected",
"allTrashedItemsForever": "all trashed items forever",
"addShortcut": "Add shortcut",
"removeShortcut": "Remove shortcut",
"resetShortcut": "Reset shortcut",
"resetAllKeyboardShortcuts": "reset all keyboard shortcuts",
"mustNotBeBlank": "Must not be blank.",
"rightClickToRemoveLabel": "Right click to remove label.",
@ -372,8 +375,8 @@
"latestIndicator": "(Latest)",
"noDateSelected": "No date selected",
"assetsDropzoneDescription": "Drag and drop files here, or click to upload.",
"assetsDropFileDescription": "Drop to upload 1 file",
"assetsDropFilesDescription": "Drop to upload $0 files",
"assetsDropFileDescription": "Drop here to upload 1 file",
"assetsDropFilesDescription": "Drop here to upload $0 files",
"hidePassword": "Hide password",
"showPassword": "Show password",
"copiedToClipboard": "Copied to clipboard",

View File

@ -9,7 +9,14 @@ import * as backendModule from '#/services/Backend'
export interface AssetTreeNodeData
extends Pick<
AssetTreeNode,
'children' | 'depth' | 'directoryId' | 'directoryKey' | 'item' | 'key'
| 'children'
| 'createdAt'
| 'depth'
| 'directoryId'
| 'directoryKey'
| 'isExpanded'
| 'item'
| 'key'
> {}
/** All possible variants of {@link AssetTreeNode}s. */
@ -31,14 +38,16 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
public readonly directoryKey: backendModule.DirectoryId,
/** The actual id of the asset's parent directory (or the placeholder id for new assets). */
public readonly directoryId: backendModule.DirectoryId,
/** This is `null` if the asset is not a directory asset, OR if it is a collapsed directory
* asset. */
/** This is `null` if the asset is not a directory asset, OR a directory asset whose contents
* have not yet been fetched. */
public readonly children: AnyAssetTreeNode[] | null,
public readonly depth: number,
/** The internal (to the frontend) id of the asset (or the placeholder id for new assets).
* This must never change, otherwise the component's state is lost when receiving the real id
* from the backend. */
public readonly key: Item['id'] = item.id
public readonly key: Item['id'] = item.id,
public readonly isExpanded = false,
public readonly createdAt = new Date()
) {
this.type = item.type
}
@ -92,7 +101,9 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
// eslint-disable-next-line eqeqeq
update.children === null ? update.children : update.children ?? this.children,
update.depth ?? this.depth,
update.key ?? this.key
update.key ?? this.key,
update.isExpanded ?? this.isExpanded,
update.createdAt ?? this.createdAt
).asUnion()
}
@ -160,8 +171,24 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
preorderTraversal(
preprocess: ((tree: AnyAssetTreeNode[]) => AnyAssetTreeNode[]) | null = null
): AnyAssetTreeNode[] {
return (preprocess?.(this.children ?? []) ?? this.children ?? []).flatMap(node =>
const children = !this.isExpanded ? [] : this.children ?? []
return (preprocess?.(children) ?? children).flatMap(node =>
node.children == null ? [node] : [node, ...node.preorderTraversal(preprocess)]
)
}
/** Check whether a pending rename is valid. */
isNewTitleValid(newTitle: string, siblings?: readonly AssetTreeNode[] | null) {
siblings ??= []
return (
newTitle !== '' &&
newTitle !== this.item.title &&
siblings.every(sibling => {
const isSelf = sibling.key === this.key
const hasSameType = sibling.item.type === this.item.type
const hasSameTitle = sibling.item.title === newTitle
return !(!isSelf && hasSameType && hasSameTitle)
})
)
}
}

View File

@ -0,0 +1,63 @@
/** @file Utilities related to debugging. */
import * as React from 'react'
import * as debugHooks from '#/hooks/debugHooks'
/* eslint-disable no-restricted-properties */
// =============
// === Debug ===
// =============
/** Props for a {@Link Debug}. */
interface DebugProps {
readonly name?: string
readonly monitorProps?: boolean
readonly monitorRender?: boolean
readonly children: JSX.Element
}
/** A component that adds debugging info to its direct child. */
export default function Debug(props: DebugProps) {
const { name, monitorProps = false, monitorRender = false, children } = props
const childPropsRaw: unknown = children.props
const childProps: object = typeof childPropsRaw === 'object' ? childPropsRaw ?? {} : {}
const propsValues: unknown[] = Object.values(childProps)
const typeRaw: unknown = children.type
const typeName = name ?? (typeof typeRaw === 'function' ? typeRaw.name : String(children.type))
debugHooks.useMonitorDependencies(
[children.key, ...propsValues],
typeName,
['key', ...Object.keys(childProps)],
monitorProps
)
const patchedChildProps = Object.fromEntries(
Object.entries(childProps).map(([key, value]: [string, unknown]) => [
key,
typeof value !== 'function'
? value
: (...args: unknown[]) => {
console.group(`[Debug(${typeName})] Prop '${key}' called with args: [`, ...args, ']')
const result: unknown = value(...args)
console.log('Returned', result)
console.groupEnd()
return result
},
])
)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const Component = children.type
if (monitorRender) {
console.group(`[Debug(${typeName})] Rendering`, Component, childProps)
}
const element = <Component {...patchedChildProps} />
React.useEffect(() => {
if (monitorRender) {
console.log(`[Debug(${typeName})] Finished rendering`)
console.groupEnd()
}
})
return element
}

View File

@ -9,33 +9,62 @@ import * as validation from '#/utilities/validation'
/** Runs all tests. */
v.test('password validation', () => {
const pattern = new RegExp(`^(?:${validation.PASSWORD_PATTERN})$`)
const regex = validation.PASSWORD_REGEX
const emptyPassword = ''
v.expect(emptyPassword, `'${emptyPassword}' fails validation`).not.toMatch(pattern)
v.expect(emptyPassword, `'${emptyPassword}' fails validation`).not.toMatch(regex)
const shortPassword = 'Aa0!'
v.expect(shortPassword, `'${shortPassword}' is too short`).not.toMatch(pattern)
v.expect(shortPassword, `'${shortPassword}' is too short`).not.toMatch(regex)
const passwordMissingDigit = 'Aa!Aa!Aa!'
v.expect(passwordMissingDigit, `'${passwordMissingDigit}' is missing a digit`).not.toMatch(
pattern
)
v.expect(passwordMissingDigit, `'${passwordMissingDigit}' is missing a digit`).not.toMatch(regex)
const passwordMissingLowercase = 'A0!A0!A0!'
v.expect(
passwordMissingLowercase,
`'${passwordMissingLowercase}' is missing a lowercase letter`
).not.toMatch(pattern)
).not.toMatch(regex)
const passwordMissingUppercase = 'a0!a0!a0!'
v.expect(
passwordMissingUppercase,
`'${passwordMissingUppercase}' is missing an uppercase letter`
).not.toMatch(pattern)
).not.toMatch(regex)
const passwordMissingSymbol = 'Aa0Aa0Aa0'
v.expect(passwordMissingSymbol, `'${passwordMissingSymbol}' is missing a symbol`).not.toMatch(
pattern
regex
)
const validPassword = 'Aa0!Aa0!'
v.expect(validPassword, `'${validPassword}' passes validation`).toMatch(pattern)
v.expect(validPassword, `'${validPassword}' passes validation`).toMatch(regex)
const basicPassword = 'Password0!'
v.expect(basicPassword, `'${basicPassword}' passes validation`).toMatch(pattern)
v.expect(basicPassword, `'${basicPassword}' passes validation`).toMatch(regex)
const issue7498Password = 'ÑéFÛÅÐåÒ.ú¿¼\u00b4N@aö¶U¹jÙÇ3'
v.expect(issue7498Password, `'${issue7498Password}' passes validation`).toMatch(pattern)
v.expect(issue7498Password, `'${issue7498Password}' passes validation`).toMatch(regex)
})
v.test.each([
{ name: 'foo', valid: true },
{ name: 'foo/', valid: false },
{ name: 'foo\\', valid: false },
{ name: 'foo/bar', valid: false },
{ name: 'foo\\bar', valid: false },
{ name: '/bar', valid: false },
{ name: '\\bar', valid: false },
{ name: '\\', valid: false },
{ name: '/', valid: false },
{ name: '......', valid: false },
{ name: '..', valid: false },
{ name: '.', valid: true },
{ name: 'a.a.a.a.a.a.a.a.', valid: true },
{ name: 'a.a.a.a.a.a.a.a.a', valid: true },
{ name: '.a.a.a.a.a.a.a.a', valid: true },
{ name: 'a.a.a.a.a.a.a.a..', valid: false },
{ name: './', valid: false },
{ name: '//', valid: false },
{ name: '/\\', valid: false },
{ name: '\\/', valid: false },
])('directory name validation', args => {
const { name, valid } = args
const regex = validation.DIRECTORY_NAME_REGEX
if (valid) {
v.expect(name, `'${name}' is a valid directory name`).toMatch(regex)
} else {
v.expect(name, `'${name}' is not a valid directory name`).not.toMatch(regex)
}
})

View File

@ -106,9 +106,7 @@ export function isElementSingleLineTextInput(
// === isElementPartOfMonaco ===
// =============================
/**
* Whether the element is part of a Monaco editor.
*/
/** Whether the element is part of a Monaco editor. */
export function isElementPartOfMonaco(element: EventTarget | null) {
const recursiveCheck = (htmlElement: HTMLElement | null): boolean => {
if (htmlElement == null || htmlElement === document.body) {
@ -125,6 +123,24 @@ export function isElementPartOfMonaco(element: EventTarget | null) {
return element != null && element instanceof HTMLElement && recursiveCheck(element)
}
// =========================
// === isElementInBounds ===
// =========================
/** Whether the event occurred within the given {@link DOMRect}. */
export function isElementInBounds(
event: Pick<MouseEvent, 'clientX' | 'clientY'>,
bounds: DOMRect,
margin = 0
) {
return (
bounds.left - margin <= event.clientX &&
event.clientX <= bounds.right + margin &&
bounds.top - margin <= event.clientY &&
event.clientY <= bounds.bottom + margin
)
}
// ==================
// === submitForm ===
// ==================

View File

@ -1,4 +1,13 @@
/** @file Immutably shallowly merge an object with a partial update. */
/** @file Functions related to manipulating objects. */
// ===============
// === Mutable ===
// ===============
/** Remove the `readonly` modifier from all fields in a type. */
export type Mutable<T> = {
-readonly [K in keyof T]: T[K]
}
// =============
// === merge ===
@ -10,12 +19,20 @@ type NoInfer<T> = [T][T extends T ? 0 : never]
/** Immutably shallowly merge an object with a partial update.
* Does not preserve classes. Useful for preserving order of properties. */
export function merge<T extends object>(object: T, update: Partial<T>): T {
return Object.assign({ ...object }, update)
for (const [key, value] of Object.entries(update)) {
// eslint-disable-next-line no-restricted-syntax
if (!Object.is(value, (object as Record<string, unknown>)[key])) {
// This is FINE, as the matching `return` is below this `return`.
// eslint-disable-next-line no-restricted-syntax
return Object.assign({ ...object }, update)
}
}
return object
}
/** Return a function to update an object with the given partial update. */
export function merger<T extends object>(update: Partial<NoInfer<T>>): (object: T) => T {
return object => Object.assign({ ...object }, update)
return object => merge(object, update)
}
// ================

View File

@ -12,6 +12,10 @@ export const TAILWIND_MERGE_CONFIG = {
p: [{ p: [() => true] }],
w: [{ w: [() => true] }],
h: [{ h: [() => true] }],
size: [{ size: [() => true] }],
},
conflictingClassGroups: {
size: ['w', 'h'] as const,
},
},
}

View File

@ -27,3 +27,9 @@ export const PASSWORD_REGEX = new RegExp('^' + PASSWORD_PATTERN + '$')
* - allow any non-empty string
*/
export const LOCAL_PROJECT_NAME_PATTERN = '.*\\S.*'
/** Match only valid names for titles. The following substrings are disallowed:
* - `/` - folder separator (non-Windows)
* - `\` - folder separator (Windows)
* - `..` - parent directory */
export const DIRECTORY_NAME_REGEX = /^(?:[^/\\.]|[.](?=[^.]|$))+$/

View File

@ -469,10 +469,9 @@ inset 0 -36px 51px -51px #00000014`,
// === States ===
'.focus-ring, .focus-ring:focus, .focus-ring-outset, .focus-ring-outset:focus, .focus-ring-within[data-focus-visible=true]':
{
'@apply outline outline-2 -outline-offset-2 outline-primary transition-all': '',
},
'.focus-ring, .focus-ring:focus, .focus-ring-outset, .focus-ring-outset:focus': {
'@apply outline outline-2 -outline-offset-2 outline-primary transition-all': '',
},
'.focus-ring.checkbox, .focus-ring-outset, .focus-ring-outset:focus': {
'@apply outline-offset-0': '',
},