Merge branch 'release/3.20.2'
This commit is contained in:
12
.babelrc
12
.babelrc
@@ -1,12 +1,4 @@
|
|||||||
{
|
{
|
||||||
"presets": [
|
"presets": ["@babel/preset-typescript", "@babel/preset-env"],
|
||||||
"@babel/preset-typescript",
|
"plugins": [["@babel/plugin-transform-react-jsx"]]
|
||||||
"@babel/preset-env"
|
|
||||||
],
|
|
||||||
"plugins": [
|
|
||||||
["@babel/plugin-transform-react-jsx", {
|
|
||||||
"pragma": "h",
|
|
||||||
"pragmaFrag": "Fragment"
|
|
||||||
}]
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
14
.github/workflows/beta.yml
vendored
14
.github/workflows/beta.yml
vendored
@@ -6,23 +6,23 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tsc:
|
test:
|
||||||
name: Check types & lint
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --pure-lockfile
|
run: yarn install --pure-lockfile
|
||||||
- name: Typescript
|
- name: Bundle
|
||||||
run: yarn tsc
|
run: yarn bundle
|
||||||
- name: ESLint
|
- name: ESLint
|
||||||
run: yarn lint --quiet
|
run: yarn lint
|
||||||
|
- name: Test
|
||||||
|
run: yarn test
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: tsc
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|||||||
21
.github/workflows/dev.yml
vendored
21
.github/workflows/dev.yml
vendored
@@ -10,31 +10,26 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
tsc:
|
|
||||||
|
|
||||||
name: Check types & lint
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --pure-lockfile
|
run: yarn install --pure-lockfile
|
||||||
|
- name: Bundle
|
||||||
- name: Typescript
|
run: yarn bundle
|
||||||
run: yarn tsc
|
|
||||||
|
|
||||||
- name: ESLint
|
- name: ESLint
|
||||||
run: yarn lint --quiet
|
run: yarn lint
|
||||||
|
- name: Test
|
||||||
|
run: yarn test
|
||||||
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
needs: tsc
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|||||||
17
.github/workflows/pr.yml
vendored
17
.github/workflows/pr.yml
vendored
@@ -7,21 +7,16 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
tsc:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --pure-lockfile
|
run: yarn install --pure-lockfile
|
||||||
|
- name: Bundle
|
||||||
- name: Typescript
|
run: yarn bundle
|
||||||
run: yarn tsc
|
|
||||||
|
|
||||||
- name: ESLint
|
- name: ESLint
|
||||||
run: yarn lint --quiet
|
run: yarn lint
|
||||||
|
- name: Test
|
||||||
|
run: yarn test
|
||||||
|
|||||||
21
.github/workflows/prod.yml
vendored
21
.github/workflows/prod.yml
vendored
@@ -9,32 +9,25 @@ on:
|
|||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
tsc:
|
|
||||||
|
|
||||||
name: Check types & lint
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --pure-lockfile
|
run: yarn install --pure-lockfile
|
||||||
|
- name: Bundle
|
||||||
- name: Typescript
|
run: yarn bundle
|
||||||
run: yarn tsc
|
|
||||||
|
|
||||||
- name: ESLint
|
- name: ESLint
|
||||||
run: yarn lint --quiet
|
run: yarn lint
|
||||||
|
- name: Test
|
||||||
|
run: yarn test
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
needs: tsc
|
needs: test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|||||||
@@ -20,16 +20,15 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import { IsWebPlatform, WebAppVersion } from '@/Version'
|
import { IsWebPlatform, WebAppVersion } from '@/Constants/Version'
|
||||||
import { DesktopManagerInterface, SNLog } from '@standardnotes/snjs'
|
import { DesktopManagerInterface, SNLog } from '@standardnotes/snjs'
|
||||||
import { render } from 'preact'
|
import ApplicationGroupView from './Components/ApplicationGroupView/ApplicationGroupView'
|
||||||
import { ApplicationGroupView } from './Components/ApplicationGroupView'
|
import { WebDevice } from './Application/Device/WebDevice'
|
||||||
import { WebDevice } from './Device/WebDevice'
|
import { StartApplication } from './Application/Device/StartApplication'
|
||||||
import { StartApplication } from './Device/StartApplication'
|
import { ApplicationGroup } from './Application/ApplicationGroup'
|
||||||
import { ApplicationGroup } from './UIModels/ApplicationGroup'
|
import { WebOrDesktopDevice } from './Application/Device/WebOrDesktopDevice'
|
||||||
import { WebOrDesktopDevice } from './Device/WebOrDesktopDevice'
|
import { WebApplication } from './Application/Application'
|
||||||
import { WebApplication } from './UIModels/Application'
|
import { createRoot, Root } from 'react-dom/client'
|
||||||
import { unmountComponentAtRoot } from './Utils/PreactUtils'
|
|
||||||
|
|
||||||
let keyCount = 0
|
let keyCount = 0
|
||||||
const getKey = () => {
|
const getKey = () => {
|
||||||
@@ -46,21 +45,22 @@ const startApplication: StartApplication = async function startApplication(
|
|||||||
) {
|
) {
|
||||||
SNLog.onLog = console.log
|
SNLog.onLog = console.log
|
||||||
SNLog.onError = console.error
|
SNLog.onError = console.error
|
||||||
|
let root: Root
|
||||||
|
|
||||||
const onDestroy = () => {
|
const onDestroy = () => {
|
||||||
const root = document.getElementById(RootId) as HTMLElement
|
const rootElement = document.getElementById(RootId) as HTMLElement
|
||||||
unmountComponentAtRoot(root)
|
root.unmount()
|
||||||
root.remove()
|
rootElement.remove()
|
||||||
renderApp()
|
renderApp()
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderApp = () => {
|
const renderApp = () => {
|
||||||
const root = document.createElement('div')
|
const rootElement = document.createElement('div')
|
||||||
root.id = RootId
|
rootElement.id = RootId
|
||||||
|
const appendedRootNode = document.body.appendChild(rootElement)
|
||||||
|
root = createRoot(appendedRootNode)
|
||||||
|
|
||||||
const parentNode = document.body.appendChild(root)
|
root.render(
|
||||||
|
|
||||||
render(
|
|
||||||
<ApplicationGroupView
|
<ApplicationGroupView
|
||||||
key={getKey()}
|
key={getKey()}
|
||||||
server={defaultSyncServerHost}
|
server={defaultSyncServerHost}
|
||||||
@@ -69,7 +69,6 @@ const startApplication: StartApplication = async function startApplication(
|
|||||||
websocketUrl={webSocketUrl}
|
websocketUrl={webSocketUrl}
|
||||||
onDestroy={onDestroy}
|
onDestroy={onDestroy}
|
||||||
/>,
|
/>,
|
||||||
parentNode,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { WebCrypto } from '@/Crypto'
|
import { WebCrypto } from '@/Application/Crypto'
|
||||||
import { WebAlertService } from '@/Services/AlertService'
|
import { WebAlertService } from '@/Services/AlertService'
|
||||||
import { ArchiveManager } from '@/Services/ArchiveManager'
|
import { ArchiveManager } from '@/Services/ArchiveManager'
|
||||||
import { AutolockService } from '@/Services/AutolockService'
|
import { AutolockService } from '@/Services/AutolockService'
|
||||||
import { DesktopManager } from '@/Services/DesktopManager'
|
import { DesktopManager } from '@/Services/DesktopManager'
|
||||||
import { IOService } from '@/Services/IOService'
|
import { IOService } from '@/Services/IOService'
|
||||||
import { ThemeManager } from '@/Services/ThemeManager'
|
import { ThemeManager } from '@/Services/ThemeManager'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { WebOrDesktopDevice } from '@/Device/WebOrDesktopDevice'
|
import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice'
|
||||||
import {
|
import {
|
||||||
DeinitSource,
|
DeinitSource,
|
||||||
Platform,
|
Platform,
|
||||||
@@ -14,14 +14,21 @@ import {
|
|||||||
NoteGroupController,
|
NoteGroupController,
|
||||||
removeFromArray,
|
removeFromArray,
|
||||||
IconsController,
|
IconsController,
|
||||||
Runtime,
|
|
||||||
DesktopDeviceInterface,
|
DesktopDeviceInterface,
|
||||||
isDesktopDevice,
|
isDesktopDevice,
|
||||||
DeinitMode,
|
DeinitMode,
|
||||||
|
PrefKey,
|
||||||
|
SNTag,
|
||||||
|
ContentType,
|
||||||
|
DecryptedItemInterface,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
|
import { makeObservable, observable } from 'mobx'
|
||||||
|
import { PanelResizedData } from '@/Types/PanelResizedData'
|
||||||
|
import { WebAppEvent } from './WebAppEvent'
|
||||||
|
import { isDesktopApplication } from '@/Utils'
|
||||||
|
|
||||||
type WebServices = {
|
type WebServices = {
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
desktopService?: DesktopManager
|
desktopService?: DesktopManager
|
||||||
autolockService: AutolockService
|
autolockService: AutolockService
|
||||||
archiveService: ArchiveManager
|
archiveService: ArchiveManager
|
||||||
@@ -29,19 +36,14 @@ type WebServices = {
|
|||||||
io: IOService
|
io: IOService
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum WebAppEvent {
|
export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void
|
||||||
NewUpdateAvailable = 'NewUpdateAvailable',
|
|
||||||
DesktopWindowGainedFocus = 'DesktopWindowGainedFocus',
|
|
||||||
DesktopWindowLostFocus = 'DesktopWindowLostFocus',
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebEventObserver = (event: WebAppEvent) => void
|
|
||||||
|
|
||||||
export class WebApplication extends SNApplication {
|
export class WebApplication extends SNApplication {
|
||||||
private webServices!: WebServices
|
private webServices!: WebServices
|
||||||
private webEventObservers: WebEventObserver[] = []
|
private webEventObservers: WebEventObserver[] = []
|
||||||
public noteControllerGroup: NoteGroupController
|
public noteControllerGroup: NoteGroupController
|
||||||
public iconsController: IconsController
|
public iconsController: IconsController
|
||||||
|
private onVisibilityChange: () => void
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
deviceInterface: WebOrDesktopDevice,
|
deviceInterface: WebOrDesktopDevice,
|
||||||
@@ -49,7 +51,6 @@ export class WebApplication extends SNApplication {
|
|||||||
identifier: string,
|
identifier: string,
|
||||||
defaultSyncServerHost: string,
|
defaultSyncServerHost: string,
|
||||||
webSocketUrl: string,
|
webSocketUrl: string,
|
||||||
runtime: Runtime,
|
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
environment: deviceInterface.environment,
|
environment: deviceInterface.environment,
|
||||||
@@ -61,12 +62,26 @@ export class WebApplication extends SNApplication {
|
|||||||
defaultHost: defaultSyncServerHost,
|
defaultHost: defaultSyncServerHost,
|
||||||
appVersion: deviceInterface.appVersion,
|
appVersion: deviceInterface.appVersion,
|
||||||
webSocketUrl: webSocketUrl,
|
webSocketUrl: webSocketUrl,
|
||||||
runtime,
|
supportsFileNavigation: window.enabledUnfinishedFeatures || false,
|
||||||
|
})
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
dealloced: observable,
|
||||||
})
|
})
|
||||||
|
|
||||||
deviceInterface.setApplication(this)
|
deviceInterface.setApplication(this)
|
||||||
this.noteControllerGroup = new NoteGroupController(this)
|
this.noteControllerGroup = new NoteGroupController(this)
|
||||||
this.iconsController = new IconsController()
|
this.iconsController = new IconsController()
|
||||||
|
|
||||||
|
this.onVisibilityChange = () => {
|
||||||
|
const visible = document.visibilityState === 'visible'
|
||||||
|
const event = visible ? WebAppEvent.WindowDidFocus : WebAppEvent.WindowDidBlur
|
||||||
|
this.notifyWebEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDesktopApplication()) {
|
||||||
|
document.addEventListener('visibilitychange', this.onVisibilityChange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override deinit(mode: DeinitMode, source: DeinitSource): void {
|
override deinit(mode: DeinitMode, source: DeinitSource): void {
|
||||||
@@ -91,6 +106,9 @@ export class WebApplication extends SNApplication {
|
|||||||
;(this.noteControllerGroup as unknown) = undefined
|
;(this.noteControllerGroup as unknown) = undefined
|
||||||
|
|
||||||
this.webEventObservers.length = 0
|
this.webEventObservers.length = 0
|
||||||
|
|
||||||
|
document.removeEventListener('visibilitychange', this.onVisibilityChange)
|
||||||
|
;(this.onVisibilityChange as unknown) = undefined
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error while deiniting application', error)
|
console.error('Error while deiniting application', error)
|
||||||
}
|
}
|
||||||
@@ -102,19 +120,28 @@ export class WebApplication extends SNApplication {
|
|||||||
|
|
||||||
public addWebEventObserver(observer: WebEventObserver): () => void {
|
public addWebEventObserver(observer: WebEventObserver): () => void {
|
||||||
this.webEventObservers.push(observer)
|
this.webEventObservers.push(observer)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
removeFromArray(this.webEventObservers, observer)
|
removeFromArray(this.webEventObservers, observer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public notifyWebEvent(event: WebAppEvent): void {
|
public notifyWebEvent(event: WebAppEvent, data?: unknown): void {
|
||||||
for (const observer of this.webEventObservers) {
|
for (const observer of this.webEventObservers) {
|
||||||
observer(event)
|
observer(event, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAppState(): AppState {
|
publishPanelDidResizeEvent(name: string, collapsed: boolean) {
|
||||||
return this.webServices.appState
|
const data: PanelResizedData = {
|
||||||
|
panel: name,
|
||||||
|
collapsed: collapsed,
|
||||||
|
}
|
||||||
|
this.notifyWebEvent(WebAppEvent.PanelResized, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
public getViewControllerManager(): ViewControllerManager {
|
||||||
|
return this.webServices.viewControllerManager
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDesktopService(): DesktopManager | undefined {
|
public getDesktopService(): DesktopManager | undefined {
|
||||||
@@ -160,4 +187,23 @@ export class WebApplication extends SNApplication {
|
|||||||
|
|
||||||
return this.user.signOut()
|
return this.user.signOut()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isGlobalSpellcheckEnabled(): boolean {
|
||||||
|
return this.getPreference(PrefKey.EditorSpellcheck, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
public getItemTags(item: DecryptedItemInterface) {
|
||||||
|
return this.items.itemsReferencingItem(item).filter((ref) => {
|
||||||
|
return ref.content_type === ContentType.Tag
|
||||||
|
}) as SNTag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
public get version(): string {
|
||||||
|
return (this.deviceInterface as WebOrDesktopDevice).appVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleGlobalSpellcheck() {
|
||||||
|
const currentValue = this.isGlobalSpellcheckEnabled()
|
||||||
|
return this.setPreference(PrefKey.EditorSpellcheck, !currentValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,25 +3,23 @@ import {
|
|||||||
ApplicationDescriptor,
|
ApplicationDescriptor,
|
||||||
SNApplicationGroup,
|
SNApplicationGroup,
|
||||||
Platform,
|
Platform,
|
||||||
Runtime,
|
|
||||||
InternalEventBus,
|
InternalEventBus,
|
||||||
isDesktopDevice,
|
isDesktopDevice,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { getPlatform, isDesktopApplication } from '@/Utils'
|
import { getPlatform, isDesktopApplication } from '@/Utils'
|
||||||
import { ArchiveManager } from '@/Services/ArchiveManager'
|
import { ArchiveManager } from '@/Services/ArchiveManager'
|
||||||
import { DesktopManager } from '@/Services/DesktopManager'
|
import { DesktopManager } from '@/Services/DesktopManager'
|
||||||
import { IOService } from '@/Services/IOService'
|
import { IOService } from '@/Services/IOService'
|
||||||
import { AutolockService } from '@/Services/AutolockService'
|
import { AutolockService } from '@/Services/AutolockService'
|
||||||
import { ThemeManager } from '@/Services/ThemeManager'
|
import { ThemeManager } from '@/Services/ThemeManager'
|
||||||
import { WebOrDesktopDevice } from '@/Device/WebOrDesktopDevice'
|
import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice'
|
||||||
|
|
||||||
const createApplication = (
|
const createApplication = (
|
||||||
descriptor: ApplicationDescriptor,
|
descriptor: ApplicationDescriptor,
|
||||||
deviceInterface: WebOrDesktopDevice,
|
deviceInterface: WebOrDesktopDevice,
|
||||||
defaultSyncServerHost: string,
|
defaultSyncServerHost: string,
|
||||||
device: WebOrDesktopDevice,
|
device: WebOrDesktopDevice,
|
||||||
runtime: Runtime,
|
|
||||||
webSocketUrl: string,
|
webSocketUrl: string,
|
||||||
) => {
|
) => {
|
||||||
const platform = getPlatform()
|
const platform = getPlatform()
|
||||||
@@ -32,17 +30,16 @@ const createApplication = (
|
|||||||
descriptor.identifier,
|
descriptor.identifier,
|
||||||
defaultSyncServerHost,
|
defaultSyncServerHost,
|
||||||
webSocketUrl,
|
webSocketUrl,
|
||||||
runtime,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const appState = new AppState(application, device)
|
const viewControllerManager = new ViewControllerManager(application, device)
|
||||||
const archiveService = new ArchiveManager(application)
|
const archiveService = new ArchiveManager(application)
|
||||||
const io = new IOService(platform === Platform.MacWeb || platform === Platform.MacDesktop)
|
const io = new IOService(platform === Platform.MacWeb || platform === Platform.MacDesktop)
|
||||||
const autolockService = new AutolockService(application, new InternalEventBus())
|
const autolockService = new AutolockService(application, new InternalEventBus())
|
||||||
const themeService = new ThemeManager(application)
|
const themeService = new ThemeManager(application)
|
||||||
|
|
||||||
application.setWebServices({
|
application.setWebServices({
|
||||||
appState,
|
viewControllerManager,
|
||||||
archiveService,
|
archiveService,
|
||||||
desktopService: isDesktopDevice(device) ? new DesktopManager(application, device) : undefined,
|
desktopService: isDesktopDevice(device) ? new DesktopManager(application, device) : undefined,
|
||||||
io,
|
io,
|
||||||
@@ -54,23 +51,17 @@ const createApplication = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ApplicationGroup extends SNApplicationGroup<WebOrDesktopDevice> {
|
export class ApplicationGroup extends SNApplicationGroup<WebOrDesktopDevice> {
|
||||||
constructor(
|
constructor(private defaultSyncServerHost: string, device: WebOrDesktopDevice, private webSocketUrl: string) {
|
||||||
private defaultSyncServerHost: string,
|
|
||||||
device: WebOrDesktopDevice,
|
|
||||||
private runtime: Runtime,
|
|
||||||
private webSocketUrl: string,
|
|
||||||
) {
|
|
||||||
super(device)
|
super(device)
|
||||||
}
|
}
|
||||||
|
|
||||||
override async initialize(): Promise<void> {
|
override async initialize(): Promise<void> {
|
||||||
const defaultSyncServerHost = this.defaultSyncServerHost
|
const defaultSyncServerHost = this.defaultSyncServerHost
|
||||||
const runtime = this.runtime
|
|
||||||
const webSocketUrl = this.webSocketUrl
|
const webSocketUrl = this.webSocketUrl
|
||||||
|
|
||||||
await super.initialize({
|
await super.initialize({
|
||||||
applicationCreator: async (descriptor, device) => {
|
applicationCreator: async (descriptor, device) => {
|
||||||
return createApplication(descriptor, device, defaultSyncServerHost, device, runtime, webSocketUrl)
|
return createApplication(descriptor, device, defaultSyncServerHost, device, webSocketUrl)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
9
app/assets/javascripts/Application/WebAppEvent.ts
Normal file
9
app/assets/javascripts/Application/WebAppEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export enum WebAppEvent {
|
||||||
|
NewUpdateAvailable = 'NewUpdateAvailable',
|
||||||
|
EditorFocused = 'EditorFocused',
|
||||||
|
BeganBackupDownload = 'BeganBackupDownload',
|
||||||
|
EndedBackupDownload = 'EndedBackupDownload',
|
||||||
|
PanelResized = 'PanelResized',
|
||||||
|
WindowDidFocus = 'WindowDidFocus',
|
||||||
|
WindowDidBlur = 'WindowDidBlur',
|
||||||
|
}
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
import { ApplicationEvent } from '@standardnotes/snjs'
|
import { ApplicationEvent } from '@standardnotes/snjs'
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { AppState, AppStateEvent } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx'
|
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx'
|
||||||
import { Component } from 'preact'
|
import { Component } from 'react'
|
||||||
import { findDOMNode, unmountComponentAtNode } from 'preact/compat'
|
|
||||||
|
|
||||||
export type PureComponentState = Partial<Record<string, any>>
|
export type PureComponentState = Partial<Record<string, any>>
|
||||||
export type PureComponentProps = Partial<Record<string, any>>
|
export type PureComponentProps = Partial<Record<string, any>>
|
||||||
|
|
||||||
export abstract class PureComponent<P = PureComponentProps, S = PureComponentState> extends Component<P, S> {
|
export abstract class PureComponent<P = PureComponentProps, S = PureComponentState> extends Component<P, S> {
|
||||||
private unsubApp!: () => void
|
private unsubApp!: () => void
|
||||||
private unsubState!: () => void
|
|
||||||
private reactionDisposers: IReactionDisposer[] = []
|
private reactionDisposers: IReactionDisposer[] = []
|
||||||
|
|
||||||
constructor(props: P, protected application: WebApplication) {
|
constructor(props: P, protected application: WebApplication) {
|
||||||
@@ -19,63 +17,34 @@ export abstract class PureComponent<P = PureComponentProps, S = PureComponentSta
|
|||||||
|
|
||||||
override componentDidMount() {
|
override componentDidMount() {
|
||||||
this.addAppEventObserver()
|
this.addAppEventObserver()
|
||||||
this.addAppStateObserver()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit(): void {
|
deinit(): void {
|
||||||
this.unsubApp?.()
|
this.unsubApp?.()
|
||||||
this.unsubState?.()
|
|
||||||
for (const disposer of this.reactionDisposers) {
|
for (const disposer of this.reactionDisposers) {
|
||||||
disposer()
|
disposer()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reactionDisposers.length = 0
|
this.reactionDisposers.length = 0
|
||||||
;(this.unsubApp as unknown) = undefined
|
;(this.unsubApp as unknown) = undefined
|
||||||
;(this.unsubState as unknown) = undefined
|
|
||||||
;(this.application as unknown) = undefined
|
;(this.application as unknown) = undefined
|
||||||
;(this.props as unknown) = undefined
|
;(this.props as unknown) = undefined
|
||||||
;(this.state as unknown) = undefined
|
;(this.state as unknown) = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
protected dismissModal(): void {
|
|
||||||
const elem = this.getElement()
|
|
||||||
if (!elem) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const parent = elem.parentElement
|
|
||||||
if (!parent) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parent.remove()
|
|
||||||
unmountComponentAtNode(parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
override componentWillUnmount(): void {
|
override componentWillUnmount(): void {
|
||||||
this.deinit()
|
this.deinit()
|
||||||
}
|
}
|
||||||
|
|
||||||
public get appState(): AppState {
|
public get viewControllerManager(): ViewControllerManager {
|
||||||
return this.application.getAppState()
|
return this.application.getViewControllerManager()
|
||||||
}
|
|
||||||
|
|
||||||
protected getElement(): Element | null {
|
|
||||||
return findDOMNode(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
autorun(view: (r: IReactionPublic) => void): void {
|
autorun(view: (r: IReactionPublic) => void): void {
|
||||||
this.reactionDisposers.push(autorun(view))
|
this.reactionDisposers.push(autorun(view))
|
||||||
}
|
}
|
||||||
|
|
||||||
addAppStateObserver() {
|
|
||||||
this.unsubState = this.application.getAppState().addObserver(async (eventName, data) => {
|
|
||||||
this.onAppStateEvent(eventName, data)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onAppStateEvent(_eventName: AppStateEvent, _data: unknown) {
|
|
||||||
/** Optional override */
|
|
||||||
}
|
|
||||||
|
|
||||||
addAppEventObserver() {
|
addAppEventObserver() {
|
||||||
if (this.application.isStarted()) {
|
if (this.application.isStarted()) {
|
||||||
this.onAppStart().catch(console.error)
|
this.onAppStart().catch(console.error)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
|
||||||
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { useCallback, useRef, FunctionComponent, KeyboardEventHandler } from 'react'
|
||||||
|
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||||
|
import { AccountMenuPane } from './AccountMenuPane'
|
||||||
|
import MenuPaneSelector from './MenuPaneSelector'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
viewControllerManager: ViewControllerManager
|
||||||
|
application: WebApplication
|
||||||
|
onClickOutside: () => void
|
||||||
|
mainApplicationGroup: ApplicationGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountMenu: FunctionComponent<Props> = ({
|
||||||
|
application,
|
||||||
|
viewControllerManager,
|
||||||
|
onClickOutside,
|
||||||
|
mainApplicationGroup,
|
||||||
|
}) => {
|
||||||
|
const { currentPane, shouldAnimateCloseMenu } = viewControllerManager.accountMenuController
|
||||||
|
|
||||||
|
const closeAccountMenu = useCallback(() => {
|
||||||
|
viewControllerManager.accountMenuController.closeAccountMenu()
|
||||||
|
}, [viewControllerManager])
|
||||||
|
|
||||||
|
const setCurrentPane = useCallback(
|
||||||
|
(pane: AccountMenuPane) => {
|
||||||
|
viewControllerManager.accountMenuController.setCurrentPane(pane)
|
||||||
|
},
|
||||||
|
[viewControllerManager],
|
||||||
|
)
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
useCloseOnClickOutside(ref, () => {
|
||||||
|
onClickOutside()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = useCallback(
|
||||||
|
(event) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Escape':
|
||||||
|
if (currentPane === AccountMenuPane.GeneralMenu) {
|
||||||
|
closeAccountMenu()
|
||||||
|
} else if (currentPane === AccountMenuPane.ConfirmPassword) {
|
||||||
|
setCurrentPane(AccountMenuPane.Register)
|
||||||
|
} else {
|
||||||
|
setCurrentPane(AccountMenuPane.GeneralMenu)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[closeAccountMenu, currentPane, setCurrentPane],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} id="account-menu" className="sn-component">
|
||||||
|
<div
|
||||||
|
className={`sn-account-menu sn-dropdown ${
|
||||||
|
shouldAnimateCloseMenu ? 'slide-up-animation' : 'sn-dropdown--animated'
|
||||||
|
} min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto absolute`}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<MenuPaneSelector
|
||||||
|
viewControllerManager={viewControllerManager}
|
||||||
|
application={application}
|
||||||
|
mainApplicationGroup={mainApplicationGroup}
|
||||||
|
menuPane={currentPane}
|
||||||
|
setMenuPane={setCurrentPane}
|
||||||
|
closeMenu={closeAccountMenu}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(AccountMenu)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export enum AccountMenuPane {
|
||||||
|
GeneralMenu,
|
||||||
|
SignIn,
|
||||||
|
Register,
|
||||||
|
ConfirmPassword,
|
||||||
|
}
|
||||||
@@ -1,184 +1,190 @@
|
|||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'preact'
|
import { ChangeEventHandler, FunctionComponent, useCallback, useEffect, useState } from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
import Checkbox from '@/Components/Checkbox/Checkbox'
|
||||||
import { Checkbox } from '@/Components/Checkbox'
|
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onPrivateWorkspaceChange?: (isPrivate: boolean, identifier?: string) => void
|
onPrivateWorkspaceChange?: (isPrivate: boolean, identifier?: string) => void
|
||||||
onStrictSignInChange?: (isStrictSignIn: boolean) => void
|
onStrictSignInChange?: (isStrictSignIn: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AdvancedOptions: FunctionComponent<Props> = observer(
|
const AdvancedOptions: FunctionComponent<Props> = ({
|
||||||
({ appState, application, disabled = false, onPrivateWorkspaceChange, onStrictSignInChange, children }) => {
|
viewControllerManager,
|
||||||
const { server, setServer, enableServerOption, setEnableServerOption } = appState.accountMenu
|
application,
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
disabled = false,
|
||||||
|
onPrivateWorkspaceChange,
|
||||||
|
onStrictSignInChange,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const { server, setServer, enableServerOption, setEnableServerOption } = viewControllerManager.accountMenuController
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||||
|
|
||||||
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
|
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
|
||||||
const [privateWorkspaceName, setPrivateWorkspaceName] = useState('')
|
const [privateWorkspaceName, setPrivateWorkspaceName] = useState('')
|
||||||
const [privateWorkspaceUserphrase, setPrivateWorkspaceUserphrase] = useState('')
|
const [privateWorkspaceUserphrase, setPrivateWorkspaceUserphrase] = useState('')
|
||||||
|
|
||||||
const [isStrictSignin, setIsStrictSignin] = useState(false)
|
const [isStrictSignin, setIsStrictSignin] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const recomputePrivateWorkspaceIdentifier = async () => {
|
const recomputePrivateWorkspaceIdentifier = async () => {
|
||||||
const identifier = await application.computePrivateWorkspaceIdentifier(
|
const identifier = await application.computePrivateWorkspaceIdentifier(
|
||||||
privateWorkspaceName,
|
privateWorkspaceName,
|
||||||
privateWorkspaceUserphrase,
|
privateWorkspaceUserphrase,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!identifier) {
|
if (!identifier) {
|
||||||
if (privateWorkspaceName?.length > 0 && privateWorkspaceUserphrase?.length > 0) {
|
if (privateWorkspaceName?.length > 0 && privateWorkspaceUserphrase?.length > 0) {
|
||||||
application.alertService.alert('Unable to compute private workspace name.').catch(console.error)
|
application.alertService.alert('Unable to compute private workspace name.').catch(console.error)
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
onPrivateWorkspaceChange?.(true, identifier)
|
return
|
||||||
}
|
}
|
||||||
|
onPrivateWorkspaceChange?.(true, identifier)
|
||||||
|
}
|
||||||
|
|
||||||
if (privateWorkspaceName && privateWorkspaceUserphrase) {
|
if (privateWorkspaceName && privateWorkspaceUserphrase) {
|
||||||
recomputePrivateWorkspaceIdentifier().catch(console.error)
|
recomputePrivateWorkspaceIdentifier().catch(console.error)
|
||||||
|
}
|
||||||
|
}, [privateWorkspaceName, privateWorkspaceUserphrase, application, onPrivateWorkspaceChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPrivateWorkspaceChange?.(isPrivateWorkspace)
|
||||||
|
}, [isPrivateWorkspace, onPrivateWorkspaceChange])
|
||||||
|
|
||||||
|
const handleIsPrivateWorkspaceChange = useCallback(() => {
|
||||||
|
setIsPrivateWorkspace(!isPrivateWorkspace)
|
||||||
|
}, [isPrivateWorkspace])
|
||||||
|
|
||||||
|
const handlePrivateWorkspaceNameChange = useCallback((name: string) => {
|
||||||
|
setPrivateWorkspaceName(name)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePrivateWorkspaceUserphraseChange = useCallback((userphrase: string) => {
|
||||||
|
setPrivateWorkspaceUserphrase(userphrase)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleServerOptionChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (e.target instanceof HTMLInputElement) {
|
||||||
|
setEnableServerOption(e.target.checked)
|
||||||
}
|
}
|
||||||
}, [privateWorkspaceName, privateWorkspaceUserphrase, application, onPrivateWorkspaceChange])
|
},
|
||||||
|
[setEnableServerOption],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSyncServerChange = useCallback(
|
||||||
onPrivateWorkspaceChange?.(isPrivateWorkspace)
|
(server: string) => {
|
||||||
}, [isPrivateWorkspace, onPrivateWorkspaceChange])
|
setServer(server)
|
||||||
|
application.setCustomHost(server).catch(console.error)
|
||||||
|
},
|
||||||
|
[application, setServer],
|
||||||
|
)
|
||||||
|
|
||||||
const handleIsPrivateWorkspaceChange = useCallback(() => {
|
const handleStrictSigninChange = useCallback(() => {
|
||||||
setIsPrivateWorkspace(!isPrivateWorkspace)
|
const newValue = !isStrictSignin
|
||||||
}, [isPrivateWorkspace])
|
setIsStrictSignin(newValue)
|
||||||
|
onStrictSignInChange?.(newValue)
|
||||||
|
}, [isStrictSignin, onStrictSignInChange])
|
||||||
|
|
||||||
const handlePrivateWorkspaceNameChange = useCallback((name: string) => {
|
const toggleShowAdvanced = useCallback(() => {
|
||||||
setPrivateWorkspaceName(name)
|
setShowAdvanced(!showAdvanced)
|
||||||
}, [])
|
}, [showAdvanced])
|
||||||
|
|
||||||
const handlePrivateWorkspaceUserphraseChange = useCallback((userphrase: string) => {
|
return (
|
||||||
setPrivateWorkspaceUserphrase(userphrase)
|
<>
|
||||||
}, [])
|
<button
|
||||||
|
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none font-bold"
|
||||||
|
onClick={toggleShowAdvanced}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Advanced options
|
||||||
|
<Icon type="chevron-down" className="color-passive-1 ml-1" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{showAdvanced ? (
|
||||||
|
<div className="px-3 my-2">
|
||||||
|
{children}
|
||||||
|
|
||||||
const handleServerOptionChange = useCallback(
|
<div className="flex justify-between items-center mb-1">
|
||||||
(e: Event) => {
|
<Checkbox
|
||||||
if (e.target instanceof HTMLInputElement) {
|
name="private-workspace"
|
||||||
setEnableServerOption(e.target.checked)
|
label="Private workspace"
|
||||||
}
|
checked={isPrivateWorkspace}
|
||||||
},
|
disabled={disabled}
|
||||||
[setEnableServerOption],
|
onChange={handleIsPrivateWorkspaceChange}
|
||||||
)
|
/>
|
||||||
|
<a href="https://standardnotes.com/help/80" target="_blank" rel="noopener noreferrer" title="Learn more">
|
||||||
const handleSyncServerChange = useCallback(
|
<Icon type="info" className="color-neutral" />
|
||||||
(server: string) => {
|
</a>
|
||||||
setServer(server)
|
|
||||||
application.setCustomHost(server).catch(console.error)
|
|
||||||
},
|
|
||||||
[application, setServer],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleStrictSigninChange = useCallback(() => {
|
|
||||||
const newValue = !isStrictSignin
|
|
||||||
setIsStrictSignin(newValue)
|
|
||||||
onStrictSignInChange?.(newValue)
|
|
||||||
}, [isStrictSignin, onStrictSignInChange])
|
|
||||||
|
|
||||||
const toggleShowAdvanced = useCallback(() => {
|
|
||||||
setShowAdvanced(!showAdvanced)
|
|
||||||
}, [showAdvanced])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none font-bold"
|
|
||||||
onClick={toggleShowAdvanced}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
Advanced options
|
|
||||||
<Icon type="chevron-down" className="color-grey-1 ml-1" />
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
{showAdvanced ? (
|
|
||||||
<div className="px-3 my-2">
|
|
||||||
{children}
|
|
||||||
|
|
||||||
|
{isPrivateWorkspace && (
|
||||||
|
<>
|
||||||
|
<DecoratedInput
|
||||||
|
className={'mb-2'}
|
||||||
|
left={[<Icon type="server" className="color-neutral" />]}
|
||||||
|
type="text"
|
||||||
|
placeholder="Userphrase"
|
||||||
|
value={privateWorkspaceUserphrase}
|
||||||
|
onChange={handlePrivateWorkspaceUserphraseChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<DecoratedInput
|
||||||
|
className={'mb-2'}
|
||||||
|
left={[<Icon type="folder" className="color-neutral" />]}
|
||||||
|
type="text"
|
||||||
|
placeholder="Name"
|
||||||
|
value={privateWorkspaceName}
|
||||||
|
onChange={handlePrivateWorkspaceNameChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onStrictSignInChange && (
|
||||||
<div className="flex justify-between items-center mb-1">
|
<div className="flex justify-between items-center mb-1">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
name="private-workspace"
|
name="use-strict-signin"
|
||||||
label="Private workspace"
|
label="Use strict sign-in"
|
||||||
checked={isPrivateWorkspace}
|
checked={isStrictSignin}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={handleIsPrivateWorkspaceChange}
|
onChange={handleStrictSigninChange}
|
||||||
/>
|
/>
|
||||||
<a href="https://standardnotes.com/help/80" target="_blank" rel="noopener noreferrer" title="Learn more">
|
<a
|
||||||
|
href="https://standardnotes.com/help/security"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Learn more"
|
||||||
|
>
|
||||||
<Icon type="info" className="color-neutral" />
|
<Icon type="info" className="color-neutral" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isPrivateWorkspace && (
|
<Checkbox
|
||||||
<>
|
name="custom-sync-server"
|
||||||
<DecoratedInput
|
label="Custom sync server"
|
||||||
className={'mb-2'}
|
checked={enableServerOption}
|
||||||
left={[<Icon type="server" className="color-neutral" />]}
|
onChange={handleServerOptionChange}
|
||||||
type="text"
|
disabled={disabled}
|
||||||
placeholder="Userphrase"
|
/>
|
||||||
value={privateWorkspaceUserphrase}
|
<DecoratedInput
|
||||||
onChange={handlePrivateWorkspaceUserphraseChange}
|
type="text"
|
||||||
disabled={disabled}
|
left={[<Icon type="server" className="color-neutral" />]}
|
||||||
/>
|
placeholder="https://api.standardnotes.com"
|
||||||
<DecoratedInput
|
value={server}
|
||||||
className={'mb-2'}
|
onChange={handleSyncServerChange}
|
||||||
left={[<Icon type="folder" className="color-neutral" />]}
|
disabled={!enableServerOption && !disabled}
|
||||||
type="text"
|
/>
|
||||||
placeholder="Name"
|
</div>
|
||||||
value={privateWorkspaceName}
|
) : null}
|
||||||
onChange={handlePrivateWorkspaceNameChange}
|
</>
|
||||||
disabled={disabled}
|
)
|
||||||
/>
|
}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onStrictSignInChange && (
|
export default observer(AdvancedOptions)
|
||||||
<div className="flex justify-between items-center mb-1">
|
|
||||||
<Checkbox
|
|
||||||
name="use-strict-signin"
|
|
||||||
label="Use strict sign-in"
|
|
||||||
checked={isStrictSignin}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={handleStrictSigninChange}
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="https://standardnotes.com/help/security"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title="Learn more"
|
|
||||||
>
|
|
||||||
<Icon type="info" className="color-neutral" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
name="custom-sync-server"
|
|
||||||
label="Custom sync server"
|
|
||||||
checked={enableServerOption}
|
|
||||||
onChange={handleServerOptionChange}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<DecoratedInput
|
|
||||||
type="text"
|
|
||||||
left={[<Icon type="server" className="color-neutral" />]}
|
|
||||||
placeholder="https://api.standardnotes.com"
|
|
||||||
value={server}
|
|
||||||
onChange={handleSyncServerChange}
|
|
||||||
disabled={!enableServerOption && !disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,158 +1,163 @@
|
|||||||
import { STRING_NON_MATCHING_PASSWORDS } from '@/Strings'
|
import { STRING_NON_MATCHING_PASSWORDS } from '@/Constants/Strings'
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
import { AccountMenuPane } from './AccountMenuPane'
|
||||||
import { AccountMenuPane } from '.'
|
import Button from '@/Components/Button/Button'
|
||||||
import { Button } from '@/Components/Button/Button'
|
import Checkbox from '@/Components/Checkbox/Checkbox'
|
||||||
import { Checkbox } from '@/Components/Checkbox'
|
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
|
||||||
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
import IconButton from '@/Components/Button/IconButton'
|
||||||
import { IconButton } from '@/Components/Button/IconButton'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
setMenuPane: (pane: AccountMenuPane) => void
|
setMenuPane: (pane: AccountMenuPane) => void
|
||||||
email: string
|
email: string
|
||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfirmPassword: FunctionComponent<Props> = observer(
|
const ConfirmPassword: FunctionComponent<Props> = ({
|
||||||
({ application, appState, setMenuPane, email, password }) => {
|
application,
|
||||||
const { notesAndTagsCount } = appState.accountMenu
|
viewControllerManager,
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
setMenuPane,
|
||||||
const [isRegistering, setIsRegistering] = useState(false)
|
email,
|
||||||
const [isEphemeral, setIsEphemeral] = useState(false)
|
password,
|
||||||
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
|
}) => {
|
||||||
const [error, setError] = useState('')
|
const { notesAndTagsCount } = viewControllerManager.accountMenuController
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [isRegistering, setIsRegistering] = useState(false)
|
||||||
|
const [isEphemeral, setIsEphemeral] = useState(false)
|
||||||
|
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
passwordInputRef.current?.focus()
|
passwordInputRef.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handlePasswordChange = useCallback((text: string) => {
|
const handlePasswordChange = useCallback((text: string) => {
|
||||||
setConfirmPassword(text)
|
setConfirmPassword(text)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleEphemeralChange = useCallback(() => {
|
const handleEphemeralChange = useCallback(() => {
|
||||||
setIsEphemeral(!isEphemeral)
|
setIsEphemeral(!isEphemeral)
|
||||||
}, [isEphemeral])
|
}, [isEphemeral])
|
||||||
|
|
||||||
const handleShouldMergeChange = useCallback(() => {
|
const handleShouldMergeChange = useCallback(() => {
|
||||||
setShouldMergeLocal(!shouldMergeLocal)
|
setShouldMergeLocal(!shouldMergeLocal)
|
||||||
}, [shouldMergeLocal])
|
}, [shouldMergeLocal])
|
||||||
|
|
||||||
const handleConfirmFormSubmit = useCallback(
|
const handleConfirmFormSubmit = useCallback(
|
||||||
(e: Event) => {
|
(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
passwordInputRef.current?.focus()
|
passwordInputRef.current?.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password === confirmPassword) {
|
if (password === confirmPassword) {
|
||||||
setIsRegistering(true)
|
setIsRegistering(true)
|
||||||
application
|
application
|
||||||
.register(email, password, isEphemeral, shouldMergeLocal)
|
.register(email, password, isEphemeral, shouldMergeLocal)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
throw new Error(res.error.message)
|
throw new Error(res.error.message)
|
||||||
}
|
}
|
||||||
appState.accountMenu.closeAccountMenu()
|
viewControllerManager.accountMenuController.closeAccountMenu()
|
||||||
appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu)
|
viewControllerManager.accountMenuController.setCurrentPane(AccountMenuPane.GeneralMenu)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsRegistering(false)
|
setIsRegistering(false)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setError(STRING_NON_MATCHING_PASSWORDS)
|
setError(STRING_NON_MATCHING_PASSWORDS)
|
||||||
setConfirmPassword('')
|
setConfirmPassword('')
|
||||||
passwordInputRef.current?.focus()
|
passwordInputRef.current?.focus()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appState, application, confirmPassword, email, isEphemeral, password, shouldMergeLocal],
|
[viewControllerManager, application, confirmPassword, email, isEphemeral, password, shouldMergeLocal],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e) => {
|
||||||
if (error.length) {
|
if (error.length) {
|
||||||
setError('')
|
setError('')
|
||||||
}
|
}
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
handleConfirmFormSubmit(e)
|
handleConfirmFormSubmit(e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleConfirmFormSubmit, error],
|
[handleConfirmFormSubmit, error],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleGoBack = useCallback(() => {
|
const handleGoBack = useCallback(() => {
|
||||||
setMenuPane(AccountMenuPane.Register)
|
setMenuPane(AccountMenuPane.Register)
|
||||||
}, [setMenuPane])
|
}, [setMenuPane])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center px-3 mt-1 mb-3">
|
<div className="flex items-center px-3 mt-1 mb-3">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="arrow-left"
|
icon="arrow-left"
|
||||||
title="Go back"
|
title="Go back"
|
||||||
className="flex mr-2 color-neutral p-0"
|
className="flex mr-2 color-neutral p-0"
|
||||||
onClick={handleGoBack}
|
onClick={handleGoBack}
|
||||||
focusable={true}
|
focusable={true}
|
||||||
disabled={isRegistering}
|
disabled={isRegistering}
|
||||||
/>
|
/>
|
||||||
<div className="sn-account-menu-headline">Confirm password</div>
|
<div className="sn-account-menu-headline">Confirm password</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 mb-3 text-sm">
|
<div className="px-3 mb-3 text-sm">
|
||||||
Because your notes are encrypted using your password,{' '}
|
Because your notes are encrypted using your password,{' '}
|
||||||
<span className="color-dark-red">Standard Notes does not have a password reset option</span>. If you forget
|
<span className="color-danger">Standard Notes does not have a password reset option</span>. If you forget your
|
||||||
your password, you will permanently lose access to your data.
|
password, you will permanently lose access to your data.
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1">
|
<form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1">
|
||||||
<DecoratedPasswordInput
|
<DecoratedPasswordInput
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
disabled={isRegistering}
|
disabled={isRegistering}
|
||||||
left={[<Icon type="password" className="color-neutral" />]}
|
left={[<Icon type="password" className="color-neutral" />]}
|
||||||
onChange={handlePasswordChange}
|
onChange={handlePasswordChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Confirm password"
|
placeholder="Confirm password"
|
||||||
ref={passwordInputRef}
|
ref={passwordInputRef}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
/>
|
/>
|
||||||
{error ? <div className="color-dark-red my-2">{error}</div> : null}
|
{error ? <div className="color-danger my-2">{error}</div> : null}
|
||||||
<Button
|
<Button
|
||||||
className="btn-w-full mt-1 mb-3"
|
className="btn-w-full mt-1 mb-3"
|
||||||
label={isRegistering ? 'Creating account...' : 'Create account & sign in'}
|
label={isRegistering ? 'Creating account...' : 'Create account & sign in'}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={handleConfirmFormSubmit}
|
onClick={handleConfirmFormSubmit}
|
||||||
disabled={isRegistering}
|
disabled={isRegistering}
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
name="is-ephemeral"
|
||||||
|
label="Stay signed in"
|
||||||
|
checked={!isEphemeral}
|
||||||
|
onChange={handleEphemeralChange}
|
||||||
|
disabled={isRegistering}
|
||||||
|
/>
|
||||||
|
{notesAndTagsCount > 0 ? (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
name="is-ephemeral"
|
name="should-merge-local"
|
||||||
label="Stay signed in"
|
label={`Merge local data (${notesAndTagsCount} notes and tags)`}
|
||||||
checked={!isEphemeral}
|
checked={shouldMergeLocal}
|
||||||
onChange={handleEphemeralChange}
|
onChange={handleShouldMergeChange}
|
||||||
disabled={isRegistering}
|
disabled={isRegistering}
|
||||||
/>
|
/>
|
||||||
{notesAndTagsCount > 0 ? (
|
) : null}
|
||||||
<Checkbox
|
</form>
|
||||||
name="should-merge-local"
|
</>
|
||||||
label={`Merge local data (${notesAndTagsCount} notes and tags)`}
|
)
|
||||||
checked={shouldMergeLocal}
|
}
|
||||||
onChange={handleShouldMergeChange}
|
|
||||||
disabled={isRegistering}
|
export default observer(ConfirmPassword)
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,140 +1,147 @@
|
|||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { StateUpdater, useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
import { AccountMenuPane } from './AccountMenuPane'
|
||||||
import { AccountMenuPane } from '.'
|
import Button from '@/Components/Button/Button'
|
||||||
import { Button } from '@/Components/Button/Button'
|
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
|
||||||
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
import IconButton from '@/Components/Button/IconButton'
|
||||||
import { IconButton } from '@/Components/Button/IconButton'
|
import AdvancedOptions from './AdvancedOptions'
|
||||||
import { AdvancedOptions } from './AdvancedOptions'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
setMenuPane: (pane: AccountMenuPane) => void
|
setMenuPane: (pane: AccountMenuPane) => void
|
||||||
email: string
|
email: string
|
||||||
setEmail: StateUpdater<string>
|
setEmail: React.Dispatch<React.SetStateAction<string>>
|
||||||
password: string
|
password: string
|
||||||
setPassword: StateUpdater<string>
|
setPassword: React.Dispatch<React.SetStateAction<string>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateAccount: FunctionComponent<Props> = observer(
|
const CreateAccount: FunctionComponent<Props> = ({
|
||||||
({ appState, application, setMenuPane, email, setEmail, password, setPassword }) => {
|
viewControllerManager,
|
||||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
application,
|
||||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
setMenuPane,
|
||||||
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
|
email,
|
||||||
|
setEmail,
|
||||||
|
password,
|
||||||
|
setPassword,
|
||||||
|
}) => {
|
||||||
|
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (emailInputRef.current) {
|
if (emailInputRef.current) {
|
||||||
|
emailInputRef.current?.focus()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleEmailChange = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
setEmail(text)
|
||||||
|
},
|
||||||
|
[setEmail],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePasswordChange = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
setPassword(text)
|
||||||
|
},
|
||||||
|
[setPassword],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRegisterFormSubmit = useCallback(
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!email || email.length === 0) {
|
||||||
emailInputRef.current?.focus()
|
emailInputRef.current?.focus()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleEmailChange = useCallback(
|
if (!password || password.length === 0) {
|
||||||
(text: string) => {
|
passwordInputRef.current?.focus()
|
||||||
setEmail(text)
|
return
|
||||||
},
|
}
|
||||||
[setEmail],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handlePasswordChange = useCallback(
|
setEmail(email)
|
||||||
(text: string) => {
|
setPassword(password)
|
||||||
setPassword(text)
|
setMenuPane(AccountMenuPane.ConfirmPassword)
|
||||||
},
|
},
|
||||||
[setPassword],
|
[email, password, setPassword, setMenuPane, setEmail],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleRegisterFormSubmit = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
(e: Event) => {
|
(e) => {
|
||||||
e.preventDefault()
|
if (e.key === 'Enter') {
|
||||||
|
handleRegisterFormSubmit(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleRegisterFormSubmit],
|
||||||
|
)
|
||||||
|
|
||||||
if (!email || email.length === 0) {
|
const handleClose = useCallback(() => {
|
||||||
emailInputRef.current?.focus()
|
setMenuPane(AccountMenuPane.GeneralMenu)
|
||||||
return
|
setEmail('')
|
||||||
}
|
setPassword('')
|
||||||
|
}, [setEmail, setMenuPane, setPassword])
|
||||||
|
|
||||||
if (!password || password.length === 0) {
|
const onPrivateWorkspaceChange = useCallback(
|
||||||
passwordInputRef.current?.focus()
|
(isPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
|
||||||
return
|
setIsPrivateWorkspace(isPrivateWorkspace)
|
||||||
}
|
if (isPrivateWorkspace && privateWorkspaceIdentifier) {
|
||||||
|
setEmail(privateWorkspaceIdentifier)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setEmail],
|
||||||
|
)
|
||||||
|
|
||||||
setEmail(email)
|
return (
|
||||||
setPassword(password)
|
<>
|
||||||
setMenuPane(AccountMenuPane.ConfirmPassword)
|
<div className="flex items-center px-3 mt-1 mb-3">
|
||||||
},
|
<IconButton
|
||||||
[email, password, setPassword, setMenuPane, setEmail],
|
icon="arrow-left"
|
||||||
)
|
title="Go back"
|
||||||
|
className="flex mr-2 color-neutral p-0"
|
||||||
const handleKeyDown = useCallback(
|
onClick={handleClose}
|
||||||
(e: KeyboardEvent) => {
|
focusable={true}
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleRegisterFormSubmit(e)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleRegisterFormSubmit],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
setMenuPane(AccountMenuPane.GeneralMenu)
|
|
||||||
setEmail('')
|
|
||||||
setPassword('')
|
|
||||||
}, [setEmail, setMenuPane, setPassword])
|
|
||||||
|
|
||||||
const onPrivateWorkspaceChange = useCallback(
|
|
||||||
(isPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
|
|
||||||
setIsPrivateWorkspace(isPrivateWorkspace)
|
|
||||||
if (isPrivateWorkspace && privateWorkspaceIdentifier) {
|
|
||||||
setEmail(privateWorkspaceIdentifier)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setEmail],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center px-3 mt-1 mb-3">
|
|
||||||
<IconButton
|
|
||||||
icon="arrow-left"
|
|
||||||
title="Go back"
|
|
||||||
className="flex mr-2 color-neutral p-0"
|
|
||||||
onClick={handleClose}
|
|
||||||
focusable={true}
|
|
||||||
/>
|
|
||||||
<div className="sn-account-menu-headline">Create account</div>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleRegisterFormSubmit} className="px-3 mb-1">
|
|
||||||
<DecoratedInput
|
|
||||||
className="mb-2"
|
|
||||||
disabled={isPrivateWorkspace}
|
|
||||||
left={[<Icon type="email" className="color-neutral" />]}
|
|
||||||
onChange={handleEmailChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Email"
|
|
||||||
ref={emailInputRef}
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
/>
|
|
||||||
<DecoratedPasswordInput
|
|
||||||
className="mb-2"
|
|
||||||
left={[<Icon type="password" className="color-neutral" />]}
|
|
||||||
onChange={handlePasswordChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Password"
|
|
||||||
ref={passwordInputRef}
|
|
||||||
value={password}
|
|
||||||
/>
|
|
||||||
<Button className="btn-w-full mt-1" label="Next" variant="primary" onClick={handleRegisterFormSubmit} />
|
|
||||||
</form>
|
|
||||||
<div className="h-1px my-2 bg-border"></div>
|
|
||||||
<AdvancedOptions
|
|
||||||
application={application}
|
|
||||||
appState={appState}
|
|
||||||
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
|
|
||||||
/>
|
/>
|
||||||
</>
|
<div className="sn-account-menu-headline">Create account</div>
|
||||||
)
|
</div>
|
||||||
},
|
<form onSubmit={handleRegisterFormSubmit} className="px-3 mb-1">
|
||||||
)
|
<DecoratedInput
|
||||||
|
className="mb-2"
|
||||||
|
disabled={isPrivateWorkspace}
|
||||||
|
left={[<Icon type="email" className="color-neutral" />]}
|
||||||
|
onChange={handleEmailChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Email"
|
||||||
|
ref={emailInputRef}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
/>
|
||||||
|
<DecoratedPasswordInput
|
||||||
|
className="mb-2"
|
||||||
|
left={[<Icon type="password" className="color-neutral" />]}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Password"
|
||||||
|
ref={passwordInputRef}
|
||||||
|
value={password}
|
||||||
|
/>
|
||||||
|
<Button className="btn-w-full mt-1" label="Next" variant="primary" onClick={handleRegisterFormSubmit} />
|
||||||
|
</form>
|
||||||
|
<div className="h-1px my-2 bg-border"></div>
|
||||||
|
<AdvancedOptions
|
||||||
|
application={application}
|
||||||
|
viewControllerManager={viewControllerManager}
|
||||||
|
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(CreateAccount)
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { Icon } from '@/Components/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { formatLastSyncDate } from '@/Components/Preferences/Panes/Account/Sync'
|
|
||||||
import { SyncQueueStrategy } from '@standardnotes/snjs'
|
import { SyncQueueStrategy } from '@standardnotes/snjs'
|
||||||
import { STRING_GENERIC_SYNC_ERROR } from '@/Strings'
|
import { STRING_GENERIC_SYNC_ERROR } from '@/Constants/Strings'
|
||||||
import { useCallback, useMemo, useState } from 'preact/hooks'
|
import { useCallback, useMemo, useState, FunctionComponent } from 'react'
|
||||||
import { AccountMenuPane } from '.'
|
import { AccountMenuPane } from './AccountMenuPane'
|
||||||
import { FunctionComponent } from 'preact'
|
import Menu from '@/Components/Menu/Menu'
|
||||||
import { Menu } from '@/Components/Menu/Menu'
|
import MenuItem from '@/Components/Menu/MenuItem'
|
||||||
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem'
|
import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
|
||||||
import { WorkspaceSwitcherOption } from './WorkspaceSwitcher/WorkspaceSwitcherOption'
|
import { MenuItemType } from '@/Components/Menu/MenuItemType'
|
||||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
import WorkspaceSwitcherOption from './WorkspaceSwitcher/WorkspaceSwitcherOption'
|
||||||
|
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||||
|
import { formatLastSyncDate } from '@/Utils/FormatLastSyncDate'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
mainApplicationGroup: ApplicationGroup
|
mainApplicationGroup: ApplicationGroup
|
||||||
setMenuPane: (pane: AccountMenuPane) => void
|
setMenuPane: (pane: AccountMenuPane) => void
|
||||||
@@ -23,156 +24,165 @@ type Props = {
|
|||||||
|
|
||||||
const iconClassName = 'color-neutral mr-2'
|
const iconClassName = 'color-neutral mr-2'
|
||||||
|
|
||||||
export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
const GeneralAccountMenu: FunctionComponent<Props> = ({
|
||||||
({ application, appState, setMenuPane, closeMenu, mainApplicationGroup }) => {
|
application,
|
||||||
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
|
viewControllerManager,
|
||||||
const [lastSyncDate, setLastSyncDate] = useState(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
|
setMenuPane,
|
||||||
|
closeMenu,
|
||||||
|
mainApplicationGroup,
|
||||||
|
}) => {
|
||||||
|
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
|
||||||
|
const [lastSyncDate, setLastSyncDate] = useState(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
|
||||||
|
|
||||||
const doSynchronization = useCallback(async () => {
|
const doSynchronization = useCallback(async () => {
|
||||||
setIsSyncingInProgress(true)
|
setIsSyncingInProgress(true)
|
||||||
|
|
||||||
application.sync
|
application.sync
|
||||||
.sync({
|
.sync({
|
||||||
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
|
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
|
||||||
checkIntegrity: true,
|
checkIntegrity: true,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res && (res as any).error) {
|
if (res && (res as any).error) {
|
||||||
throw new Error()
|
throw new Error()
|
||||||
} else {
|
} else {
|
||||||
setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
|
setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
|
application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsSyncingInProgress(false)
|
setIsSyncingInProgress(false)
|
||||||
})
|
})
|
||||||
}, [application])
|
}, [application])
|
||||||
|
|
||||||
const user = useMemo(() => application.getUser(), [application])
|
const user = useMemo(() => application.getUser(), [application])
|
||||||
|
|
||||||
const openPreferences = useCallback(() => {
|
const openPreferences = useCallback(() => {
|
||||||
appState.accountMenu.closeAccountMenu()
|
viewControllerManager.accountMenuController.closeAccountMenu()
|
||||||
appState.preferences.setCurrentPane('account')
|
viewControllerManager.preferencesController.setCurrentPane('account')
|
||||||
appState.preferences.openPreferences()
|
viewControllerManager.preferencesController.openPreferences()
|
||||||
}, [appState])
|
}, [viewControllerManager])
|
||||||
|
|
||||||
const openHelp = useCallback(() => {
|
const openHelp = useCallback(() => {
|
||||||
appState.accountMenu.closeAccountMenu()
|
viewControllerManager.accountMenuController.closeAccountMenu()
|
||||||
appState.preferences.setCurrentPane('help-feedback')
|
viewControllerManager.preferencesController.setCurrentPane('help-feedback')
|
||||||
appState.preferences.openPreferences()
|
viewControllerManager.preferencesController.openPreferences()
|
||||||
}, [appState])
|
}, [viewControllerManager])
|
||||||
|
|
||||||
const signOut = useCallback(() => {
|
const signOut = useCallback(() => {
|
||||||
appState.accountMenu.setSigningOut(true)
|
viewControllerManager.accountMenuController.setSigningOut(true)
|
||||||
}, [appState])
|
}, [viewControllerManager])
|
||||||
|
|
||||||
const activateRegisterPane = useCallback(() => {
|
const activateRegisterPane = useCallback(() => {
|
||||||
setMenuPane(AccountMenuPane.Register)
|
setMenuPane(AccountMenuPane.Register)
|
||||||
}, [setMenuPane])
|
}, [setMenuPane])
|
||||||
|
|
||||||
const activateSignInPane = useCallback(() => {
|
const activateSignInPane = useCallback(() => {
|
||||||
setMenuPane(AccountMenuPane.SignIn)
|
setMenuPane(AccountMenuPane.SignIn)
|
||||||
}, [setMenuPane])
|
}, [setMenuPane])
|
||||||
|
|
||||||
const CREATE_ACCOUNT_INDEX = 1
|
const CREATE_ACCOUNT_INDEX = 1
|
||||||
const SWITCHER_INDEX = 0
|
const SWITCHER_INDEX = 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between px-3 mt-1 mb-1">
|
<div className="flex items-center justify-between px-3 mt-1 mb-1">
|
||||||
<div className="sn-account-menu-headline">Account</div>
|
<div className="sn-account-menu-headline">Account</div>
|
||||||
<div className="flex cursor-pointer" onClick={closeMenu}>
|
<div className="flex cursor-pointer" onClick={closeMenu}>
|
||||||
<Icon type="close" className="color-neutral" />
|
<Icon type="close" className="color-neutral" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{user ? (
|
</div>
|
||||||
<>
|
{user ? (
|
||||||
<div className="px-3 mb-3 color-foreground text-sm">
|
<>
|
||||||
<div>You're signed in as:</div>
|
<div className="px-3 mb-3 color-foreground text-sm">
|
||||||
<div className="my-0.5 font-bold wrap">{user.email}</div>
|
<div>You're signed in as:</div>
|
||||||
<span className="color-neutral">{application.getHost()}</span>
|
<div className="my-0.5 font-bold wrap">{user.email}</div>
|
||||||
</div>
|
<span className="color-neutral">{application.getHost()}</span>
|
||||||
<div className="flex items-start justify-between px-3 mb-3">
|
</div>
|
||||||
{isSyncingInProgress ? (
|
<div className="flex items-start justify-between px-3 mb-3">
|
||||||
<div className="flex items-center color-info font-semibold">
|
{isSyncingInProgress ? (
|
||||||
<div className="sk-spinner w-5 h-5 mr-2 spinner-info"></div>
|
<div className="flex items-center color-info font-semibold">
|
||||||
Syncing...
|
<div className="sk-spinner w-5 h-5 mr-2 spinner-info"></div>
|
||||||
</div>
|
Syncing...
|
||||||
) : (
|
|
||||||
<div className="flex items-start">
|
|
||||||
<Icon type="check-circle" className="mr-2 success" />
|
|
||||||
<div>
|
|
||||||
<div class="font-semibold success">Last synced:</div>
|
|
||||||
<div class="color-text">{lastSyncDate}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex cursor-pointer color-grey-1" onClick={doSynchronization}>
|
|
||||||
<Icon type="sync" />
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start">
|
||||||
|
<Icon type="check-circle" className="mr-2 success" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold success">Last synced:</div>
|
||||||
|
<div className="color-text">{lastSyncDate}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex cursor-pointer color-passive-1" onClick={doSynchronization}>
|
||||||
|
<Icon type="sync" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="px-3 mb-1">
|
||||||
|
<div className="mb-3 color-foreground">
|
||||||
|
You’re offline. Sign in to sync your notes and preferences across all your devices and enable end-to-end
|
||||||
|
encryption.
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center color-passive-1">
|
||||||
|
<Icon type="cloud-off" className="mr-2" />
|
||||||
|
<span className="font-semibold">Offline</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Menu
|
||||||
|
isOpen={viewControllerManager.accountMenuController.show}
|
||||||
|
a11yLabel="General account menu"
|
||||||
|
closeMenu={closeMenu}
|
||||||
|
initialFocus={!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX}
|
||||||
|
>
|
||||||
|
<MenuItemSeparator />
|
||||||
|
<WorkspaceSwitcherOption
|
||||||
|
mainApplicationGroup={mainApplicationGroup}
|
||||||
|
viewControllerManager={viewControllerManager}
|
||||||
|
/>
|
||||||
|
<MenuItemSeparator />
|
||||||
|
{user ? (
|
||||||
|
<MenuItem type={MenuItemType.IconButton} onClick={openPreferences}>
|
||||||
|
<Icon type="user" className={iconClassName} />
|
||||||
|
Account settings
|
||||||
|
</MenuItem>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 mb-1">
|
<MenuItem type={MenuItemType.IconButton} onClick={activateRegisterPane}>
|
||||||
<div className="mb-3 color-foreground">
|
<Icon type="user" className={iconClassName} />
|
||||||
You’re offline. Sign in to sync your notes and preferences across all your devices and enable end-to-end
|
Create free account
|
||||||
encryption.
|
</MenuItem>
|
||||||
</div>
|
<MenuItem type={MenuItemType.IconButton} onClick={activateSignInPane}>
|
||||||
<div className="flex items-center color-grey-1">
|
<Icon type="signIn" className={iconClassName} />
|
||||||
<Icon type="cloud-off" className="mr-2" />
|
Sign in
|
||||||
<span className="font-semibold">Offline</span>
|
</MenuItem>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Menu
|
<MenuItem className="justify-between" type={MenuItemType.IconButton} onClick={openHelp}>
|
||||||
isOpen={appState.accountMenu.show}
|
<div className="flex items-center">
|
||||||
a11yLabel="General account menu"
|
<Icon type="help" className={iconClassName} />
|
||||||
closeMenu={closeMenu}
|
Help & feedback
|
||||||
initialFocus={!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX}
|
</div>
|
||||||
>
|
<span className="color-neutral">v{application.version}</span>
|
||||||
<MenuItemSeparator />
|
</MenuItem>
|
||||||
<WorkspaceSwitcherOption mainApplicationGroup={mainApplicationGroup} appState={appState} />
|
{user ? (
|
||||||
<MenuItemSeparator />
|
<>
|
||||||
{user ? (
|
<MenuItemSeparator />
|
||||||
<MenuItem type={MenuItemType.IconButton} onClick={openPreferences}>
|
<MenuItem type={MenuItemType.IconButton} onClick={signOut}>
|
||||||
<Icon type="user" className={iconClassName} />
|
<Icon type="signOut" className={iconClassName} />
|
||||||
Account settings
|
Sign out workspace
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : (
|
</>
|
||||||
<>
|
) : null}
|
||||||
<MenuItem type={MenuItemType.IconButton} onClick={activateRegisterPane}>
|
</Menu>
|
||||||
<Icon type="user" className={iconClassName} />
|
</>
|
||||||
Create free account
|
)
|
||||||
</MenuItem>
|
}
|
||||||
<MenuItem type={MenuItemType.IconButton} onClick={activateSignInPane}>
|
|
||||||
<Icon type="signIn" className={iconClassName} />
|
export default observer(GeneralAccountMenu)
|
||||||
Sign in
|
|
||||||
</MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<MenuItem className="justify-between" type={MenuItemType.IconButton} onClick={openHelp}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Icon type="help" className={iconClassName} />
|
|
||||||
Help & feedback
|
|
||||||
</div>
|
|
||||||
<span className="color-neutral">v{appState.version}</span>
|
|
||||||
</MenuItem>
|
|
||||||
{user ? (
|
|
||||||
<>
|
|
||||||
<MenuItemSeparator />
|
|
||||||
<MenuItem type={MenuItemType.IconButton} onClick={signOut}>
|
|
||||||
<Icon type="signOut" className={iconClassName} />
|
|
||||||
Sign out workspace
|
|
||||||
</MenuItem>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||||
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { FunctionComponent, useState } from 'react'
|
||||||
|
import { AccountMenuPane } from './AccountMenuPane'
|
||||||
|
import ConfirmPassword from './ConfirmPassword'
|
||||||
|
import CreateAccount from './CreateAccount'
|
||||||
|
import GeneralAccountMenu from './GeneralAccountMenu'
|
||||||
|
import SignInPane from './SignIn'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
viewControllerManager: ViewControllerManager
|
||||||
|
application: WebApplication
|
||||||
|
mainApplicationGroup: ApplicationGroup
|
||||||
|
menuPane: AccountMenuPane
|
||||||
|
setMenuPane: (pane: AccountMenuPane) => void
|
||||||
|
closeMenu: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuPaneSelector: FunctionComponent<Props> = ({
|
||||||
|
application,
|
||||||
|
viewControllerManager,
|
||||||
|
menuPane,
|
||||||
|
setMenuPane,
|
||||||
|
closeMenu,
|
||||||
|
mainApplicationGroup,
|
||||||
|
}) => {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
|
||||||
|
switch (menuPane) {
|
||||||
|
case AccountMenuPane.GeneralMenu:
|
||||||
|
return (
|
||||||
|
<GeneralAccountMenu
|
||||||
|
viewControllerManager={viewControllerManager}
|
||||||
|
application={application}
|
||||||
|
mainApplicationGroup={mainApplicationGroup}
|
||||||
|
setMenuPane={setMenuPane}
|
||||||
|
closeMenu={closeMenu}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case AccountMenuPane.SignIn:
|
||||||
|
return (
|
||||||
|
<SignInPane viewControllerManager={viewControllerManager} application={application} setMenuPane={setMenuPane} />
|
||||||
|
)
|
||||||
|
case AccountMenuPane.Register:
|
||||||
|
return (
|
||||||
|
<CreateAccount
|
||||||
|
viewControllerManager={viewControllerManager}
|
||||||
|
application={application}
|
||||||
|
setMenuPane={setMenuPane}
|
||||||
|
email={email}
|
||||||
|
setEmail={setEmail}
|
||||||
|
password={password}
|
||||||
|
setPassword={setPassword}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case AccountMenuPane.ConfirmPassword:
|
||||||
|
return (
|
||||||
|
<ConfirmPassword
|
||||||
|
viewControllerManager={viewControllerManager}
|
||||||
|
application={application}
|
||||||
|
setMenuPane={setMenuPane}
|
||||||
|
email={email}
|
||||||
|
password={password}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(MenuPaneSelector)
|
||||||
@@ -1,26 +1,25 @@
|
|||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { isDev } from '@/Utils'
|
import { isDev } from '@/Utils'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'preact'
|
import React, { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
import { AccountMenuPane } from './AccountMenuPane'
|
||||||
import { AccountMenuPane } from '.'
|
import Button from '@/Components/Button/Button'
|
||||||
import { Button } from '@/Components/Button/Button'
|
import Checkbox from '@/Components/Checkbox/Checkbox'
|
||||||
import { Checkbox } from '@/Components/Checkbox'
|
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
|
||||||
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
import IconButton from '@/Components/Button/IconButton'
|
||||||
import { IconButton } from '@/Components/Button/IconButton'
|
import AdvancedOptions from './AdvancedOptions'
|
||||||
import { AdvancedOptions } from './AdvancedOptions'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
setMenuPane: (pane: AccountMenuPane) => void
|
setMenuPane: (pane: AccountMenuPane) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SignInPane: FunctionComponent<Props> = observer(({ application, appState, setMenuPane }) => {
|
const SignInPane: FunctionComponent<Props> = ({ application, viewControllerManager, setMenuPane }) => {
|
||||||
const { notesAndTagsCount } = appState.accountMenu
|
const { notesAndTagsCount } = viewControllerManager.accountMenuController
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -87,7 +86,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
|
|||||||
if (res.error) {
|
if (res.error) {
|
||||||
throw new Error(res.error.message)
|
throw new Error(res.error.message)
|
||||||
}
|
}
|
||||||
appState.accountMenu.closeAccountMenu()
|
viewControllerManager.accountMenuController.closeAccountMenu()
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
@@ -98,7 +97,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsSigningIn(false)
|
setIsSigningIn(false)
|
||||||
})
|
})
|
||||||
}, [appState, application, email, isEphemeral, isStrictSignin, password, shouldMergeLocal])
|
}, [viewControllerManager, application, email, isEphemeral, isStrictSignin, password, shouldMergeLocal])
|
||||||
|
|
||||||
const onPrivateWorkspaceChange = useCallback(
|
const onPrivateWorkspaceChange = useCallback(
|
||||||
(newIsPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
|
(newIsPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
|
||||||
@@ -111,7 +110,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleSignInFormSubmit = useCallback(
|
const handleSignInFormSubmit = useCallback(
|
||||||
(e: Event) => {
|
(e: React.SyntheticEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (!email || email.length === 0) {
|
if (!email || email.length === 0) {
|
||||||
@@ -129,8 +128,8 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
|
|||||||
[email, password, signIn],
|
[email, password, signIn],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
handleSignInFormSubmit(e)
|
handleSignInFormSubmit(e)
|
||||||
}
|
}
|
||||||
@@ -153,7 +152,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-3 mb-1">
|
<div className="px-3 mb-1">
|
||||||
<DecoratedInput
|
<DecoratedInput
|
||||||
className={`mb-2 ${error ? 'border-dark-red' : null}`}
|
className={`mb-2 ${error ? 'border-danger' : null}`}
|
||||||
left={[<Icon type="email" className="color-neutral" />]}
|
left={[<Icon type="email" className="color-neutral" />]}
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
@@ -165,7 +164,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
|
|||||||
ref={emailInputRef}
|
ref={emailInputRef}
|
||||||
/>
|
/>
|
||||||
<DecoratedPasswordInput
|
<DecoratedPasswordInput
|
||||||
className={`mb-2 ${error ? 'border-dark-red' : null}`}
|
className={`mb-2 ${error ? 'border-danger' : null}`}
|
||||||
disabled={isSigningIn}
|
disabled={isSigningIn}
|
||||||
left={[<Icon type="password" className="color-neutral" />]}
|
left={[<Icon type="password" className="color-neutral" />]}
|
||||||
onChange={handlePasswordChange}
|
onChange={handlePasswordChange}
|
||||||
@@ -175,7 +174,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
|
|||||||
ref={passwordInputRef}
|
ref={passwordInputRef}
|
||||||
value={password}
|
value={password}
|
||||||
/>
|
/>
|
||||||
{error ? <div className="color-dark-red my-2">{error}</div> : null}
|
{error ? <div className="color-danger my-2">{error}</div> : null}
|
||||||
<Button
|
<Button
|
||||||
className="btn-w-full mt-1 mb-3"
|
className="btn-w-full mt-1 mb-3"
|
||||||
label={isSigningIn ? 'Signing in...' : 'Sign in'}
|
label={isSigningIn ? 'Signing in...' : 'Sign in'}
|
||||||
@@ -202,7 +201,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-1px my-2 bg-border"></div>
|
<div className="h-1px my-2 bg-border"></div>
|
||||||
<AdvancedOptions
|
<AdvancedOptions
|
||||||
appState={appState}
|
viewControllerManager={viewControllerManager}
|
||||||
application={application}
|
application={application}
|
||||||
disabled={isSigningIn}
|
disabled={isSigningIn}
|
||||||
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
|
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
|
||||||
@@ -210,4 +209,6 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export default observer(SignInPane)
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { User as UserType } from '@standardnotes/snjs'
|
import { User as UserType } from '@standardnotes/snjs'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
}
|
}
|
||||||
|
|
||||||
const User = observer(({ appState, application }: Props) => {
|
const User = ({ viewControllerManager, application }: Props) => {
|
||||||
const { server } = appState.accountMenu
|
const { server } = viewControllerManager.accountMenuController
|
||||||
const user = application.getUser() as UserType
|
const user = application.getUser() as UserType
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sk-panel-section">
|
<div className="sk-panel-section">
|
||||||
{appState.sync.errorMessage && (
|
{viewControllerManager.syncStatusController.errorMessage && (
|
||||||
<div className="sk-notification danger">
|
<div className="sk-notification danger">
|
||||||
<div className="sk-notification-title">Sync Unreachable</div>
|
<div className="sk-notification-title">Sync Unreachable</div>
|
||||||
<div className="sk-notification-text">
|
<div className="sk-notification-text">
|
||||||
Hmm...we can't seem to sync your account. The reason: {appState.sync.errorMessage}
|
Hmm...we can't seem to sync your account. The reason:{' '}
|
||||||
|
{viewControllerManager.syncStatusController.errorMessage}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
className="sk-a info-contrast sk-bold sk-panel-row"
|
className="sk-a info-contrast sk-bold sk-panel-row"
|
||||||
@@ -39,6 +40,6 @@ const User = observer(({ appState, application }: Props) => {
|
|||||||
<div className="sk-panel-row" />
|
<div className="sk-panel-row" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
export default User
|
export default observer(User)
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { Icon } from '@/Components/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { MenuItem, MenuItemType } from '@/Components/Menu/MenuItem'
|
import MenuItem from '@/Components/Menu/MenuItem'
|
||||||
|
import { MenuItemType } from '@/Components/Menu/MenuItemType'
|
||||||
import { KeyboardKey } from '@/Services/IOService'
|
import { KeyboardKey } from '@/Services/IOService'
|
||||||
import { ApplicationDescriptor } from '@standardnotes/snjs'
|
import { ApplicationDescriptor } from '@standardnotes/snjs'
|
||||||
import { FunctionComponent } from 'preact'
|
import {
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
ChangeEventHandler,
|
||||||
|
FocusEventHandler,
|
||||||
|
FunctionComponent,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
descriptor: ApplicationDescriptor
|
descriptor: ApplicationDescriptor
|
||||||
@@ -13,7 +22,7 @@ type Props = {
|
|||||||
hideOptions: boolean
|
hideOptions: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
||||||
descriptor,
|
descriptor,
|
||||||
onClick,
|
onClick,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -21,6 +30,7 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
|||||||
hideOptions,
|
hideOptions,
|
||||||
}) => {
|
}) => {
|
||||||
const [isRenaming, setIsRenaming] = useState(false)
|
const [isRenaming, setIsRenaming] = useState(false)
|
||||||
|
const [inputValue, setInputValue] = useState(descriptor.label)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,20 +39,21 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [isRenaming])
|
}, [isRenaming])
|
||||||
|
|
||||||
const handleInputKeyDown = useCallback((event: KeyboardEvent) => {
|
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback((event) => {
|
||||||
|
setInputValue(event.target.value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleInputKeyDown: KeyboardEventHandler = useCallback((event) => {
|
||||||
if (event.key === KeyboardKey.Enter) {
|
if (event.key === KeyboardKey.Enter) {
|
||||||
inputRef.current?.blur()
|
inputRef.current?.blur()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleInputBlur = useCallback(
|
const handleInputBlur: FocusEventHandler<HTMLInputElement> = useCallback(() => {
|
||||||
(event: FocusEvent) => {
|
renameDescriptor(inputValue)
|
||||||
const name = (event.target as HTMLInputElement).value
|
setIsRenaming(false)
|
||||||
renameDescriptor(name)
|
setInputValue('')
|
||||||
setIsRenaming(false)
|
}, [inputValue, renameDescriptor])
|
||||||
},
|
|
||||||
[renameDescriptor],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -51,12 +62,13 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
checked={descriptor.primary}
|
checked={descriptor.primary}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full ml-2">
|
||||||
{isRenaming ? (
|
{isRenaming ? (
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={descriptor.label}
|
value={inputValue}
|
||||||
|
onChange={handleChange}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
/>
|
/>
|
||||||
@@ -65,7 +77,8 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
|||||||
)}
|
)}
|
||||||
{descriptor.primary && !hideOptions && (
|
{descriptor.primary && !hideOptions && (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<a
|
||||||
|
role="button"
|
||||||
className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -73,8 +86,9 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon type="pencil" className="sn-icon--mid color-neutral" />
|
<Icon type="pencil" className="sn-icon--mid color-neutral" />
|
||||||
</button>
|
</a>
|
||||||
<button
|
<a
|
||||||
|
role="button"
|
||||||
className="w-5 h-5 p-0 border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
className="w-5 h-5 p-0 border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -82,10 +96,12 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon type="trash" className="sn-icon--mid color-danger" />
|
<Icon type="trash" className="sn-icon--mid color-danger" />
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default WorkspaceMenuItem
|
||||||
|
|||||||
@@ -1,89 +1,95 @@
|
|||||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { ApplicationDescriptor, ApplicationGroupEvent, ButtonType } from '@standardnotes/snjs'
|
import { ApplicationDescriptor, ApplicationGroupEvent, ButtonType } from '@standardnotes/snjs'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
import Menu from '@/Components/Menu/Menu'
|
||||||
import { Menu } from '@/Components/Menu/Menu'
|
import MenuItem from '@/Components/Menu/MenuItem'
|
||||||
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem'
|
import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
|
||||||
import { WorkspaceMenuItem } from './WorkspaceMenuItem'
|
import { MenuItemType } from '@/Components/Menu/MenuItemType'
|
||||||
|
import WorkspaceMenuItem from './WorkspaceMenuItem'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mainApplicationGroup: ApplicationGroup
|
mainApplicationGroup: ApplicationGroup
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
hideWorkspaceOptions?: boolean
|
hideWorkspaceOptions?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceSwitcherMenu: FunctionComponent<Props> = observer(
|
const WorkspaceSwitcherMenu: FunctionComponent<Props> = ({
|
||||||
({ mainApplicationGroup, appState, isOpen, hideWorkspaceOptions = false }: Props) => {
|
mainApplicationGroup,
|
||||||
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>([])
|
viewControllerManager,
|
||||||
|
isOpen,
|
||||||
|
hideWorkspaceOptions = false,
|
||||||
|
}: Props) => {
|
||||||
|
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const applicationDescriptors = mainApplicationGroup.getDescriptors()
|
const applicationDescriptors = mainApplicationGroup.getDescriptors()
|
||||||
setApplicationDescriptors(applicationDescriptors)
|
setApplicationDescriptors(applicationDescriptors)
|
||||||
|
|
||||||
const removeAppGroupObserver = mainApplicationGroup.addEventObserver((event) => {
|
const removeAppGroupObserver = mainApplicationGroup.addEventObserver((event) => {
|
||||||
if (event === ApplicationGroupEvent.DescriptorsDataChanged) {
|
if (event === ApplicationGroupEvent.DescriptorsDataChanged) {
|
||||||
const applicationDescriptors = mainApplicationGroup.getDescriptors()
|
const applicationDescriptors = mainApplicationGroup.getDescriptors()
|
||||||
setApplicationDescriptors(applicationDescriptors)
|
setApplicationDescriptors(applicationDescriptors)
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
removeAppGroupObserver()
|
|
||||||
}
|
}
|
||||||
}, [mainApplicationGroup])
|
})
|
||||||
|
|
||||||
const signoutAll = useCallback(async () => {
|
return () => {
|
||||||
const confirmed = await appState.application.alertService.confirm(
|
removeAppGroupObserver()
|
||||||
'Are you sure you want to sign out of all workspaces on this device?',
|
}
|
||||||
undefined,
|
}, [mainApplicationGroup])
|
||||||
'Sign out all',
|
|
||||||
ButtonType.Danger,
|
|
||||||
)
|
|
||||||
if (!confirmed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mainApplicationGroup.signOutAllWorkspaces().catch(console.error)
|
|
||||||
}, [mainApplicationGroup, appState])
|
|
||||||
|
|
||||||
const destroyWorkspace = useCallback(() => {
|
const signoutAll = useCallback(async () => {
|
||||||
appState.accountMenu.setSigningOut(true)
|
const confirmed = await viewControllerManager.application.alertService.confirm(
|
||||||
}, [appState])
|
'Are you sure you want to sign out of all workspaces on this device?',
|
||||||
|
undefined,
|
||||||
return (
|
'Sign out all',
|
||||||
<Menu a11yLabel="Workspace switcher menu" className="px-0 focus:shadow-none" isOpen={isOpen}>
|
ButtonType.Danger,
|
||||||
{applicationDescriptors.map((descriptor) => (
|
|
||||||
<WorkspaceMenuItem
|
|
||||||
key={descriptor.identifier}
|
|
||||||
descriptor={descriptor}
|
|
||||||
hideOptions={hideWorkspaceOptions}
|
|
||||||
onDelete={destroyWorkspace}
|
|
||||||
onClick={() => void mainApplicationGroup.unloadCurrentAndActivateDescriptor(descriptor)}
|
|
||||||
renameDescriptor={(label: string) => mainApplicationGroup.renameDescriptor(descriptor, label)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<MenuItemSeparator />
|
|
||||||
|
|
||||||
<MenuItem
|
|
||||||
type={MenuItemType.IconButton}
|
|
||||||
onClick={() => {
|
|
||||||
void mainApplicationGroup.unloadCurrentAndCreateNewDescriptor()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon type="user-add" className="color-neutral mr-2" />
|
|
||||||
Add another workspace
|
|
||||||
</MenuItem>
|
|
||||||
|
|
||||||
{!hideWorkspaceOptions && (
|
|
||||||
<MenuItem type={MenuItemType.IconButton} onClick={signoutAll}>
|
|
||||||
<Icon type="signOut" className="color-neutral mr-2" />
|
|
||||||
Sign out all workspaces
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
)
|
)
|
||||||
},
|
if (!confirmed) {
|
||||||
)
|
return
|
||||||
|
}
|
||||||
|
mainApplicationGroup.signOutAllWorkspaces().catch(console.error)
|
||||||
|
}, [mainApplicationGroup, viewControllerManager])
|
||||||
|
|
||||||
|
const destroyWorkspace = useCallback(() => {
|
||||||
|
viewControllerManager.accountMenuController.setSigningOut(true)
|
||||||
|
}, [viewControllerManager])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu a11yLabel="Workspace switcher menu" className="px-0 focus:shadow-none" isOpen={isOpen}>
|
||||||
|
{applicationDescriptors.map((descriptor) => (
|
||||||
|
<WorkspaceMenuItem
|
||||||
|
key={descriptor.identifier}
|
||||||
|
descriptor={descriptor}
|
||||||
|
hideOptions={hideWorkspaceOptions}
|
||||||
|
onDelete={destroyWorkspace}
|
||||||
|
onClick={() => void mainApplicationGroup.unloadCurrentAndActivateDescriptor(descriptor)}
|
||||||
|
renameDescriptor={(label: string) => mainApplicationGroup.renameDescriptor(descriptor, label)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<MenuItemSeparator />
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
type={MenuItemType.IconButton}
|
||||||
|
onClick={() => {
|
||||||
|
void mainApplicationGroup.unloadCurrentAndCreateNewDescriptor()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="user-add" className="color-neutral mr-2" />
|
||||||
|
Add another workspace
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
{!hideWorkspaceOptions && (
|
||||||
|
<MenuItem type={MenuItemType.IconButton} onClick={signoutAll}>
|
||||||
|
<Icon type="signOut" className="color-neutral mr-2" />
|
||||||
|
Sign out all workspaces
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(WorkspaceSwitcherMenu)
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
import WorkspaceSwitcherMenu from './WorkspaceSwitcherMenu'
|
||||||
import { WorkspaceSwitcherMenu } from './WorkspaceSwitcherMenu'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mainApplicationGroup: ApplicationGroup
|
mainApplicationGroup: ApplicationGroup
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(({ mainApplicationGroup, appState }) => {
|
const WorkspaceSwitcherOption: FunctionComponent<Props> = ({ mainApplicationGroup, viewControllerManager }) => {
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
@@ -59,9 +58,15 @@ export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(({ mai
|
|||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}>
|
<div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}>
|
||||||
<WorkspaceSwitcherMenu mainApplicationGroup={mainApplicationGroup} appState={appState} isOpen={isOpen} />
|
<WorkspaceSwitcherMenu
|
||||||
|
mainApplicationGroup={mainApplicationGroup}
|
||||||
|
viewControllerManager={viewControllerManager}
|
||||||
|
isOpen={isOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export default observer(WorkspaceSwitcherOption)
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
import { observer } from 'mobx-react-lite'
|
|
||||||
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
|
|
||||||
import { AppState } from '@/UIModels/AppState'
|
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
|
||||||
import { useCallback, useRef, useState } from 'preact/hooks'
|
|
||||||
import { GeneralAccountMenu } from './GeneralAccountMenu'
|
|
||||||
import { FunctionComponent } from 'preact'
|
|
||||||
import { SignInPane } from './SignIn'
|
|
||||||
import { CreateAccount } from './CreateAccount'
|
|
||||||
import { ConfirmPassword } from './ConfirmPassword'
|
|
||||||
import { JSXInternal } from 'preact/src/jsx'
|
|
||||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
|
||||||
|
|
||||||
export enum AccountMenuPane {
|
|
||||||
GeneralMenu,
|
|
||||||
SignIn,
|
|
||||||
Register,
|
|
||||||
ConfirmPassword,
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
appState: AppState
|
|
||||||
application: WebApplication
|
|
||||||
onClickOutside: () => void
|
|
||||||
mainApplicationGroup: ApplicationGroup
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaneSelectorProps = {
|
|
||||||
appState: AppState
|
|
||||||
application: WebApplication
|
|
||||||
mainApplicationGroup: ApplicationGroup
|
|
||||||
menuPane: AccountMenuPane
|
|
||||||
setMenuPane: (pane: AccountMenuPane) => void
|
|
||||||
closeMenu: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
|
|
||||||
({ application, appState, menuPane, setMenuPane, closeMenu, mainApplicationGroup }) => {
|
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
|
|
||||||
switch (menuPane) {
|
|
||||||
case AccountMenuPane.GeneralMenu:
|
|
||||||
return (
|
|
||||||
<GeneralAccountMenu
|
|
||||||
appState={appState}
|
|
||||||
application={application}
|
|
||||||
mainApplicationGroup={mainApplicationGroup}
|
|
||||||
setMenuPane={setMenuPane}
|
|
||||||
closeMenu={closeMenu}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case AccountMenuPane.SignIn:
|
|
||||||
return <SignInPane appState={appState} application={application} setMenuPane={setMenuPane} />
|
|
||||||
case AccountMenuPane.Register:
|
|
||||||
return (
|
|
||||||
<CreateAccount
|
|
||||||
appState={appState}
|
|
||||||
application={application}
|
|
||||||
setMenuPane={setMenuPane}
|
|
||||||
email={email}
|
|
||||||
setEmail={setEmail}
|
|
||||||
password={password}
|
|
||||||
setPassword={setPassword}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case AccountMenuPane.ConfirmPassword:
|
|
||||||
return (
|
|
||||||
<ConfirmPassword
|
|
||||||
appState={appState}
|
|
||||||
application={application}
|
|
||||||
setMenuPane={setMenuPane}
|
|
||||||
email={email}
|
|
||||||
password={password}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export const AccountMenu: FunctionComponent<Props> = observer(
|
|
||||||
({ application, appState, onClickOutside, mainApplicationGroup }) => {
|
|
||||||
const { currentPane, shouldAnimateCloseMenu } = appState.accountMenu
|
|
||||||
|
|
||||||
const closeAccountMenu = useCallback(() => {
|
|
||||||
appState.accountMenu.closeAccountMenu()
|
|
||||||
}, [appState])
|
|
||||||
|
|
||||||
const setCurrentPane = useCallback(
|
|
||||||
(pane: AccountMenuPane) => {
|
|
||||||
appState.accountMenu.setCurrentPane(pane)
|
|
||||||
},
|
|
||||||
[appState],
|
|
||||||
)
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
|
||||||
useCloseOnClickOutside(ref, () => {
|
|
||||||
onClickOutside()
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = useCallback(
|
|
||||||
(event) => {
|
|
||||||
switch (event.key) {
|
|
||||||
case 'Escape':
|
|
||||||
if (currentPane === AccountMenuPane.GeneralMenu) {
|
|
||||||
closeAccountMenu()
|
|
||||||
} else if (currentPane === AccountMenuPane.ConfirmPassword) {
|
|
||||||
setCurrentPane(AccountMenuPane.Register)
|
|
||||||
} else {
|
|
||||||
setCurrentPane(AccountMenuPane.GeneralMenu)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[closeAccountMenu, currentPane, setCurrentPane],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} id="account-menu" className="sn-component">
|
|
||||||
<div
|
|
||||||
className={`sn-menu-border sn-account-menu sn-dropdown ${
|
|
||||||
shouldAnimateCloseMenu ? 'slide-up-animation' : 'sn-dropdown--animated'
|
|
||||||
} min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto absolute`}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
<MenuPaneSelector
|
|
||||||
appState={appState}
|
|
||||||
application={application}
|
|
||||||
mainApplicationGroup={mainApplicationGroup}
|
|
||||||
menuPane={currentPane}
|
|
||||||
setMenuPane={setCurrentPane}
|
|
||||||
closeMenu={closeAccountMenu}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { Component } from 'preact'
|
import { Component } from 'react'
|
||||||
import { ApplicationView } from '@/Components/ApplicationView'
|
import ApplicationView from '@/Components/ApplicationView/ApplicationView'
|
||||||
import { WebOrDesktopDevice } from '@/Device/WebOrDesktopDevice'
|
import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice'
|
||||||
import { ApplicationGroupEvent, Runtime, ApplicationGroupEventData, DeinitSource } from '@standardnotes/snjs'
|
import { ApplicationGroupEvent, ApplicationGroupEventData, DeinitSource } from '@standardnotes/snjs'
|
||||||
import { unmountComponentAtNode, findDOMNode } from 'preact/compat'
|
|
||||||
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
||||||
import { isDesktopApplication } from '@/Utils'
|
import { isDesktopApplication } from '@/Utils'
|
||||||
|
import DeallocateHandler from '../DeallocateHandler/DeallocateHandler'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
server: string
|
server: string
|
||||||
@@ -23,7 +23,7 @@ type State = {
|
|||||||
deviceDestroyed?: boolean
|
deviceDestroyed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApplicationGroupView extends Component<Props, State> {
|
class ApplicationGroupView extends Component<Props, State> {
|
||||||
applicationObserverRemover?: () => void
|
applicationObserverRemover?: () => void
|
||||||
private group?: ApplicationGroup
|
private group?: ApplicationGroup
|
||||||
private application?: WebApplication
|
private application?: WebApplication
|
||||||
@@ -39,12 +39,7 @@ export class ApplicationGroupView extends Component<Props, State> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.group = new ApplicationGroup(
|
this.group = new ApplicationGroup(props.server, props.device, props.websocketUrl)
|
||||||
props.server,
|
|
||||||
props.device,
|
|
||||||
props.enableUnfinished ? Runtime.Dev : Runtime.Prod,
|
|
||||||
props.websocketUrl,
|
|
||||||
)
|
|
||||||
|
|
||||||
window.mainApplicationGroup = this.group
|
window.mainApplicationGroup = this.group
|
||||||
|
|
||||||
@@ -79,17 +74,15 @@ export class ApplicationGroupView extends Component<Props, State> {
|
|||||||
|
|
||||||
const onDestroy = this.props.onDestroy
|
const onDestroy = this.props.onDestroy
|
||||||
|
|
||||||
const node = findDOMNode(this) as Element
|
|
||||||
unmountComponentAtNode(node)
|
|
||||||
|
|
||||||
onDestroy()
|
onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
override render() {
|
||||||
const renderDialog = (message: string) => {
|
const renderDialog = (message: string) => {
|
||||||
return (
|
return (
|
||||||
<DialogOverlay className={'sn-component challenge-modal-overlay'}>
|
<DialogOverlay className={'sn-component challenge-modal-overlay'}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
aria-label="Switching workspace"
|
||||||
className={
|
className={
|
||||||
'challenge-modal flex flex-col items-center bg-default p-8 rounded relative shadow-overlay-light border-1 border-solid border-main'
|
'challenge-modal flex flex-col items-center bg-default p-8 rounded relative shadow-overlay-light border-1 border-solid border-main'
|
||||||
}
|
}
|
||||||
@@ -121,12 +114,16 @@ export class ApplicationGroupView extends Component<Props, State> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div id={this.state.activeApplication.identifier} key={this.state.activeApplication.ephemeralIdentifier}>
|
<div id={this.state.activeApplication.identifier} key={this.state.activeApplication.ephemeralIdentifier}>
|
||||||
<ApplicationView
|
<DeallocateHandler application={this.state.activeApplication}>
|
||||||
key={this.state.activeApplication.ephemeralIdentifier}
|
<ApplicationView
|
||||||
mainApplicationGroup={this.group}
|
key={this.state.activeApplication.ephemeralIdentifier}
|
||||||
application={this.state.activeApplication}
|
mainApplicationGroup={this.group}
|
||||||
/>
|
application={this.state.activeApplication}
|
||||||
|
/>
|
||||||
|
</DeallocateHandler>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ApplicationGroupView
|
||||||
@@ -1,55 +1,45 @@
|
|||||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||||
import { getPlatformString, getWindowUrlParams } from '@/Utils'
|
import { getPlatformString, getWindowUrlParams } from '@/Utils'
|
||||||
import { AppStateEvent, PanelResizedData } from '@/UIModels/AppState'
|
import { ApplicationEvent, Challenge, removeFromArray } from '@standardnotes/snjs'
|
||||||
import { ApplicationEvent, Challenge, PermissionDialog, removeFromArray } from '@standardnotes/snjs'
|
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/Constants/Constants'
|
||||||
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/Constants'
|
|
||||||
import { alertDialog } from '@/Services/AlertService'
|
import { alertDialog } from '@/Services/AlertService'
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { Navigation } from '@/Components/Navigation'
|
import { WebAppEvent } from '@/Application/WebAppEvent'
|
||||||
import { NotesView } from '@/Components/NotesView'
|
import Navigation from '@/Components/Navigation/Navigation'
|
||||||
import { NoteGroupView } from '@/Components/NoteGroupView'
|
import NoteGroupView from '@/Components/NoteGroupView/NoteGroupView'
|
||||||
import { Footer } from '@/Components/Footer'
|
import Footer from '@/Components/Footer/Footer'
|
||||||
import { SessionsModal } from '@/Components/SessionsModal'
|
import SessionsModal from '@/Components/SessionsModal/SessionsModal'
|
||||||
import { PreferencesViewWrapper } from '@/Components/Preferences/PreferencesViewWrapper'
|
import PreferencesViewWrapper from '@/Components/Preferences/PreferencesViewWrapper'
|
||||||
import { ChallengeModal } from '@/Components/ChallengeModal/ChallengeModal'
|
import ChallengeModal from '@/Components/ChallengeModal/ChallengeModal'
|
||||||
import { NotesContextMenu } from '@/Components/NotesContextMenu'
|
import NotesContextMenu from '@/Components/NotesContextMenu/NotesContextMenu'
|
||||||
import { PurchaseFlowWrapper } from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
|
import PurchaseFlowWrapper from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
|
||||||
import { render, FunctionComponent } from 'preact'
|
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { PermissionsModal } from '@/Components/PermissionsModal'
|
import RevisionHistoryModalWrapper from '@/Components/RevisionHistoryModal/RevisionHistoryModalWrapper'
|
||||||
import { RevisionHistoryModalWrapper } from '@/Components/RevisionHistoryModal/RevisionHistoryModalWrapper'
|
import PremiumModalProvider from '@/Hooks/usePremiumModal'
|
||||||
import { PremiumModalProvider } from '@/Hooks/usePremiumModal'
|
import ConfirmSignoutContainer from '@/Components/ConfirmSignoutModal/ConfirmSignoutModal'
|
||||||
import { ConfirmSignoutContainer } from '@/Components/ConfirmSignoutModal'
|
import TagsContextMenuWrapper from '@/Components/Tags/TagContextMenu'
|
||||||
import { TagsContextMenu } from '@/Components/Tags/TagContextMenu'
|
|
||||||
import { ToastContainer } from '@standardnotes/stylekit'
|
import { ToastContainer } from '@standardnotes/stylekit'
|
||||||
import { FilePreviewModal } from '../Files/FilePreviewModal'
|
import FilePreviewModalWrapper from '@/Components/Files/FilePreviewModal'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
|
import ContentListView from '@/Components/ContentListView/ContentListView'
|
||||||
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
|
import FileContextMenuWrapper from '@/Components/FileContextMenu/FileContextMenu'
|
||||||
|
import PermissionsModalWrapper from '@/Components/PermissionsModal/PermissionsModalWrapper'
|
||||||
|
import { PanelResizedData } from '@/Types/PanelResizedData'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
mainApplicationGroup: ApplicationGroup
|
mainApplicationGroup: ApplicationGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicationGroup }) => {
|
const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicationGroup }) => {
|
||||||
const platformString = getPlatformString()
|
const platformString = getPlatformString()
|
||||||
const [appClass, setAppClass] = useState('')
|
const [appClass, setAppClass] = useState('')
|
||||||
const [launched, setLaunched] = useState(false)
|
const [launched, setLaunched] = useState(false)
|
||||||
const [needsUnlock, setNeedsUnlock] = useState(true)
|
const [needsUnlock, setNeedsUnlock] = useState(true)
|
||||||
const [challenges, setChallenges] = useState<Challenge[]>([])
|
const [challenges, setChallenges] = useState<Challenge[]>([])
|
||||||
const [dealloced, setDealloced] = useState(false)
|
|
||||||
|
|
||||||
const componentManager = application.componentManager
|
const viewControllerManager = application.getViewControllerManager()
|
||||||
const appState = application.getAppState()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDealloced(application.dealloced)
|
|
||||||
}, [application.dealloced])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dealloced) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const desktopService = application.getDesktopService()
|
const desktopService = application.getDesktopService()
|
||||||
|
|
||||||
if (desktopService) {
|
if (desktopService) {
|
||||||
@@ -69,7 +59,7 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
|
|||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [application, dealloced])
|
}, [application])
|
||||||
|
|
||||||
const removeChallenge = useCallback(
|
const removeChallenge = useCallback(
|
||||||
(challenge: Challenge) => {
|
(challenge: Challenge) => {
|
||||||
@@ -80,29 +70,9 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
|
|||||||
[challenges],
|
[challenges],
|
||||||
)
|
)
|
||||||
|
|
||||||
const presentPermissionsDialog = useCallback(
|
|
||||||
(dialog: PermissionDialog) => {
|
|
||||||
render(
|
|
||||||
<PermissionsModal
|
|
||||||
application={application}
|
|
||||||
callback={dialog.callback}
|
|
||||||
component={dialog.component}
|
|
||||||
permissionsString={dialog.permissionsString}
|
|
||||||
/>,
|
|
||||||
document.body.appendChild(document.createElement('div')),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[application],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onAppStart = useCallback(() => {
|
const onAppStart = useCallback(() => {
|
||||||
setNeedsUnlock(application.hasPasscode())
|
setNeedsUnlock(application.hasPasscode())
|
||||||
componentManager.presentPermissionsDialog = presentPermissionsDialog
|
}, [application])
|
||||||
|
|
||||||
return () => {
|
|
||||||
;(componentManager.presentPermissionsDialog as unknown) = undefined
|
|
||||||
}
|
|
||||||
}, [application, componentManager, presentPermissionsDialog])
|
|
||||||
|
|
||||||
const handleDemoSignInFromParams = useCallback(() => {
|
const handleDemoSignInFromParams = useCallback(() => {
|
||||||
const token = getWindowUrlParams().get('demo-token')
|
const token = getWindowUrlParams().get('demo-token')
|
||||||
@@ -150,8 +120,8 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
|
|||||||
}, [application, onAppLaunch, onAppStart])
|
}, [application, onAppLaunch, onAppStart])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeObserver = application.getAppState().addObserver(async (eventName, data) => {
|
const removeObserver = application.addWebEventObserver(async (eventName, data) => {
|
||||||
if (eventName === AppStateEvent.PanelResized) {
|
if (eventName === WebAppEvent.PanelResized) {
|
||||||
const { panel, collapsed } = data as PanelResizedData
|
const { panel, collapsed } = data as PanelResizedData
|
||||||
let appClass = ''
|
let appClass = ''
|
||||||
if (panel === PANEL_NAME_NOTES && collapsed) {
|
if (panel === PANEL_NAME_NOTES && collapsed) {
|
||||||
@@ -161,7 +131,7 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
|
|||||||
appClass += ' collapsed-navigation'
|
appClass += ' collapsed-navigation'
|
||||||
}
|
}
|
||||||
setAppClass(appClass)
|
setAppClass(appClass)
|
||||||
} else if (eventName === AppStateEvent.WindowDidFocus) {
|
} else if (eventName === WebAppEvent.WindowDidFocus) {
|
||||||
if (!(await application.isLocked())) {
|
if (!(await application.isLocked())) {
|
||||||
application.sync.sync().catch(console.error)
|
application.sync.sync().catch(console.error)
|
||||||
}
|
}
|
||||||
@@ -182,11 +152,11 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
|
|||||||
<>
|
<>
|
||||||
{challenges.map((challenge) => {
|
{challenges.map((challenge) => {
|
||||||
return (
|
return (
|
||||||
<div className="sk-modal">
|
<div className="sk-modal" key={`${challenge.id}${application.ephemeralIdentifier}`}>
|
||||||
<ChallengeModal
|
<ChallengeModal
|
||||||
key={`${challenge.id}${application.ephemeralIdentifier}`}
|
key={`${challenge.id}${application.ephemeralIdentifier}`}
|
||||||
application={application}
|
application={application}
|
||||||
appState={appState}
|
viewControllerManager={viewControllerManager}
|
||||||
mainApplicationGroup={mainApplicationGroup}
|
mainApplicationGroup={mainApplicationGroup}
|
||||||
challenge={challenge}
|
challenge={challenge}
|
||||||
onDismiss={removeChallenge}
|
onDismiss={removeChallenge}
|
||||||
@@ -196,47 +166,47 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
|
|||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}, [appState, challenges, mainApplicationGroup, removeChallenge, application])
|
}, [viewControllerManager, challenges, mainApplicationGroup, removeChallenge, application])
|
||||||
|
|
||||||
if (dealloced || isStateDealloced(appState)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!renderAppContents) {
|
if (!renderAppContents) {
|
||||||
return renderChallenges()
|
return renderChallenges()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PremiumModalProvider application={application} appState={appState}>
|
<PremiumModalProvider application={application} viewControllerManager={viewControllerManager}>
|
||||||
<div className={platformString + ' main-ui-view sn-component'}>
|
<div className={platformString + ' main-ui-view sn-component'}>
|
||||||
<div id="app" className={appClass + ' app app-column-container'}>
|
<div id="app" className={appClass + ' app app-column-container'}>
|
||||||
<Navigation application={application} />
|
<Navigation application={application} />
|
||||||
<NotesView application={application} appState={appState} />
|
<ContentListView application={application} viewControllerManager={viewControllerManager} />
|
||||||
<NoteGroupView application={application} />
|
<NoteGroupView application={application} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<Footer application={application} applicationGroup={mainApplicationGroup} />
|
<Footer application={application} applicationGroup={mainApplicationGroup} />
|
||||||
<SessionsModal application={application} appState={appState} />
|
<SessionsModal application={application} viewControllerManager={viewControllerManager} />
|
||||||
<PreferencesViewWrapper appState={appState} application={application} />
|
<PreferencesViewWrapper viewControllerManager={viewControllerManager} application={application} />
|
||||||
<RevisionHistoryModalWrapper application={application} appState={appState} />
|
<RevisionHistoryModalWrapper application={application} viewControllerManager={viewControllerManager} />
|
||||||
</>
|
</>
|
||||||
|
|
||||||
{renderChallenges()}
|
{renderChallenges()}
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<NotesContextMenu application={application} appState={appState} />
|
<NotesContextMenu application={application} viewControllerManager={viewControllerManager} />
|
||||||
<TagsContextMenu appState={appState} />
|
<TagsContextMenuWrapper viewControllerManager={viewControllerManager} />
|
||||||
<PurchaseFlowWrapper application={application} appState={appState} />
|
<FileContextMenuWrapper viewControllerManager={viewControllerManager} />
|
||||||
|
<PurchaseFlowWrapper application={application} viewControllerManager={viewControllerManager} />
|
||||||
<ConfirmSignoutContainer
|
<ConfirmSignoutContainer
|
||||||
applicationGroup={mainApplicationGroup}
|
applicationGroup={mainApplicationGroup}
|
||||||
appState={appState}
|
viewControllerManager={viewControllerManager}
|
||||||
application={application}
|
application={application}
|
||||||
/>
|
/>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<FilePreviewModal application={application} appState={appState} />
|
<FilePreviewModalWrapper application={application} viewControllerManager={viewControllerManager} />
|
||||||
|
<PermissionsModalWrapper application={application} />
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
</PremiumModalProvider>
|
</PremiumModalProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ApplicationView
|
||||||
@@ -1,154 +1,135 @@
|
|||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
|
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||||
import VisuallyHidden from '@reach/visually-hidden'
|
import VisuallyHidden from '@reach/visually-hidden'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
|
||||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||||
import { ChallengeReason, CollectionSort, ContentType, FileItem, SNNote } from '@standardnotes/snjs'
|
import { ChallengeReason, ContentType, FileItem, SNNote } from '@standardnotes/snjs'
|
||||||
import { confirmDialog } from '@/Services/AlertService'
|
import { confirmDialog } from '@/Services/AlertService'
|
||||||
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
|
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
|
||||||
import { StreamingFileReader } from '@standardnotes/filepicker'
|
import { StreamingFileReader } from '@standardnotes/filepicker'
|
||||||
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
|
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||||
import { AttachedFilesPopover } from './AttachedFilesPopover'
|
import AttachedFilesPopover from './AttachedFilesPopover'
|
||||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||||
import { PopoverTabs } from './PopoverTabs'
|
import { PopoverTabs } from './PopoverTabs'
|
||||||
import { isHandlingFileDrag } from '@/Utils/DragTypeCheck'
|
import { isHandlingFileDrag } from '@/Utils/DragTypeCheck'
|
||||||
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
onClickPreprocessing?: () => Promise<void>
|
onClickPreprocessing?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
const AttachedFilesButton: FunctionComponent<Props> = ({
|
||||||
({ application, appState, onClickPreprocessing }: Props) => {
|
application,
|
||||||
if (isStateDealloced(appState)) {
|
viewControllerManager,
|
||||||
return null
|
onClickPreprocessing,
|
||||||
|
}: Props) => {
|
||||||
|
const premiumModal = usePremiumModal()
|
||||||
|
const note: SNNote | undefined = viewControllerManager.notesController.firstSelectedNote
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [position, setPosition] = useState({
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
})
|
||||||
|
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewControllerManager.filePreviewModalController.isOpen) {
|
||||||
|
keepMenuOpen(true)
|
||||||
|
} else {
|
||||||
|
keepMenuOpen(false)
|
||||||
}
|
}
|
||||||
|
}, [viewControllerManager.filePreviewModalController.isOpen, keepMenuOpen])
|
||||||
|
|
||||||
const premiumModal = usePremiumModal()
|
const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles)
|
||||||
const note: SNNote | undefined = Object.values(appState.notes.selectedNotes)[0]
|
const [allFiles, setAllFiles] = useState<FileItem[]>([])
|
||||||
|
const [attachedFiles, setAttachedFiles] = useState<FileItem[]>([])
|
||||||
|
const attachedFilesCount = attachedFiles.length
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
useEffect(() => {
|
||||||
const [position, setPosition] = useState({
|
const unregisterFileStream = application.streamItems(ContentType.File, () => {
|
||||||
top: 0,
|
setAllFiles(application.items.getDisplayableFiles())
|
||||||
right: 0,
|
if (note) {
|
||||||
|
setAttachedFiles(application.items.getFilesForNote(note))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null)
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen)
|
|
||||||
|
|
||||||
useEffect(() => {
|
return () => {
|
||||||
if (appState.filePreviewModal.isOpen) {
|
unregisterFileStream()
|
||||||
keepMenuOpen(true)
|
}
|
||||||
} else {
|
}, [application, note])
|
||||||
keepMenuOpen(false)
|
|
||||||
|
const toggleAttachedFilesMenu = useCallback(async () => {
|
||||||
|
const rect = buttonRef.current?.getBoundingClientRect()
|
||||||
|
if (rect) {
|
||||||
|
const { clientHeight } = document.documentElement
|
||||||
|
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
||||||
|
const footerHeightInPx = footerElementRect?.height
|
||||||
|
|
||||||
|
if (footerHeightInPx) {
|
||||||
|
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
|
||||||
}
|
}
|
||||||
}, [appState.filePreviewModal.isOpen, keepMenuOpen])
|
|
||||||
|
|
||||||
const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles)
|
setPosition({
|
||||||
const [allFiles, setAllFiles] = useState<FileItem[]>([])
|
top: rect.bottom,
|
||||||
const [attachedFiles, setAttachedFiles] = useState<FileItem[]>([])
|
right: document.body.clientWidth - rect.right,
|
||||||
const attachedFilesCount = attachedFiles.length
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
application.items.setDisplayOptions(ContentType.File, CollectionSort.Title, 'dsc')
|
|
||||||
|
|
||||||
const unregisterFileStream = application.streamItems(ContentType.File, () => {
|
|
||||||
setAllFiles(application.items.getDisplayableItems<FileItem>(ContentType.File))
|
|
||||||
if (note) {
|
|
||||||
setAttachedFiles(application.items.getFilesForNote(note))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
const newOpenState = !open
|
||||||
unregisterFileStream()
|
if (newOpenState && onClickPreprocessing) {
|
||||||
|
await onClickPreprocessing()
|
||||||
}
|
}
|
||||||
}, [application, note])
|
|
||||||
|
|
||||||
const toggleAttachedFilesMenu = useCallback(async () => {
|
setOpen(newOpenState)
|
||||||
const rect = buttonRef.current?.getBoundingClientRect()
|
}
|
||||||
if (rect) {
|
}, [onClickPreprocessing, open])
|
||||||
const { clientHeight } = document.documentElement
|
|
||||||
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
|
||||||
const footerHeightInPx = footerElementRect?.height
|
|
||||||
|
|
||||||
if (footerHeightInPx) {
|
const prospectivelyShowFilesPremiumModal = useCallback(() => {
|
||||||
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
|
if (!viewControllerManager.featuresController.hasFiles) {
|
||||||
}
|
premiumModal.activate('Files')
|
||||||
|
}
|
||||||
|
}, [viewControllerManager.featuresController.hasFiles, premiumModal])
|
||||||
|
|
||||||
setPosition({
|
const toggleAttachedFilesMenuWithEntitlementCheck = useCallback(async () => {
|
||||||
top: rect.bottom,
|
prospectivelyShowFilesPremiumModal()
|
||||||
right: document.body.clientWidth - rect.right,
|
|
||||||
})
|
|
||||||
|
|
||||||
const newOpenState = !open
|
await toggleAttachedFilesMenu()
|
||||||
if (newOpenState && onClickPreprocessing) {
|
}, [toggleAttachedFilesMenu, prospectivelyShowFilesPremiumModal])
|
||||||
await onClickPreprocessing()
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(newOpenState)
|
const deleteFile = async (file: FileItem) => {
|
||||||
}
|
const shouldDelete = await confirmDialog({
|
||||||
}, [onClickPreprocessing, open])
|
text: `Are you sure you want to permanently delete "${file.name}"?`,
|
||||||
|
confirmButtonStyle: 'danger',
|
||||||
const prospectivelyShowFilesPremiumModal = useCallback(() => {
|
})
|
||||||
if (!appState.features.hasFiles) {
|
if (shouldDelete) {
|
||||||
premiumModal.activate('Files')
|
const deletingToastId = addToast({
|
||||||
}
|
type: ToastType.Loading,
|
||||||
}, [appState.features.hasFiles, premiumModal])
|
message: `Deleting file "${file.name}"...`,
|
||||||
|
|
||||||
const toggleAttachedFilesMenuWithEntitlementCheck = useCallback(async () => {
|
|
||||||
prospectivelyShowFilesPremiumModal()
|
|
||||||
|
|
||||||
await toggleAttachedFilesMenu()
|
|
||||||
}, [toggleAttachedFilesMenu, prospectivelyShowFilesPremiumModal])
|
|
||||||
|
|
||||||
const deleteFile = async (file: FileItem) => {
|
|
||||||
const shouldDelete = await confirmDialog({
|
|
||||||
text: `Are you sure you want to permanently delete "${file.name}"?`,
|
|
||||||
confirmButtonStyle: 'danger',
|
|
||||||
})
|
})
|
||||||
if (shouldDelete) {
|
await application.files.deleteFile(file)
|
||||||
const deletingToastId = addToast({
|
addToast({
|
||||||
type: ToastType.Loading,
|
type: ToastType.Success,
|
||||||
message: `Deleting file "${file.name}"...`,
|
message: `Deleted file "${file.name}"`,
|
||||||
})
|
})
|
||||||
await application.files.deleteFile(file)
|
dismissToast(deletingToastId)
|
||||||
addToast({
|
|
||||||
type: ToastType.Success,
|
|
||||||
message: `Deleted file "${file.name}"`,
|
|
||||||
})
|
|
||||||
dismissToast(deletingToastId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const downloadFile = async (file: FileItem) => {
|
const downloadFile = async (file: FileItem) => {
|
||||||
appState.files.downloadFile(file).catch(console.error)
|
viewControllerManager.filesController.downloadFile(file).catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachFileToNote = useCallback(
|
const attachFileToNote = useCallback(
|
||||||
async (file: FileItem) => {
|
async (file: FileItem) => {
|
||||||
if (!note) {
|
|
||||||
addToast({
|
|
||||||
type: ToastType.Error,
|
|
||||||
message: 'Could not attach file because selected note was deleted',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await application.items.associateFileWithNote(file, note)
|
|
||||||
},
|
|
||||||
[application.items, note],
|
|
||||||
)
|
|
||||||
|
|
||||||
const detachFileFromNote = async (file: FileItem) => {
|
|
||||||
if (!note) {
|
if (!note) {
|
||||||
addToast({
|
addToast({
|
||||||
type: ToastType.Error,
|
type: ToastType.Error,
|
||||||
@@ -156,268 +137,283 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await application.items.disassociateFileWithNote(file, note)
|
|
||||||
|
await application.items.associateFileWithNote(file, note)
|
||||||
|
},
|
||||||
|
[application.items, note],
|
||||||
|
)
|
||||||
|
|
||||||
|
const detachFileFromNote = async (file: FileItem) => {
|
||||||
|
if (!note) {
|
||||||
|
addToast({
|
||||||
|
type: ToastType.Error,
|
||||||
|
message: 'Could not attach file because selected note was deleted',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await application.items.disassociateFileWithNote(file, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFileProtection = async (file: FileItem) => {
|
||||||
|
let result: FileItem | undefined
|
||||||
|
if (file.protected) {
|
||||||
|
keepMenuOpen(true)
|
||||||
|
result = await application.mutator.unprotectFile(file)
|
||||||
|
keepMenuOpen(false)
|
||||||
|
buttonRef.current?.focus()
|
||||||
|
} else {
|
||||||
|
result = await application.mutator.protectFile(file)
|
||||||
|
}
|
||||||
|
const isProtected = result ? result.protected : file.protected
|
||||||
|
return isProtected
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorizeProtectedActionForFile = async (file: FileItem, challengeReason: ChallengeReason) => {
|
||||||
|
const authorizedFiles = await application.protections.authorizeProtectedActionForItems([file], challengeReason)
|
||||||
|
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file)
|
||||||
|
return isAuthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameFile = async (file: FileItem, fileName: string) => {
|
||||||
|
await application.items.renameFile(file, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileAction = async (action: PopoverFileItemAction) => {
|
||||||
|
const file = action.type !== PopoverFileItemActionType.RenameFile ? action.payload : action.payload.file
|
||||||
|
let isAuthorizedForAction = true
|
||||||
|
|
||||||
|
if (file.protected && action.type !== PopoverFileItemActionType.ToggleFileProtection) {
|
||||||
|
keepMenuOpen(true)
|
||||||
|
isAuthorizedForAction = await authorizeProtectedActionForFile(file, ChallengeReason.AccessProtectedFile)
|
||||||
|
keepMenuOpen(false)
|
||||||
|
buttonRef.current?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleFileProtection = async (file: FileItem) => {
|
if (!isAuthorizedForAction) {
|
||||||
let result: FileItem | undefined
|
return false
|
||||||
if (file.protected) {
|
}
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
case PopoverFileItemActionType.AttachFileToNote:
|
||||||
|
await attachFileToNote(file)
|
||||||
|
break
|
||||||
|
case PopoverFileItemActionType.DetachFileToNote:
|
||||||
|
await detachFileFromNote(file)
|
||||||
|
break
|
||||||
|
case PopoverFileItemActionType.DeleteFile:
|
||||||
|
await deleteFile(file)
|
||||||
|
break
|
||||||
|
case PopoverFileItemActionType.DownloadFile:
|
||||||
|
await downloadFile(file)
|
||||||
|
break
|
||||||
|
case PopoverFileItemActionType.ToggleFileProtection: {
|
||||||
|
const isProtected = await toggleFileProtection(file)
|
||||||
|
action.callback(isProtected)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case PopoverFileItemActionType.RenameFile:
|
||||||
|
await renameFile(file, action.payload.name)
|
||||||
|
break
|
||||||
|
case PopoverFileItemActionType.PreviewFile: {
|
||||||
keepMenuOpen(true)
|
keepMenuOpen(true)
|
||||||
result = await application.mutator.unprotectFile(file)
|
const otherFiles = currentTab === PopoverTabs.AllFiles ? allFiles : attachedFiles
|
||||||
keepMenuOpen(false)
|
viewControllerManager.filePreviewModalController.activate(
|
||||||
buttonRef.current?.focus()
|
file,
|
||||||
} else {
|
otherFiles.filter((file) => !file.protected),
|
||||||
result = await application.mutator.protectFile(file)
|
)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
const isProtected = result ? result.protected : file.protected
|
|
||||||
return isProtected
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorizeProtectedActionForFile = async (file: FileItem, challengeReason: ChallengeReason) => {
|
if (
|
||||||
const authorizedFiles = await application.protections.authorizeProtectedActionForFiles([file], challengeReason)
|
action.type !== PopoverFileItemActionType.DownloadFile &&
|
||||||
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file)
|
action.type !== PopoverFileItemActionType.PreviewFile
|
||||||
return isAuthorized
|
) {
|
||||||
|
application.sync.sync().catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renameFile = async (file: FileItem, fileName: string) => {
|
return true
|
||||||
await application.items.renameFile(file, fileName)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileAction = async (action: PopoverFileItemAction) => {
|
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
|
||||||
const file = action.type !== PopoverFileItemActionType.RenameFile ? action.payload : action.payload.file
|
const dragCounter = useRef(0)
|
||||||
let isAuthorizedForAction = true
|
|
||||||
|
|
||||||
if (file.protected && action.type !== PopoverFileItemActionType.ToggleFileProtection) {
|
|
||||||
keepMenuOpen(true)
|
|
||||||
isAuthorizedForAction = await authorizeProtectedActionForFile(file, ChallengeReason.AccessProtectedFile)
|
|
||||||
keepMenuOpen(false)
|
|
||||||
buttonRef.current?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthorizedForAction) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action.type) {
|
|
||||||
case PopoverFileItemActionType.AttachFileToNote:
|
|
||||||
await attachFileToNote(file)
|
|
||||||
break
|
|
||||||
case PopoverFileItemActionType.DetachFileToNote:
|
|
||||||
await detachFileFromNote(file)
|
|
||||||
break
|
|
||||||
case PopoverFileItemActionType.DeleteFile:
|
|
||||||
await deleteFile(file)
|
|
||||||
break
|
|
||||||
case PopoverFileItemActionType.DownloadFile:
|
|
||||||
await downloadFile(file)
|
|
||||||
break
|
|
||||||
case PopoverFileItemActionType.ToggleFileProtection: {
|
|
||||||
const isProtected = await toggleFileProtection(file)
|
|
||||||
action.callback(isProtected)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case PopoverFileItemActionType.RenameFile:
|
|
||||||
await renameFile(file, action.payload.name)
|
|
||||||
break
|
|
||||||
case PopoverFileItemActionType.PreviewFile: {
|
|
||||||
keepMenuOpen(true)
|
|
||||||
const otherFiles = currentTab === PopoverTabs.AllFiles ? allFiles : attachedFiles
|
|
||||||
appState.filePreviewModal.activate(
|
|
||||||
file,
|
|
||||||
otherFiles.filter((file) => !file.protected),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
action.type !== PopoverFileItemActionType.DownloadFile &&
|
|
||||||
action.type !== PopoverFileItemActionType.PreviewFile
|
|
||||||
) {
|
|
||||||
application.sync.sync().catch(console.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
|
|
||||||
const dragCounter = useRef(0)
|
|
||||||
|
|
||||||
const handleDrag = useCallback(
|
|
||||||
(event: DragEvent) => {
|
|
||||||
if (isHandlingFileDrag(event, application)) {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[application],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDragIn = useCallback(
|
|
||||||
(event: DragEvent) => {
|
|
||||||
if (!isHandlingFileDrag(event, application)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const handleDrag = useCallback(
|
||||||
|
(event: DragEvent) => {
|
||||||
|
if (isHandlingFileDrag(event, application)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[application],
|
||||||
|
)
|
||||||
|
|
||||||
switch ((event.target as HTMLElement).id) {
|
const handleDragIn = useCallback(
|
||||||
case PopoverTabs.AllFiles:
|
(event: DragEvent) => {
|
||||||
setCurrentTab(PopoverTabs.AllFiles)
|
if (!isHandlingFileDrag(event, application)) {
|
||||||
break
|
return
|
||||||
case PopoverTabs.AttachedFiles:
|
}
|
||||||
setCurrentTab(PopoverTabs.AttachedFiles)
|
|
||||||
break
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
switch ((event.target as HTMLElement).id) {
|
||||||
|
case PopoverTabs.AllFiles:
|
||||||
|
setCurrentTab(PopoverTabs.AllFiles)
|
||||||
|
break
|
||||||
|
case PopoverTabs.AttachedFiles:
|
||||||
|
setCurrentTab(PopoverTabs.AttachedFiles)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
dragCounter.current = dragCounter.current + 1
|
||||||
|
|
||||||
|
if (event.dataTransfer?.items.length) {
|
||||||
|
setIsDraggingFiles(true)
|
||||||
|
if (!open) {
|
||||||
|
toggleAttachedFilesMenu().catch(console.error)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[open, toggleAttachedFilesMenu, application],
|
||||||
|
)
|
||||||
|
|
||||||
dragCounter.current = dragCounter.current + 1
|
const handleDragOut = useCallback(
|
||||||
|
(event: DragEvent) => {
|
||||||
|
if (!isHandlingFileDrag(event, application)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.dataTransfer?.items.length) {
|
event.preventDefault()
|
||||||
setIsDraggingFiles(true)
|
event.stopPropagation()
|
||||||
if (!open) {
|
|
||||||
toggleAttachedFilesMenu().catch(console.error)
|
dragCounter.current = dragCounter.current - 1
|
||||||
|
|
||||||
|
if (dragCounter.current > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDraggingFiles(false)
|
||||||
|
},
|
||||||
|
[application],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(event: DragEvent) => {
|
||||||
|
if (!isHandlingFileDrag(event, application)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
setIsDraggingFiles(false)
|
||||||
|
|
||||||
|
if (!viewControllerManager.featuresController.hasFiles) {
|
||||||
|
prospectivelyShowFilesPremiumModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.dataTransfer?.items.length) {
|
||||||
|
Array.from(event.dataTransfer.items).forEach(async (item) => {
|
||||||
|
const fileOrHandle = StreamingFileReader.available()
|
||||||
|
? ((await item.getAsFileSystemHandle()) as FileSystemFileHandle)
|
||||||
|
: item.getAsFile()
|
||||||
|
|
||||||
|
if (!fileOrHandle) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
[open, toggleAttachedFilesMenu, application],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDragOut = useCallback(
|
const uploadedFiles = await viewControllerManager.filesController.uploadNewFile(fileOrHandle)
|
||||||
(event: DragEvent) => {
|
|
||||||
if (!isHandlingFileDrag(event, application)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
if (!uploadedFiles) {
|
||||||
event.stopPropagation()
|
return
|
||||||
|
}
|
||||||
|
|
||||||
dragCounter.current = dragCounter.current - 1
|
if (currentTab === PopoverTabs.AttachedFiles) {
|
||||||
|
uploadedFiles.forEach((file) => {
|
||||||
|
attachFileToNote(file).catch(console.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (dragCounter.current > 0) {
|
event.dataTransfer.clearData()
|
||||||
return
|
dragCounter.current = 0
|
||||||
}
|
|
||||||
|
|
||||||
setIsDraggingFiles(false)
|
|
||||||
},
|
|
||||||
[application],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
|
||||||
(event: DragEvent) => {
|
|
||||||
if (!isHandlingFileDrag(event, application)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
setIsDraggingFiles(false)
|
|
||||||
|
|
||||||
if (!appState.features.hasFiles) {
|
|
||||||
prospectivelyShowFilesPremiumModal()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.dataTransfer?.items.length) {
|
|
||||||
Array.from(event.dataTransfer.items).forEach(async (item) => {
|
|
||||||
const fileOrHandle = StreamingFileReader.available()
|
|
||||||
? ((await item.getAsFileSystemHandle()) as FileSystemFileHandle)
|
|
||||||
: item.getAsFile()
|
|
||||||
|
|
||||||
if (!fileOrHandle) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadedFiles = await appState.files.uploadNewFile(fileOrHandle)
|
|
||||||
|
|
||||||
if (!uploadedFiles) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentTab === PopoverTabs.AttachedFiles) {
|
|
||||||
uploadedFiles.forEach((file) => {
|
|
||||||
attachFileToNote(file).catch(console.error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
event.dataTransfer.clearData()
|
|
||||||
dragCounter.current = 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
appState.files,
|
|
||||||
appState.features.hasFiles,
|
|
||||||
attachFileToNote,
|
|
||||||
currentTab,
|
|
||||||
application,
|
|
||||||
prospectivelyShowFilesPremiumModal,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener('dragenter', handleDragIn)
|
|
||||||
window.addEventListener('dragleave', handleDragOut)
|
|
||||||
window.addEventListener('dragover', handleDrag)
|
|
||||||
window.addEventListener('drop', handleDrop)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('dragenter', handleDragIn)
|
|
||||||
window.removeEventListener('dragleave', handleDragOut)
|
|
||||||
window.removeEventListener('dragover', handleDrag)
|
|
||||||
window.removeEventListener('drop', handleDrop)
|
|
||||||
}
|
}
|
||||||
}, [handleDragIn, handleDrop, handleDrag, handleDragOut])
|
},
|
||||||
|
[
|
||||||
|
viewControllerManager.filesController,
|
||||||
|
viewControllerManager.featuresController.hasFiles,
|
||||||
|
attachFileToNote,
|
||||||
|
currentTab,
|
||||||
|
application,
|
||||||
|
prospectivelyShowFilesPremiumModal,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<div ref={containerRef}>
|
window.addEventListener('dragenter', handleDragIn)
|
||||||
<Disclosure open={open} onChange={toggleAttachedFilesMenuWithEntitlementCheck}>
|
window.addEventListener('dragleave', handleDragOut)
|
||||||
<DisclosureButton
|
window.addEventListener('dragover', handleDrag)
|
||||||
onKeyDown={(event) => {
|
window.addEventListener('drop', handleDrop)
|
||||||
if (event.key === 'Escape') {
|
|
||||||
setOpen(false)
|
return () => {
|
||||||
}
|
window.removeEventListener('dragenter', handleDragIn)
|
||||||
}}
|
window.removeEventListener('dragleave', handleDragOut)
|
||||||
ref={buttonRef}
|
window.removeEventListener('dragover', handleDrag)
|
||||||
className={`sn-icon-button border-contrast ${attachedFilesCount > 0 ? 'py-1 px-3' : ''}`}
|
window.removeEventListener('drop', handleDrop)
|
||||||
onBlur={closeOnBlur}
|
}
|
||||||
>
|
}, [handleDragIn, handleDrop, handleDrag, handleDragOut])
|
||||||
<VisuallyHidden>Attached files</VisuallyHidden>
|
|
||||||
<Icon type="attachment-file" className="block" />
|
return (
|
||||||
{attachedFilesCount > 0 && <span className="ml-2">{attachedFilesCount}</span>}
|
<div ref={containerRef}>
|
||||||
</DisclosureButton>
|
<Disclosure open={open} onChange={toggleAttachedFilesMenuWithEntitlementCheck}>
|
||||||
<DisclosurePanel
|
<DisclosureButton
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
buttonRef.current?.focus()
|
}
|
||||||
}
|
}}
|
||||||
}}
|
ref={buttonRef}
|
||||||
ref={panelRef}
|
className={`sn-icon-button border-contrast ${attachedFilesCount > 0 ? 'py-1 px-3' : ''}`}
|
||||||
style={{
|
onBlur={closeOnBlur}
|
||||||
...position,
|
>
|
||||||
maxHeight,
|
<VisuallyHidden>Attached files</VisuallyHidden>
|
||||||
}}
|
<Icon type="attachment-file" className="block" />
|
||||||
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
|
{attachedFilesCount > 0 && <span className="ml-2">{attachedFilesCount}</span>}
|
||||||
onBlur={closeOnBlur}
|
</DisclosureButton>
|
||||||
>
|
<DisclosurePanel
|
||||||
{open && (
|
onKeyDown={(event) => {
|
||||||
<AttachedFilesPopover
|
if (event.key === 'Escape') {
|
||||||
application={application}
|
setOpen(false)
|
||||||
appState={appState}
|
buttonRef.current?.focus()
|
||||||
attachedFiles={attachedFiles}
|
}
|
||||||
allFiles={allFiles}
|
}}
|
||||||
closeOnBlur={closeOnBlur}
|
ref={panelRef}
|
||||||
currentTab={currentTab}
|
style={{
|
||||||
handleFileAction={handleFileAction}
|
...position,
|
||||||
isDraggingFiles={isDraggingFiles}
|
maxHeight,
|
||||||
setCurrentTab={setCurrentTab}
|
}}
|
||||||
/>
|
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
|
||||||
)}
|
onBlur={closeOnBlur}
|
||||||
</DisclosurePanel>
|
>
|
||||||
</Disclosure>
|
{open && (
|
||||||
</div>
|
<AttachedFilesPopover
|
||||||
)
|
application={application}
|
||||||
},
|
viewControllerManager={viewControllerManager}
|
||||||
)
|
attachedFiles={attachedFiles}
|
||||||
|
allFiles={allFiles}
|
||||||
|
closeOnBlur={closeOnBlur}
|
||||||
|
currentTab={currentTab}
|
||||||
|
handleFileAction={handleFileAction}
|
||||||
|
isDraggingFiles={isDraggingFiles}
|
||||||
|
setCurrentTab={setCurrentTab}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DisclosurePanel>
|
||||||
|
</Disclosure>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(AttachedFilesButton)
|
||||||
|
|||||||
@@ -1,173 +1,172 @@
|
|||||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { FileItem } from '@standardnotes/snjs'
|
import { FileItem } from '@standardnotes/snjs'
|
||||||
import { FilesIllustration } from '@standardnotes/icons'
|
import { FilesIllustration } from '@standardnotes/icons'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'preact'
|
import { Dispatch, FunctionComponent, SetStateAction, useRef, useState } from 'react'
|
||||||
import { StateUpdater, useRef, useState } from 'preact/hooks'
|
import Button from '@/Components/Button/Button'
|
||||||
import { Button } from '@/Components/Button/Button'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
import PopoverFileItem from './PopoverFileItem'
|
||||||
import { PopoverFileItem } from './PopoverFileItem'
|
|
||||||
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
|
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||||
import { PopoverTabs } from './PopoverTabs'
|
import { PopoverTabs } from './PopoverTabs'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
allFiles: FileItem[]
|
allFiles: FileItem[]
|
||||||
attachedFiles: FileItem[]
|
attachedFiles: FileItem[]
|
||||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||||
currentTab: PopoverTabs
|
currentTab: PopoverTabs
|
||||||
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
|
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
|
||||||
isDraggingFiles: boolean
|
isDraggingFiles: boolean
|
||||||
setCurrentTab: StateUpdater<PopoverTabs>
|
setCurrentTab: Dispatch<SetStateAction<PopoverTabs>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AttachedFilesPopover: FunctionComponent<Props> = observer(
|
const AttachedFilesPopover: FunctionComponent<Props> = ({
|
||||||
({
|
application,
|
||||||
application,
|
viewControllerManager,
|
||||||
appState,
|
allFiles,
|
||||||
allFiles,
|
attachedFiles,
|
||||||
attachedFiles,
|
closeOnBlur,
|
||||||
closeOnBlur,
|
currentTab,
|
||||||
currentTab,
|
handleFileAction,
|
||||||
handleFileAction,
|
isDraggingFiles,
|
||||||
isDraggingFiles,
|
setCurrentTab,
|
||||||
setCurrentTab,
|
}) => {
|
||||||
}) => {
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles
|
const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles
|
||||||
|
|
||||||
const filteredList =
|
const filteredList =
|
||||||
searchQuery.length > 0
|
searchQuery.length > 0
|
||||||
? filesList.filter((file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1)
|
? filesList.filter((file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1)
|
||||||
: filesList
|
: filesList
|
||||||
|
|
||||||
const handleAttachFilesClick = async () => {
|
const handleAttachFilesClick = async () => {
|
||||||
const uploadedFiles = await appState.files.uploadNewFile()
|
const uploadedFiles = await viewControllerManager.filesController.uploadNewFile()
|
||||||
if (!uploadedFiles) {
|
if (!uploadedFiles) {
|
||||||
return
|
return
|
||||||
}
|
|
||||||
if (currentTab === PopoverTabs.AttachedFiles) {
|
|
||||||
uploadedFiles.forEach((file) => {
|
|
||||||
handleFileAction({
|
|
||||||
type: PopoverFileItemActionType.AttachFileToNote,
|
|
||||||
payload: file,
|
|
||||||
}).catch(console.error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (currentTab === PopoverTabs.AttachedFiles) {
|
||||||
|
uploadedFiles.forEach((file) => {
|
||||||
|
handleFileAction({
|
||||||
|
type: PopoverFileItemActionType.AttachFileToNote,
|
||||||
|
payload: file,
|
||||||
|
}).catch(console.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col"
|
className="flex flex-col"
|
||||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||||
style={{
|
style={{
|
||||||
border: isDraggingFiles ? '2px dashed var(--sn-stylekit-info-color)' : '',
|
border: isDraggingFiles ? '2px dashed var(--sn-stylekit-info-color)' : '',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex border-0 border-b-1 border-solid border-main">
|
<div className="flex border-0 border-b-1 border-solid border-main">
|
||||||
<button
|
<button
|
||||||
id={PopoverTabs.AttachedFiles}
|
id={PopoverTabs.AttachedFiles}
|
||||||
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
|
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
|
||||||
currentTab === PopoverTabs.AttachedFiles ? 'color-info font-medium shadow-bottom' : 'color-text'
|
currentTab === PopoverTabs.AttachedFiles ? 'color-info font-medium shadow-bottom' : 'color-text'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentTab(PopoverTabs.AttachedFiles)
|
setCurrentTab(PopoverTabs.AttachedFiles)
|
||||||
}}
|
}}
|
||||||
onBlur={closeOnBlur}
|
onBlur={closeOnBlur}
|
||||||
>
|
>
|
||||||
Attached
|
Attached
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id={PopoverTabs.AllFiles}
|
id={PopoverTabs.AllFiles}
|
||||||
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
|
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
|
||||||
currentTab === PopoverTabs.AllFiles ? 'color-info font-medium shadow-bottom' : 'color-text'
|
currentTab === PopoverTabs.AllFiles ? 'color-info font-medium shadow-bottom' : 'color-text'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentTab(PopoverTabs.AllFiles)
|
setCurrentTab(PopoverTabs.AllFiles)
|
||||||
}}
|
}}
|
||||||
onBlur={closeOnBlur}
|
onBlur={closeOnBlur}
|
||||||
>
|
>
|
||||||
All files
|
All files
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 max-h-110 overflow-y-auto">
|
<div className="min-h-0 max-h-110 overflow-y-auto">
|
||||||
{filteredList.length > 0 || searchQuery.length > 0 ? (
|
{filteredList.length > 0 || searchQuery.length > 0 ? (
|
||||||
<div className="sticky top-0 left-0 p-3 bg-default border-0 border-b-1 border-solid border-main">
|
<div className="sticky top-0 left-0 p-3 bg-default border-0 border-b-1 border-solid border-main">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="color-text w-full rounded py-1.5 px-3 text-input bg-default border-solid border-1 border-main"
|
className="color-text w-full rounded py-1.5 px-3 text-input bg-default border-solid border-1 border-main"
|
||||||
placeholder="Search files..."
|
placeholder="Search files..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
setSearchQuery((e.target as HTMLInputElement).value)
|
setSearchQuery((e.target as HTMLInputElement).value)
|
||||||
|
}}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
ref={searchInputRef}
|
||||||
|
/>
|
||||||
|
{searchQuery.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="flex absolute right-2 p-0 bg-transparent border-0 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('')
|
||||||
|
searchInputRef.current?.focus()
|
||||||
}}
|
}}
|
||||||
onBlur={closeOnBlur}
|
onBlur={closeOnBlur}
|
||||||
ref={searchInputRef}
|
>
|
||||||
/>
|
<Icon type="clear-circle-filled" className="color-neutral" />
|
||||||
{searchQuery.length > 0 && (
|
</button>
|
||||||
<button
|
)}
|
||||||
className="flex absolute right-2 p-0 bg-transparent border-0 top-1/2 -translate-y-1/2 cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
setSearchQuery('')
|
|
||||||
searchInputRef.current?.focus()
|
|
||||||
}}
|
|
||||||
onBlur={closeOnBlur}
|
|
||||||
>
|
|
||||||
<Icon type="clear-circle-filled" className="color-neutral" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</div>
|
||||||
{filteredList.length > 0 ? (
|
) : null}
|
||||||
filteredList.map((file: FileItem) => {
|
{filteredList.length > 0 ? (
|
||||||
return (
|
filteredList.map((file: FileItem) => {
|
||||||
<PopoverFileItem
|
return (
|
||||||
key={file.uuid}
|
<PopoverFileItem
|
||||||
file={file}
|
key={file.uuid}
|
||||||
isAttachedToNote={attachedFiles.includes(file)}
|
file={file}
|
||||||
handleFileAction={handleFileAction}
|
isAttachedToNote={attachedFiles.includes(file)}
|
||||||
getIconType={application.iconsController.getIconForFileType}
|
handleFileAction={handleFileAction}
|
||||||
closeOnBlur={closeOnBlur}
|
getIconType={application.iconsController.getIconForFileType}
|
||||||
/>
|
closeOnBlur={closeOnBlur}
|
||||||
)
|
/>
|
||||||
})
|
)
|
||||||
) : (
|
})
|
||||||
<div className="flex flex-col items-center justify-center w-full py-8">
|
) : (
|
||||||
<div className="w-18 h-18 mb-2">
|
<div className="flex flex-col items-center justify-center w-full py-8">
|
||||||
<FilesIllustration />
|
<div className="w-18 h-18 mb-2">
|
||||||
</div>
|
<FilesIllustration />
|
||||||
<div className="text-sm font-medium mb-3">
|
|
||||||
{searchQuery.length > 0
|
|
||||||
? 'No result found'
|
|
||||||
: currentTab === PopoverTabs.AttachedFiles
|
|
||||||
? 'No files attached to this note'
|
|
||||||
: 'No files found in this account'}
|
|
||||||
</div>
|
|
||||||
<Button variant="normal" onClick={handleAttachFilesClick} onBlur={closeOnBlur}>
|
|
||||||
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
|
|
||||||
</Button>
|
|
||||||
<div className="text-xs color-grey-0 mt-3">Or drop your files here</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="text-sm font-medium mb-3">
|
||||||
</div>
|
{searchQuery.length > 0
|
||||||
{filteredList.length > 0 && (
|
? 'No result found'
|
||||||
<button
|
: currentTab === PopoverTabs.AttachedFiles
|
||||||
className="sn-dropdown-item py-3 border-0 border-t-1px border-solid border-main focus:bg-info-backdrop"
|
? 'No files attached to this note'
|
||||||
onClick={handleAttachFilesClick}
|
: 'No files found in this account'}
|
||||||
onBlur={closeOnBlur}
|
</div>
|
||||||
>
|
<Button variant="normal" onClick={handleAttachFilesClick} onBlur={closeOnBlur}>
|
||||||
<Icon type="add" className="mr-2 color-neutral" />
|
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
|
||||||
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
|
</Button>
|
||||||
</button>
|
<div className="text-xs color-passive-0 mt-3">Or drop your files here</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
{filteredList.length > 0 && (
|
||||||
},
|
<button
|
||||||
)
|
className="sn-dropdown-item py-3 border-0 border-t-1px border-solid border-main focus:bg-info-backdrop"
|
||||||
|
onClick={handleAttachFilesClick}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
<Icon type="add" className="mr-2 color-neutral" />
|
||||||
|
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(AttachedFilesPopover)
|
||||||
|
|||||||
@@ -1,28 +1,15 @@
|
|||||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||||
import { KeyboardKey } from '@/Services/IOService'
|
import { KeyboardKey } from '@/Services/IOService'
|
||||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||||
import { IconType, FileItem } from '@standardnotes/snjs'
|
import { FileItem } from '@standardnotes/snjs'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FormEventHandler, FunctionComponent, KeyboardEventHandler, useEffect, useRef, useState } from 'react'
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon, ICONS } from '@/Components/Icon'
|
import { PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||||
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
|
import PopoverFileSubmenu from './PopoverFileSubmenu'
|
||||||
import { PopoverFileSubmenu } from './PopoverFileSubmenu'
|
import { getFileIconComponent } from './getFileIconComponent'
|
||||||
|
import { PopoverFileItemProps } from './PopoverFileItemProps'
|
||||||
|
|
||||||
export const getFileIconComponent = (iconType: string, className: string) => {
|
const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||||
const IconComponent = ICONS[iconType as keyof typeof ICONS]
|
|
||||||
|
|
||||||
return <IconComponent className={className} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PopoverFileItemProps = {
|
|
||||||
file: FileItem
|
|
||||||
isAttachedToNote: boolean
|
|
||||||
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
|
|
||||||
getIconType(type: string): IconType
|
|
||||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
|
||||||
file,
|
file,
|
||||||
isAttachedToNote,
|
isAttachedToNote,
|
||||||
handleFileAction,
|
handleFileAction,
|
||||||
@@ -51,11 +38,11 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
|||||||
setIsRenamingFile(false)
|
setIsRenamingFile(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileNameInput = (event: Event) => {
|
const handleFileNameInput: FormEventHandler<HTMLInputElement> = (event) => {
|
||||||
setFileName((event.target as HTMLInputElement).value)
|
setFileName((event.target as HTMLInputElement).value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileNameInputKeyDown = (event: KeyboardEvent) => {
|
const handleFileNameInputKeyDown: KeyboardEventHandler = (event) => {
|
||||||
if (event.key === KeyboardKey.Enter) {
|
if (event.key === KeyboardKey.Enter) {
|
||||||
itemRef.current?.focus()
|
itemRef.current?.focus()
|
||||||
}
|
}
|
||||||
@@ -99,7 +86,7 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs color-grey-0">
|
<div className="text-xs color-passive-0">
|
||||||
{file.created_at.toLocaleString()} · {formatSizeToReadableString(file.decryptedSize)}
|
{file.created_at.toLocaleString()} · {formatSizeToReadableString(file.decryptedSize)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,3 +102,5 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default PopoverFileItem
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { IconType, FileItem } from '@standardnotes/snjs'
|
||||||
|
import { PopoverFileItemAction } from './PopoverFileItemAction'
|
||||||
|
|
||||||
|
export type PopoverFileItemProps = {
|
||||||
|
file: FileItem
|
||||||
|
isAttachedToNote: boolean
|
||||||
|
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
|
||||||
|
getIconType(type: string): IconType
|
||||||
|
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||||
|
}
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||||
import { FunctionComponent } from 'preact'
|
import { Dispatch, FunctionComponent, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { StateUpdater, useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
import Switch from '@/Components/Switch/Switch'
|
||||||
import { Switch } from '@/Components/Switch'
|
|
||||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||||
import { PopoverFileItemProps } from './PopoverFileItem'
|
import { PopoverFileItemProps } from './PopoverFileItemProps'
|
||||||
import { PopoverFileItemActionType } from './PopoverFileItemAction'
|
import { PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||||
|
|
||||||
type Props = Omit<PopoverFileItemProps, 'renameFile' | 'getIconType'> & {
|
type Props = Omit<PopoverFileItemProps, 'renameFile' | 'getIconType'> & {
|
||||||
setIsRenamingFile: StateUpdater<boolean>
|
setIsRenamingFile: Dispatch<SetStateAction<boolean>>
|
||||||
previewHandler: () => void
|
previewHandler: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PopoverFileSubmenu: FunctionComponent<Props> = ({
|
const PopoverFileSubmenu: FunctionComponent<Props> = ({
|
||||||
file,
|
file,
|
||||||
isAttachedToNote,
|
isAttachedToNote,
|
||||||
handleFileAction,
|
handleFileAction,
|
||||||
@@ -197,3 +196,5 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default PopoverFileSubmenu
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ICONS } from '@/Components/Icon/Icon'
|
||||||
|
|
||||||
|
export const getFileIconComponent = (iconType: string, className: string) => {
|
||||||
|
const IconComponent = ICONS[iconType as keyof typeof ICONS]
|
||||||
|
|
||||||
|
return <IconComponent className={className} />
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
interface BubbleProperties {
|
import { FunctionComponent } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
label: string
|
label: string
|
||||||
selected: boolean
|
selected: boolean
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
@@ -10,7 +12,7 @@ const styles = {
|
|||||||
selected: 'border-info bg-info color-neutral-contrast',
|
selected: 'border-info bg-info color-neutral-contrast',
|
||||||
}
|
}
|
||||||
|
|
||||||
const Bubble = ({ label, selected, onSelect }: BubbleProperties) => (
|
const Bubble: FunctionComponent<Props> = ({ label, selected, onSelect }) => (
|
||||||
<span
|
<span
|
||||||
role="tab"
|
role="tab"
|
||||||
className={`bubble ${styles.base} ${selected ? styles.selected : styles.unselected}`}
|
className={`bubble ${styles.base} ${selected ? styles.selected : styles.unselected}`}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { JSXInternal } from 'preact/src/jsx'
|
import { Ref, forwardRef, ReactNode, ComponentPropsWithoutRef } from 'react'
|
||||||
import { ComponentChildren, FunctionComponent, Ref } from 'preact'
|
|
||||||
import { forwardRef } from 'preact/compat'
|
|
||||||
|
|
||||||
const baseClass = 'rounded px-4 py-1.75 font-bold text-sm fit-content'
|
const baseClass = 'rounded px-4 py-1.75 font-bold text-sm fit-content'
|
||||||
|
|
||||||
@@ -22,7 +20,7 @@ const getClassName = (variant: ButtonVariant, danger: boolean, disabled: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
colors = variant === 'normal' ? 'bg-default color-grey-2' : 'bg-grey-2 color-info-contrast'
|
colors = variant === 'normal' ? 'bg-default color-passive-2' : 'bg-passive-2 color-info-contrast'
|
||||||
focusHoverStates =
|
focusHoverStates =
|
||||||
variant === 'normal'
|
variant === 'normal'
|
||||||
? 'focus:bg-default focus:outline-none hover:bg-default'
|
? 'focus:bg-default focus:outline-none hover:bg-default'
|
||||||
@@ -32,15 +30,15 @@ const getClassName = (variant: ButtonVariant, danger: boolean, disabled: boolean
|
|||||||
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`
|
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ButtonProps = JSXInternal.HTMLAttributes<HTMLButtonElement> & {
|
interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
|
||||||
children?: ComponentChildren
|
children?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
variant?: ButtonVariant
|
variant?: ButtonVariant
|
||||||
dangerStyle?: boolean
|
dangerStyle?: boolean
|
||||||
label?: string
|
label?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Button: FunctionComponent<ButtonProps> = forwardRef(
|
const Button = forwardRef(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
variant = 'normal',
|
variant = 'normal',
|
||||||
@@ -66,3 +64,5 @@ export const Button: FunctionComponent<ButtonProps> = forwardRef(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export default Button
|
||||||
|
|||||||
@@ -1,34 +1,18 @@
|
|||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, MouseEventHandler } from 'react'
|
||||||
import { Icon } from '@/Components/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { IconType } from '@standardnotes/snjs'
|
import { IconType } from '@standardnotes/snjs'
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
/**
|
|
||||||
* onClick - preventDefault is handled within the component
|
|
||||||
*/
|
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
|
|
||||||
className?: string
|
className?: string
|
||||||
|
|
||||||
icon: IconType
|
icon: IconType
|
||||||
|
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
|
|
||||||
/**
|
|
||||||
* Button tooltip
|
|
||||||
*/
|
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
focusable: boolean
|
focusable: boolean
|
||||||
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const IconButton: FunctionComponent<Props> = ({
|
||||||
* IconButton component with an icon
|
|
||||||
* preventDefault is already handled within the component
|
|
||||||
*/
|
|
||||||
export const IconButton: FunctionComponent<Props> = ({
|
|
||||||
onClick,
|
onClick,
|
||||||
className = '',
|
className = '',
|
||||||
icon,
|
icon,
|
||||||
@@ -37,7 +21,7 @@ export const IconButton: FunctionComponent<Props> = ({
|
|||||||
iconClassName = '',
|
iconClassName = '',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const click = (e: MouseEvent) => {
|
const click: MouseEventHandler = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onClick()
|
onClick()
|
||||||
}
|
}
|
||||||
@@ -55,3 +39,5 @@ export const IconButton: FunctionComponent<Props> = ({
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default IconButton
|
||||||
|
|||||||
@@ -1,28 +1,18 @@
|
|||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, MouseEventHandler } from 'react'
|
||||||
import { Icon } from '@/Components/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { IconType } from '@standardnotes/snjs'
|
import { IconType } from '@standardnotes/snjs'
|
||||||
|
|
||||||
type ButtonType = 'normal' | 'primary'
|
type ButtonType = 'normal' | 'primary'
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
/**
|
|
||||||
* onClick - preventDefault is handled within the component
|
|
||||||
*/
|
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
|
|
||||||
type: ButtonType
|
type: ButtonType
|
||||||
|
|
||||||
className?: string
|
className?: string
|
||||||
|
|
||||||
icon: IconType
|
icon: IconType
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const RoundIconButton: FunctionComponent<Props> = ({ onClick, type, className, icon: iconType }) => {
|
||||||
* IconButton component with an icon
|
const click: MouseEventHandler = (e) => {
|
||||||
* preventDefault is already handled within the component
|
|
||||||
*/
|
|
||||||
export const RoundIconButton: FunctionComponent<Props> = ({ onClick, type, className, icon: iconType }) => {
|
|
||||||
const click = (e: MouseEvent) => {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onClick()
|
onClick()
|
||||||
}
|
}
|
||||||
@@ -33,3 +23,5 @@ export const RoundIconButton: FunctionComponent<Props> = ({ onClick, type, class
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default RoundIconButton
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
||||||
import {
|
import {
|
||||||
ButtonType,
|
ButtonType,
|
||||||
@@ -9,26 +9,18 @@ import {
|
|||||||
removeFromArray,
|
removeFromArray,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { ProtectedIllustration } from '@standardnotes/icons'
|
import { ProtectedIllustration } from '@standardnotes/icons'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
import Button from '@/Components/Button/Button'
|
||||||
import { Button } from '@/Components/Button/Button'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
import ChallengeModalPrompt from './ChallengePrompt'
|
||||||
import { ChallengeModalPrompt } from './ChallengePrompt'
|
import LockscreenWorkspaceSwitcher from './LockscreenWorkspaceSwitcher'
|
||||||
import { LockscreenWorkspaceSwitcher } from './LockscreenWorkspaceSwitcher'
|
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ChallengeModalValues } from './ChallengeModalValues'
|
||||||
|
|
||||||
type InputValue = {
|
|
||||||
prompt: ChallengePrompt
|
|
||||||
value: string | number | boolean
|
|
||||||
invalid: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
mainApplicationGroup: ApplicationGroup
|
mainApplicationGroup: ApplicationGroup
|
||||||
challenge: Challenge
|
challenge: Challenge
|
||||||
onDismiss?: (challenge: Challenge) => void
|
onDismiss?: (challenge: Challenge) => void
|
||||||
@@ -50,9 +42,9 @@ const validateValues = (values: ChallengeModalValues, prompts: ChallengePrompt[]
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChallengeModal: FunctionComponent<Props> = ({
|
const ChallengeModal: FunctionComponent<Props> = ({
|
||||||
application,
|
application,
|
||||||
appState,
|
viewControllerManager,
|
||||||
mainApplicationGroup,
|
mainApplicationGroup,
|
||||||
challenge,
|
challenge,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
@@ -191,6 +183,7 @@ export const ChallengeModal: FunctionComponent<Props> = ({
|
|||||||
key={challenge.id}
|
key={challenge.id}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
aria-label="Challenge modal"
|
||||||
className={`challenge-modal flex flex-col items-center bg-default p-8 rounded relative ${
|
className={`challenge-modal flex flex-col items-center bg-default p-8 rounded relative ${
|
||||||
challenge.reason !== ChallengeReason.ApplicationUnlock
|
challenge.reason !== ChallengeReason.ApplicationUnlock
|
||||||
? 'shadow-overlay-light border-1 border-solid border-main'
|
? 'shadow-overlay-light border-1 border-solid border-main'
|
||||||
@@ -262,9 +255,14 @@ export const ChallengeModal: FunctionComponent<Props> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{shouldShowWorkspaceSwitcher && (
|
{shouldShowWorkspaceSwitcher && (
|
||||||
<LockscreenWorkspaceSwitcher mainApplicationGroup={mainApplicationGroup} appState={appState} />
|
<LockscreenWorkspaceSwitcher
|
||||||
|
mainApplicationGroup={mainApplicationGroup}
|
||||||
|
viewControllerManager={viewControllerManager}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogOverlay>
|
</DialogOverlay>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ChallengeModal
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { ChallengePrompt } from '@standardnotes/snjs'
|
||||||
|
import { InputValue } from './InputValue'
|
||||||
|
|
||||||
|
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs'
|
import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, useEffect, useRef } from 'react'
|
||||||
import { useEffect, useRef } from 'preact/hooks'
|
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||||
import { DecoratedInput } from '@/Components/Input/DecoratedInput'
|
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
|
||||||
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput'
|
import { ChallengeModalValues } from './ChallengeModalValues'
|
||||||
import { ChallengeModalValues } from './ChallengeModal'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
prompt: ChallengePrompt
|
prompt: ChallengePrompt
|
||||||
@@ -13,7 +12,7 @@ type Props = {
|
|||||||
isInvalid: boolean
|
isInvalid: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index, onValueChange, isInvalid }) => {
|
const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index, onValueChange, isInvalid }) => {
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,13 +32,14 @@ export const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values,
|
|||||||
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
|
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
|
||||||
<div className="min-w-76">
|
<div className="min-w-76">
|
||||||
<div className="text-sm font-medium mb-2">Allow protected access for</div>
|
<div className="text-sm font-medium mb-2">Allow protected access for</div>
|
||||||
<div className="flex items-center justify-between bg-grey-4 rounded p-1">
|
<div className="flex items-center justify-between bg-passive-4 rounded p-1">
|
||||||
{ProtectionSessionDurations.map((option) => {
|
{ProtectionSessionDurations.map((option) => {
|
||||||
const selected = option.valueInSeconds === values[prompt.id].value
|
const selected = option.valueInSeconds === values[prompt.id].value
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
|
key={option.label}
|
||||||
className={`cursor-pointer px-2 py-1.5 rounded ${
|
className={`cursor-pointer px-2 py-1.5 rounded ${
|
||||||
selected ? 'bg-default color-foreground font-semibold' : 'color-grey-0 hover:bg-grey-3'
|
selected ? 'bg-default color-foreground font-semibold' : 'color-passive-0 hover:bg-passive-3'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -80,3 +80,5 @@ export const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values,
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ChallengeModalPrompt
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ChallengePrompt } from '@standardnotes/snjs'
|
||||||
|
|
||||||
|
export type InputValue = {
|
||||||
|
prompt: ChallengePrompt
|
||||||
|
value: string | number | boolean
|
||||||
|
invalid: boolean
|
||||||
|
}
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
import WorkspaceSwitcherMenu from '@/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu'
|
||||||
import { WorkspaceSwitcherMenu } from '@/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu'
|
import Button from '@/Components/Button/Button'
|
||||||
import { Button } from '@/Components/Button/Button'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
|
||||||
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
|
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mainApplicationGroup: ApplicationGroup
|
mainApplicationGroup: ApplicationGroup
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainApplicationGroup, appState }) => {
|
const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainApplicationGroup, viewControllerManager }) => {
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -56,7 +55,7 @@ export const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainAppl
|
|||||||
<div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}>
|
<div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}>
|
||||||
<WorkspaceSwitcherMenu
|
<WorkspaceSwitcherMenu
|
||||||
mainApplicationGroup={mainApplicationGroup}
|
mainApplicationGroup={mainApplicationGroup}
|
||||||
appState={appState}
|
viewControllerManager={viewControllerManager}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
hideWorkspaceOptions={true}
|
hideWorkspaceOptions={true}
|
||||||
/>
|
/>
|
||||||
@@ -65,3 +64,5 @@ export const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainAppl
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default LockscreenWorkspaceSwitcher
|
||||||
|
|||||||
@@ -1,114 +1,112 @@
|
|||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
|
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||||
import VisuallyHidden from '@reach/visually-hidden'
|
import VisuallyHidden from '@reach/visually-hidden'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, useRef, useState } from 'react'
|
||||||
import { useRef, useState } from 'preact/hooks'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
import ChangeEditorMenu from './ChangeEditorMenu'
|
||||||
import { ChangeEditorMenu } from './ChangeEditorMenu'
|
|
||||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||||
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
onClickPreprocessing?: () => Promise<void>
|
onClickPreprocessing?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChangeEditorButton: FunctionComponent<Props> = observer(
|
const ChangeEditorButton: FunctionComponent<Props> = ({
|
||||||
({ application, appState, onClickPreprocessing }: Props) => {
|
application,
|
||||||
if (isStateDealloced(appState)) {
|
viewControllerManager,
|
||||||
return null
|
onClickPreprocessing,
|
||||||
}
|
}: Props) => {
|
||||||
|
const note = viewControllerManager.notesController.firstSelectedNote
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
const [position, setPosition] = useState({
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
})
|
||||||
|
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [closeOnBlur] = useCloseOnBlur(containerRef, setIsOpen)
|
||||||
|
|
||||||
const note = Object.values(appState.notes.selectedNotes)[0]
|
const toggleChangeEditorMenu = async () => {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const rect = buttonRef.current?.getBoundingClientRect()
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
if (rect) {
|
||||||
const [position, setPosition] = useState({
|
const { clientHeight } = document.documentElement
|
||||||
top: 0,
|
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
||||||
right: 0,
|
const footerHeightInPx = footerElementRect?.height
|
||||||
})
|
|
||||||
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null)
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [closeOnBlur] = useCloseOnBlur(containerRef, setIsOpen)
|
|
||||||
|
|
||||||
const toggleChangeEditorMenu = async () => {
|
if (footerHeightInPx) {
|
||||||
const rect = buttonRef.current?.getBoundingClientRect()
|
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
|
||||||
if (rect) {
|
|
||||||
const { clientHeight } = document.documentElement
|
|
||||||
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
|
||||||
const footerHeightInPx = footerElementRect?.height
|
|
||||||
|
|
||||||
if (footerHeightInPx) {
|
|
||||||
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
|
|
||||||
}
|
|
||||||
|
|
||||||
setPosition({
|
|
||||||
top: rect.bottom,
|
|
||||||
right: document.body.clientWidth - rect.right,
|
|
||||||
})
|
|
||||||
|
|
||||||
const newOpenState = !isOpen
|
|
||||||
if (newOpenState && onClickPreprocessing) {
|
|
||||||
await onClickPreprocessing()
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsOpen(newOpenState)
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsVisible(newOpenState)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
setPosition({
|
||||||
<div ref={containerRef}>
|
top: rect.bottom,
|
||||||
<Disclosure open={isOpen} onChange={toggleChangeEditorMenu}>
|
right: document.body.clientWidth - rect.right,
|
||||||
<DisclosureButton
|
})
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Escape') {
|
const newOpenState = !isOpen
|
||||||
|
if (newOpenState && onClickPreprocessing) {
|
||||||
|
await onClickPreprocessing()
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(newOpenState)
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsVisible(newOpenState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<Disclosure open={isOpen} onChange={toggleChangeEditorMenu}>
|
||||||
|
<DisclosureButton
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
ref={buttonRef}
|
||||||
|
className="sn-icon-button border-contrast"
|
||||||
|
>
|
||||||
|
<VisuallyHidden>Change note type</VisuallyHidden>
|
||||||
|
<Icon type="dashboard" className="block" />
|
||||||
|
</DisclosureButton>
|
||||||
|
<DisclosurePanel
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setIsOpen(false)
|
||||||
|
buttonRef.current?.focus()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={panelRef}
|
||||||
|
style={{
|
||||||
|
...position,
|
||||||
|
maxHeight,
|
||||||
|
}}
|
||||||
|
className="sn-dropdown sn-dropdown--animated min-w-68 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
{isOpen && (
|
||||||
|
<ChangeEditorMenu
|
||||||
|
closeOnBlur={closeOnBlur}
|
||||||
|
application={application}
|
||||||
|
isVisible={isVisible}
|
||||||
|
note={note}
|
||||||
|
closeMenu={() => {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}
|
}}
|
||||||
}}
|
/>
|
||||||
onBlur={closeOnBlur}
|
)}
|
||||||
ref={buttonRef}
|
</DisclosurePanel>
|
||||||
className="sn-icon-button border-contrast"
|
</Disclosure>
|
||||||
>
|
</div>
|
||||||
<VisuallyHidden>Change note type</VisuallyHidden>
|
)
|
||||||
<Icon type="dashboard" className="block" />
|
}
|
||||||
</DisclosureButton>
|
|
||||||
<DisclosurePanel
|
export default observer(ChangeEditorButton)
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
setIsOpen(false)
|
|
||||||
buttonRef.current?.focus()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
ref={panelRef}
|
|
||||||
style={{
|
|
||||||
...position,
|
|
||||||
maxHeight,
|
|
||||||
}}
|
|
||||||
className="sn-dropdown sn-dropdown--animated min-w-68 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
|
|
||||||
onBlur={closeOnBlur}
|
|
||||||
>
|
|
||||||
{isOpen && (
|
|
||||||
<ChangeEditorMenu
|
|
||||||
closeOnBlur={closeOnBlur}
|
|
||||||
application={application}
|
|
||||||
isVisible={isVisible}
|
|
||||||
note={note}
|
|
||||||
closeMenu={() => {
|
|
||||||
setIsOpen(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DisclosurePanel>
|
|
||||||
</Disclosure>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { Icon } from '@/Components/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Menu } from '@/Components/Menu/Menu'
|
import Menu from '@/Components/Menu/Menu'
|
||||||
import { MenuItem, MenuItemType } from '@/Components/Menu/MenuItem'
|
import MenuItem from '@/Components/Menu/MenuItem'
|
||||||
import {
|
import { MenuItemType } from '@/Components/Menu/MenuItemType'
|
||||||
reloadFont,
|
|
||||||
transactionForAssociateComponentWithCurrentNote,
|
|
||||||
transactionForDisassociateComponentWithCurrentNote,
|
|
||||||
} from '@/Components/NoteView/NoteView'
|
|
||||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||||
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Strings'
|
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Constants/Strings'
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import {
|
import {
|
||||||
ComponentArea,
|
ComponentArea,
|
||||||
ItemMutator,
|
ItemMutator,
|
||||||
@@ -18,23 +14,28 @@ import {
|
|||||||
SNNote,
|
SNNote,
|
||||||
TransactionalMutation,
|
TransactionalMutation,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { Fragment, FunctionComponent } from 'preact'
|
import { Fragment, FunctionComponent, useCallback, useEffect, useState } from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup'
|
||||||
import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption'
|
import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem'
|
||||||
import { createEditorMenuGroups } from './createEditorMenuGroups'
|
import { createEditorMenuGroups } from './createEditorMenuGroups'
|
||||||
import { PLAIN_EDITOR_NAME } from '@/Constants'
|
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
|
||||||
|
import {
|
||||||
|
transactionForAssociateComponentWithCurrentNote,
|
||||||
|
transactionForDisassociateComponentWithCurrentNote,
|
||||||
|
} from '../NoteView/TransactionFunctions'
|
||||||
|
import { reloadFont } from '../NoteView/FontFunctions'
|
||||||
|
|
||||||
type ChangeEditorMenuProps = {
|
type ChangeEditorMenuProps = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||||
closeMenu: () => void
|
closeMenu: () => void
|
||||||
isVisible: boolean
|
isVisible: boolean
|
||||||
note: SNNote
|
note: SNNote | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-')
|
const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-')
|
||||||
|
|
||||||
export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||||
application,
|
application,
|
||||||
closeOnBlur,
|
closeOnBlur,
|
||||||
closeMenu,
|
closeMenu,
|
||||||
@@ -43,7 +44,7 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [editors] = useState<SNComponent[]>(() =>
|
const [editors] = useState<SNComponent[]>(() =>
|
||||||
application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => {
|
application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => {
|
||||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1
|
return a.displayName.toLowerCase() < b.displayName.toLowerCase() ? -1 : 1
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const [groups, setGroups] = useState<EditorMenuGroup[]>([])
|
const [groups, setGroups] = useState<EditorMenuGroup[]>([])
|
||||||
@@ -75,97 +76,103 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
|||||||
[currentEditor],
|
[currentEditor],
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectComponent = async (component: SNComponent | null, note: SNNote) => {
|
const selectComponent = useCallback(
|
||||||
if (component) {
|
async (component: SNComponent | null, note: SNNote) => {
|
||||||
if (component.conflictOf) {
|
if (component) {
|
||||||
application.mutator
|
if (component.conflictOf) {
|
||||||
.changeAndSaveItem(component, (mutator) => {
|
application.mutator
|
||||||
mutator.conflictOf = undefined
|
.changeAndSaveItem(component, (mutator) => {
|
||||||
|
mutator.conflictOf = undefined
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactions: TransactionalMutation[] = []
|
||||||
|
|
||||||
|
await application.getViewControllerManager().itemListController.insertCurrentIfTemplate()
|
||||||
|
|
||||||
|
if (note.locked) {
|
||||||
|
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT).catch(console.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!component) {
|
||||||
|
if (!note.prefersPlainEditor) {
|
||||||
|
transactions.push({
|
||||||
|
itemUuid: note.uuid,
|
||||||
|
mutate: (m: ItemMutator) => {
|
||||||
|
const noteMutator = m as NoteMutator
|
||||||
|
noteMutator.prefersPlainEditor = true
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
}
|
||||||
|
const currentEditor = application.componentManager.editorForNote(note)
|
||||||
|
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) {
|
||||||
|
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
|
||||||
|
}
|
||||||
|
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled))
|
||||||
|
} else if (component.area === ComponentArea.Editor) {
|
||||||
|
const currentEditor = application.componentManager.editorForNote(note)
|
||||||
|
if (currentEditor && component.uuid !== currentEditor.uuid) {
|
||||||
|
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
|
||||||
|
}
|
||||||
|
const prefersPlain = note.prefersPlainEditor
|
||||||
|
if (prefersPlain) {
|
||||||
|
transactions.push({
|
||||||
|
itemUuid: note.uuid,
|
||||||
|
mutate: (m: ItemMutator) => {
|
||||||
|
const noteMutator = m as NoteMutator
|
||||||
|
noteMutator.prefersPlainEditor = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
transactions.push(transactionForAssociateComponentWithCurrentNote(component, note))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const transactions: TransactionalMutation[] = []
|
await application.mutator.runTransactionalMutations(transactions)
|
||||||
|
/** Dirtying can happen above */
|
||||||
|
application.sync.sync().catch(console.error)
|
||||||
|
|
||||||
await application.getAppState().notesView.insertCurrentIfTemplate()
|
setCurrentEditor(application.componentManager.editorForNote(note))
|
||||||
|
},
|
||||||
|
[application],
|
||||||
|
)
|
||||||
|
|
||||||
if (note.locked) {
|
const selectEditor = useCallback(
|
||||||
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT).catch(console.error)
|
async (itemToBeSelected: EditorMenuItem) => {
|
||||||
return
|
if (!itemToBeSelected.isEntitled) {
|
||||||
}
|
premiumModal.activate(itemToBeSelected.name)
|
||||||
|
return
|
||||||
if (!component) {
|
|
||||||
if (!note.prefersPlainEditor) {
|
|
||||||
transactions.push({
|
|
||||||
itemUuid: note.uuid,
|
|
||||||
mutate: (m: ItemMutator) => {
|
|
||||||
const noteMutator = m as NoteMutator
|
|
||||||
noteMutator.prefersPlainEditor = true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const currentEditor = application.componentManager.editorForNote(note)
|
|
||||||
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) {
|
const areBothEditorsPlain = !currentEditor && !itemToBeSelected.component
|
||||||
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
|
|
||||||
|
if (areBothEditorsPlain) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled))
|
|
||||||
} else if (component.area === ComponentArea.Editor) {
|
let shouldSelectEditor = true
|
||||||
const currentEditor = application.componentManager.editorForNote(note)
|
|
||||||
if (currentEditor && component.uuid !== currentEditor.uuid) {
|
if (itemToBeSelected.component) {
|
||||||
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
|
const changeRequiresAlert = application.componentManager.doesEditorChangeRequireAlert(
|
||||||
|
currentEditor,
|
||||||
|
itemToBeSelected.component,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (changeRequiresAlert) {
|
||||||
|
shouldSelectEditor = await application.componentManager.showEditorChangeAlert()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const prefersPlain = note.prefersPlainEditor
|
|
||||||
if (prefersPlain) {
|
if (shouldSelectEditor && note) {
|
||||||
transactions.push({
|
selectComponent(itemToBeSelected.component ?? null, note).catch(console.error)
|
||||||
itemUuid: note.uuid,
|
|
||||||
mutate: (m: ItemMutator) => {
|
|
||||||
const noteMutator = m as NoteMutator
|
|
||||||
noteMutator.prefersPlainEditor = false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
transactions.push(transactionForAssociateComponentWithCurrentNote(component, note))
|
|
||||||
}
|
|
||||||
|
|
||||||
await application.mutator.runTransactionalMutations(transactions)
|
closeMenu()
|
||||||
/** Dirtying can happen above */
|
},
|
||||||
application.sync.sync().catch(console.error)
|
[application.componentManager, closeMenu, currentEditor, note, premiumModal, selectComponent],
|
||||||
|
)
|
||||||
setCurrentEditor(application.componentManager.editorForNote(note))
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectEditor = async (itemToBeSelected: EditorMenuItem) => {
|
|
||||||
if (!itemToBeSelected.isEntitled) {
|
|
||||||
premiumModal.activate(itemToBeSelected.name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const areBothEditorsPlain = !currentEditor && !itemToBeSelected.component
|
|
||||||
|
|
||||||
if (areBothEditorsPlain) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldSelectEditor = true
|
|
||||||
|
|
||||||
if (itemToBeSelected.component) {
|
|
||||||
const changeRequiresAlert = application.componentManager.doesEditorChangeRequireAlert(
|
|
||||||
currentEditor,
|
|
||||||
itemToBeSelected.component,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (changeRequiresAlert) {
|
|
||||||
shouldSelectEditor = await application.componentManager.showEditorChangeAlert()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldSelectEditor) {
|
|
||||||
selectComponent(itemToBeSelected.component ?? null, note).catch(console.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
closeMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu className="pt-0.5 pb-1" a11yLabel="Change note type menu" isOpen={isVisible}>
|
<Menu className="pt-0.5 pb-1" a11yLabel="Change note type menu" isOpen={isVisible}>
|
||||||
@@ -176,37 +183,38 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={groupId}>
|
<Fragment key={groupId}>
|
||||||
<div
|
<div className={`py-1 border-0 border-t-1px border-solid border-main ${index === 0 ? 'border-t-0' : ''}`}>
|
||||||
className={`flex items-center px-2.5 py-2 text-xs font-semibold color-text border-0 border-y-1px border-solid border-main ${
|
{group.items.map((item) => {
|
||||||
index === 0 ? 'border-t-0 mb-2' : 'my-2'
|
const onClickEditorItem = () => {
|
||||||
}`}
|
selectEditor(item).catch(console.error)
|
||||||
>
|
}
|
||||||
{group.icon && <Icon type={group.icon} className={`mr-2 ${group.iconClassName}`} />}
|
return (
|
||||||
<div className="font-semibold text-input">{group.title}</div>
|
<MenuItem
|
||||||
|
key={item.name}
|
||||||
|
type={MenuItemType.RadioButton}
|
||||||
|
onClick={onClickEditorItem}
|
||||||
|
className={
|
||||||
|
'sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none flex-row-reverse'
|
||||||
|
}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
checked={isSelectedEditor(item)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-grow items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{group.icon && <Icon type={group.icon} className={`mr-2 ${group.iconClassName}`} />}
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
{!item.isEntitled && <Icon type="premium-feature" />}
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{group.items.map((item) => {
|
|
||||||
const onClickEditorItem = () => {
|
|
||||||
selectEditor(item).catch(console.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
type={MenuItemType.RadioButton}
|
|
||||||
onClick={onClickEditorItem}
|
|
||||||
className={'sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none'}
|
|
||||||
onBlur={closeOnBlur}
|
|
||||||
checked={isSelectedEditor(item)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-grow items-center justify-between">
|
|
||||||
{item.name}
|
|
||||||
{!item.isEntitled && <Icon type="premium-feature" />}
|
|
||||||
</div>
|
|
||||||
</MenuItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Menu>
|
</Menu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ChangeEditorMenu
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import {
|
import {
|
||||||
ContentType,
|
ContentType,
|
||||||
FeatureStatus,
|
FeatureStatus,
|
||||||
@@ -8,8 +8,9 @@ import {
|
|||||||
GetFeatures,
|
GetFeatures,
|
||||||
NoteType,
|
NoteType,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption'
|
import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup'
|
||||||
import { PLAIN_EDITOR_NAME } from '@/Constants'
|
import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem'
|
||||||
|
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
|
||||||
|
|
||||||
type EditorGroup = NoteType | 'plain' | 'others'
|
type EditorGroup = NoteType | 'plain' | 'others'
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ export const createEditorMenuGroups = (application: WebApplication, editors: SNC
|
|||||||
|
|
||||||
editors.forEach((editor) => {
|
editors.forEach((editor) => {
|
||||||
const editorItem: EditorMenuItem = {
|
const editorItem: EditorMenuItem = {
|
||||||
name: editor.name,
|
name: editor.displayName,
|
||||||
component: editor,
|
component: editor,
|
||||||
isEntitled: application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled,
|
isEntitled: application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { FunctionComponent } from 'preact'
|
import { ChangeEventHandler, FunctionComponent } from 'react'
|
||||||
|
|
||||||
type CheckboxProps = {
|
type CheckboxProps = {
|
||||||
name: string
|
name: string
|
||||||
checked: boolean
|
checked: boolean
|
||||||
onChange: (e: Event) => void
|
onChange: ChangeEventHandler<HTMLInputElement>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Checkbox: FunctionComponent<CheckboxProps> = ({ name, checked, onChange, disabled, label }) => {
|
const Checkbox: FunctionComponent<CheckboxProps> = ({ name, checked, onChange, disabled, label }) => {
|
||||||
return (
|
return (
|
||||||
<label htmlFor={name} className="flex items-center fit-content mb-2">
|
<label htmlFor={name} className="flex items-center fit-content mb-2">
|
||||||
<input
|
<input
|
||||||
@@ -24,3 +24,5 @@ export const Checkbox: FunctionComponent<CheckboxProps> = ({ name, checked, onCh
|
|||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Checkbox
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import {
|
||||||
|
ComponentAction,
|
||||||
|
FeatureStatus,
|
||||||
|
SNComponent,
|
||||||
|
dateToLocalizedString,
|
||||||
|
ComponentViewer,
|
||||||
|
ComponentViewerEvent,
|
||||||
|
ComponentViewerError,
|
||||||
|
} from '@standardnotes/snjs'
|
||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import OfflineRestricted from '@/Components/ComponentView/OfflineRestricted'
|
||||||
|
import UrlMissing from '@/Components/ComponentView/UrlMissing'
|
||||||
|
import IsDeprecated from '@/Components/ComponentView/IsDeprecated'
|
||||||
|
import IsExpired from '@/Components/ComponentView/IsExpired'
|
||||||
|
import IssueOnLoading from '@/Components/ComponentView/IssueOnLoading'
|
||||||
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
|
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
application: WebApplication
|
||||||
|
viewControllerManager: ViewControllerManager
|
||||||
|
componentViewer: ComponentViewer
|
||||||
|
requestReload?: (viewer: ComponentViewer, force?: boolean) => void
|
||||||
|
onLoad?: (component: SNComponent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum amount of time we'll wait for a component
|
||||||
|
* to load before displaying error
|
||||||
|
*/
|
||||||
|
const MaxLoadThreshold = 4000
|
||||||
|
const VisibilityChangeKey = 'visibilitychange'
|
||||||
|
const MSToWaitAfterIframeLoadToAvoidFlicker = 35
|
||||||
|
|
||||||
|
const ComponentView: FunctionComponent<IProps> = ({ application, onLoad, componentViewer, requestReload }) => {
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
|
const [loadTimeout, setLoadTimeout] = useState<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
|
|
||||||
|
const [hasIssueLoading, setHasIssueLoading] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(componentViewer.getFeatureStatus())
|
||||||
|
const [isComponentValid, setIsComponentValid] = useState(true)
|
||||||
|
const [error, setError] = useState<ComponentViewerError | undefined>(undefined)
|
||||||
|
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined)
|
||||||
|
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false)
|
||||||
|
const [didAttemptReload, setDidAttemptReload] = useState(false)
|
||||||
|
|
||||||
|
const component: SNComponent = componentViewer.component
|
||||||
|
|
||||||
|
const manageSubscription = useCallback(() => {
|
||||||
|
openSubscriptionDashboard(application)
|
||||||
|
}, [application])
|
||||||
|
|
||||||
|
const reloadValidityStatus = useCallback(() => {
|
||||||
|
setFeatureStatus(componentViewer.getFeatureStatus())
|
||||||
|
if (!componentViewer.lockReadonly) {
|
||||||
|
componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled)
|
||||||
|
}
|
||||||
|
setIsComponentValid(componentViewer.shouldRender())
|
||||||
|
|
||||||
|
if (isLoading && !isComponentValid) {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(componentViewer.getError())
|
||||||
|
setDeprecationMessage(component.deprecationMessage)
|
||||||
|
}, [componentViewer, component.deprecationMessage, featureStatus, isComponentValid, isLoading])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadValidityStatus()
|
||||||
|
}, [reloadValidityStatus])
|
||||||
|
|
||||||
|
const dismissDeprecationMessage = () => {
|
||||||
|
setIsDeprecationMessageDismissed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVisibilityChange = useCallback(() => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hasIssueLoading) {
|
||||||
|
requestReload?.(componentViewer)
|
||||||
|
}
|
||||||
|
}, [hasIssueLoading, componentViewer, requestReload])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTimeout = setTimeout(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
setHasIssueLoading(true)
|
||||||
|
|
||||||
|
if (!didAttemptReload) {
|
||||||
|
setDidAttemptReload(true)
|
||||||
|
requestReload?.(componentViewer)
|
||||||
|
} else {
|
||||||
|
document.addEventListener(VisibilityChangeKey, onVisibilityChange)
|
||||||
|
}
|
||||||
|
}, MaxLoadThreshold)
|
||||||
|
|
||||||
|
setLoadTimeout(loadTimeout)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (loadTimeout) {
|
||||||
|
clearTimeout(loadTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [componentViewer])
|
||||||
|
|
||||||
|
const onIframeLoad = useCallback(() => {
|
||||||
|
const iframe = iframeRef.current as HTMLIFrameElement
|
||||||
|
const contentWindow = iframe.contentWindow as Window
|
||||||
|
|
||||||
|
if (loadTimeout) {
|
||||||
|
clearTimeout(loadTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
componentViewer.setWindow(contentWindow)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
setHasIssueLoading(false)
|
||||||
|
onLoad?.(component)
|
||||||
|
}, MSToWaitAfterIframeLoadToAvoidFlicker)
|
||||||
|
}, [componentViewer, onLoad, component, loadTimeout])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeFeaturesChangedObserver = componentViewer.addEventObserver((event) => {
|
||||||
|
if (event === ComponentViewerEvent.FeatureStatusUpdated) {
|
||||||
|
setFeatureStatus(componentViewer.getFeatureStatus())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeFeaturesChangedObserver()
|
||||||
|
}
|
||||||
|
}, [componentViewer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeActionObserver = componentViewer.addActionObserver((action, data) => {
|
||||||
|
switch (action) {
|
||||||
|
case ComponentAction.KeyDown:
|
||||||
|
application.io.handleComponentKeyDown(data.keyboardModifier)
|
||||||
|
break
|
||||||
|
case ComponentAction.KeyUp:
|
||||||
|
application.io.handleComponentKeyUp(data.keyboardModifier)
|
||||||
|
break
|
||||||
|
case ComponentAction.Click:
|
||||||
|
application.getViewControllerManager().notesController.setContextMenuOpen(false)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
removeActionObserver()
|
||||||
|
}
|
||||||
|
}, [componentViewer, application])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unregisterDesktopObserver = application
|
||||||
|
.getDesktopService()
|
||||||
|
?.registerUpdateObserver((updatedComponent: SNComponent) => {
|
||||||
|
if (updatedComponent.uuid === component.uuid && updatedComponent.active) {
|
||||||
|
requestReload?.(componentViewer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregisterDesktopObserver?.()
|
||||||
|
}
|
||||||
|
}, [application, requestReload, componentViewer, component.uuid])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hasIssueLoading && (
|
||||||
|
<IssueOnLoading
|
||||||
|
componentName={component.displayName}
|
||||||
|
reloadIframe={() => {
|
||||||
|
reloadValidityStatus(), requestReload?.(componentViewer, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{featureStatus !== FeatureStatus.Entitled && (
|
||||||
|
<IsExpired
|
||||||
|
expiredDate={dateToLocalizedString(component.valid_until)}
|
||||||
|
featureStatus={featureStatus}
|
||||||
|
componentName={component.displayName}
|
||||||
|
manageSubscription={manageSubscription}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deprecationMessage && !isDeprecationMessageDismissed && (
|
||||||
|
<IsDeprecated deprecationMessage={deprecationMessage} dismissDeprecationMessage={dismissDeprecationMessage} />
|
||||||
|
)}
|
||||||
|
{error === ComponentViewerError.OfflineRestricted && <OfflineRestricted />}
|
||||||
|
{error === ComponentViewerError.MissingUrl && <UrlMissing componentName={component.displayName} />}
|
||||||
|
{component.uuid && isComponentValid && (
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
onLoad={onIframeLoad}
|
||||||
|
data-component-viewer-id={componentViewer.identifier}
|
||||||
|
frameBorder={0}
|
||||||
|
src={componentViewer.url || ''}
|
||||||
|
sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads"
|
||||||
|
>
|
||||||
|
Loading
|
||||||
|
</iframe>
|
||||||
|
)}
|
||||||
|
{isLoading && <div className={'loading-overlay'} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(ComponentView)
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { FunctionalComponent } from 'preact'
|
import { FunctionComponent } from 'react'
|
||||||
|
|
||||||
interface IProps {
|
type Props = {
|
||||||
deprecationMessage: string | undefined
|
deprecationMessage: string | undefined
|
||||||
dismissDeprecationMessage: () => void
|
dismissDeprecationMessage: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IsDeprecated: FunctionalComponent<IProps> = ({ deprecationMessage, dismissDeprecationMessage }) => {
|
const IsDeprecated: FunctionComponent<Props> = ({ deprecationMessage, dismissDeprecationMessage }) => {
|
||||||
return (
|
return (
|
||||||
<div className={'sn-component'}>
|
<div className={'sn-component'}>
|
||||||
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
||||||
@@ -23,3 +23,5 @@ export const IsDeprecated: FunctionalComponent<IProps> = ({ deprecationMessage,
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default IsDeprecated
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FeatureStatus } from '@standardnotes/snjs'
|
import { FeatureStatus } from '@standardnotes/snjs'
|
||||||
import { FunctionalComponent } from 'preact'
|
import { FunctionComponent } from 'react'
|
||||||
|
|
||||||
interface IProps {
|
type Props = {
|
||||||
expiredDate: string
|
expiredDate: string
|
||||||
componentName: string
|
componentName: string
|
||||||
featureStatus: FeatureStatus
|
featureStatus: FeatureStatus
|
||||||
@@ -21,12 +21,7 @@ const statusString = (featureStatus: FeatureStatus, expiredDate: string, compone
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IsExpired: FunctionalComponent<IProps> = ({
|
const IsExpired: FunctionComponent<Props> = ({ expiredDate, featureStatus, componentName, manageSubscription }) => {
|
||||||
expiredDate,
|
|
||||||
featureStatus,
|
|
||||||
componentName,
|
|
||||||
manageSubscription,
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<div className={'sn-component'}>
|
<div className={'sn-component'}>
|
||||||
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
||||||
@@ -52,3 +47,5 @@ export const IsExpired: FunctionalComponent<IProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default IsExpired
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { FunctionalComponent } from 'preact'
|
import { FunctionComponent } from 'react'
|
||||||
|
|
||||||
interface IProps {
|
type Props = {
|
||||||
componentName: string
|
componentName: string
|
||||||
reloadIframe: () => void
|
reloadIframe: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IssueOnLoading: FunctionalComponent<IProps> = ({ componentName, reloadIframe }) => {
|
const IssueOnLoading: FunctionComponent<Props> = ({ componentName, reloadIframe }) => {
|
||||||
return (
|
return (
|
||||||
<div className={'sn-component'}>
|
<div className={'sn-component'}>
|
||||||
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
||||||
@@ -23,3 +23,5 @@ export const IssueOnLoading: FunctionalComponent<IProps> = ({ componentName, rel
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default IssueOnLoading
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FunctionalComponent } from 'preact'
|
import { FunctionComponent } from 'react'
|
||||||
|
|
||||||
export const OfflineRestricted: FunctionalComponent = () => {
|
const OfflineRestricted: FunctionComponent = () => {
|
||||||
return (
|
return (
|
||||||
<div className={'sn-component'}>
|
<div className={'sn-component'}>
|
||||||
<div className={'sk-panel static'}>
|
<div className={'sk-panel static'}>
|
||||||
@@ -29,3 +29,5 @@ export const OfflineRestricted: FunctionalComponent = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default OfflineRestricted
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { FunctionalComponent } from 'preact'
|
import { FunctionComponent } from 'react'
|
||||||
|
|
||||||
interface IProps {
|
type Props = {
|
||||||
componentName: string
|
componentName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => {
|
const UrlMissing: FunctionComponent<Props> = ({ componentName }) => {
|
||||||
return (
|
return (
|
||||||
<div className={'sn-component'}>
|
<div className={'sn-component'}>
|
||||||
<div className={'sk-panel static'}>
|
<div className={'sk-panel static'}>
|
||||||
@@ -20,3 +20,5 @@ export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default UrlMissing
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
import {
|
|
||||||
ComponentAction,
|
|
||||||
FeatureStatus,
|
|
||||||
SNComponent,
|
|
||||||
dateToLocalizedString,
|
|
||||||
ComponentViewer,
|
|
||||||
ComponentViewerEvent,
|
|
||||||
ComponentViewerError,
|
|
||||||
} from '@standardnotes/snjs'
|
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
|
||||||
import { FunctionalComponent } from 'preact'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
||||||
import { observer } from 'mobx-react-lite'
|
|
||||||
import { OfflineRestricted } from '@/Components/ComponentView/OfflineRestricted'
|
|
||||||
import { UrlMissing } from '@/Components/ComponentView/UrlMissing'
|
|
||||||
import { IsDeprecated } from '@/Components/ComponentView/IsDeprecated'
|
|
||||||
import { IsExpired } from '@/Components/ComponentView/IsExpired'
|
|
||||||
import { IssueOnLoading } from '@/Components/ComponentView/IssueOnLoading'
|
|
||||||
import { AppState } from '@/UIModels/AppState'
|
|
||||||
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
application: WebApplication
|
|
||||||
appState: AppState
|
|
||||||
componentViewer: ComponentViewer
|
|
||||||
requestReload?: (viewer: ComponentViewer, force?: boolean) => void
|
|
||||||
onLoad?: (component: SNComponent) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The maximum amount of time we'll wait for a component
|
|
||||||
* to load before displaying error
|
|
||||||
*/
|
|
||||||
const MaxLoadThreshold = 4000
|
|
||||||
const VisibilityChangeKey = 'visibilitychange'
|
|
||||||
const MSToWaitAfterIframeLoadToAvoidFlicker = 35
|
|
||||||
|
|
||||||
export const ComponentView: FunctionalComponent<IProps> = observer(
|
|
||||||
({ application, onLoad, componentViewer, requestReload }) => {
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
||||||
const [loadTimeout, setLoadTimeout] = useState<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
||||||
|
|
||||||
const [hasIssueLoading, setHasIssueLoading] = useState(false)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(componentViewer.getFeatureStatus())
|
|
||||||
const [isComponentValid, setIsComponentValid] = useState(true)
|
|
||||||
const [error, setError] = useState<ComponentViewerError | undefined>(undefined)
|
|
||||||
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined)
|
|
||||||
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false)
|
|
||||||
const [didAttemptReload, setDidAttemptReload] = useState(false)
|
|
||||||
|
|
||||||
const component = componentViewer.component
|
|
||||||
|
|
||||||
const manageSubscription = useCallback(() => {
|
|
||||||
openSubscriptionDashboard(application)
|
|
||||||
}, [application])
|
|
||||||
|
|
||||||
const reloadValidityStatus = useCallback(() => {
|
|
||||||
setFeatureStatus(componentViewer.getFeatureStatus())
|
|
||||||
if (!componentViewer.lockReadonly) {
|
|
||||||
componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled)
|
|
||||||
}
|
|
||||||
setIsComponentValid(componentViewer.shouldRender())
|
|
||||||
|
|
||||||
if (isLoading && !isComponentValid) {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(componentViewer.getError())
|
|
||||||
setDeprecationMessage(component.deprecationMessage)
|
|
||||||
}, [componentViewer, component.deprecationMessage, featureStatus, isComponentValid, isLoading])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reloadValidityStatus()
|
|
||||||
}, [reloadValidityStatus])
|
|
||||||
|
|
||||||
const dismissDeprecationMessage = () => {
|
|
||||||
setIsDeprecationMessageDismissed(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onVisibilityChange = useCallback(() => {
|
|
||||||
if (document.visibilityState === 'hidden') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (hasIssueLoading) {
|
|
||||||
requestReload?.(componentViewer)
|
|
||||||
}
|
|
||||||
}, [hasIssueLoading, componentViewer, requestReload])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadTimeout = setTimeout(() => {
|
|
||||||
setIsLoading(false)
|
|
||||||
setHasIssueLoading(true)
|
|
||||||
|
|
||||||
if (!didAttemptReload) {
|
|
||||||
setDidAttemptReload(true)
|
|
||||||
requestReload?.(componentViewer)
|
|
||||||
} else {
|
|
||||||
document.addEventListener(VisibilityChangeKey, onVisibilityChange)
|
|
||||||
}
|
|
||||||
}, MaxLoadThreshold)
|
|
||||||
|
|
||||||
setLoadTimeout(loadTimeout)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (loadTimeout) {
|
|
||||||
clearTimeout(loadTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [componentViewer])
|
|
||||||
|
|
||||||
const onIframeLoad = useCallback(() => {
|
|
||||||
const iframe = iframeRef.current as HTMLIFrameElement
|
|
||||||
const contentWindow = iframe.contentWindow as Window
|
|
||||||
|
|
||||||
if (loadTimeout) {
|
|
||||||
clearTimeout(loadTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
componentViewer.setWindow(contentWindow)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsLoading(false)
|
|
||||||
setHasIssueLoading(false)
|
|
||||||
onLoad?.(component)
|
|
||||||
}, MSToWaitAfterIframeLoadToAvoidFlicker)
|
|
||||||
}, [componentViewer, onLoad, component, loadTimeout])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const removeFeaturesChangedObserver = componentViewer.addEventObserver((event) => {
|
|
||||||
if (event === ComponentViewerEvent.FeatureStatusUpdated) {
|
|
||||||
setFeatureStatus(componentViewer.getFeatureStatus())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
removeFeaturesChangedObserver()
|
|
||||||
}
|
|
||||||
}, [componentViewer])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const removeActionObserver = componentViewer.addActionObserver((action, data) => {
|
|
||||||
switch (action) {
|
|
||||||
case ComponentAction.KeyDown:
|
|
||||||
application.io.handleComponentKeyDown(data.keyboardModifier)
|
|
||||||
break
|
|
||||||
case ComponentAction.KeyUp:
|
|
||||||
application.io.handleComponentKeyUp(data.keyboardModifier)
|
|
||||||
break
|
|
||||||
case ComponentAction.Click:
|
|
||||||
application.getAppState().notes.setContextMenuOpen(false)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
removeActionObserver()
|
|
||||||
}
|
|
||||||
}, [componentViewer, application])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unregisterDesktopObserver = application
|
|
||||||
.getDesktopService()
|
|
||||||
?.registerUpdateObserver((updatedComponent: SNComponent) => {
|
|
||||||
if (updatedComponent.uuid === component.uuid && updatedComponent.active) {
|
|
||||||
requestReload?.(componentViewer)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unregisterDesktopObserver?.()
|
|
||||||
}
|
|
||||||
}, [application, requestReload, componentViewer, component.uuid])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{hasIssueLoading && (
|
|
||||||
<IssueOnLoading
|
|
||||||
componentName={component.name}
|
|
||||||
reloadIframe={() => {
|
|
||||||
reloadValidityStatus(), requestReload?.(componentViewer, true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{featureStatus !== FeatureStatus.Entitled && (
|
|
||||||
<IsExpired
|
|
||||||
expiredDate={dateToLocalizedString(component.valid_until)}
|
|
||||||
featureStatus={featureStatus}
|
|
||||||
componentName={component.name}
|
|
||||||
manageSubscription={manageSubscription}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{deprecationMessage && !isDeprecationMessageDismissed && (
|
|
||||||
<IsDeprecated deprecationMessage={deprecationMessage} dismissDeprecationMessage={dismissDeprecationMessage} />
|
|
||||||
)}
|
|
||||||
{error === ComponentViewerError.OfflineRestricted && <OfflineRestricted />}
|
|
||||||
{error === ComponentViewerError.MissingUrl && <UrlMissing componentName={component.name} />}
|
|
||||||
{component.uuid && isComponentValid && (
|
|
||||||
<iframe
|
|
||||||
ref={iframeRef}
|
|
||||||
onLoad={onIframeLoad}
|
|
||||||
data-component-viewer-id={componentViewer.identifier}
|
|
||||||
frameBorder={0}
|
|
||||||
src={componentViewer.url || ''}
|
|
||||||
sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads"
|
|
||||||
>
|
|
||||||
Loading
|
|
||||||
</iframe>
|
|
||||||
)}
|
|
||||||
{isLoading && <div className={'loading-overlay'} />}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,38 +1,31 @@
|
|||||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
import { FunctionComponent, useEffect, useRef, useState } from 'react'
|
||||||
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
||||||
import { STRING_SIGN_OUT_CONFIRMATION } from '@/Strings'
|
import { STRING_SIGN_OUT_CONFIRMATION } from '@/Constants/Strings'
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||||
import { isDesktopApplication } from '@/Utils'
|
import { isDesktopApplication } from '@/Utils'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
applicationGroup: ApplicationGroup
|
applicationGroup: ApplicationGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfirmSignoutContainer = observer((props: Props) => {
|
const ConfirmSignoutModal: FunctionComponent<Props> = ({ application, viewControllerManager, applicationGroup }) => {
|
||||||
if (!props.appState.accountMenu.signingOut) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return <ConfirmSignoutModal {...props} />
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ConfirmSignoutModal = observer(({ application, appState, applicationGroup }: Props) => {
|
|
||||||
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false)
|
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false)
|
||||||
|
|
||||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
appState.accountMenu.setSigningOut(false)
|
viewControllerManager.accountMenuController.setSigningOut(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [localBackupsCount, setLocalBackupsCount] = useState(0)
|
const [localBackupsCount, setLocalBackupsCount] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
application.desktopDevice?.localBackupsCount().then(setLocalBackupsCount).catch(console.error)
|
application.desktopDevice?.localBackupsCount().then(setLocalBackupsCount).catch(console.error)
|
||||||
}, [appState.accountMenu.signingOut, application.desktopDevice])
|
}, [viewControllerManager.accountMenuController.signingOut, application.desktopDevice])
|
||||||
|
|
||||||
const workspaces = applicationGroup.getDescriptors()
|
const workspaces = applicationGroup.getDescriptors()
|
||||||
const showWorkspaceWarning = workspaces.length > 1 && isDesktopApplication()
|
const showWorkspaceWarning = workspaces.length > 1 && isDesktopApplication()
|
||||||
@@ -114,4 +107,15 @@ export const ConfirmSignoutModal = observer(({ application, appState, applicatio
|
|||||||
</div>
|
</div>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
ConfirmSignoutModal.displayName = 'ConfirmSignoutModal'
|
||||||
|
|
||||||
|
const ConfirmSignoutContainer = (props: Props) => {
|
||||||
|
if (!props.viewControllerManager.accountMenuController.signingOut) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return <ConfirmSignoutModal {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(ConfirmSignoutContainer)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { KeyboardKey } from '@/Services/IOService'
|
||||||
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
|
import { UuidString } from '@standardnotes/snjs'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { FunctionComponent, KeyboardEventHandler, UIEventHandler, useCallback } from 'react'
|
||||||
|
import { FOCUSABLE_BUT_NOT_TABBABLE, NOTES_LIST_SCROLL_THRESHOLD } from '@/Constants/Constants'
|
||||||
|
import { ListableContentItem } from './Types/ListableContentItem'
|
||||||
|
import ContentListItem from './ContentListItem'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
application: WebApplication
|
||||||
|
viewControllerManager: ViewControllerManager
|
||||||
|
items: ListableContentItem[]
|
||||||
|
selectedItems: Record<UuidString, ListableContentItem>
|
||||||
|
paginate: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContentList: FunctionComponent<Props> = ({
|
||||||
|
application,
|
||||||
|
viewControllerManager,
|
||||||
|
items,
|
||||||
|
selectedItems,
|
||||||
|
paginate,
|
||||||
|
}) => {
|
||||||
|
const { selectPreviousItem, selectNextItem } = viewControllerManager.itemListController
|
||||||
|
const { hideTags, hideDate, hideNotePreview, hideEditorIcon } =
|
||||||
|
viewControllerManager.itemListController.webDisplayOptions
|
||||||
|
const { sortBy } = viewControllerManager.itemListController.displayOptions
|
||||||
|
|
||||||
|
const onScroll: UIEventHandler = useCallback(
|
||||||
|
(e) => {
|
||||||
|
const offset = NOTES_LIST_SCROLL_THRESHOLD
|
||||||
|
const element = e.target as HTMLElement
|
||||||
|
if (element.scrollTop + element.offsetHeight >= element.scrollHeight - offset) {
|
||||||
|
paginate()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[paginate],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onKeyDown: KeyboardEventHandler = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (e.key === KeyboardKey.Up) {
|
||||||
|
e.preventDefault()
|
||||||
|
selectPreviousItem()
|
||||||
|
} else if (e.key === KeyboardKey.Down) {
|
||||||
|
e.preventDefault()
|
||||||
|
selectNextItem()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectNextItem, selectPreviousItem],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="infinite-scroll focus:shadow-none focus:outline-none"
|
||||||
|
id="notes-scrollable"
|
||||||
|
onScroll={onScroll}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<ContentListItem
|
||||||
|
key={item.uuid}
|
||||||
|
application={application}
|
||||||
|
viewControllerManager={viewControllerManager}
|
||||||
|
item={item}
|
||||||
|
selected={!!selectedItems[item.uuid]}
|
||||||
|
hideDate={hideDate}
|
||||||
|
hidePreview={hideNotePreview}
|
||||||
|
hideTags={hideTags}
|
||||||
|
hideIcon={hideEditorIcon}
|
||||||
|
sortBy={sortBy}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(ContentList)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ContentType, SNTag } from '@standardnotes/snjs'
|
||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
import FileListItem from './FileListItem'
|
||||||
|
import NoteListItem from './NoteListItem'
|
||||||
|
import { AbstractListItemProps } from './Types/AbstractListItemProps'
|
||||||
|
|
||||||
|
const ContentListItem: FunctionComponent<AbstractListItemProps> = (props) => {
|
||||||
|
const getTags = () => {
|
||||||
|
if (props.hideTags) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTag = props.viewControllerManager.navigationController.selected
|
||||||
|
if (!selectedTag) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = props.application.getItemTags(props.item)
|
||||||
|
|
||||||
|
const isNavigatingOnlyTag = selectedTag instanceof SNTag && tags.length === 1
|
||||||
|
if (isNavigatingOnlyTag) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (props.item.content_type) {
|
||||||
|
case ContentType.Note:
|
||||||
|
return <NoteListItem tags={getTags()} {...props} />
|
||||||
|
case ContentType.File:
|
||||||
|
return <FileListItem tags={getTags()} {...props} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContentListItem
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { CollectionSort, CollectionSortProperty, PrefKey, SystemViewId } from '@standardnotes/snjs'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { FunctionComponent, useCallback, useState } from 'react'
|
||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import Menu from '@/Components/Menu/Menu'
|
||||||
|
import MenuItem from '@/Components/Menu/MenuItem'
|
||||||
|
import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
|
||||||
|
import { MenuItemType } from '@/Components/Menu/MenuItemType'
|
||||||
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
application: WebApplication
|
||||||
|
viewControllerManager: ViewControllerManager
|
||||||
|
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||||
|
closeDisplayOptionsMenu: () => void
|
||||||
|
isOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContentListOptionsMenu: FunctionComponent<Props> = ({
|
||||||
|
closeDisplayOptionsMenu,
|
||||||
|
closeOnBlur,
|
||||||
|
application,
|
||||||
|
viewControllerManager,
|
||||||
|
isOpen,
|
||||||
|
}) => {
|
||||||
|
const [sortBy, setSortBy] = useState(() => application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt))
|
||||||
|
const [sortReverse, setSortReverse] = useState(() => application.getPreference(PrefKey.SortNotesReverse, false))
|
||||||
|
const [hidePreview, setHidePreview] = useState(() => application.getPreference(PrefKey.NotesHideNotePreview, false))
|
||||||
|
const [hideDate, setHideDate] = useState(() => application.getPreference(PrefKey.NotesHideDate, false))
|
||||||
|
const [hideTags, setHideTags] = useState(() => application.getPreference(PrefKey.NotesHideTags, true))
|
||||||
|
const [hidePinned, setHidePinned] = useState(() => application.getPreference(PrefKey.NotesHidePinned, false))
|
||||||
|
const [showArchived, setShowArchived] = useState(() => application.getPreference(PrefKey.NotesShowArchived, false))
|
||||||
|
const [showTrashed, setShowTrashed] = useState(() => application.getPreference(PrefKey.NotesShowTrashed, false))
|
||||||
|
const [hideProtected, setHideProtected] = useState(() => application.getPreference(PrefKey.NotesHideProtected, false))
|
||||||
|
const [hideEditorIcon, setHideEditorIcon] = useState(() =>
|
||||||
|
application.getPreference(PrefKey.NotesHideEditorIcon, false),
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleSortReverse = useCallback(() => {
|
||||||
|
application.setPreference(PrefKey.SortNotesReverse, !sortReverse).catch(console.error)
|
||||||
|
setSortReverse(!sortReverse)
|
||||||
|
}, [application, sortReverse])
|
||||||
|
|
||||||
|
const toggleSortBy = useCallback(
|
||||||
|
(sort: CollectionSortProperty) => {
|
||||||
|
if (sortBy === sort) {
|
||||||
|
toggleSortReverse()
|
||||||
|
} else {
|
||||||
|
setSortBy(sort)
|
||||||
|
application.setPreference(PrefKey.SortNotesBy, sort).catch(console.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[application, sortBy, toggleSortReverse],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleSortByDateModified = useCallback(() => {
|
||||||
|
toggleSortBy(CollectionSort.UpdatedAt)
|
||||||
|
}, [toggleSortBy])
|
||||||
|
|
||||||
|
const toggleSortByCreationDate = useCallback(() => {
|
||||||
|
toggleSortBy(CollectionSort.CreatedAt)
|
||||||
|
}, [toggleSortBy])
|
||||||
|
|
||||||
|
const toggleSortByTitle = useCallback(() => {
|
||||||
|
toggleSortBy(CollectionSort.Title)
|
||||||
|
}, [toggleSortBy])
|
||||||
|
|
||||||
|
const toggleHidePreview = useCallback(() => {
|
||||||
|
setHidePreview(!hidePreview)
|
||||||
|
application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview).catch(console.error)
|
||||||
|
}, [application, hidePreview])
|
||||||
|
|
||||||
|
const toggleHideDate = useCallback(() => {
|
||||||
|
setHideDate(!hideDate)
|
||||||
|
application.setPreference(PrefKey.NotesHideDate, !hideDate).catch(console.error)
|
||||||
|
}, [application, hideDate])
|
||||||
|
|
||||||
|
const toggleHideTags = useCallback(() => {
|
||||||
|
setHideTags(!hideTags)
|
||||||
|
application.setPreference(PrefKey.NotesHideTags, !hideTags).catch(console.error)
|
||||||
|
}, [application, hideTags])
|
||||||
|
|
||||||
|
const toggleHidePinned = useCallback(() => {
|
||||||
|
setHidePinned(!hidePinned)
|
||||||
|
application.setPreference(PrefKey.NotesHidePinned, !hidePinned).catch(console.error)
|
||||||
|
}, [application, hidePinned])
|
||||||
|
|
||||||
|
const toggleShowArchived = useCallback(() => {
|
||||||
|
setShowArchived(!showArchived)
|
||||||
|
application.setPreference(PrefKey.NotesShowArchived, !showArchived).catch(console.error)
|
||||||
|
}, [application, showArchived])
|
||||||
|
|
||||||
|
const toggleShowTrashed = useCallback(() => {
|
||||||
|
setShowTrashed(!showTrashed)
|
||||||
|
application.setPreference(PrefKey.NotesShowTrashed, !showTrashed).catch(console.error)
|
||||||
|
}, [application, showTrashed])
|
||||||
|
|
||||||
|
const toggleHideProtected = useCallback(() => {
|
||||||
|
setHideProtected(!hideProtected)
|
||||||
|
application.setPreference(PrefKey.NotesHideProtected, !hideProtected).catch(console.error)
|
||||||
|
}, [application, hideProtected])
|
||||||
|
|
||||||
|
const toggleEditorIcon = useCallback(() => {
|
||||||
|
setHideEditorIcon(!hideEditorIcon)
|
||||||
|
application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon).catch(console.error)
|
||||||
|
}, [application, hideEditorIcon])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
className={
|
||||||
|
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
|
||||||
|
border-1 border-solid border-main text-sm z-index-dropdown-menu \
|
||||||
|
flex flex-col py-2 top-full left-2 absolute'
|
||||||
|
}
|
||||||
|
a11yLabel="Notes list options menu"
|
||||||
|
closeMenu={closeDisplayOptionsMenu}
|
||||||
|
isOpen={isOpen}
|
||||||
|
>
|
||||||
|
<div className="px-3 my-1 text-xs font-semibold color-text uppercase">Sort by</div>
|
||||||
|
<MenuItem
|
||||||
|
className="py-2"
|
||||||
|
type={MenuItemType.RadioButton}
|
||||||
|
onClick={toggleSortByDateModified}
|
||||||
|
checked={sortBy === CollectionSort.UpdatedAt}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
<div className="flex flex-grow items-center justify-between ml-2">
|
||||||
|
<span>Date modified</span>
|
||||||
|
{sortBy === CollectionSort.UpdatedAt ? (
|
||||||
|
sortReverse ? (
|
||||||
|
<Icon type="arrows-sort-up" className="color-neutral" />
|
||||||
|
) : (
|
||||||
|
<Icon type="arrows-sort-down" className="color-neutral" />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
className="py-2"
|
||||||
|
type={MenuItemType.RadioButton}
|
||||||
|
onClick={toggleSortByCreationDate}
|
||||||
|
checked={sortBy === CollectionSort.CreatedAt}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
<div className="flex flex-grow items-center justify-between ml-2">
|
||||||
|
<span>Creation date</span>
|
||||||
|
{sortBy === CollectionSort.CreatedAt ? (
|
||||||
|
sortReverse ? (
|
||||||
|
<Icon type="arrows-sort-up" className="color-neutral" />
|
||||||
|
) : (
|
||||||
|
<Icon type="arrows-sort-down" className="color-neutral" />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
className="py-2"
|
||||||
|
type={MenuItemType.RadioButton}
|
||||||
|
onClick={toggleSortByTitle}
|
||||||
|
checked={sortBy === CollectionSort.Title}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
<div className="flex flex-grow items-center justify-between ml-2">
|
||||||
|
<span>Title</span>
|
||||||
|
{sortBy === CollectionSort.Title ? (
|
||||||
|
sortReverse ? (
|
||||||
|
<Icon type="arrows-sort-up" className="color-neutral" />
|
||||||
|
) : (
|
||||||
|
<Icon type="arrows-sort-down" className="color-neutral" />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItemSeparator />
|
||||||
|
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">View</div>
|
||||||
|
{viewControllerManager.navigationController.selectedUuid !== SystemViewId.Files && (
|
||||||
|
<MenuItem
|
||||||
|
type={MenuItemType.SwitchButton}
|
||||||
|
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||||
|
checked={!hidePreview}
|
||||||
|
onChange={toggleHidePreview}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col max-w-3/4">Show note preview</div>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
type={MenuItemType.SwitchButton}
|
||||||
|
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||||
|
checked={!hideDate}
|
||||||
|
onChange={toggleHideDate}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
Show date
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
type={MenuItemType.SwitchButton}
|
||||||
|
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||||
|
checked={!hideTags}
|
||||||
|
onChange={toggleHideTags}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
Show tags
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
type={MenuItemType.SwitchButton}
|
||||||
|
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||||
|
checked={!hideEditorIcon}
|
||||||
|
onChange={toggleEditorIcon}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
Show icon
|
||||||
|
</MenuItem>
|
||||||
|
<div className="h-1px my-2 bg-border"></div>
|
||||||
|
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">Other</div>
|
||||||
|
<MenuItem
|
||||||
|
type={MenuItemType.SwitchButton}
|
||||||
|
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||||
|
checked={!hidePinned}
|
||||||
|
onChange={toggleHidePinned}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
Show pinned
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
type={MenuItemType.SwitchButton}
|
||||||
|
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||||
|
checked={!hideProtected}
|
||||||
|
onChange={toggleHideProtected}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
Show protected
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
type={MenuItemType.SwitchButton}
|
||||||
|
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||||
|
checked={showArchived}
|
||||||
|
onChange={toggleShowArchived}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
Show archived
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
type={MenuItemType.SwitchButton}
|
||||||
|
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||||
|
checked={showTrashed}
|
||||||
|
onChange={toggleShowTrashed}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
Show trashed
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(ContentListOptionsMenu)
|
||||||
@@ -1,59 +1,73 @@
|
|||||||
import { KeyboardKey, KeyboardModifier } from '@/Services/IOService'
|
import { KeyboardKey, KeyboardModifier } from '@/Services/IOService'
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { PANEL_NAME_NOTES } from '@/Constants'
|
import { PANEL_NAME_NOTES } from '@/Constants/Constants'
|
||||||
import { PrefKey } from '@standardnotes/snjs'
|
import { PrefKey, SystemViewId } from '@standardnotes/snjs'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'preact'
|
import {
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
ChangeEventHandler,
|
||||||
import { NoAccountWarning } from '@/Components/NoAccountWarning'
|
FunctionComponent,
|
||||||
import { NotesList } from '@/Components/NotesList'
|
KeyboardEventHandler,
|
||||||
import { NotesListOptionsMenu } from '@/Components/NotesList/NotesListOptionsMenu'
|
useCallback,
|
||||||
import { SearchOptions } from '@/Components/SearchOptions'
|
useEffect,
|
||||||
import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer'
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import ContentList from '@/Components/ContentListView/ContentList'
|
||||||
|
import NoAccountWarningWrapper from '@/Components/NoAccountWarning/NoAccountWarning'
|
||||||
|
import SearchOptions from '@/Components/SearchOptions/SearchOptions'
|
||||||
|
import PanelResizer, { PanelSide, ResizeFinishCallback, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||||
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
|
import ContentListOptionsMenu from './ContentListOptionsMenu'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NotesView: FunctionComponent<Props> = observer(({ application, appState }: Props) => {
|
const ContentListView: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
|
||||||
if (isStateDealloced(appState)) {
|
const itemsViewPanelRef = useRef<HTMLDivElement>(null)
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const notesViewPanelRef = useRef<HTMLDivElement>(null)
|
|
||||||
const displayOptionsMenuRef = useRef<HTMLDivElement>(null)
|
const displayOptionsMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
completedFullSync,
|
completedFullSync,
|
||||||
displayOptions,
|
|
||||||
noteFilterText,
|
noteFilterText,
|
||||||
optionsSubtitle,
|
optionsSubtitle,
|
||||||
panelTitle,
|
panelTitle,
|
||||||
renderedNotes,
|
renderedItems,
|
||||||
|
setNoteFilterText,
|
||||||
searchBarElement,
|
searchBarElement,
|
||||||
|
selectNextItem,
|
||||||
|
selectPreviousItem,
|
||||||
|
onFilterEnter,
|
||||||
|
clearFilterText,
|
||||||
paginate,
|
paginate,
|
||||||
panelWidth,
|
panelWidth,
|
||||||
} = appState.notesView
|
createNewNote,
|
||||||
|
} = viewControllerManager.itemListController
|
||||||
|
|
||||||
const { selectedNotes } = appState.notes
|
const { selectedItems } = viewControllerManager.selectionController
|
||||||
|
|
||||||
const createNewNote = useCallback(() => appState.notesView.createNewNote(), [appState])
|
|
||||||
const onFilterEnter = useCallback(() => appState.notesView.onFilterEnter(), [appState])
|
|
||||||
const clearFilterText = useCallback(() => appState.notesView.clearFilterText(), [appState])
|
|
||||||
const setNoteFilterText = useCallback((text: string) => appState.notesView.setNoteFilterText(text), [appState])
|
|
||||||
const selectNextNote = useCallback(() => appState.notesView.selectNextNote(), [appState])
|
|
||||||
const selectPreviousNote = useCallback(() => appState.notesView.selectPreviousNote(), [appState])
|
|
||||||
|
|
||||||
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false)
|
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false)
|
||||||
const [focusedSearch, setFocusedSearch] = useState(false)
|
const [focusedSearch, setFocusedSearch] = useState(false)
|
||||||
|
|
||||||
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(displayOptionsMenuRef, setShowDisplayOptionsMenu)
|
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(displayOptionsMenuRef, setShowDisplayOptionsMenu)
|
||||||
|
|
||||||
|
const isFilesSmartView = useMemo(
|
||||||
|
() => viewControllerManager.navigationController.selected?.uuid === SystemViewId.Files,
|
||||||
|
[viewControllerManager.navigationController.selected?.uuid],
|
||||||
|
)
|
||||||
|
|
||||||
|
const addNewItem = useCallback(() => {
|
||||||
|
if (isFilesSmartView) {
|
||||||
|
void viewControllerManager.filesController.uploadNewFile()
|
||||||
|
} else {
|
||||||
|
void createNewNote()
|
||||||
|
}
|
||||||
|
}, [viewControllerManager.filesController, createNewNote, isFilesSmartView])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/**
|
/**
|
||||||
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
|
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
|
||||||
@@ -65,7 +79,7 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
|
|||||||
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl],
|
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl],
|
||||||
onKeyDown: (event) => {
|
onKeyDown: (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void createNewNote()
|
addNewItem()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -76,7 +90,7 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
|
|||||||
if (searchBarElement === document.activeElement) {
|
if (searchBarElement === document.activeElement) {
|
||||||
searchBarElement?.blur()
|
searchBarElement?.blur()
|
||||||
}
|
}
|
||||||
selectNextNote()
|
selectNextItem()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -84,7 +98,7 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
|
|||||||
key: KeyboardKey.Up,
|
key: KeyboardKey.Up,
|
||||||
element: document.body,
|
element: document.body,
|
||||||
onKeyDown: () => {
|
onKeyDown: () => {
|
||||||
selectPreviousNote()
|
selectPreviousItem()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -104,11 +118,11 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
|
|||||||
previousNoteKeyObserver()
|
previousNoteKeyObserver()
|
||||||
searchKeyObserver()
|
searchKeyObserver()
|
||||||
}
|
}
|
||||||
}, [application, createNewNote, selectPreviousNote, searchBarElement, selectNextNote])
|
}, [addNewItem, application.io, createNewNote, searchBarElement, selectNextItem, selectPreviousItem])
|
||||||
|
|
||||||
const onNoteFilterTextChange = useCallback(
|
const onNoteFilterTextChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
(e: Event) => {
|
(e) => {
|
||||||
setNoteFilterText((e.target as HTMLInputElement).value)
|
setNoteFilterText(e.target.value)
|
||||||
},
|
},
|
||||||
[setNoteFilterText],
|
[setNoteFilterText],
|
||||||
)
|
)
|
||||||
@@ -116,8 +130,8 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
|
|||||||
const onSearchFocused = useCallback(() => setFocusedSearch(true), [])
|
const onSearchFocused = useCallback(() => setFocusedSearch(true), [])
|
||||||
const onSearchBlurred = useCallback(() => setFocusedSearch(false), [])
|
const onSearchBlurred = useCallback(() => setFocusedSearch(false), [])
|
||||||
|
|
||||||
const onNoteFilterKeyUp = useCallback(
|
const onNoteFilterKeyUp: KeyboardEventHandler = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e) => {
|
||||||
if (e.key === KeyboardKey.Enter) {
|
if (e.key === KeyboardKey.Enter) {
|
||||||
onFilterEnter()
|
onFilterEnter()
|
||||||
}
|
}
|
||||||
@@ -128,37 +142,42 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
|
|||||||
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
||||||
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
|
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
|
||||||
application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error)
|
application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error)
|
||||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
|
||||||
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed)
|
application.publishPanelDidResizeEvent(PANEL_NAME_NOTES, isCollapsed)
|
||||||
},
|
},
|
||||||
[appState, application],
|
[viewControllerManager, application],
|
||||||
)
|
)
|
||||||
|
|
||||||
const panelWidthEventCallback = useCallback(() => {
|
const panelWidthEventCallback = useCallback(() => {
|
||||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
|
||||||
}, [appState])
|
}, [viewControllerManager])
|
||||||
|
|
||||||
const toggleDisplayOptionsMenu = useCallback(() => {
|
const toggleDisplayOptionsMenu = useCallback(() => {
|
||||||
setShowDisplayOptionsMenu(!showDisplayOptionsMenu)
|
setShowDisplayOptionsMenu(!showDisplayOptionsMenu)
|
||||||
}, [showDisplayOptionsMenu])
|
}, [showDisplayOptionsMenu])
|
||||||
|
|
||||||
|
const addButtonLabel = useMemo(
|
||||||
|
() => (isFilesSmartView ? 'Upload file' : 'Create a new note in the selected tag'),
|
||||||
|
[isFilesSmartView],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="notes-column"
|
id="items-column"
|
||||||
className="sn-component section notes app-column app-column-second"
|
className="sn-component section app-column app-column-second"
|
||||||
aria-label="Notes"
|
aria-label={'Notes & Files'}
|
||||||
ref={notesViewPanelRef}
|
ref={itemsViewPanelRef}
|
||||||
>
|
>
|
||||||
<div className="content">
|
<div className="content">
|
||||||
<div id="notes-title-bar" className="section-title-bar">
|
<div id="items-title-bar" className="section-title-bar">
|
||||||
<div id="notes-title-bar-container">
|
<div id="items-title-bar-container">
|
||||||
<div className="section-title-bar-header">
|
<div className="section-title-bar-header">
|
||||||
<div className="sk-h2 font-semibold title">{panelTitle}</div>
|
<div className="sk-h2 font-semibold title">{panelTitle}</div>
|
||||||
<button
|
<button
|
||||||
className="sk-button contrast wide"
|
className="sk-button contrast wide"
|
||||||
title="Create a new note in the selected tag"
|
title={addButtonLabel}
|
||||||
aria-label="Create new note"
|
aria-label={addButtonLabel}
|
||||||
onClick={() => createNewNote()}
|
onClick={addNewItem}
|
||||||
>
|
>
|
||||||
<div className="sk-label">
|
<div className="sk-label">
|
||||||
<i className="ion-plus add-button" aria-hidden></i>
|
<i className="ion-plus add-button" aria-hidden></i>
|
||||||
@@ -172,16 +191,16 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
|
|||||||
id="search-bar"
|
id="search-bar"
|
||||||
className="filter-bar"
|
className="filter-bar"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
title="Searches notes in the currently selected tag"
|
title="Searches notes and files in the currently selected tag"
|
||||||
value={noteFilterText}
|
value={noteFilterText}
|
||||||
onChange={onNoteFilterTextChange}
|
onChange={onNoteFilterTextChange}
|
||||||
onKeyUp={onNoteFilterKeyUp}
|
onKeyUp={onNoteFilterKeyUp}
|
||||||
onFocus={onSearchFocused}
|
onFocus={onSearchFocused}
|
||||||
onBlur={onSearchBlurred}
|
onBlur={onSearchBlurred}
|
||||||
autocomplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
{noteFilterText && (
|
{noteFilterText && (
|
||||||
<button onClick={clearFilterText} aria-role="button" id="search-clear-button">
|
<button onClick={clearFilterText} id="search-clear-button">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -189,13 +208,13 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
|
|||||||
|
|
||||||
{(focusedSearch || noteFilterText) && (
|
{(focusedSearch || noteFilterText) && (
|
||||||
<div className="animate-fade-from-top">
|
<div className="animate-fade-from-top">
|
||||||
<SearchOptions application={application} appState={appState} />
|
<SearchOptions application={application} viewControllerManager={viewControllerManager} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<NoAccountWarning appState={appState} />
|
<NoAccountWarningWrapper viewControllerManager={viewControllerManager} />
|
||||||
</div>
|
</div>
|
||||||
<div id="notes-menu-bar" className="sn-component" ref={displayOptionsMenuRef}>
|
<div id="items-menu-bar" className="sn-component" ref={displayOptionsMenuRef}>
|
||||||
<div className="sk-app-bar no-edges">
|
<div className="sk-app-bar no-edges">
|
||||||
<div className="left">
|
<div className="left">
|
||||||
<Disclosure open={showDisplayOptionsMenu} onChange={toggleDisplayOptionsMenu}>
|
<Disclosure open={showDisplayOptionsMenu} onChange={toggleDisplayOptionsMenu}>
|
||||||
@@ -214,8 +233,9 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
|
|||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
<DisclosurePanel onBlur={closeDisplayOptMenuOnBlur}>
|
<DisclosurePanel onBlur={closeDisplayOptMenuOnBlur}>
|
||||||
{showDisplayOptionsMenu && (
|
{showDisplayOptionsMenu && (
|
||||||
<NotesListOptionsMenu
|
<ContentListOptionsMenu
|
||||||
application={application}
|
application={application}
|
||||||
|
viewControllerManager={viewControllerManager}
|
||||||
closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
|
closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
|
||||||
closeOnBlur={closeDisplayOptMenuOnBlur}
|
closeOnBlur={closeDisplayOptMenuOnBlur}
|
||||||
isOpen={showDisplayOptionsMenu}
|
isOpen={showDisplayOptionsMenu}
|
||||||
@@ -227,27 +247,24 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{completedFullSync && !renderedNotes.length ? <p className="empty-notes-list faded">No notes.</p> : null}
|
{completedFullSync && !renderedItems.length ? <p className="empty-items-list faded">No items.</p> : null}
|
||||||
{!completedFullSync && !renderedNotes.length ? (
|
{!completedFullSync && !renderedItems.length ? <p className="empty-items-list faded">Loading...</p> : null}
|
||||||
<p className="empty-notes-list faded">Loading notes...</p>
|
{renderedItems.length ? (
|
||||||
) : null}
|
<ContentList
|
||||||
{renderedNotes.length ? (
|
items={renderedItems}
|
||||||
<NotesList
|
selectedItems={selectedItems}
|
||||||
notes={renderedNotes}
|
|
||||||
selectedNotes={selectedNotes}
|
|
||||||
application={application}
|
application={application}
|
||||||
appState={appState}
|
viewControllerManager={viewControllerManager}
|
||||||
displayOptions={displayOptions}
|
|
||||||
paginate={paginate}
|
paginate={paginate}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{notesViewPanelRef.current && (
|
{itemsViewPanelRef.current && (
|
||||||
<PanelResizer
|
<PanelResizer
|
||||||
collapsable={true}
|
collapsable={true}
|
||||||
hoverable={true}
|
hoverable={true}
|
||||||
defaultWidth={300}
|
defaultWidth={300}
|
||||||
panel={notesViewPanelRef.current}
|
panel={itemsViewPanelRef.current}
|
||||||
side={PanelSide.Right}
|
side={PanelSide.Right}
|
||||||
type={PanelResizeType.WidthOnly}
|
type={PanelResizeType.WidthOnly}
|
||||||
resizeFinishCallback={panelResizeFinishCallback}
|
resizeFinishCallback={panelResizeFinishCallback}
|
||||||
@@ -258,4 +275,6 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export default observer(ContentListView)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { FileItem } from '@standardnotes/snjs'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { FunctionComponent, useCallback } from 'react'
|
||||||
|
import { getFileIconComponent } from '../AttachedFilesPopover/getFileIconComponent'
|
||||||
|
import ListItemConflictIndicator from './ListItemConflictIndicator'
|
||||||
|
import ListItemFlagIcons from './ListItemFlagIcons'
|
||||||
|
import ListItemTags from './ListItemTags'
|
||||||
|
import ListItemMetadata from './ListItemMetadata'
|
||||||
|
import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
|
||||||
|
|
||||||
|
const FileListItem: FunctionComponent<DisplayableListItemProps> = ({
|
||||||
|
application,
|
||||||
|
viewControllerManager,
|
||||||
|
hideDate,
|
||||||
|
hideIcon,
|
||||||
|
hideTags,
|
||||||
|
item,
|
||||||
|
selected,
|
||||||
|
sortBy,
|
||||||
|
tags,
|
||||||
|
}) => {
|
||||||
|
const openFileContextMenu = useCallback(
|
||||||
|
(posX: number, posY: number) => {
|
||||||
|
viewControllerManager.filesController.setFileContextMenuLocation({
|
||||||
|
x: posX,
|
||||||
|
y: posY,
|
||||||
|
})
|
||||||
|
viewControllerManager.filesController.setShowFileContextMenu(true)
|
||||||
|
},
|
||||||
|
[viewControllerManager.filesController],
|
||||||
|
)
|
||||||
|
|
||||||
|
const openContextMenu = useCallback(
|
||||||
|
async (posX: number, posY: number) => {
|
||||||
|
const { didSelect } = await viewControllerManager.selectionController.selectItem(item.uuid)
|
||||||
|
if (didSelect) {
|
||||||
|
openFileContextMenu(posX, posY)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[viewControllerManager.selectionController, item.uuid, openFileContextMenu],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
void viewControllerManager.selectionController.selectItem(item.uuid, true).then(({ didSelect }) => {
|
||||||
|
if (didSelect && viewControllerManager.selectionController.selectedItemsCount < 2) {
|
||||||
|
viewControllerManager.filePreviewModalController.activate(
|
||||||
|
item as FileItem,
|
||||||
|
viewControllerManager.filesController.allFiles,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
viewControllerManager.filePreviewModalController,
|
||||||
|
viewControllerManager.filesController.allFiles,
|
||||||
|
viewControllerManager.selectionController,
|
||||||
|
item,
|
||||||
|
])
|
||||||
|
|
||||||
|
const IconComponent = () =>
|
||||||
|
getFileIconComponent(
|
||||||
|
application.iconsController.getIconForFileType((item as FileItem).mimeType),
|
||||||
|
'w-5 h-5 flex-shrink-0',
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`content-list-item flex items-stretch w-full cursor-pointer ${
|
||||||
|
selected && 'selected border-0 border-l-2px border-solid border-info'
|
||||||
|
}`}
|
||||||
|
id={item.uuid}
|
||||||
|
onClick={onClick}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
void openContextMenu(event.clientX, event.clientY)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!hideIcon ? (
|
||||||
|
<div className="flex flex-col items-center justify-between p-4.5 pr-3 mr-0">
|
||||||
|
<IconComponent />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pr-4" />
|
||||||
|
)}
|
||||||
|
<div className="flex-grow min-w-0 py-4 px-0 border-0 border-b-1 border-solid border-main">
|
||||||
|
<div className="flex items-start justify-between font-semibold text-base leading-1.3 overflow-hidden">
|
||||||
|
<div className="break-word mr-2">{item.title}</div>
|
||||||
|
</div>
|
||||||
|
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
|
||||||
|
<ListItemTags hideTags={hideTags} tags={tags} />
|
||||||
|
<ListItemConflictIndicator item={item} />
|
||||||
|
</div>
|
||||||
|
<ListItemFlagIcons item={item} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(FileListItem)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
import { ListableContentItem } from './Types/ListableContentItem'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: {
|
||||||
|
conflictOf?: ListableContentItem['conflictOf']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListItemConflictIndicator: FunctionComponent<Props> = ({ item }) => {
|
||||||
|
return item.conflictOf ? (
|
||||||
|
<div className="flex flex-wrap items-center mt-0.5">
|
||||||
|
<div className={'py-1 px-1.5 rounded mr-1 mt-2 bg-danger color-danger-contrast'}>
|
||||||
|
<div className="text-xs font-bold text-center">Conflicted Copy</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ListItemConflictIndicator
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import { ListableContentItem } from './Types/ListableContentItem'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: {
|
||||||
|
locked: ListableContentItem['locked']
|
||||||
|
trashed: ListableContentItem['trashed']
|
||||||
|
archived: ListableContentItem['archived']
|
||||||
|
pinned: ListableContentItem['pinned']
|
||||||
|
}
|
||||||
|
hasFiles?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListItemFlagIcons: FunctionComponent<Props> = ({ item, hasFiles = false }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start p-4 pl-0 border-0 border-b-1 border-solid border-main">
|
||||||
|
{item.locked && (
|
||||||
|
<span className="flex items-center" title="Editing Disabled">
|
||||||
|
<Icon ariaLabel="Editing Disabled" type="pencil-off" className="sn-icon--small color-info" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.trashed && (
|
||||||
|
<span className="flex items-center ml-1.5" title="Trashed">
|
||||||
|
<Icon ariaLabel="Trashed" type="trash-filled" className="sn-icon--small color-danger" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.archived && (
|
||||||
|
<span className="flex items-center ml-1.5" title="Archived">
|
||||||
|
<Icon ariaLabel="Archived" type="archive" className="sn-icon--mid color-accessory-tint-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.pinned && (
|
||||||
|
<span className="flex items-center ml-1.5" title="Pinned">
|
||||||
|
<Icon ariaLabel="Pinned" type="pin-filled" className="sn-icon--small color-info" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasFiles && (
|
||||||
|
<span className="flex items-center ml-1.5" title="Files">
|
||||||
|
<Icon ariaLabel="Files" type="attachment-file" className="sn-icon--small color-info" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ListItemFlagIcons
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { CollectionSort, SortableItem } from '@standardnotes/snjs'
|
||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
import { ListableContentItem } from './Types/ListableContentItem'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: {
|
||||||
|
protected: ListableContentItem['protected']
|
||||||
|
updatedAtString?: ListableContentItem['updatedAtString']
|
||||||
|
createdAtString?: ListableContentItem['createdAtString']
|
||||||
|
}
|
||||||
|
hideDate: boolean
|
||||||
|
sortBy: keyof SortableItem | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListItemMetadata: FunctionComponent<Props> = ({ item, hideDate, sortBy }) => {
|
||||||
|
const showModifiedDate = sortBy === CollectionSort.UpdatedAt
|
||||||
|
|
||||||
|
if (hideDate && !item.protected) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-xs leading-1.4 mt-1 faded">
|
||||||
|
{item.protected && <span>Protected {hideDate ? '' : ' • '}</span>}
|
||||||
|
{!hideDate && showModifiedDate && <span>Modified {item.updatedAtString || 'Now'}</span>}
|
||||||
|
{!hideDate && !showModifiedDate && <span>{item.createdAtString || 'Now'}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ListItemMetadata
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
hideTags: boolean
|
||||||
|
tags: DisplayableListItemProps['tags']
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListItemTags: FunctionComponent<Props> = ({ hideTags, tags }) => {
|
||||||
|
if (hideTags || !tags.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap mt-1.5 text-xs gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center py-1 px-1.5 bg-passive-4-opacity-variant color-foreground rounded-0.5"
|
||||||
|
key={tag.uuid}
|
||||||
|
>
|
||||||
|
<Icon type="hashtag" className="sn-icon--small color-passive-1 mr-1" />
|
||||||
|
<span>{tag.title}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ListItemTags
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
|
||||||
|
import { sanitizeHtmlString, SNNote } from '@standardnotes/snjs'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import ListItemConflictIndicator from './ListItemConflictIndicator'
|
||||||
|
import ListItemFlagIcons from './ListItemFlagIcons'
|
||||||
|
import ListItemTags from './ListItemTags'
|
||||||
|
import ListItemMetadata from './ListItemMetadata'
|
||||||
|
import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
|
||||||
|
|
||||||
|
const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
|
||||||
|
application,
|
||||||
|
viewControllerManager,
|
||||||
|
hideDate,
|
||||||
|
hideIcon,
|
||||||
|
hideTags,
|
||||||
|
hidePreview,
|
||||||
|
item,
|
||||||
|
selected,
|
||||||
|
sortBy,
|
||||||
|
tags,
|
||||||
|
}) => {
|
||||||
|
const editorForNote = application.componentManager.editorForNote(item as SNNote)
|
||||||
|
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME
|
||||||
|
const [icon, tint] = application.iconsController.getIconAndTintForNoteType(editorForNote?.package_info.note_type)
|
||||||
|
const hasFiles = application.items.getFilesForNote(item as SNNote).length > 0
|
||||||
|
|
||||||
|
const openNoteContextMenu = (posX: number, posY: number) => {
|
||||||
|
viewControllerManager.notesController.setContextMenuClickLocation({
|
||||||
|
x: posX,
|
||||||
|
y: posY,
|
||||||
|
})
|
||||||
|
viewControllerManager.notesController.reloadContextMenuLayout()
|
||||||
|
viewControllerManager.notesController.setContextMenuOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openContextMenu = async (posX: number, posY: number) => {
|
||||||
|
const { didSelect } = await viewControllerManager.selectionController.selectItem(item.uuid, true)
|
||||||
|
if (didSelect) {
|
||||||
|
openNoteContextMenu(posX, posY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`content-list-item flex items-stretch w-full cursor-pointer ${
|
||||||
|
selected && 'selected border-0 border-l-2px border-solid border-info'
|
||||||
|
}`}
|
||||||
|
id={item.uuid}
|
||||||
|
onClick={() => {
|
||||||
|
void viewControllerManager.selectionController.selectItem(item.uuid, true)
|
||||||
|
}}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
void openContextMenu(event.clientX, event.clientY)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!hideIcon ? (
|
||||||
|
<div className="flex flex-col items-center justify-between p-4 pr-3 mr-0">
|
||||||
|
<Icon ariaLabel={`Icon for ${editorName}`} type={icon} className={`color-accessory-tint-${tint}`} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pr-4" />
|
||||||
|
)}
|
||||||
|
<div className="flex-grow min-w-0 py-4 px-0 border-0 border-b-1 border-solid border-main">
|
||||||
|
<div className="flex items-start justify-between font-semibold text-base leading-1.3 overflow-hidden">
|
||||||
|
<div className="break-word mr-2">{item.title}</div>
|
||||||
|
</div>
|
||||||
|
{!hidePreview && !item.hidePreview && !item.protected && (
|
||||||
|
<div className="overflow-hidden overflow-ellipsis text-sm">
|
||||||
|
{item.preview_html && (
|
||||||
|
<div
|
||||||
|
className="my-1"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: sanitizeHtmlString(item.preview_html),
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
{!item.preview_html && item.preview_plain && (
|
||||||
|
<div className="leading-1.3 overflow-hidden line-clamp-1 mt-1">{item.preview_plain}</div>
|
||||||
|
)}
|
||||||
|
{!item.preview_html && !item.preview_plain && item.text && (
|
||||||
|
<div className="leading-1.3 overflow-hidden line-clamp-1 mt-1">{item.text}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
|
||||||
|
<ListItemTags hideTags={hideTags} tags={tags} />
|
||||||
|
<ListItemConflictIndicator item={item} />
|
||||||
|
</div>
|
||||||
|
<ListItemFlagIcons item={item} hasFiles={hasFiles} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(NoteListItem)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
|
import { SortableItem } from '@standardnotes/snjs'
|
||||||
|
import { ListableContentItem } from './ListableContentItem'
|
||||||
|
|
||||||
|
export type AbstractListItemProps = {
|
||||||
|
application: WebApplication
|
||||||
|
viewControllerManager: ViewControllerManager
|
||||||
|
hideDate: boolean
|
||||||
|
hideIcon: boolean
|
||||||
|
hideTags: boolean
|
||||||
|
hidePreview: boolean
|
||||||
|
item: ListableContentItem
|
||||||
|
selected: boolean
|
||||||
|
sortBy: keyof SortableItem | undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { SNTag } from '@standardnotes/snjs'
|
||||||
|
import { AbstractListItemProps } from './AbstractListItemProps'
|
||||||
|
|
||||||
|
export type DisplayableListItemProps = AbstractListItemProps & {
|
||||||
|
tags: {
|
||||||
|
uuid: SNTag['uuid']
|
||||||
|
title: SNTag['title']
|
||||||
|
}[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ContentType, DecryptedItem, ItemContent } from '@standardnotes/snjs'
|
||||||
|
|
||||||
|
export type ListableContentItem = DecryptedItem<ItemContent> & {
|
||||||
|
title: string
|
||||||
|
protected: boolean
|
||||||
|
uuid: string
|
||||||
|
content_type: ContentType
|
||||||
|
updatedAtString?: string
|
||||||
|
createdAtString?: string
|
||||||
|
hidePreview?: boolean
|
||||||
|
preview_html?: string
|
||||||
|
preview_plain?: string
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
application: WebApplication
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeallocateHandler: FunctionComponent<Props> = ({ application, children }) => {
|
||||||
|
if (application.dealloced) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(DeallocateHandler)
|
||||||
@@ -1,16 +1,8 @@
|
|||||||
import { ListboxArrow, ListboxButton, ListboxInput, ListboxList, ListboxOption, ListboxPopover } from '@reach/listbox'
|
import { ListboxArrow, ListboxButton, ListboxInput, ListboxList, ListboxOption, ListboxPopover } from '@reach/listbox'
|
||||||
import VisuallyHidden from '@reach/visually-hidden'
|
import VisuallyHidden from '@reach/visually-hidden'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent } from 'react'
|
||||||
import { Icon } from '@/Components/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { IconType } from '@standardnotes/snjs'
|
import { DropdownItem } from './DropdownItem'
|
||||||
|
|
||||||
export type DropdownItem = {
|
|
||||||
icon?: IconType
|
|
||||||
iconClassName?: string
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownProps = {
|
type DropdownProps = {
|
||||||
id: string
|
id: string
|
||||||
@@ -41,12 +33,12 @@ const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({
|
|||||||
<div className="dropdown-selected-label">{label}</div>
|
<div className="dropdown-selected-label">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
<ListboxArrow className={`sn-dropdown-arrow ${isExpanded ? 'sn-dropdown-arrow-flipped' : ''}`}>
|
<ListboxArrow className={`sn-dropdown-arrow ${isExpanded ? 'sn-dropdown-arrow-flipped' : ''}`}>
|
||||||
<Icon type="menu-arrow-down" className="sn-icon--small color-grey-1" />
|
<Icon type="menu-arrow-down" className="sn-icon--small color-passive-1" />
|
||||||
</ListboxArrow>
|
</ListboxArrow>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, onChange, disabled }) => {
|
const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, onChange, disabled }) => {
|
||||||
const labelId = `${id}-label`
|
const labelId = `${id}-label`
|
||||||
|
|
||||||
const handleChange = (value: string) => {
|
const handleChange = (value: string) => {
|
||||||
@@ -79,6 +71,7 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, v
|
|||||||
<ListboxList>
|
<ListboxList>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
|
key={item.value}
|
||||||
className="sn-dropdown-item"
|
className="sn-dropdown-item"
|
||||||
value={item.value}
|
value={item.value}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
@@ -99,3 +92,5 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, v
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Dropdown
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { IconType } from '@standardnotes/snjs'
|
||||||
|
|
||||||
|
export type DropdownItem = {
|
||||||
|
icon?: IconType
|
||||||
|
iconClassName?: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { MAX_MENU_SIZE_MULTIPLIER, MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
|
||||||
|
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||||
|
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
|
||||||
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { PopoverFileItemAction } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||||
|
import { PopoverTabs } from '../AttachedFilesPopover/PopoverTabs'
|
||||||
|
import FileMenuOptions from './FileMenuOptions'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
viewControllerManager: ViewControllerManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileContextMenu: FunctionComponent<Props> = observer(({ viewControllerManager }) => {
|
||||||
|
const { selectedFiles, showFileContextMenu, setShowFileContextMenu, fileContextMenuLocation } =
|
||||||
|
viewControllerManager.filesController
|
||||||
|
|
||||||
|
const [contextMenuStyle, setContextMenuStyle] = useState<React.CSSProperties>({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
visibility: 'hidden',
|
||||||
|
})
|
||||||
|
const [contextMenuMaxHeight, setContextMenuMaxHeight] = useState<number | 'auto'>('auto')
|
||||||
|
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) => setShowFileContextMenu(open))
|
||||||
|
useCloseOnClickOutside(contextMenuRef, () => viewControllerManager.filesController.setShowFileContextMenu(false))
|
||||||
|
|
||||||
|
const selectedFile = selectedFiles[0]
|
||||||
|
|
||||||
|
const reloadContextMenuLayout = useCallback(() => {
|
||||||
|
const { clientHeight } = document.documentElement
|
||||||
|
const defaultFontSize = window.getComputedStyle(document.documentElement).fontSize
|
||||||
|
const maxContextMenuHeight = parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER
|
||||||
|
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
||||||
|
const footerHeightInPx = footerElementRect?.height
|
||||||
|
|
||||||
|
let openUpBottom = true
|
||||||
|
|
||||||
|
if (footerHeightInPx) {
|
||||||
|
const bottomSpace = clientHeight - footerHeightInPx - fileContextMenuLocation.y
|
||||||
|
const upSpace = fileContextMenuLocation.y
|
||||||
|
|
||||||
|
if (maxContextMenuHeight > bottomSpace) {
|
||||||
|
if (upSpace > maxContextMenuHeight) {
|
||||||
|
openUpBottom = false
|
||||||
|
setContextMenuMaxHeight('auto')
|
||||||
|
} else {
|
||||||
|
if (upSpace > bottomSpace) {
|
||||||
|
setContextMenuMaxHeight(upSpace - MENU_MARGIN_FROM_APP_BORDER)
|
||||||
|
openUpBottom = false
|
||||||
|
} else {
|
||||||
|
setContextMenuMaxHeight(bottomSpace - MENU_MARGIN_FROM_APP_BORDER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setContextMenuMaxHeight('auto')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openUpBottom) {
|
||||||
|
setContextMenuStyle({
|
||||||
|
top: fileContextMenuLocation.y,
|
||||||
|
left: fileContextMenuLocation.x,
|
||||||
|
visibility: 'visible',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setContextMenuStyle({
|
||||||
|
bottom: clientHeight - fileContextMenuLocation.y,
|
||||||
|
left: fileContextMenuLocation.x,
|
||||||
|
visibility: 'visible',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [fileContextMenuLocation.x, fileContextMenuLocation.y])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showFileContextMenu) {
|
||||||
|
reloadContextMenuLayout()
|
||||||
|
}
|
||||||
|
}, [reloadContextMenuLayout, showFileContextMenu])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('resize', reloadContextMenuLayout)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', reloadContextMenuLayout)
|
||||||
|
}
|
||||||
|
}, [reloadContextMenuLayout])
|
||||||
|
|
||||||
|
const handleFileAction = useCallback(
|
||||||
|
async (action: PopoverFileItemAction) => {
|
||||||
|
const { didHandleAction } = await viewControllerManager.filesController.handleFileAction(
|
||||||
|
action,
|
||||||
|
PopoverTabs.AllFiles,
|
||||||
|
)
|
||||||
|
return didHandleAction
|
||||||
|
},
|
||||||
|
[viewControllerManager.filesController],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={contextMenuRef}
|
||||||
|
className="sn-dropdown min-w-60 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto fixed"
|
||||||
|
style={{
|
||||||
|
...contextMenuStyle,
|
||||||
|
maxHeight: contextMenuMaxHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileMenuOptions
|
||||||
|
file={selectedFile}
|
||||||
|
handleFileAction={handleFileAction}
|
||||||
|
closeOnBlur={closeOnBlur}
|
||||||
|
closeMenu={() => setShowFileContextMenu(false)}
|
||||||
|
shouldShowRenameOption={false}
|
||||||
|
shouldShowAttachOption={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
FileContextMenu.displayName = 'FileContextMenu'
|
||||||
|
|
||||||
|
const FileContextMenuWrapper: FunctionComponent<Props> = ({ viewControllerManager }) => {
|
||||||
|
const { selectedFiles, showFileContextMenu } = viewControllerManager.filesController
|
||||||
|
|
||||||
|
const selectedFile = selectedFiles[0]
|
||||||
|
|
||||||
|
if (!showFileContextMenu || !selectedFile) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FileContextMenu viewControllerManager={viewControllerManager} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(FileContextMenuWrapper)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||||
|
import { FileItem } from '@standardnotes/snjs'
|
||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
import { PopoverFileItemAction, PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import Switch from '@/Components/Switch/Switch'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
closeMenu: () => void
|
||||||
|
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||||
|
file: FileItem
|
||||||
|
fileProtectionToggleCallback?: (isProtected: boolean) => void
|
||||||
|
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
|
||||||
|
isFileAttachedToNote?: boolean
|
||||||
|
renameToggleCallback?: (isRenamingFile: boolean) => void
|
||||||
|
shouldShowRenameOption: boolean
|
||||||
|
shouldShowAttachOption: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileMenuOptions: FunctionComponent<Props> = ({
|
||||||
|
closeMenu,
|
||||||
|
closeOnBlur,
|
||||||
|
file,
|
||||||
|
fileProtectionToggleCallback,
|
||||||
|
handleFileAction,
|
||||||
|
isFileAttachedToNote,
|
||||||
|
renameToggleCallback,
|
||||||
|
shouldShowRenameOption,
|
||||||
|
shouldShowAttachOption,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||||
|
onClick={() => {
|
||||||
|
handleFileAction({
|
||||||
|
type: PopoverFileItemActionType.PreviewFile,
|
||||||
|
payload: file,
|
||||||
|
}).catch(console.error)
|
||||||
|
closeMenu()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="file" className="mr-2 color-neutral" />
|
||||||
|
Preview file
|
||||||
|
</button>
|
||||||
|
{isFileAttachedToNote ? (
|
||||||
|
<button
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||||
|
onClick={() => {
|
||||||
|
handleFileAction({
|
||||||
|
type: PopoverFileItemActionType.DetachFileToNote,
|
||||||
|
payload: file,
|
||||||
|
}).catch(console.error)
|
||||||
|
closeMenu()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="link-off" className="mr-2 color-neutral" />
|
||||||
|
Detach from note
|
||||||
|
</button>
|
||||||
|
) : shouldShowAttachOption ? (
|
||||||
|
<button
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||||
|
onClick={() => {
|
||||||
|
handleFileAction({
|
||||||
|
type: PopoverFileItemActionType.AttachFileToNote,
|
||||||
|
payload: file,
|
||||||
|
}).catch(console.error)
|
||||||
|
closeMenu()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="link" className="mr-2 color-neutral" />
|
||||||
|
Attach to note
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div className="min-h-1px my-1 bg-border"></div>
|
||||||
|
<button
|
||||||
|
className="sn-dropdown-item justify-between focus:bg-info-backdrop"
|
||||||
|
onClick={() => {
|
||||||
|
handleFileAction({
|
||||||
|
type: PopoverFileItemActionType.ToggleFileProtection,
|
||||||
|
payload: file,
|
||||||
|
callback: (isProtected: boolean) => {
|
||||||
|
fileProtectionToggleCallback?.(isProtected)
|
||||||
|
},
|
||||||
|
}).catch(console.error)
|
||||||
|
}}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Icon type="password" className="mr-2 color-neutral" />
|
||||||
|
Password protection
|
||||||
|
</span>
|
||||||
|
<Switch className="px-0 pointer-events-none" tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} checked={file.protected} />
|
||||||
|
</button>
|
||||||
|
<div className="min-h-1px my-1 bg-border"></div>
|
||||||
|
<button
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||||
|
onClick={() => {
|
||||||
|
handleFileAction({
|
||||||
|
type: PopoverFileItemActionType.DownloadFile,
|
||||||
|
payload: file,
|
||||||
|
}).catch(console.error)
|
||||||
|
closeMenu()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="download" className="mr-2 color-neutral" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
{shouldShowRenameOption && (
|
||||||
|
<button
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||||
|
onClick={() => {
|
||||||
|
renameToggleCallback?.(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="pencil" className="mr-2 color-neutral" />
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||||
|
onClick={() => {
|
||||||
|
handleFileAction({
|
||||||
|
type: PopoverFileItemActionType.DeleteFile,
|
||||||
|
payload: file,
|
||||||
|
}).catch(console.error)
|
||||||
|
closeMenu()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="trash" className="mr-2 color-danger" />
|
||||||
|
<span className="color-danger">Delete permanently</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileMenuOptions
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||||
import { FileItem } from '@standardnotes/snjs'
|
import { FileItem } from '@standardnotes/snjs'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent } from 'react'
|
||||||
import { Icon } from '@/Components/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
file: FileItem
|
file: FileItem
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
|
const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-w-70 p-4 border-0 border-l-1px border-solid border-main">
|
<div className="flex flex-col min-w-70 p-4 border-0 border-l-1px border-solid border-main">
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
@@ -35,3 +35,5 @@ export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default FilePreviewInfoPanel
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
|
import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
|
||||||
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
||||||
import { addToast, ToastType } from '@standardnotes/stylekit'
|
import { addToast, ToastType } from '@standardnotes/stylekit'
|
||||||
import { NoPreviewIllustration } from '@standardnotes/icons'
|
import { NoPreviewIllustration } from '@standardnotes/icons'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
import { getFileIconComponent } from '@/Components/AttachedFilesPopover/getFileIconComponent'
|
||||||
import { getFileIconComponent } from '@/Components/AttachedFilesPopover/PopoverFileItem'
|
import Button from '@/Components/Button/Button'
|
||||||
import { Button } from '@/Components/Button/Button'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
import FilePreviewInfoPanel from './FilePreviewInfoPanel'
|
||||||
import { FilePreviewInfoPanel } from './FilePreviewInfoPanel'
|
|
||||||
import { isFileTypePreviewable } from './isFilePreviewable'
|
import { isFileTypePreviewable } from './isFilePreviewable'
|
||||||
import { PreviewComponent } from './PreviewComponent'
|
import PreviewComponent from './PreviewComponent'
|
||||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||||
import { KeyboardKey } from '@/Services/IOService'
|
import { KeyboardKey } from '@/Services/IOService'
|
||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FilePreviewModal: FunctionComponent<Props> = observer(({ application, appState }) => {
|
const FilePreviewModal: FunctionComponent<Props> = observer(({ application, viewControllerManager }) => {
|
||||||
const { currentFile, setCurrentFile, otherFiles, dismiss, isOpen } = appState.filePreviewModal
|
const { currentFile, setCurrentFile, otherFiles, dismiss } = viewControllerManager.filePreviewModalController
|
||||||
|
|
||||||
if (!currentFile || !isOpen) {
|
if (!currentFile) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,34 +86,46 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
|
|||||||
}
|
}
|
||||||
}, [currentFile, getObjectUrl, objectUrl])
|
}, [currentFile, getObjectUrl, objectUrl])
|
||||||
|
|
||||||
const keyDownHandler = (event: KeyboardEvent) => {
|
const keyDownHandler: KeyboardEventHandler = useCallback(
|
||||||
if (event.key !== KeyboardKey.Left && event.key !== KeyboardKey.Right) {
|
(event) => {
|
||||||
return
|
if (event.key !== KeyboardKey.Left && event.key !== KeyboardKey.Right) {
|
||||||
}
|
return
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
const currentFileIndex = otherFiles.findIndex((fileFromArray) => fileFromArray.uuid === currentFile.uuid)
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case KeyboardKey.Left: {
|
|
||||||
const previousFileIndex = currentFileIndex - 1 >= 0 ? currentFileIndex - 1 : otherFiles.length - 1
|
|
||||||
const previousFile = otherFiles[previousFileIndex]
|
|
||||||
if (previousFile) {
|
|
||||||
setCurrentFile(previousFile)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
case KeyboardKey.Right: {
|
|
||||||
const nextFileIndex = currentFileIndex + 1 < otherFiles.length ? currentFileIndex + 1 : 0
|
event.preventDefault()
|
||||||
const nextFile = otherFiles[nextFileIndex]
|
|
||||||
if (nextFile) {
|
const currentFileIndex = otherFiles.findIndex((fileFromArray) => fileFromArray.uuid === currentFile.uuid)
|
||||||
setCurrentFile(nextFile)
|
|
||||||
|
switch (event.key) {
|
||||||
|
case KeyboardKey.Left: {
|
||||||
|
const previousFileIndex = currentFileIndex - 1 >= 0 ? currentFileIndex - 1 : otherFiles.length - 1
|
||||||
|
const previousFile = otherFiles[previousFileIndex]
|
||||||
|
if (previousFile) {
|
||||||
|
setCurrentFile(previousFile)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case KeyboardKey.Right: {
|
||||||
|
const nextFileIndex = currentFileIndex + 1 < otherFiles.length ? currentFileIndex + 1 : 0
|
||||||
|
const nextFile = otherFiles[nextFileIndex]
|
||||||
|
if (nextFile) {
|
||||||
|
setCurrentFile(nextFile)
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
[currentFile.uuid, otherFiles, setCurrentFile],
|
||||||
|
)
|
||||||
|
|
||||||
|
const IconComponent = useMemo(
|
||||||
|
() =>
|
||||||
|
getFileIconComponent(
|
||||||
|
application.iconsController.getIconForFileType(currentFile.mimeType),
|
||||||
|
'w-6 h-6 flex-shrink-0',
|
||||||
|
),
|
||||||
|
[application.iconsController, currentFile.mimeType],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogOverlay
|
<DialogOverlay
|
||||||
@@ -125,12 +136,13 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
|
|||||||
dangerouslyBypassScrollLock
|
dangerouslyBypassScrollLock
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
aria-label="File preview modal"
|
||||||
className="flex flex-col rounded shadow-overlay"
|
className="flex flex-col rounded shadow-overlay"
|
||||||
style={{
|
style={{
|
||||||
width: '90%',
|
width: '90%',
|
||||||
maxWidth: '90%',
|
maxWidth: '90%',
|
||||||
minHeight: '90%',
|
minHeight: '90%',
|
||||||
background: 'var(--sn-stylekit-background-color)',
|
background: 'var(--modal-background-color)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -139,12 +151,7 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
|
|||||||
onKeyDown={keyDownHandler}
|
onKeyDown={keyDownHandler}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-6 h-6">
|
<div className="w-6 h-6">{IconComponent}</div>
|
||||||
{getFileIconComponent(
|
|
||||||
application.iconsController.getIconForFileType(currentFile.mimeType),
|
|
||||||
'w-6 h-6 flex-shrink-0',
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="ml-3 font-medium">{currentFile.name}</span>
|
<span className="ml-3 font-medium">{currentFile.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -197,7 +204,7 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
|
|||||||
<div className="font-bold text-base mb-2">This file can't be previewed.</div>
|
<div className="font-bold text-base mb-2">This file can't be previewed.</div>
|
||||||
{isFilePreviewable ? (
|
{isFilePreviewable ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-sm text-center color-grey-0 mb-4 max-w-35ch">
|
<div className="text-sm text-center color-passive-0 mb-4 max-w-35ch">
|
||||||
There was an error loading the file. Try again, or download the file and open it using another
|
There was an error loading the file. Try again, or download the file and open it using another
|
||||||
application.
|
application.
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +221,10 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
|
|||||||
<Button
|
<Button
|
||||||
variant="normal"
|
variant="normal"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
application.getAppState().files.downloadFile(currentFile).catch(console.error)
|
application
|
||||||
|
.getViewControllerManager()
|
||||||
|
.filesController.downloadFile(currentFile)
|
||||||
|
.catch(console.error)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
@@ -223,13 +233,16 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-sm text-center color-grey-0 mb-4 max-w-35ch">
|
<div className="text-sm text-center color-passive-0 mb-4 max-w-35ch">
|
||||||
To view this file, download it and open it using another application.
|
To view this file, download it and open it using another application.
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
application.getAppState().files.downloadFile(currentFile).catch(console.error)
|
application
|
||||||
|
.getViewControllerManager()
|
||||||
|
.filesController.downloadFile(currentFile)
|
||||||
|
.catch(console.error)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
@@ -245,3 +258,13 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
|
|||||||
</DialogOverlay>
|
</DialogOverlay>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
FilePreviewModal.displayName = 'FilePreviewModal'
|
||||||
|
|
||||||
|
const FilePreviewModalWrapper: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
|
||||||
|
return viewControllerManager.filePreviewModalController.isOpen ? (
|
||||||
|
<FilePreviewModal application={application} viewControllerManager={viewControllerManager} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(FilePreviewModalWrapper)
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { IconType } from '@standardnotes/snjs'
|
import { IconType } from '@standardnotes/snjs'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, useRef, useState } from 'react'
|
||||||
import { useRef, useState } from 'preact/hooks'
|
import IconButton from '../Button/IconButton'
|
||||||
import { IconButton } from '../Button/IconButton'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
objectUrl: string
|
objectUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
|
const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
|
||||||
const initialImgHeightRef = useRef<number>()
|
const initialImgHeightRef = useRef<number>()
|
||||||
|
|
||||||
const [imageZoomPercent, setImageZoomPercent] = useState(100)
|
const [imageZoomPercent, setImageZoomPercent] = useState(100)
|
||||||
@@ -21,12 +20,12 @@ export const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
|
|||||||
height: `${imageZoomPercent}%`,
|
height: `${imageZoomPercent}%`,
|
||||||
...(imageZoomPercent <= 100
|
...(imageZoomPercent <= 100
|
||||||
? {
|
? {
|
||||||
'min-width': '100%',
|
minWidth: '100%',
|
||||||
'object-fit': 'contain',
|
objectFit: 'contain',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
top: 0,
|
||||||
margin: 'auto',
|
margin: 'auto',
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
@@ -69,3 +68,5 @@ export const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ImagePreview
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { FileItem } from '@standardnotes/snjs'
|
import { FileItem } from '@standardnotes/snjs'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent } from 'react'
|
||||||
import { ImagePreview } from './ImagePreview'
|
import ImagePreview from './ImagePreview'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
file: FileItem
|
file: FileItem
|
||||||
objectUrl: string
|
objectUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PreviewComponent: FunctionComponent<Props> = ({ file, objectUrl }) => {
|
const PreviewComponent: FunctionComponent<Props> = ({ file, objectUrl }) => {
|
||||||
if (file.mimeType.startsWith('image/')) {
|
if (file.mimeType.startsWith('image/')) {
|
||||||
return <ImagePreview objectUrl={objectUrl} />
|
return <ImagePreview objectUrl={objectUrl} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.mimeType.startsWith('video/')) {
|
if (file.mimeType.startsWith('video/')) {
|
||||||
return <video className="w-full h-full" src={objectUrl} controls />
|
return <video className="w-full h-full" src={objectUrl} controls autoPlay />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.mimeType.startsWith('audio/')) {
|
if (file.mimeType.startsWith('audio/')) {
|
||||||
@@ -22,3 +22,5 @@ export const PreviewComponent: FunctionComponent<Props> = ({ file, objectUrl })
|
|||||||
|
|
||||||
return <object className="w-full h-full" data={objectUrl} />
|
return <object className="w-full h-full" data={objectUrl} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default PreviewComponent
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import { WebAppEvent, WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
|
import { WebAppEvent } from '@/Application/WebAppEvent'
|
||||||
|
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||||
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
||||||
import { destroyAllObjectProperties, preventRefreshing } from '@/Utils'
|
import { destroyAllObjectProperties, preventRefreshing } from '@/Utils'
|
||||||
import { ApplicationEvent, ContentType, CollectionSort, ApplicationDescriptor } from '@standardnotes/snjs'
|
import { ApplicationEvent, ApplicationDescriptor } from '@standardnotes/snjs'
|
||||||
import {
|
import {
|
||||||
STRING_NEW_UPDATE_READY,
|
STRING_NEW_UPDATE_READY,
|
||||||
STRING_CONFIRM_APP_QUIT_DURING_UPGRADE,
|
STRING_CONFIRM_APP_QUIT_DURING_UPGRADE,
|
||||||
STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
|
STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
|
||||||
STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
|
STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
|
||||||
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
|
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
|
||||||
} from '@/Strings'
|
} from '@/Constants/Strings'
|
||||||
import { alertDialog, confirmDialog } from '@/Services/AlertService'
|
import { alertDialog, confirmDialog } from '@/Services/AlertService'
|
||||||
import { AccountMenu, AccountMenuPane } from '@/Components/AccountMenu'
|
import AccountMenu from '@/Components/AccountMenu/AccountMenu'
|
||||||
import { AppStateEvent, EventSource } from '@/UIModels/AppState'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { Icon } from '@/Components/Icon'
|
import QuickSettingsMenu from '@/Components/QuickSettingsMenu/QuickSettingsMenu'
|
||||||
import { QuickSettingsMenu } from '@/Components/QuickSettingsMenu'
|
import SyncResolutionMenu from '@/Components/SyncResolutionMenu/SyncResolutionMenu'
|
||||||
import { SyncResolutionMenu } from '@/Components/SyncResolutionMenu'
|
import { Fragment } from 'react'
|
||||||
import { Fragment } from 'preact'
|
import { AccountMenuPane } from '../AccountMenu/AccountMenuPane'
|
||||||
|
import { EditorEventSource } from '@/Types/EditorEventSource'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
@@ -38,7 +40,7 @@ type State = {
|
|||||||
arbitraryStatusMessage?: string
|
arbitraryStatusMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Footer extends PureComponent<Props, State> {
|
class Footer extends PureComponent<Props, State> {
|
||||||
public user?: unknown
|
public user?: unknown
|
||||||
private didCheckForOffline = false
|
private didCheckForOffline = false
|
||||||
private completedInitialSync = false
|
private completedInitialSync = false
|
||||||
@@ -62,9 +64,34 @@ export class Footer extends PureComponent<Props, State> {
|
|||||||
showQuickSettingsMenu: false,
|
showQuickSettingsMenu: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.webEventListenerDestroyer = props.application.addWebEventObserver((event) => {
|
this.webEventListenerDestroyer = props.application.addWebEventObserver((event, data) => {
|
||||||
if (event === WebAppEvent.NewUpdateAvailable) {
|
const statusService = this.application.status
|
||||||
this.onNewUpdateAvailable()
|
|
||||||
|
switch (event) {
|
||||||
|
case WebAppEvent.NewUpdateAvailable:
|
||||||
|
this.onNewUpdateAvailable()
|
||||||
|
break
|
||||||
|
case WebAppEvent.EditorFocused:
|
||||||
|
if ((data as any).eventSource === EditorEventSource.UserInteraction) {
|
||||||
|
this.closeAccountMenu()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case WebAppEvent.BeganBackupDownload:
|
||||||
|
statusService.setMessage('Saving local backup…')
|
||||||
|
break
|
||||||
|
case WebAppEvent.EndedBackupDownload: {
|
||||||
|
const successMessage = 'Successfully saved backup.'
|
||||||
|
const errorMessage = 'Unable to save local backup.'
|
||||||
|
statusService.setMessage((data as any).success ? successMessage : errorMessage)
|
||||||
|
|
||||||
|
const twoSeconds = 2000
|
||||||
|
setTimeout(() => {
|
||||||
|
if (statusService.message === successMessage || statusService.message === errorMessage) {
|
||||||
|
statusService.setMessage('')
|
||||||
|
}
|
||||||
|
}, twoSeconds)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -91,11 +118,11 @@ export class Footer extends PureComponent<Props, State> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.autorun(() => {
|
this.autorun(() => {
|
||||||
const showBetaWarning = this.appState.showBetaWarning
|
const showBetaWarning = this.viewControllerManager.showBetaWarning
|
||||||
this.setState({
|
this.setState({
|
||||||
showBetaWarning: showBetaWarning,
|
showBetaWarning: showBetaWarning,
|
||||||
showAccountMenu: this.appState.accountMenu.show,
|
showAccountMenu: this.viewControllerManager.accountMenuController.show,
|
||||||
showQuickSettingsMenu: this.appState.quickSettingsMenu.open,
|
showQuickSettingsMenu: this.viewControllerManager.quickSettingsMenuController.open,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -118,7 +145,6 @@ export class Footer extends PureComponent<Props, State> {
|
|||||||
this.reloadUpgradeStatus()
|
this.reloadUpgradeStatus()
|
||||||
this.updateOfflineStatus()
|
this.updateOfflineStatus()
|
||||||
this.findErrors()
|
this.findErrors()
|
||||||
this.streamItems()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadUser() {
|
reloadUser() {
|
||||||
@@ -132,33 +158,6 @@ export class Footer extends PureComponent<Props, State> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
override onAppStateEvent(eventName: AppStateEvent, data: any) {
|
|
||||||
const statusService = this.application.status
|
|
||||||
switch (eventName) {
|
|
||||||
case AppStateEvent.EditorFocused:
|
|
||||||
if (data.eventSource === EventSource.UserInteraction) {
|
|
||||||
this.closeAccountMenu()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case AppStateEvent.BeganBackupDownload:
|
|
||||||
statusService.setMessage('Saving local backup…')
|
|
||||||
break
|
|
||||||
case AppStateEvent.EndedBackupDownload: {
|
|
||||||
const successMessage = 'Successfully saved backup.'
|
|
||||||
const errorMessage = 'Unable to save local backup.'
|
|
||||||
statusService.setMessage(data.success ? successMessage : errorMessage)
|
|
||||||
|
|
||||||
const twoSeconds = 2000
|
|
||||||
setTimeout(() => {
|
|
||||||
if (statusService.message === successMessage || statusService.message === errorMessage) {
|
|
||||||
statusService.setMessage('')
|
|
||||||
}
|
|
||||||
}, twoSeconds)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override async onAppKeyChange() {
|
override async onAppKeyChange() {
|
||||||
super.onAppKeyChange().catch(console.error)
|
super.onAppKeyChange().catch(console.error)
|
||||||
this.reloadPasscodeStatus().catch(console.error)
|
this.reloadPasscodeStatus().catch(console.error)
|
||||||
@@ -187,7 +186,7 @@ export class Footer extends PureComponent<Props, State> {
|
|||||||
if (!this.didCheckForOffline) {
|
if (!this.didCheckForOffline) {
|
||||||
this.didCheckForOffline = true
|
this.didCheckForOffline = true
|
||||||
if (this.state.offline && this.application.items.getNoteCount() === 0) {
|
if (this.state.offline && this.application.items.getNoteCount() === 0) {
|
||||||
this.appState.accountMenu.setShow(true)
|
this.viewControllerManager.accountMenuController.setShow(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.findErrors()
|
this.findErrors()
|
||||||
@@ -217,10 +216,6 @@ export class Footer extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
streamItems() {
|
|
||||||
this.application.items.setDisplayOptions(ContentType.Theme, CollectionSort.Title, 'asc')
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSyncStatus() {
|
updateSyncStatus() {
|
||||||
const statusManager = this.application.status
|
const statusManager = this.application.status
|
||||||
const syncStatus = this.application.sync.getSyncStatus()
|
const syncStatus = this.application.sync.getSyncStatus()
|
||||||
@@ -292,13 +287,13 @@ export class Footer extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
accountMenuClickHandler = () => {
|
accountMenuClickHandler = () => {
|
||||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu()
|
this.viewControllerManager.quickSettingsMenuController.closeQuickSettingsMenu()
|
||||||
this.appState.accountMenu.toggleShow()
|
this.viewControllerManager.accountMenuController.toggleShow()
|
||||||
}
|
}
|
||||||
|
|
||||||
quickSettingsClickHandler = () => {
|
quickSettingsClickHandler = () => {
|
||||||
this.appState.accountMenu.closeAccountMenu()
|
this.viewControllerManager.accountMenuController.closeAccountMenu()
|
||||||
this.appState.quickSettingsMenu.toggle()
|
this.viewControllerManager.quickSettingsMenuController.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
syncResolutionClickHandler = () => {
|
syncResolutionClickHandler = () => {
|
||||||
@@ -308,8 +303,8 @@ export class Footer extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeAccountMenu = () => {
|
closeAccountMenu = () => {
|
||||||
this.appState.accountMenu.setShow(false)
|
this.viewControllerManager.accountMenuController.setShow(false)
|
||||||
this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu)
|
this.viewControllerManager.accountMenuController.setCurrentPane(AccountMenuPane.GeneralMenu)
|
||||||
}
|
}
|
||||||
|
|
||||||
lockClickHandler = () => {
|
lockClickHandler = () => {
|
||||||
@@ -337,11 +332,11 @@ export class Footer extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clickOutsideAccountMenu = () => {
|
clickOutsideAccountMenu = () => {
|
||||||
this.appState.accountMenu.closeAccountMenu()
|
this.viewControllerManager.accountMenuController.closeAccountMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
clickOutsideQuickSettingsMenu = () => {
|
clickOutsideQuickSettingsMenu = () => {
|
||||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu()
|
this.viewControllerManager.quickSettingsMenuController.closeQuickSettingsMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
@@ -364,7 +359,7 @@ export class Footer extends PureComponent<Props, State> {
|
|||||||
{this.state.showAccountMenu && (
|
{this.state.showAccountMenu && (
|
||||||
<AccountMenu
|
<AccountMenu
|
||||||
onClickOutside={this.clickOutsideAccountMenu}
|
onClickOutside={this.clickOutsideAccountMenu}
|
||||||
appState={this.appState}
|
viewControllerManager={this.viewControllerManager}
|
||||||
application={this.application}
|
application={this.application}
|
||||||
mainApplicationGroup={this.props.applicationGroup}
|
mainApplicationGroup={this.props.applicationGroup}
|
||||||
/>
|
/>
|
||||||
@@ -385,7 +380,7 @@ export class Footer extends PureComponent<Props, State> {
|
|||||||
{this.state.showQuickSettingsMenu && (
|
{this.state.showQuickSettingsMenu && (
|
||||||
<QuickSettingsMenu
|
<QuickSettingsMenu
|
||||||
onClickOutside={this.clickOutsideQuickSettingsMenu}
|
onClickOutside={this.clickOutsideQuickSettingsMenu}
|
||||||
appState={this.appState}
|
viewControllerManager={this.viewControllerManager}
|
||||||
application={this.application}
|
application={this.application}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -459,3 +454,5 @@ export class Footer extends PureComponent<Props, State> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Footer
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FunctionalComponent } from 'preact'
|
import { FunctionComponent } from 'react'
|
||||||
import { IconType } from '@standardnotes/snjs'
|
import { IconType } from '@standardnotes/snjs'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -187,7 +187,7 @@ type Props = {
|
|||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Icon: FunctionalComponent<Props> = ({ type, className = '', ariaLabel }) => {
|
const Icon: FunctionComponent<Props> = ({ type, className = '', ariaLabel }) => {
|
||||||
const IconComponent = ICONS[type as keyof typeof ICONS]
|
const IconComponent = ICONS[type as keyof typeof ICONS]
|
||||||
if (!IconComponent) {
|
if (!IconComponent) {
|
||||||
return null
|
return null
|
||||||
@@ -200,3 +200,5 @@ export const Icon: FunctionalComponent<Props> = ({ type, className = '', ariaLab
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Icon
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { FunctionalComponent, Ref } from 'preact'
|
import { forwardRef, Fragment, Ref } from 'react'
|
||||||
import { forwardRef } from 'preact/compat'
|
|
||||||
import { DecoratedInputProps } from './DecoratedInputProps'
|
import { DecoratedInputProps } from './DecoratedInputProps'
|
||||||
|
|
||||||
const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean) => {
|
const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean) => {
|
||||||
@@ -10,14 +9,14 @@ const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean
|
|||||||
input: `w-full border-0 focus:shadow-none bg-transparent color-text ${
|
input: `w-full border-0 focus:shadow-none bg-transparent color-text ${
|
||||||
!hasLeftDecorations && hasRightDecorations ? 'pl-2' : ''
|
!hasLeftDecorations && hasRightDecorations ? 'pl-2' : ''
|
||||||
} ${hasRightDecorations ? 'pr-2' : ''}`,
|
} ${hasRightDecorations ? 'pr-2' : ''}`,
|
||||||
disabled: 'bg-grey-5 cursor-not-allowed',
|
disabled: 'bg-passive-5 cursor-not-allowed',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Input that can be decorated on the left and right side
|
* Input that can be decorated on the left and right side
|
||||||
*/
|
*/
|
||||||
export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardRef(
|
const DecoratedInput = forwardRef(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
type = 'text',
|
type = 'text',
|
||||||
@@ -42,8 +41,8 @@ export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardR
|
|||||||
<div className={`${classNames.container} ${disabled ? classNames.disabled : ''} ${className}`}>
|
<div className={`${classNames.container} ${disabled ? classNames.disabled : ''} ${className}`}>
|
||||||
{left && (
|
{left && (
|
||||||
<div className="flex items-center px-2 py-1.5">
|
<div className="flex items-center px-2 py-1.5">
|
||||||
{left.map((leftChild) => (
|
{left.map((leftChild, index) => (
|
||||||
<>{leftChild}</>
|
<Fragment key={index}>{leftChild}</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -58,14 +57,16 @@ export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardR
|
|||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
data-lpignore={type !== 'password' ? true : false}
|
data-lpignore={type !== 'password' ? true : false}
|
||||||
autocomplete={autocomplete ? 'on' : 'off'}
|
autoComplete={autocomplete ? 'on' : 'off'}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{right && (
|
{right && (
|
||||||
<div className="flex items-center px-2 py-1.5">
|
<div className="flex items-center px-2 py-1.5">
|
||||||
{right.map((rightChild, index) => (
|
{right.map((rightChild, index) => (
|
||||||
<div className={index > 0 ? 'ml-3' : ''}>{rightChild}</div>
|
<div className={index > 0 ? 'ml-3' : ''} key={index}>
|
||||||
|
{rightChild}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -73,3 +74,5 @@ export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardR
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export default DecoratedInput
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { ComponentChild } from 'preact'
|
import { FocusEventHandler, KeyboardEventHandler, ReactNode } from 'react'
|
||||||
|
|
||||||
export type DecoratedInputProps = {
|
export type DecoratedInputProps = {
|
||||||
type?: 'text' | 'email' | 'password'
|
type?: 'text' | 'email' | 'password'
|
||||||
className?: string
|
className?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
left?: ComponentChild[]
|
left?: ReactNode[]
|
||||||
right?: ComponentChild[]
|
right?: ReactNode[]
|
||||||
value?: string
|
value?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
onChange?: (text: string) => void
|
onChange?: (text: string) => void
|
||||||
onFocus?: (event: FocusEvent) => void
|
onFocus?: FocusEventHandler
|
||||||
onKeyDown?: (event: KeyboardEvent) => void
|
onKeyDown?: KeyboardEventHandler
|
||||||
autocomplete?: boolean
|
autocomplete?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { FunctionComponent, Ref } from 'preact'
|
import { Dispatch, FunctionComponent, Ref, SetStateAction, forwardRef, useState } from 'react'
|
||||||
import { forwardRef } from 'preact/compat'
|
import DecoratedInput from './DecoratedInput'
|
||||||
import { StateUpdater, useState } from 'preact/hooks'
|
import IconButton from '@/Components/Button/IconButton'
|
||||||
import { DecoratedInput } from './DecoratedInput'
|
|
||||||
import { IconButton } from '@/Components/Button/IconButton'
|
|
||||||
import { DecoratedInputProps } from './DecoratedInputProps'
|
import { DecoratedInputProps } from './DecoratedInputProps'
|
||||||
|
|
||||||
const Toggle: FunctionComponent<{
|
const Toggle: FunctionComponent<{
|
||||||
isToggled: boolean
|
isToggled: boolean
|
||||||
setIsToggled: StateUpdater<boolean>
|
setIsToggled: Dispatch<SetStateAction<boolean>>
|
||||||
}> = ({ isToggled, setIsToggled }) => (
|
}> = ({ isToggled, setIsToggled }) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className="w-5 h-5 p-0 justify-center sk-circle hover:bg-grey-4 color-neutral"
|
className="w-5 h-5 p-0 justify-center sk-circle hover:bg-passive-4 color-neutral"
|
||||||
icon={isToggled ? 'eye-off' : 'eye'}
|
icon={isToggled ? 'eye-off' : 'eye'}
|
||||||
iconClassName="sn-icon--small"
|
iconClassName="sn-icon--small"
|
||||||
title="Show/hide password"
|
title="Show/hide password"
|
||||||
@@ -22,19 +20,19 @@ const Toggle: FunctionComponent<{
|
|||||||
/**
|
/**
|
||||||
* Password input that has a toggle to show/hide password and can be decorated on the left and right side
|
* Password input that has a toggle to show/hide password and can be decorated on the left and right side
|
||||||
*/
|
*/
|
||||||
export const DecoratedPasswordInput: FunctionComponent<Omit<DecoratedInputProps, 'type'>> = forwardRef(
|
const DecoratedPasswordInput = forwardRef((props: DecoratedInputProps, ref: Ref<HTMLInputElement>) => {
|
||||||
(props, ref: Ref<HTMLInputElement>) => {
|
const [isToggled, setIsToggled] = useState(false)
|
||||||
const [isToggled, setIsToggled] = useState(false)
|
|
||||||
|
|
||||||
const rightSideDecorations = props.right ? [...props.right] : []
|
const rightSideDecorations = props.right ? [...props.right] : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DecoratedInput
|
<DecoratedInput
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type={isToggled ? 'text' : 'password'}
|
type={isToggled ? 'text' : 'password'}
|
||||||
right={[...rightSideDecorations, <Toggle isToggled={isToggled} setIsToggled={setIsToggled} />]}
|
right={[...rightSideDecorations, <Toggle isToggled={isToggled} setIsToggled={setIsToggled} />]}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
export default DecoratedPasswordInput
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { FunctionComponent, Ref } from 'preact'
|
import { ChangeEventHandler, Ref, forwardRef, useState } from 'react'
|
||||||
import { JSXInternal } from 'preact/src/jsx'
|
|
||||||
import { forwardRef } from 'preact/compat'
|
|
||||||
import { useState } from 'preact/hooks'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id: string
|
id: string
|
||||||
type: 'text' | 'email' | 'password'
|
type: 'text' | 'email' | 'password'
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
onChange: JSXInternal.GenericEventHandler<HTMLInputElement>
|
onChange: ChangeEventHandler<HTMLInputElement>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
labelClassName?: string
|
labelClassName?: string
|
||||||
@@ -16,7 +13,7 @@ type Props = {
|
|||||||
isInvalid?: boolean
|
isInvalid?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FloatingLabelInput: FunctionComponent<Props> = forwardRef(
|
const FloatingLabelInput = forwardRef(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -38,12 +35,12 @@ export const FloatingLabelInput: FunctionComponent<Props> = forwardRef(
|
|||||||
|
|
||||||
const LABEL_CLASSNAME = `hidden absolute ${!focused ? 'color-neutral' : 'color-info'} ${
|
const LABEL_CLASSNAME = `hidden absolute ${!focused ? 'color-neutral' : 'color-info'} ${
|
||||||
focused || value ? 'flex top-0 left-2 pt-1.5 px-1' : ''
|
focused || value ? 'flex top-0 left-2 pt-1.5 px-1' : ''
|
||||||
} ${isInvalid ? 'color-dark-red' : ''} ${labelClassName}`
|
} ${isInvalid ? 'color-danger' : ''} ${labelClassName}`
|
||||||
|
|
||||||
const INPUT_CLASSNAME = `w-full h-full ${
|
const INPUT_CLASSNAME = `w-full h-full ${
|
||||||
focused || value ? 'pt-6 pb-2' : 'py-2.5'
|
focused || value ? 'pt-6 pb-2' : 'py-2.5'
|
||||||
} px-3 text-input border-1 border-solid border-main rounded placeholder-medium text-input focus:ring-info ${
|
} px-3 text-input border-1 border-solid border-main rounded placeholder-medium text-input focus:ring-info ${
|
||||||
isInvalid ? 'border-dark-red placeholder-dark-red' : ''
|
isInvalid ? 'border-danger placeholder-dark-red' : ''
|
||||||
} ${inputClassName}`
|
} ${inputClassName}`
|
||||||
|
|
||||||
const handleFocus = () => setFocused(true)
|
const handleFocus = () => setFocused(true)
|
||||||
@@ -71,3 +68,5 @@ export const FloatingLabelInput: FunctionComponent<Props> = forwardRef(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export default FloatingLabelInput
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FunctionalComponent } from 'preact'
|
import { FunctionComponent } from 'react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text?: string
|
text?: string
|
||||||
@@ -6,9 +6,11 @@ interface Props {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Input: FunctionalComponent<Props> = ({ className = '', disabled = false, text }) => {
|
const Input: FunctionComponent<Props> = ({ className = '', disabled = false, text }) => {
|
||||||
const base = 'rounded py-1.5 px-3 text-input my-1 h-8 bg-contrast'
|
const base = 'rounded py-1.5 px-3 text-input my-1 h-8 bg-contrast'
|
||||||
const stateClasses = disabled ? 'no-border' : 'border-solid border-1 border-main'
|
const stateClasses = disabled ? 'no-border' : 'border-solid border-1 border-main'
|
||||||
const classes = `${base} ${stateClasses} ${className}`
|
const classes = `${base} ${stateClasses} ${className}`
|
||||||
return <input type="text" className={classes} disabled={disabled} value={text} />
|
return <input type="text" className={classes} disabled={disabled} value={text} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Input
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import { JSX, FunctionComponent, ComponentChildren, VNode, RefCallback, ComponentChild, toChildArray } from 'preact'
|
import {
|
||||||
import { useCallback, useEffect, useRef } from 'preact/hooks'
|
CSSProperties,
|
||||||
import { JSXInternal } from 'preact/src/jsx'
|
FunctionComponent,
|
||||||
import { MenuItem, MenuItemListElement } from './MenuItem'
|
KeyboardEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react'
|
||||||
import { KeyboardKey } from '@/Services/IOService'
|
import { KeyboardKey } from '@/Services/IOService'
|
||||||
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
|
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
|
||||||
|
|
||||||
type MenuProps = {
|
type MenuProps = {
|
||||||
className?: string
|
className?: string
|
||||||
style?: string | JSX.CSSProperties | undefined
|
style?: CSSProperties | undefined
|
||||||
a11yLabel: string
|
a11yLabel: string
|
||||||
children: ComponentChildren
|
children: ReactNode
|
||||||
closeMenu?: () => void
|
closeMenu?: () => void
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
initialFocus?: number
|
initialFocus?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Menu: FunctionComponent<MenuProps> = ({
|
const Menu: FunctionComponent<MenuProps> = ({
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
style,
|
style,
|
||||||
@@ -24,16 +29,10 @@ export const Menu: FunctionComponent<MenuProps> = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
initialFocus,
|
initialFocus,
|
||||||
}: MenuProps) => {
|
}: MenuProps) => {
|
||||||
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
|
||||||
|
|
||||||
const menuElementRef = useRef<HTMLMenuElement>(null)
|
const menuElementRef = useRef<HTMLMenuElement>(null)
|
||||||
|
|
||||||
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLMenuElement> = useCallback(
|
const handleKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
if (!menuItemRefs.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === KeyboardKey.Escape) {
|
if (event.key === KeyboardKey.Escape) {
|
||||||
closeMenu?.()
|
closeMenu?.()
|
||||||
return
|
return
|
||||||
@@ -45,67 +44,24 @@ export const Menu: FunctionComponent<MenuProps> = ({
|
|||||||
useListKeyboardNavigation(menuElementRef, initialFocus)
|
useListKeyboardNavigation(menuElementRef, initialFocus)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && menuItemRefs.current.length > 0) {
|
if (isOpen) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
menuElementRef.current?.focus()
|
menuElementRef.current?.focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
const pushRefToArray: RefCallback<HTMLLIElement> = useCallback((instance) => {
|
|
||||||
if (instance && instance.children) {
|
|
||||||
Array.from(instance.children).forEach((child) => {
|
|
||||||
if (
|
|
||||||
child.getAttribute('role')?.includes('menuitem') &&
|
|
||||||
!menuItemRefs.current.includes(child as HTMLButtonElement)
|
|
||||||
) {
|
|
||||||
menuItemRefs.current.push(child as HTMLButtonElement)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const mapMenuItems = useCallback(
|
|
||||||
(child: ComponentChild, index: number, array: ComponentChild[]): ComponentChild => {
|
|
||||||
if (!child || (Array.isArray(child) && child.length < 1)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(child)) {
|
|
||||||
return child.map(mapMenuItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
const _child = child as VNode<unknown>
|
|
||||||
const isFirstMenuItem = index === array.findIndex((child) => (child as VNode<unknown>).type === MenuItem)
|
|
||||||
|
|
||||||
const hasMultipleItems = Array.isArray(_child.props.children)
|
|
||||||
? Array.from(_child.props.children as ComponentChild[]).some(
|
|
||||||
(child) => (child as VNode<unknown>).type === MenuItem,
|
|
||||||
)
|
|
||||||
: false
|
|
||||||
|
|
||||||
const items = hasMultipleItems ? [...(_child.props.children as ComponentChild[])] : [_child]
|
|
||||||
|
|
||||||
return items.map((child) => {
|
|
||||||
return (
|
|
||||||
<MenuItemListElement isFirstMenuItem={isFirstMenuItem} ref={pushRefToArray}>
|
|
||||||
{child}
|
|
||||||
</MenuItemListElement>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[pushRefToArray],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<menu
|
<menu
|
||||||
className={`m-0 p-0 list-style-none focus:shadow-none ${className}`}
|
className={`m-0 pl-0 list-style-none focus:shadow-none ${className}`}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
ref={menuElementRef}
|
ref={menuElementRef}
|
||||||
style={style}
|
style={style}
|
||||||
aria-label={a11yLabel}
|
aria-label={a11yLabel}
|
||||||
>
|
>
|
||||||
{toChildArray(children).map(mapMenuItems)}
|
{children}
|
||||||
</menu>
|
</menu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Menu
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import { ComponentChildren, FunctionComponent, VNode } from 'preact'
|
import { forwardRef, MouseEventHandler, ReactNode, Ref } from 'react'
|
||||||
import { forwardRef, Ref } from 'preact/compat'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { JSXInternal } from 'preact/src/jsx'
|
import Switch from '@/Components/Switch/Switch'
|
||||||
import { Icon } from '@/Components/Icon'
|
import { SwitchProps } from '@/Components/Switch/SwitchProps'
|
||||||
import { Switch, SwitchProps } from '@/Components/Switch'
|
|
||||||
import { IconType } from '@standardnotes/snjs'
|
import { IconType } from '@standardnotes/snjs'
|
||||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants'
|
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||||
|
import { MenuItemType } from './MenuItemType'
|
||||||
export enum MenuItemType {
|
|
||||||
IconButton,
|
|
||||||
RadioButton,
|
|
||||||
SwitchButton,
|
|
||||||
}
|
|
||||||
|
|
||||||
type MenuItemProps = {
|
type MenuItemProps = {
|
||||||
type: MenuItemType
|
type: MenuItemType
|
||||||
children: ComponentChildren
|
children: ReactNode
|
||||||
onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement>
|
onClick?: MouseEventHandler<HTMLButtonElement>
|
||||||
onChange?: SwitchProps['onChange']
|
onChange?: SwitchProps['onChange']
|
||||||
onBlur?: (event: { relatedTarget: EventTarget | null }) => void
|
onBlur?: (event: { relatedTarget: EventTarget | null }) => void
|
||||||
className?: string
|
className?: string
|
||||||
@@ -25,7 +19,7 @@ type MenuItemProps = {
|
|||||||
tabIndex?: number
|
tabIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
|
const MenuItem = forwardRef(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
children,
|
children,
|
||||||
@@ -42,63 +36,42 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
|
|||||||
ref: Ref<HTMLButtonElement>,
|
ref: Ref<HTMLButtonElement>,
|
||||||
) => {
|
) => {
|
||||||
return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? (
|
return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? (
|
||||||
<button
|
<li className="list-style-none" role="none">
|
||||||
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
|
<button
|
||||||
onClick={() => {
|
ref={ref}
|
||||||
onChange(!checked)
|
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
|
||||||
}}
|
onClick={() => {
|
||||||
onBlur={onBlur}
|
onChange(!checked)
|
||||||
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
|
}}
|
||||||
role="menuitemcheckbox"
|
onBlur={onBlur}
|
||||||
aria-checked={checked}
|
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
|
||||||
>
|
role="menuitemcheckbox"
|
||||||
<span className="flex flex-grow items-center">{children}</span>
|
aria-checked={checked}
|
||||||
<Switch className="px-0" checked={checked} />
|
>
|
||||||
</button>
|
<span className="flex flex-grow items-center">{children}</span>
|
||||||
|
<Switch className="px-0" checked={checked} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<li className="list-style-none" role="none">
|
||||||
ref={ref}
|
<button
|
||||||
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
|
ref={ref}
|
||||||
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
|
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
|
||||||
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`}
|
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
|
||||||
onClick={onClick}
|
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`}
|
||||||
onBlur={onBlur}
|
onClick={onClick}
|
||||||
{...(type === MenuItemType.RadioButton ? { 'aria-checked': checked } : {})}
|
onBlur={onBlur}
|
||||||
>
|
{...(type === MenuItemType.RadioButton ? { 'aria-checked': checked } : {})}
|
||||||
{type === MenuItemType.IconButton && icon ? <Icon type={icon} className={iconClassName} /> : null}
|
>
|
||||||
{type === MenuItemType.RadioButton && typeof checked === 'boolean' ? (
|
{type === MenuItemType.IconButton && icon ? <Icon type={icon} className={iconClassName} /> : null}
|
||||||
<div className={`pseudo-radio-btn ${checked ? 'pseudo-radio-btn--checked' : ''} mr-2 flex-shrink-0`}></div>
|
{type === MenuItemType.RadioButton && typeof checked === 'boolean' ? (
|
||||||
) : null}
|
<div className={`pseudo-radio-btn ${checked ? 'pseudo-radio-btn--checked' : ''} flex-shrink-0`}></div>
|
||||||
{children}
|
) : null}
|
||||||
</button>
|
{children}
|
||||||
)
|
</button>
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export const MenuItemSeparator: FunctionComponent = () => <div role="separator" className="h-1px my-2 bg-border"></div>
|
|
||||||
|
|
||||||
type ListElementProps = {
|
|
||||||
isFirstMenuItem: boolean
|
|
||||||
children: ComponentChildren
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MenuItemListElement: FunctionComponent<ListElementProps> = forwardRef(
|
|
||||||
({ children, isFirstMenuItem }: ListElementProps, ref: Ref<HTMLLIElement>) => {
|
|
||||||
const child = children as VNode<unknown>
|
|
||||||
return (
|
|
||||||
<li className="list-style-none" role="none" ref={ref}>
|
|
||||||
{{
|
|
||||||
...child,
|
|
||||||
props: {
|
|
||||||
...(child.props ? { ...child.props } : {}),
|
|
||||||
...(child.type === MenuItem
|
|
||||||
? {
|
|
||||||
tabIndex: isFirstMenuItem ? 0 : -1,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export default MenuItem
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
|
||||||
|
const MenuItemSeparator: FunctionComponent = () => (
|
||||||
|
<li className="list-style-none" role="none">
|
||||||
|
<div role="separator" className="h-1px my-2 bg-border" />
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default MenuItemSeparator
|
||||||
5
app/assets/javascripts/Components/Menu/MenuItemType.ts
Normal file
5
app/assets/javascripts/Components/Menu/MenuItemType.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum MenuItemType {
|
||||||
|
IconButton,
|
||||||
|
RadioButton,
|
||||||
|
SwitchButton,
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
|
import { IlNotesIcon } from '@standardnotes/icons'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import NotesOptionsPanel from '@/Components/NotesOptions/NotesOptionsPanel'
|
||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton'
|
||||||
|
import Button from '../Button/Button'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
application: WebApplication
|
||||||
|
viewControllerManager: ViewControllerManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultipleSelectedNotes = ({ application, viewControllerManager }: Props) => {
|
||||||
|
const count = viewControllerManager.notesController.selectedNotesCount
|
||||||
|
|
||||||
|
const cancelMultipleSelection = useCallback(() => {
|
||||||
|
viewControllerManager.selectionController.cancelMultipleSelection()
|
||||||
|
}, [viewControllerManager])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full items-center">
|
||||||
|
<div className="flex items-center justify-between p-4 w-full">
|
||||||
|
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="mr-3">
|
||||||
|
<PinNoteButton viewControllerManager={viewControllerManager} />
|
||||||
|
</div>
|
||||||
|
<NotesOptionsPanel application={application} viewControllerManager={viewControllerManager} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
||||||
|
<IlNotesIcon className="block" />
|
||||||
|
<h2 className="text-lg m-0 text-center mt-4">{count} selected notes</h2>
|
||||||
|
<p className="text-sm mt-2 text-center max-w-60">Actions will be performed on all selected notes.</p>
|
||||||
|
<Button className="mt-2.5" onClick={cancelMultipleSelection}>
|
||||||
|
Cancel multiple selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(MultipleSelectedNotes)
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { AppState } from '@/UIModels/AppState'
|
|
||||||
import { IlNotesIcon } from '@standardnotes/icons'
|
|
||||||
import { observer } from 'mobx-react-lite'
|
|
||||||
import { NotesOptionsPanel } from '@/Components/NotesOptions/NotesOptionsPanel'
|
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
|
||||||
import { PinNoteButton } from '@/Components/PinNoteButton'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
application: WebApplication
|
|
||||||
appState: AppState
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MultipleSelectedNotes = observer(({ application, appState }: Props) => {
|
|
||||||
const count = appState.notes.selectedNotesCount
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full items-center">
|
|
||||||
<div className="flex items-center justify-between p-4 w-full">
|
|
||||||
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
|
|
||||||
<div className="flex">
|
|
||||||
<div className="mr-3">
|
|
||||||
<PinNoteButton appState={appState} />
|
|
||||||
</div>
|
|
||||||
<NotesOptionsPanel application={application} appState={appState} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
|
||||||
<IlNotesIcon className="block" />
|
|
||||||
<h2 className="text-lg m-0 text-center mt-4">{count} selected notes</h2>
|
|
||||||
<p className="text-sm mt-2 text-center max-w-60">Actions will be performed on all selected notes.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
import { SmartViewsSection } from '@/Components/Tags/SmartViewsSection'
|
import SmartViewsSection from '@/Components/Tags/SmartViewsSection'
|
||||||
import { TagsSection } from '@/Components/Tags/TagsSection'
|
import TagsSection from '@/Components/Tags/TagsSection'
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { PANEL_NAME_NAVIGATION } from '@/Constants'
|
import { PANEL_NAME_NAVIGATION } from '@/Constants/Constants'
|
||||||
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'
|
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
|
import PanelResizer, { PanelSide, ResizeFinishCallback, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
|
||||||
import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Navigation: FunctionComponent<Props> = observer(({ application }) => {
|
const Navigation: FunctionComponent<Props> = ({ application }) => {
|
||||||
const appState = useMemo(() => application.getAppState(), [application])
|
const viewControllerManager = useMemo(() => application.getViewControllerManager(), [application])
|
||||||
const [ref, setRef] = useState<HTMLDivElement | null>()
|
const [ref, setRef] = useState<HTMLDivElement | null>()
|
||||||
const [panelWidth, setPanelWidth] = useState<number>(0)
|
const [panelWidth, setPanelWidth] = useState<number>(0)
|
||||||
|
|
||||||
@@ -33,15 +32,15 @@ export const Navigation: FunctionComponent<Props> = observer(({ application }) =
|
|||||||
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
||||||
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
|
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
|
||||||
application.setPreference(PrefKey.TagsPanelWidth, width).catch(console.error)
|
application.setPreference(PrefKey.TagsPanelWidth, width).catch(console.error)
|
||||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
|
||||||
appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed)
|
application.publishPanelDidResizeEvent(PANEL_NAME_NAVIGATION, isCollapsed)
|
||||||
},
|
},
|
||||||
[application, appState],
|
[application, viewControllerManager],
|
||||||
)
|
)
|
||||||
|
|
||||||
const panelWidthEventCallback = useCallback(() => {
|
const panelWidthEventCallback = useCallback(() => {
|
||||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
|
||||||
}, [appState])
|
}, [viewControllerManager])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -59,8 +58,8 @@ export const Navigation: FunctionComponent<Props> = observer(({ application }) =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="scrollable">
|
<div className="scrollable">
|
||||||
<SmartViewsSection appState={appState} />
|
<SmartViewsSection viewControllerManager={viewControllerManager} />
|
||||||
<TagsSection appState={appState} />
|
<TagsSection viewControllerManager={viewControllerManager} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ref && (
|
{ref && (
|
||||||
@@ -79,4 +78,6 @@ export const Navigation: FunctionComponent<Props> = observer(({ application }) =
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export default observer(Navigation)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { MouseEventHandler, useCallback } from 'react'
|
||||||
|
|
||||||
|
type Props = { viewControllerManager: ViewControllerManager }
|
||||||
|
|
||||||
|
const NoAccountWarning = observer(({ viewControllerManager }: Props) => {
|
||||||
|
const showAccountMenu: MouseEventHandler = useCallback(
|
||||||
|
(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
viewControllerManager.accountMenuController.setShow(true)
|
||||||
|
},
|
||||||
|
[viewControllerManager],
|
||||||
|
)
|
||||||
|
|
||||||
|
const hideWarning = useCallback(() => {
|
||||||
|
viewControllerManager.noAccountWarningController.hide()
|
||||||
|
}, [viewControllerManager])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 p-4 rounded-md shadow-sm grid grid-template-cols-1fr">
|
||||||
|
<h1 className="sk-h3 m-0 font-semibold">Data not backed up</h1>
|
||||||
|
<p className="m-0 mt-1 col-start-1 col-end-3">Sign in or register to back up your notes.</p>
|
||||||
|
<button className="sn-button small info mt-3 col-start-1 col-end-3 justify-self-start" onClick={showAccountMenu}>
|
||||||
|
Open Account menu
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={hideWarning}
|
||||||
|
title="Ignore warning"
|
||||||
|
aria-label="Ignore warning"
|
||||||
|
style={{ height: '20px' }}
|
||||||
|
className="border-0 m-0 p-0 bg-transparent cursor-pointer rounded-md col-start-2 row-start-1 color-neutral hover:color-info"
|
||||||
|
>
|
||||||
|
<Icon type="close" className="block" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
NoAccountWarning.displayName = 'NoAccountWarning'
|
||||||
|
|
||||||
|
const NoAccountWarningWrapper = ({ viewControllerManager }: Props) => {
|
||||||
|
const canShow = viewControllerManager.noAccountWarningController.show
|
||||||
|
|
||||||
|
return canShow ? <NoAccountWarning viewControllerManager={viewControllerManager} /> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(NoAccountWarningWrapper)
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Icon } from '@/Components/Icon'
|
|
||||||
import { AppState } from '@/UIModels/AppState'
|
|
||||||
import { observer } from 'mobx-react-lite'
|
|
||||||
import { useCallback } from 'preact/hooks'
|
|
||||||
|
|
||||||
type Props = { appState: AppState }
|
|
||||||
|
|
||||||
export const NoAccountWarning = observer(({ appState }: Props) => {
|
|
||||||
const canShow = appState.noAccountWarning.show
|
|
||||||
if (!canShow) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const showAccountMenu = useCallback(
|
|
||||||
(event: Event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
appState.accountMenu.setShow(true)
|
|
||||||
},
|
|
||||||
[appState],
|
|
||||||
)
|
|
||||||
|
|
||||||
const hideWarning = useCallback(() => {
|
|
||||||
appState.noAccountWarning.hide()
|
|
||||||
}, [appState])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-5 p-5 rounded-md shadow-sm grid grid-template-cols-1fr">
|
|
||||||
<h1 className="sk-h3 m-0 font-semibold">Data not backed up</h1>
|
|
||||||
<p className="m-0 mt-1 col-start-1 col-end-3">Sign in or register to back up your notes.</p>
|
|
||||||
<button className="sn-button small info mt-3 col-start-1 col-end-3 justify-self-start" onClick={showAccountMenu}>
|
|
||||||
Open Account menu
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={hideWarning}
|
|
||||||
title="Ignore"
|
|
||||||
label="Ignore"
|
|
||||||
style="height: 20px"
|
|
||||||
className="border-0 m-0 p-0 bg-transparent cursor-pointer rounded-md col-start-2 row-start-1 color-neutral hover:color-info"
|
|
||||||
>
|
|
||||||
<Icon type="close" className="block" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NoteViewController } from '@standardnotes/snjs'
|
import { NoteViewController } from '@standardnotes/snjs'
|
||||||
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
||||||
import { WebApplication } from '@/UIModels/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { MultipleSelectedNotes } from '@/Components/MultipleSelectedNotes'
|
import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes'
|
||||||
import { NoteView } from '@/Components/NoteView/NoteView'
|
import NoteView from '@/Components/NoteView/NoteView'
|
||||||
import { ElementIds } from '@/ElementIDs'
|
import { ElementIds } from '@/Constants/ElementIDs'
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
showMultipleSelectedNotes: boolean
|
showMultipleSelectedNotes: boolean
|
||||||
@@ -14,7 +14,7 @@ type Props = {
|
|||||||
application: WebApplication
|
application: WebApplication
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NoteGroupView extends PureComponent<Props, State> {
|
class NoteGroupView extends PureComponent<Props, State> {
|
||||||
private removeChangeObserver!: () => void
|
private removeChangeObserver!: () => void
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
@@ -37,9 +37,9 @@ export class NoteGroupView extends PureComponent<Props, State> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.autorun(() => {
|
this.autorun(() => {
|
||||||
if (this.appState && this.appState.notes) {
|
if (this.viewControllerManager && this.viewControllerManager.notesController) {
|
||||||
this.setState({
|
this.setState({
|
||||||
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
|
showMultipleSelectedNotes: this.viewControllerManager.notesController.selectedNotesCount > 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -56,7 +56,7 @@ export class NoteGroupView extends PureComponent<Props, State> {
|
|||||||
return (
|
return (
|
||||||
<div id={ElementIds.EditorColumn} className="h-full app-column app-column-third">
|
<div id={ElementIds.EditorColumn} className="h-full app-column app-column-third">
|
||||||
{this.state.showMultipleSelectedNotes && (
|
{this.state.showMultipleSelectedNotes && (
|
||||||
<MultipleSelectedNotes application={this.application} appState={this.appState} />
|
<MultipleSelectedNotes application={this.application} viewControllerManager={this.viewControllerManager} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!this.state.showMultipleSelectedNotes && (
|
{!this.state.showMultipleSelectedNotes && (
|
||||||
@@ -70,3 +70,5 @@ export class NoteGroupView extends PureComponent<Props, State> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default NoteGroupView
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
import { Icon } from '@/Components/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
import {
|
||||||
import { AppState } from '@/UIModels/AppState'
|
FocusEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
MouseEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { SNTag } from '@standardnotes/snjs'
|
import { SNTag } from '@standardnotes/snjs'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
tag: SNTag
|
tag: SNTag
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NoteTag = observer(({ appState, tag }: Props) => {
|
const NoteTag = ({ viewControllerManager, tag }: Props) => {
|
||||||
const noteTags = appState.noteTags
|
const noteTags = viewControllerManager.noteTagsController
|
||||||
|
|
||||||
const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags
|
const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags
|
||||||
|
|
||||||
@@ -25,45 +33,44 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
|
|||||||
const longTitle = noteTags.getLongTitle(tag)
|
const longTitle = noteTags.getLongTitle(tag)
|
||||||
|
|
||||||
const deleteTag = useCallback(() => {
|
const deleteTag = useCallback(() => {
|
||||||
appState.noteTags.focusPreviousTag(tag)
|
viewControllerManager.noteTagsController.focusPreviousTag(tag)
|
||||||
appState.noteTags.removeTagFromActiveNote(tag).catch(console.error)
|
viewControllerManager.noteTagsController.removeTagFromActiveNote(tag).catch(console.error)
|
||||||
}, [appState, tag])
|
}, [viewControllerManager, tag])
|
||||||
|
|
||||||
const onDeleteTagClick = useCallback(
|
const onDeleteTagClick: MouseEventHandler = useCallback(
|
||||||
(event: MouseEvent) => {
|
(event) => {
|
||||||
event.stopImmediatePropagation()
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
deleteTag()
|
deleteTag()
|
||||||
},
|
},
|
||||||
[deleteTag],
|
[deleteTag],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onTagClick = useCallback(
|
const onTagClick: MouseEventHandler = useCallback(
|
||||||
(event: MouseEvent) => {
|
(event) => {
|
||||||
if (tagClicked && event.target !== deleteTagRef.current) {
|
if (tagClicked && event.target !== deleteTagRef.current) {
|
||||||
setTagClicked(false)
|
setTagClicked(false)
|
||||||
appState.tags.selected = tag
|
void viewControllerManager.navigationController.setSelectedTag(tag)
|
||||||
} else {
|
} else {
|
||||||
setTagClicked(true)
|
setTagClicked(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appState, tagClicked, tag],
|
[viewControllerManager, tagClicked, tag],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onFocus = useCallback(() => {
|
const onFocus = useCallback(() => {
|
||||||
appState.noteTags.setFocusedTagUuid(tag.uuid)
|
viewControllerManager.noteTagsController.setFocusedTagUuid(tag.uuid)
|
||||||
setShowDeleteButton(true)
|
setShowDeleteButton(true)
|
||||||
}, [appState, tag])
|
}, [viewControllerManager, tag])
|
||||||
|
|
||||||
const onBlur = useCallback(
|
const onBlur: FocusEventHandler = useCallback(
|
||||||
(event: FocusEvent) => {
|
(event) => {
|
||||||
const relatedTarget = event.relatedTarget as Node
|
const relatedTarget = event.relatedTarget as Node
|
||||||
if (relatedTarget !== deleteTagRef.current) {
|
if (relatedTarget !== deleteTagRef.current) {
|
||||||
appState.noteTags.setFocusedTagUuid(undefined)
|
viewControllerManager.noteTagsController.setFocusedTagUuid(undefined)
|
||||||
setShowDeleteButton(false)
|
setShowDeleteButton(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appState],
|
[viewControllerManager],
|
||||||
)
|
)
|
||||||
|
|
||||||
const getTabIndex = useCallback(() => {
|
const getTabIndex = useCallback(() => {
|
||||||
@@ -76,35 +83,35 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
|
|||||||
return tags[0].uuid === tag.uuid ? 0 : -1
|
return tags[0].uuid === tag.uuid ? 0 : -1
|
||||||
}, [autocompleteInputFocused, tags, tag, focusedTagUuid])
|
}, [autocompleteInputFocused, tags, tag, focusedTagUuid])
|
||||||
|
|
||||||
const onKeyDown = useCallback(
|
const onKeyDown: KeyboardEventHandler = useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event) => {
|
||||||
const tagIndex = appState.noteTags.getTagIndex(tag, tags)
|
const tagIndex = viewControllerManager.noteTagsController.getTagIndex(tag, tags)
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'Backspace':
|
case 'Backspace':
|
||||||
deleteTag()
|
deleteTag()
|
||||||
break
|
break
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
appState.noteTags.focusPreviousTag(tag)
|
viewControllerManager.noteTagsController.focusPreviousTag(tag)
|
||||||
break
|
break
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
if (tagIndex === tags.length - 1) {
|
if (tagIndex === tags.length - 1) {
|
||||||
appState.noteTags.setAutocompleteInputFocused(true)
|
viewControllerManager.noteTagsController.setAutocompleteInputFocused(true)
|
||||||
} else {
|
} else {
|
||||||
appState.noteTags.focusNextTag(tag)
|
viewControllerManager.noteTagsController.focusNextTag(tag)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appState, deleteTag, tag, tags],
|
[viewControllerManager, deleteTag, tag, tags],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusedTagUuid === tag.uuid) {
|
if (focusedTagUuid === tag.uuid) {
|
||||||
tagRef.current?.focus()
|
tagRef.current?.focus()
|
||||||
}
|
}
|
||||||
}, [appState, focusedTagUuid, tag])
|
}, [viewControllerManager, focusedTagUuid, tag])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -119,7 +126,7 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
|
|||||||
>
|
>
|
||||||
<Icon type="hashtag" className="sn-icon--small color-info mr-1" />
|
<Icon type="hashtag" className="sn-icon--small color-info mr-1" />
|
||||||
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis max-w-290px">
|
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis max-w-290px">
|
||||||
{prefixTitle && <span className="color-grey-1">{prefixTitle}</span>}
|
{prefixTitle && <span className="color-passive-1">{prefixTitle}</span>}
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{showDeleteButton && (
|
{showDeleteButton && (
|
||||||
@@ -136,4 +143,6 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export default observer(NoteTag)
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
import { AppState } from '@/UIModels/AppState'
|
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { AutocompleteTagInput } from '@/Components/TagAutocomplete/AutocompleteTagInput'
|
import AutocompleteTagInput from '@/Components/TagAutocomplete/AutocompleteTagInput'
|
||||||
import { NoteTag } from './NoteTag'
|
import NoteTag from './NoteTag'
|
||||||
import { useEffect } from 'preact/hooks'
|
import { useEffect } from 'react'
|
||||||
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appState: AppState
|
viewControllerManager: ViewControllerManager
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NoteTagsContainer = observer(({ appState }: Props) => {
|
const NoteTagsContainer = ({ viewControllerManager }: Props) => {
|
||||||
if (isStateDealloced(appState)) {
|
const { tags, tagsContainerMaxWidth } = viewControllerManager.noteTagsController
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const { tags, tagsContainerMaxWidth } = appState.noteTags
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
appState.noteTags.reloadTagsContainerMaxWidth()
|
viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
|
||||||
}, [appState])
|
}, [viewControllerManager])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -28,9 +23,11 @@ export const NoteTagsContainer = observer(({ appState }: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<NoteTag key={tag.uuid} appState={appState} tag={tag} />
|
<NoteTag key={tag.uuid} viewControllerManager={viewControllerManager} tag={tag} />
|
||||||
))}
|
))}
|
||||||
<AutocompleteTagInput appState={appState} />
|
<AutocompleteTagInput viewControllerManager={viewControllerManager} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export default observer(NoteTagsContainer)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onMouseLeave: () => void
|
||||||
|
onMouseOver: () => void
|
||||||
|
onClick: () => void
|
||||||
|
showLockedIcon: boolean
|
||||||
|
lockText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditingDisabledBanner: FunctionComponent<Props> = ({
|
||||||
|
onMouseLeave,
|
||||||
|
onMouseOver,
|
||||||
|
onClick,
|
||||||
|
showLockedIcon,
|
||||||
|
lockText,
|
||||||
|
}) => {
|
||||||
|
const background = showLockedIcon ? 'bg-warning-faded' : 'bg-info-faded'
|
||||||
|
const iconColor = showLockedIcon ? 'color-accessory-tint-3' : 'color-accessory-tint-1'
|
||||||
|
const textColor = showLockedIcon ? 'color-warning' : 'color-accessory-tint-1'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center relative ${background} px-3.5 py-2 cursor-pointer`}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
onMouseOver={onMouseOver}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{showLockedIcon ? (
|
||||||
|
<Icon type="pencil-off" className={`${iconColor} flex fill-current mr-3`} />
|
||||||
|
) : (
|
||||||
|
<Icon type="pencil" className={`${iconColor} flex fill-current mr-3`} />
|
||||||
|
)}
|
||||||
|
<span className={textColor}>{lockText}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditingDisabledBanner
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user