Compare commits

...
Author SHA1 Message Date
StandardNotes CI 5ee60c8c24 chore(release): publish
- @standardnotes/[email protected].3
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].8
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-11-16 13:08:31 +00:00
Mo 66a898345a fix(mobile): bump android targetSdkVersion to 31 2022-11-16 06:46:28 -06:00
Aman Harwara f3e4ba8779 feat: edit smart view predicate as json (#2012) 2022-11-16 18:15:26 +05:30
StandardNotes CI 943698d506 chore(release): publish
- @standardnotes/[email protected].2
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].7
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-11-16 12:18:04 +00:00
Mo 59f8547a8d feat(labs): super editor (#2001) 2022-11-16 05:54:32 -06:00
StandardNotes CI f0c9f899e9 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].2
2022-11-16 09:49:29 +00:00
Karol Sójko ea40427e67 fix(web): remove checking for MFA permissions as all users are permitted to MFA 2022-11-16 10:26:59 +01:00
StandardNotes CI 7d8e212cfe chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].5
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2022-11-15 15:52:21 +00:00
Mo e962e9c745 fix(mobile): show premium modal on iOS 2022-11-15 09:29:59 -06:00
152 changed files with 1670 additions and 813 deletions
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.20.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/api
## [1.20.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/api
## [1.20.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/api
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api",
"version": "1.20.1",
"version": "1.20.3",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.5.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
### Features
* **labs:** super editor ([#2001](https://github.com/standardnotes/app/issues/2001)) ([59f8547](https://github.com/standardnotes/app/commit/59f8547a8de1c804cb2f01ac734c83268977fa28))
## [1.4.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-11)
### Bug Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/blocks-editor",
"version": "1.4.1",
"version": "1.5.0",
"private": true,
"main": "./src/index.ts",
"scripts": {
@@ -39,6 +39,7 @@ type BlocksEditorProps = {
children?: React.ReactNode;
previewLength: number;
spellcheck?: boolean;
ignoreFirstChange?: boolean;
};
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
@@ -47,11 +48,12 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
children,
previewLength,
spellcheck,
ignoreFirstChange = false,
}) => {
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false);
const handleChange = useCallback(
(editorState: EditorState, _editor: LexicalEditor) => {
if (!didIgnoreFirstChange) {
if (ignoreFirstChange && !didIgnoreFirstChange) {
setDidIgnoreFirstChange(true);
return;
}
@@ -88,7 +90,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
{children}
<RichTextPlugin
contentEditable={
<div id="blocks-editor" className="editor-scroller">
<div id="blocks-editor" className="editor-scroller h-full">
<div className="editor" ref={onRef}>
<ContentEditable
id={SuperEditorContentId}
@@ -120,7 +122,6 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<CodeHighlightPlugin />
<LinkPlugin />
<HashtagPlugin />
<AutoEmbedPlugin />
<TwitterPlugin />
<YouTubePlugin />
+16
View File
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.23.301](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.300](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.299](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.298](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.297](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/desktop
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@standardnotes/desktop",
"main": "./app/dist/index.js",
"version": "3.23.297",
"version": "3.23.301",
"license": "AGPL-3.0-or-later",
"author": "Standard Notes.",
"private": true,
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.19.11](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/encryption
## [1.19.10](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/encryption
## [1.19.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/encryption
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/encryption",
"version": "1.19.9",
"version": "1.19.11",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.55.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
### Features
* **labs:** super editor ([#2001](https://github.com/standardnotes/app/issues/2001)) ([59f8547](https://github.com/standardnotes/app/commit/59f8547a8de1c804cb2f01ac734c83268977fa28))
## [1.54.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
### Reverts
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/features",
"version": "1.54.5",
"version": "1.55.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -0,0 +1,5 @@
import { FeatureIdentifier } from './../Feature/FeatureIdentifier'
type ThirdPartyIdentifier = string
export type EditorIdentifier = FeatureIdentifier | ThirdPartyIdentifier
@@ -0,0 +1,17 @@
import { FeatureIdentifier } from '@standardnotes/features'
import { noteTypeForEditorIdentifier, NoteType } from './NoteType'
describe('note type', () => {
it('should return the correct note type for editor identifier', () => {
expect(noteTypeForEditorIdentifier(FeatureIdentifier.PlainEditor)).toEqual(NoteType.Plain)
expect(noteTypeForEditorIdentifier(FeatureIdentifier.SuperEditor)).toEqual(NoteType.Super)
expect(noteTypeForEditorIdentifier(FeatureIdentifier.MarkdownVisualEditor)).toEqual(NoteType.Markdown)
expect(noteTypeForEditorIdentifier(FeatureIdentifier.MarkdownProEditor)).toEqual(NoteType.Markdown)
expect(noteTypeForEditorIdentifier(FeatureIdentifier.PlusEditor)).toEqual(NoteType.RichText)
expect(noteTypeForEditorIdentifier(FeatureIdentifier.CodeEditor)).toEqual(NoteType.Code)
expect(noteTypeForEditorIdentifier(FeatureIdentifier.SheetsEditor)).toEqual(NoteType.Spreadsheet)
expect(noteTypeForEditorIdentifier(FeatureIdentifier.TaskEditor)).toEqual(NoteType.Task)
expect(noteTypeForEditorIdentifier(FeatureIdentifier.TokenVaultEditor)).toEqual(NoteType.Authentication)
expect(noteTypeForEditorIdentifier('org.third.party')).toEqual(NoteType.Unknown)
})
})
@@ -1,3 +1,7 @@
import { FindNativeFeature } from '../Feature/Features'
import { FeatureIdentifier } from './../Feature/FeatureIdentifier'
import { EditorIdentifier } from './EditorIdentifier'
export enum NoteType {
Authentication = 'authentication',
Code = 'code',
@@ -6,6 +10,21 @@ export enum NoteType {
Spreadsheet = 'spreadsheet',
Task = 'task',
Plain = 'plain-text',
Blocks = 'blocks',
Super = 'super',
Unknown = 'unknown',
}
export function noteTypeForEditorIdentifier(identifier: EditorIdentifier): NoteType {
if (identifier === FeatureIdentifier.PlainEditor) {
return NoteType.Plain
} else if (identifier === FeatureIdentifier.SuperEditor) {
return NoteType.Super
}
const feature = FindNativeFeature(identifier as FeatureIdentifier)
if (feature && feature.note_type) {
return feature.note_type
}
return NoteType.Unknown
}
@@ -13,6 +13,7 @@ type RoleFields = {
/** Statically populated. Non-influencing; used as a reference by other static consumers (such as email service) */
availableInSubscriptions: SubscriptionName[]
availableInRoles?: RoleName[]
}
export type BaseFeatureDescription = RoleFields & {
@@ -6,7 +6,6 @@ export enum FeatureIdentifier {
DailyGDriveBackup = 'org.standardnotes.daily-gdrive-backup',
DailyOneDriveBackup = 'org.standardnotes.daily-onedrive-backup',
Files = 'org.standardnotes.files',
FilesBeta = 'org.standardnotes.files-beta',
FilesLowStorageTier = 'org.standardnotes.files-low-storage-tier',
FilesMaximumStorageTier = 'org.standardnotes.files-max-storage-tier',
ListedCustomDomain = 'org.standardnotes.listed-custom-domain',
@@ -28,10 +27,12 @@ export enum FeatureIdentifier {
SolarizedDarkTheme = 'org.standardnotes.theme-solarized-dark',
TitaniumTheme = 'org.standardnotes.theme-titanium',
PlainEditor = 'com.standardnotes.plain-text',
SuperEditor = 'com.standardnotes.super-editor',
CodeEditor = 'org.standardnotes.code-editor',
MarkdownProEditor = 'org.standardnotes.advanced-markdown-editor',
MarkdownVisualEditor = 'org.standardnotes.markdown-visual-editor',
PlainTextEditor = 'org.standardnotes.plain-text-editor',
PlusEditor = 'org.standardnotes.plus-editor',
SheetsEditor = 'org.standardnotes.standard-sheets',
TaskEditor = 'org.standardnotes.simple-task-editor',
@@ -50,4 +51,4 @@ export enum FeatureIdentifier {
*/
export const LegacyFileSafeIdentifier = 'org.standardnotes.legacy.file-safe'
export const ExperimentalFeatures = []
export const ExperimentalFeatures = [FeatureIdentifier.SuperEditor]
@@ -21,18 +21,11 @@ export function clientFeatures(): ClientFeatureDescription[] {
},
{
availableInSubscriptions: [SubscriptionName.PlusPlan, SubscriptionName.ProPlan],
name: 'Encrypted files (coming soon)',
name: 'Encrypted files',
identifier: FeatureIdentifier.Files,
permission_name: PermissionName.Files,
description: '',
},
{
availableInSubscriptions: [SubscriptionName.PlusPlan, SubscriptionName.ProPlan],
name: 'Encrypted files beta',
identifier: FeatureIdentifier.FilesBeta,
permission_name: PermissionName.FilesBeta,
description: '',
},
{
availableInSubscriptions: [SubscriptionName.PlusPlan, SubscriptionName.ProPlan],
name: 'Focus Mode',
@@ -1,5 +1,18 @@
import { FeatureIdentifier } from './../Feature/FeatureIdentifier'
import { RoleName, SubscriptionName } from '@standardnotes/common'
import { FeatureDescription } from '../Feature/FeatureDescription'
import { PermissionName } from '../Permission/PermissionName'
export function experimentalFeatures(): FeatureDescription[] {
return []
const superEditor: FeatureDescription = {
name: 'Super Notes',
identifier: FeatureIdentifier.SuperEditor,
availableInSubscriptions: [SubscriptionName.PlusPlan, SubscriptionName.ProPlan],
permission_name: PermissionName.SuperEditor,
description:
'A new way to edit notes. Type / to bring up the block selection menu, or @ to embed images or link other tags and notes. Type - then space to start a list, or [] then space to start a checklist. Drag and drop an image or file to embed it in your note.',
availableInRoles: [RoleName.PlusUser, RoleName.ProUser],
}
return [superEditor]
}
@@ -39,4 +39,5 @@ export enum PermissionName {
TokenVaultEditor = 'editor:token-vault',
TwoFactorAuth = 'server:two-factor-auth',
SubscriptionSharing = 'server:subscription-sharing',
SuperEditor = 'editor:super-editor',
}
+1
View File
@@ -11,3 +11,4 @@ export * from './Component/ComponentFlag'
export * from './Component/ComponentPermission'
export * from './Component/NoteType'
export * from './Component/ThemeDockIcon'
export * from './Component/EditorIdentifier'
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.25.11](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/filepicker
## [1.25.10](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/filepicker
## [1.25.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/filepicker
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/filepicker",
"version": "1.25.9",
"version": "1.25.11",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.12.11](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/files
## [1.12.10](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/files
## [1.12.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/files
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/files",
"version": "1.12.9",
"version": "1.12.11",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+18
View File
@@ -3,6 +3,24 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.46.8](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
### Bug Fixes
* **mobile:** bump android targetSdkVersion to 31 ([66a8983](https://github.com/standardnotes/app/commit/66a898345af6ea48dfa3e7fae38fac97cb376cbe))
## [3.46.7](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/mobile
## [3.46.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/mobile
## [3.46.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/mobile
## [3.46.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/mobile
+1 -1
View File
@@ -7,7 +7,7 @@ buildscript {
buildToolsVersion = "30.0.3"
minSdkVersion = 28
compileSdkVersion = 30
targetSdkVersion = 30
targetSdkVersion = 31
androidXCore = "1.6.0"
ndkVersion = "21.4.7075529"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/mobile",
"version": "3.46.4",
"version": "3.46.8",
"author": "Standard Notes.",
"private": true,
"license": "AGPL-3.0-or-later",
+12
View File
@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.37.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
### Features
* edit smart view predicate as json ([#2012](https://github.com/standardnotes/app/issues/2012)) ([f3e4ba8](https://github.com/standardnotes/app/commit/f3e4ba87795b82cbd96663b441f87ab80f710d05))
# [1.36.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
### Features
* **labs:** super editor ([#2001](https://github.com/standardnotes/app/issues/2001)) ([59f8547](https://github.com/standardnotes/app/commit/59f8547a8de1c804cb2f01ac734c83268977fa28))
# [1.35.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/models",
"version": "1.35.0",
"version": "1.37.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -114,7 +114,8 @@ export class SNComponent extends DecryptedItem<ComponentContent> implements Comp
return this.content_type === ContentType.Theme || this.area === ComponentArea.Themes
}
public isDefaultEditor(): boolean {
/** @deprecated Use global application PrefKey.DefaultEditorIdentifier */
public legacyIsDefaultEditor(): boolean {
return this.getAppDomainValue(AppDataField.DefaultEditor) === true
}
@@ -14,10 +14,6 @@ export class ComponentMutator extends DecryptedItemMutator<ComponentContent> {
this.mutableContent.isMobileDefault = isMobileDefault
}
set defaultEditor(defaultEditor: boolean) {
this.setAppDataItem(AppDataField.DefaultEditor, defaultEditor)
}
set componentData(componentData: Record<string, unknown>) {
this.mutableContent.componentData = componentData
}
@@ -0,0 +1,58 @@
import { MutationType } from '../../Abstract/Item'
import { createSmartViewWithContent, createSmartViewWithTitle } from '../../Utilities/Test/SpecUtils'
import { SmartViewMutator } from './SmartViewMutator'
describe('smart view mutator', () => {
it('should set predicate', () => {
const smartView = createSmartViewWithContent({
title: 'foo',
predicate: {
keypath: 'title',
operator: '=',
value: 'foo',
},
})
const mutator = new SmartViewMutator(smartView, MutationType.UpdateUserTimestamps)
mutator.predicate = {
keypath: 'title',
operator: '=',
value: 'bar',
}
const result = mutator.getResult()
expect(result.content.predicate.value).toBe('bar')
})
it('preferences should be undefined if previously undefined', () => {
const smartView = createSmartViewWithTitle()
const mutator = new SmartViewMutator(smartView, MutationType.UpdateUserTimestamps)
const result = mutator.getResult()
expect(result.content.preferences).toBeFalsy()
})
it('preferences should be lazy-created if attempting to set a property', () => {
const smartView = createSmartViewWithTitle()
const mutator = new SmartViewMutator(smartView, MutationType.UpdateUserTimestamps)
mutator.preferences.sortBy = 'content_type'
const result = mutator.getResult()
expect(result.content.preferences?.sortBy).toEqual('content_type')
})
it('preferences should be nulled if client is reseting', () => {
const smartView = createSmartViewWithContent({
title: 'foo',
preferences: {
sortBy: 'content_type',
},
})
const mutator = new SmartViewMutator(smartView, MutationType.UpdateUserTimestamps)
mutator.preferences = undefined
const result = mutator.getResult()
expect(result.content.preferences).toBeFalsy()
})
})
@@ -0,0 +1,13 @@
import { DecryptedItemInterface, MutationType } from '../../Abstract/Item'
import { TagMutator } from '../Tag'
import { SmartViewContent } from './SmartViewContent'
export class SmartViewMutator extends TagMutator<SmartViewContent> {
constructor(item: DecryptedItemInterface<SmartViewContent>, type: MutationType) {
super(item, type)
}
set predicate(predicate: SmartViewContent['predicate']) {
this.mutableContent.predicate = predicate
}
}
@@ -2,3 +2,5 @@ export * from './SmartView'
export * from './SmartViewBuilder'
export * from './SystemViewId'
export * from './SmartViewContent'
export * from './SmartViewMutator'
export * from './SmartViewIcons'
@@ -11,10 +11,10 @@ import { TagToFileReference } from '../../Abstract/Reference/TagToFileReference'
import { TagPreferences } from './TagPreferences'
import { DecryptedItemInterface, MutationType } from '../../Abstract/Item'
export class TagMutator extends DecryptedItemMutator<TagContent> {
export class TagMutator<Content extends TagContent = TagContent> extends DecryptedItemMutator<Content> {
private mutablePreferences?: TagPreferences
constructor(item: DecryptedItemInterface<TagContent>, type: MutationType) {
constructor(item: DecryptedItemInterface<Content>, type: MutationType) {
super(item, type)
this.mutablePreferences = this.mutableContent.preferences
@@ -1,4 +1,4 @@
import { FeatureIdentifier } from '@standardnotes/features'
import { EditorIdentifier } from '@standardnotes/features'
import { NewNoteTitleFormat } from '../UserPrefs'
import { CollectionSortProperty } from './../../Runtime/Collection/CollectionSort'
@@ -15,7 +15,7 @@ export interface TagPreferences {
hideEditorIcon?: boolean
newNoteTitleFormat?: NewNoteTitleFormat
customNoteTitleFormat?: string
editorIdentifier?: FeatureIdentifier | string
editorIdentifier?: EditorIdentifier
entryMode?: 'normal' | 'daily'
panelWidth?: number
}
@@ -1,5 +1,5 @@
import { CollectionSortProperty } from '../../Runtime/Collection/CollectionSort'
import { FeatureIdentifier } from '@standardnotes/features'
import { EditorIdentifier, FeatureIdentifier } from '@standardnotes/features'
export enum PrefKey {
TagsPanelWidth = 'tagsPanelWidth',
@@ -38,6 +38,7 @@ export enum PrefKey {
CustomNoteTitleFormat = 'customNoteTitleFormat',
UpdateSavingStatusIndicator = 'updateSavingStatusIndicator',
DarkMode = 'darkMode',
DefaultEditorIdentifier = 'defaultEditorIdentifier',
}
export enum NewNoteTitleFormat {
@@ -101,4 +102,5 @@ export type PrefValue = {
[PrefKey.EditorFontSize]: EditorFontSize
[PrefKey.UpdateSavingStatusIndicator]: boolean
[PrefKey.DarkMode]: boolean
[PrefKey.DefaultEditorIdentifier]: EditorIdentifier
}
@@ -33,6 +33,7 @@ import {
import { DeletedItem } from '../../Abstract/Item/Implementations/DeletedItem'
import { EncryptedItemInterface } from '../../Abstract/Item/Interfaces/EncryptedItem'
import { DeletedItemInterface } from '../../Abstract/Item/Interfaces/DeletedItem'
import { SmartViewMutator } from '../../Syncable/SmartView'
type ItemClass<C extends ItemContent = ItemContent> = new (payload: DecryptedPayloadInterface<C>) => DecryptedItem<C>
@@ -56,7 +57,7 @@ const ContentTypeClassMapping: Partial<Record<ContentType, MappingEntry>> = {
[ContentType.ExtensionRepo]: { itemClass: SNFeatureRepo },
[ContentType.File]: { itemClass: FileItem, mutatorClass: FileMutator },
[ContentType.Note]: { itemClass: SNNote, mutatorClass: NoteMutator },
[ContentType.SmartView]: { itemClass: SmartView, mutatorClass: TagMutator },
[ContentType.SmartView]: { itemClass: SmartView, mutatorClass: SmartViewMutator },
[ContentType.Tag]: { itemClass: SNTag, mutatorClass: TagMutator },
[ContentType.Theme]: { itemClass: SNTheme, mutatorClass: ThemeMutator },
[ContentType.UserPrefs]: { itemClass: SNUserPrefs, mutatorClass: UserPrefsMutator },
@@ -5,6 +5,7 @@ import { DecryptedPayload, PayloadSource, PayloadTimestampDefaults } from '../..
import { FileContent, FileItem } from '../../Syncable/File'
import { NoteContent, SNNote } from '../../Syncable/Note'
import { SNTag } from '../../Syncable/Tag'
import { SmartView, SmartViewContent } from '../../Syncable/SmartView'
let currentId = 0
@@ -55,6 +56,20 @@ export const createTagWithContent = (content: Partial<TagContent>): SNTag => {
)
}
export const createSmartViewWithContent = (content: Partial<SmartViewContent>): SmartView => {
return new SmartView(
new DecryptedPayload(
{
uuid: mockUuid(),
content_type: ContentType.SmartView,
content: FillItemContent<SmartViewContent>(content),
...PayloadTimestampDefaults(),
},
PayloadSource.Constructor,
),
)
}
export const createTagWithTitle = (title = 'photos') => {
return new SNTag(
new DecryptedPayload(
@@ -69,6 +84,20 @@ export const createTagWithTitle = (title = 'photos') => {
)
}
export const createSmartViewWithTitle = (title = 'photos') => {
return new SmartView(
new DecryptedPayload(
{
uuid: mockUuid(),
content_type: ContentType.SmartView,
content: FillItemContent<SmartViewContent>({ title }),
...PayloadTimestampDefaults(),
},
PayloadSource.Constructor,
),
)
}
export const createFile = (name = 'screenshot.png') => {
return new FileItem(
new DecryptedPayload(
+16
View File
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.3.233](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.232](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.231](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.230](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.229](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/releases
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/releases",
"version": "1.3.229",
"version": "1.3.233",
"license": "AGPL-3.0-or-later",
"main": "dist/releases.json",
"types": "dist/index.d.ts",
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.12.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/responses
## [1.12.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
**Note:** Version bump only for package @standardnotes/responses
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/responses",
"version": "1.12.5",
"version": "1.12.6",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+10
View File
@@ -3,6 +3,16 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.45.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/services
# [1.45.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
### Features
* **labs:** super editor ([#2001](https://github.com/standardnotes/app/issues/2001)) ([59f8547](https://github.com/standardnotes/app/commit/59f8547a8de1c804cb2f01ac734c83268977fa28))
# [1.44.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/services",
"version": "1.44.0",
"version": "1.45.1",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -20,6 +20,6 @@ export interface ComponentManagerInterface {
urlOverride?: string,
): ComponentViewerInterface
presentPermissionsDialog(_dialog: PermissionDialog): void
getDefaultEditor(): SNComponent | undefined
legacyGetDefaultEditor(): SNComponent | undefined
componentWithIdentifier(identifier: FeatureIdentifier | string): SNComponent | undefined
}
+18
View File
@@ -3,6 +3,24 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [2.151.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
### Features
* edit smart view predicate as json ([#2012](https://github.com/standardnotes/app/issues/2012)) ([f3e4ba8](https://github.com/standardnotes/app/commit/f3e4ba87795b82cbd96663b441f87ab80f710d05))
# [2.150.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
### Features
* **labs:** super editor ([#2001](https://github.com/standardnotes/app/issues/2001)) ([59f8547](https://github.com/standardnotes/app/commit/59f8547a8de1c804cb2f01ac734c83268977fa28))
## [2.149.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
### Bug Fixes
* **web:** remove checking for MFA permissions as all users are permitted to MFA ([ea40427](https://github.com/standardnotes/app/commit/ea40427e6715ba75193430fd7d3044e3c960d6ae))
# [2.149.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
### Features
@@ -1035,10 +1035,6 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
}
}
public isMfaFeatureAvailable(): boolean {
return this.mfaService.isMfaFeatureAvailable()
}
public async isMfaActivated(): Promise<boolean> {
return this.mfaService.isMfaActivated()
}
@@ -1,34 +0,0 @@
import { SNApplication } from './../Application/Application'
import { ContentType } from '@standardnotes/common'
import { MutatorService } from './../Services/Mutator/MutatorService'
import { SNComponentManager } from './../Services/ComponentManager/ComponentManager'
import { NoteType } from '@standardnotes/features'
import { NoteViewController } from './NoteViewController'
describe('note view controller', () => {
let application: SNApplication
beforeEach(() => {
application = {} as jest.Mocked<SNApplication>
application.streamItems = jest.fn()
const componentManager = {} as jest.Mocked<SNComponentManager>
componentManager.getDefaultEditor = jest.fn()
Object.defineProperty(application, 'componentManager', { value: componentManager })
const mutator = {} as jest.Mocked<MutatorService>
mutator.createTemplateItem = jest.fn()
Object.defineProperty(application, 'mutator', { value: mutator })
})
it('should create notes with plaintext note type', async () => {
const controller = new NoteViewController(application)
await controller.initialize(false)
expect(application.mutator.createTemplateItem).toHaveBeenCalledWith(
ContentType.Note,
expect.objectContaining({ noteType: NoteType.Plain }),
expect.anything(),
)
})
})
-4
View File
@@ -1,5 +1 @@
export * from './NoteViewController'
export * from './FileViewController'
export * from './ItemGroupController'
export * from './ReactNativeToWebEvent'
export * from './TemplateNoteViewControllerOptions'
@@ -619,7 +619,7 @@ export class SNComponentManager
return editor
}
}
const defaultEditor = this.getDefaultEditor()
const defaultEditor = this.legacyGetDefaultEditor()
if (defaultEditor && !defaultEditor.isExplicitlyDisabledForItem(note.uuid)) {
return defaultEditor
@@ -628,9 +628,9 @@ export class SNComponentManager
}
}
getDefaultEditor(): SNComponent | undefined {
legacyGetDefaultEditor(): SNComponent | undefined {
const editors = this.componentsForArea(ComponentArea.Editor)
return editors.filter((e) => e.isDefaultEditor())[0]
return editors.filter((e) => e.legacyIsDefaultEditor())[0]
}
permissionsStringForPermissions(permissions: ComponentPermission[], component: SNComponent): string {
@@ -173,24 +173,19 @@ export class SNFeaturesService
public enableExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void {
const feature = this.getUserFeature(identifier)
if (!feature) {
throw Error('Attempting to enable a feature user does not have access to.')
}
this.enabledExperimentalFeatures.push(identifier)
void this.storageService.setValue(StorageKey.ExperimentalFeatures, this.enabledExperimentalFeatures)
void this.mapRemoteNativeFeaturesToItems([feature])
if (feature) {
void this.mapRemoteNativeFeaturesToItems([feature])
}
void this.notifyEvent(FeaturesEvent.FeaturesUpdated)
}
public disableExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void {
const feature = this.getUserFeature(identifier)
if (!feature) {
throw Error('Attempting to disable a feature user does not have access to.')
}
removeFromArray(this.enabledExperimentalFeatures, identifier)
void this.storageService.setValue(StorageKey.ExperimentalFeatures, this.enabledExperimentalFeatures)
@@ -486,6 +481,16 @@ export class SNFeaturesService
return FeatureStatus.Entitled
}
if (this.isExperimentalFeature(featureId)) {
const nativeFeature = FeaturesImports.FindNativeFeature(featureId)
if (nativeFeature) {
const hasRole = this.roles.some((role) => nativeFeature.availableInRoles?.includes(role))
if (hasRole) {
return FeatureStatus.Entitled
}
}
}
const isDeprecated = this.isFeatureDeprecated(featureId)
if (isDeprecated) {
if (this.hasPaidOnlineOrOfflineSubscription()) {
@@ -9,7 +9,7 @@ import * as Services from '@standardnotes/services'
import { PayloadManagerChangeData } from '../Payloads'
import { DiagnosticInfo, ItemsClientInterface, ItemRelationshipDirection } from '@standardnotes/services'
import { ApplicationDisplayOptions } from '@Lib/Application/Options/OptionalOptions'
import { CollectionSort, DecryptedItemInterface, ItemContent } from '@standardnotes/models'
import { CollectionSort, DecryptedItemInterface, ItemContent, SmartViewDefaultIconName } from '@standardnotes/models'
type ItemsChangeObserver<I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface> = {
contentType: ContentType[]
@@ -1229,7 +1229,7 @@ export class ItemManager
Models.FillItemContent({
title,
predicate: predicate.toJson(),
iconString: iconString || 'restore',
iconString: iconString || SmartViewDefaultIconName,
} as Models.SmartViewContent),
true,
) as Promise<Models.SmartView>
@@ -3,7 +3,6 @@ import { SettingName } from '@standardnotes/settings'
import { SNSettingsService } from '../Settings'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { SNFeaturesService } from '../Features/FeaturesService'
import { FeatureIdentifier } from '@standardnotes/features'
import { AbstractService, InternalEventBusInterface, SignInStrings } from '@standardnotes/services'
export class SNMfaService extends AbstractService {
@@ -47,17 +46,6 @@ export class SNMfaService extends AbstractService {
return await this.settingsService.deleteSetting(SettingName.MfaSecret)
}
isMfaFeatureAvailable(): boolean {
const feature = this.featuresService.getUserFeature(FeatureIdentifier.TwoFactorAuth)
// If the feature is not present in the collection, we don't want to block it
if (feature == undefined) {
return false
}
return feature.no_expire === true || (feature.expires_at ?? 0) > Date.now()
}
override deinit(): void {
;(this.settingsService as unknown) = undefined
;(this.crypto as unknown) = undefined
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/snjs",
"version": "2.149.0",
"version": "2.151.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.13.13](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/ui-services
## [1.13.12](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
**Note:** Version bump only for package @standardnotes/ui-services
## [1.13.11](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/ui-services
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/ui-services",
"version": "1.13.11",
"version": "1.13.13",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+24
View File
@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [3.100.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
### Features
* edit smart view predicate as json ([#2012](https://github.com/standardnotes/app/issues/2012)) ([f3e4ba8](https://github.com/standardnotes/app/commit/f3e4ba87795b82cbd96663b441f87ab80f710d05))
# [3.99.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
### Features
* **labs:** super editor ([#2001](https://github.com/standardnotes/app/issues/2001)) ([59f8547](https://github.com/standardnotes/app/commit/59f8547a8de1c804cb2f01ac734c83268977fa28))
## [3.98.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-16)
### Bug Fixes
* **web:** remove checking for MFA permissions as all users are permitted to MFA ([ea40427](https://github.com/standardnotes/app/commit/ea40427e6715ba75193430fd7d3044e3c960d6ae))
## [3.98.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
### Bug Fixes
* **mobile:** show premium modal on iOS ([e962e9c](https://github.com/standardnotes/app/commit/e962e9c7457115100aba0f5d34c949ab2d8b0e23))
# [3.98.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/web",
"version": "3.98.0",
"version": "3.100.0",
"license": "AGPL-3.0-or-later",
"main": "dist/app.js",
"author": "Standard Notes.",
@@ -0,0 +1,95 @@
/**
* @jest-environment jsdom
*/
import {
Environment,
FeatureIdentifier,
namespacedKey,
Platform,
RawStorageKey,
SNComponent,
SNComponentManager,
SNLog,
SNTag,
} from '@standardnotes/snjs'
import { WebApplication } from '@/Application/Application'
import { WebOrDesktopDevice } from './Device/WebOrDesktopDevice'
describe('web application', () => {
let application: WebApplication
let componentManager: SNComponentManager
// eslint-disable-next-line no-console
SNLog.onLog = console.log
SNLog.onError = console.error
beforeEach(() => {
const identifier = '123'
window.matchMedia = jest.fn().mockReturnValue({ matches: true, addListener: jest.fn() })
const device = {
environment: Environment.Desktop,
appVersion: '1.2.3',
setApplication: jest.fn(),
openDatabase: jest.fn().mockReturnValue(Promise.resolve()),
getRawStorageValue: jest.fn().mockImplementation((key) => {
if (key === namespacedKey(identifier, RawStorageKey.SnjsVersion)) {
return '10.0.0'
}
return undefined
}),
setRawStorageValue: jest.fn(),
} as unknown as jest.Mocked<WebOrDesktopDevice>
application = new WebApplication(device, Platform.MacWeb, identifier, 'https://sync', 'https://socket')
componentManager = {} as jest.Mocked<SNComponentManager>
componentManager.legacyGetDefaultEditor = jest.fn()
Object.defineProperty(application, 'componentManager', { value: componentManager })
application.prepareForLaunch({ receiveChallenge: jest.fn() })
})
describe('geDefaultEditorIdentifier', () => {
it('should return plain editor if no default tag editor or component editor', () => {
const editorIdentifier = application.geDefaultEditorIdentifier()
expect(editorIdentifier).toEqual(FeatureIdentifier.PlainEditor)
})
it('should return pref key based value if available', () => {
application.getPreference = jest.fn().mockReturnValue(FeatureIdentifier.SuperEditor)
const editorIdentifier = application.geDefaultEditorIdentifier()
expect(editorIdentifier).toEqual(FeatureIdentifier.SuperEditor)
})
it('should return default tag identifier if tag supplied', () => {
const tag = {
preferences: {
editorIdentifier: FeatureIdentifier.SuperEditor,
},
} as jest.Mocked<SNTag>
const editorIdentifier = application.geDefaultEditorIdentifier(tag)
expect(editorIdentifier).toEqual(FeatureIdentifier.SuperEditor)
})
it('should return legacy editor identifier', () => {
const editor = {
legacyIsDefaultEditor: jest.fn().mockReturnValue(true),
identifier: FeatureIdentifier.MarkdownProEditor,
} as unknown as jest.Mocked<SNComponent>
componentManager.legacyGetDefaultEditor = jest.fn().mockReturnValue(editor)
const editorIdentifier = application.geDefaultEditorIdentifier()
expect(editorIdentifier).toEqual(FeatureIdentifier.MarkdownProEditor)
})
})
})
@@ -5,7 +5,6 @@ import {
DeinitSource,
Platform,
SNApplication,
ItemGroupController,
removeFromArray,
DesktopDeviceInterface,
isDesktopDevice,
@@ -20,6 +19,8 @@ import {
MobileUnlockTiming,
InternalEventBus,
DecryptedItem,
EditorIdentifier,
FeatureIdentifier,
} from '@standardnotes/snjs'
import { makeObservable, observable } from 'mobx'
import { PanelResizedData } from '@/Types/PanelResizedData'
@@ -40,6 +41,8 @@ import { PrefDefaults } from '@/Constants/PrefDefaults'
import { setCustomViewportHeight } from '@/setViewportHeightWithFallback'
import { WebServices } from './WebServices'
import { FeatureName } from '@/Controllers/FeatureName'
import { ItemGroupController } from '@/Components/NoteView/Controller/ItemGroupController'
import { VisibilityObserver } from './VisibilityObserver'
export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void
@@ -47,10 +50,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
private webServices!: WebServices
private webEventObservers: WebEventObserver[] = []
public itemControllerGroup: ItemGroupController
private onVisibilityChange: () => void
private mobileWebReceiver?: MobileWebReceiver
private androidBackHandler?: AndroidBackHandler
public readonly routeService: RouteServiceInterface
private visibilityObserver?: VisibilityObserver
constructor(
deviceInterface: WebOrDesktopDevice,
@@ -106,14 +109,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
}
}
this.onVisibilityChange = () => {
const visible = document.visibilityState === 'visible'
const event = visible ? WebAppEvent.WindowDidFocus : WebAppEvent.WindowDidBlur
this.notifyWebEvent(event)
}
if (!isDesktopApplication()) {
document.addEventListener('visibilitychange', this.onVisibilityChange)
this.visibilityObserver = new VisibilityObserver((event) => {
this.notifyWebEvent(event)
})
}
}
@@ -144,8 +143,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
this.webEventObservers.length = 0
document.removeEventListener('visibilitychange', this.onVisibilityChange)
;(this.onVisibilityChange as unknown) = undefined
if (this.visibilityObserver) {
this.visibilityObserver.deinit()
this.visibilityObserver = undefined
}
} catch (error) {
console.error('Error while deiniting application', error)
}
@@ -356,7 +357,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
return this.getViewControllerManager().featuresController.entitledToFiles
}
showPremiumModal(featureName: FeatureName): void {
showPremiumModal(featureName?: FeatureName): void {
void this.getViewControllerManager().featuresController.showPremiumAlert(featureName)
}
@@ -379,4 +380,13 @@ export class WebApplication extends SNApplication implements WebApplicationInter
showAccountMenu(): void {
this.getViewControllerManager().accountMenuController.setShow(true)
}
geDefaultEditorIdentifier(currentTag?: SNTag): EditorIdentifier {
return (
currentTag?.preferences?.editorIdentifier ||
this.getPreference(PrefKey.DefaultEditorIdentifier) ||
this.componentManager.legacyGetDefaultEditor()?.identifier ||
FeatureIdentifier.PlainEditor
)
}
}
@@ -0,0 +1,40 @@
import { WebAppEvent } from '@standardnotes/snjs'
export class VisibilityObserver {
private raceTimeout?: ReturnType<typeof setTimeout>
constructor(private onEvent: (event: WebAppEvent) => void) {
/**
* Browsers may handle focus and visibilitychange events differently.
* Focus better handles window focus events but may not handle tab switching.
* We will listen for both and debouce notifying so that the most recent event wins.
*/
document.addEventListener('visibilitychange', this.onVisibilityChange)
window.addEventListener('focus', this.onFocusEvent, false)
}
onVisibilityChange = () => {
const visible = document.visibilityState === 'visible'
const event = visible ? WebAppEvent.WindowDidFocus : WebAppEvent.WindowDidBlur
this.notifyEvent(event)
}
onFocusEvent = () => {
this.notifyEvent(WebAppEvent.WindowDidFocus)
}
private notifyEvent(event: WebAppEvent): void {
if (this.raceTimeout) {
clearTimeout(this.raceTimeout)
}
this.raceTimeout = setTimeout(() => {
this.onEvent(event)
}, 250)
}
deinit(): void {
document.removeEventListener('visibilitychange', this.onVisibilityChange)
window.removeEventListener('focus', this.onFocusEvent)
;(this.onEvent as unknown) = undefined
}
}
@@ -1,22 +0,0 @@
import { WebApplication } from '@/Application/Application'
import { NoteMutator, SNNote } from '@standardnotes/snjs'
export class BlockEditorController {
constructor(private note: SNNote, private application: WebApplication) {
this.note = note
this.application = application
}
deinit() {
;(this.note as unknown) = undefined
;(this.application as unknown) = undefined
}
async save(values: { text: string; previewPlain: string; previewHtml?: string }): Promise<void> {
await this.application.mutator.changeAndSaveItem<NoteMutator>(this.note, (mutator) => {
mutator.text = values.text
mutator.preview_plain = values.previewPlain
mutator.preview_html = values.previewHtml
})
}
}
@@ -12,7 +12,7 @@ import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem'
import { createEditorMenuGroups } from '../../Utils/createEditorMenuGroups'
import { reloadFont } from '../NoteView/FontFunctions'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
import { SuperNoteImporter } from '../BlockEditor/SuperNoteImporter'
import { SuperNoteImporter } from '../NoteView/SuperEditor/SuperNoteImporter'
type ChangeEditorMenuProps = {
application: WebApplication
@@ -114,7 +114,7 @@ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
return
}
if (itemToBeSelected.noteType === NoteType.Blocks) {
if (itemToBeSelected.noteType === NoteType.Super) {
setPendingSuperItem(itemToBeSelected)
handleDisableClickoutsideRequest?.()
setShowSuperImporter(true)
@@ -1,12 +1,4 @@
import {
ComponentArea,
ComponentMutator,
FeatureIdentifier,
NewNoteTitleFormat,
PrefKey,
SNComponent,
TagPreferences,
} from '@standardnotes/snjs'
import { FeatureIdentifier, NewNoteTitleFormat, PrefKey, EditorIdentifier, TagPreferences } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { ChangeEventHandler, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { PrefDefaults } from '@/Constants/PrefDefaults'
@@ -16,31 +8,14 @@ import { WebApplication } from '@/Application/Application'
import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
import { PreferenceMode } from './PreferenceMode'
import dayjs from 'dayjs'
import { getDropdownItemsForAllEditors, PlainEditorType } from '@/Utils/DropdownItemsForEditors'
import { EditorOption, getDropdownItemsForAllEditors } from '@/Utils/DropdownItemsForEditors'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { NoteTitleFormatOptions } from './NoteTitleFormatOptions'
const PrefChangeDebounceTimeInMs = 25
const HelpPageUrl = 'https://day.js.org/docs/en/display/format#list-of-all-available-formats'
const NoteTitleFormatOptions = [
{
label: 'Current date and time',
value: NewNoteTitleFormat.CurrentDateAndTime,
},
{
label: 'Current note count',
value: NewNoteTitleFormat.CurrentNoteCount,
},
{
label: 'Custom format',
value: NewNoteTitleFormat.CustomFormat,
},
{
label: 'Empty',
value: NewNoteTitleFormat.Empty,
},
]
type Props = {
application: WebApplication
selectedTag: AnyTag
@@ -57,22 +32,24 @@ const NewNotePreferences: FunctionComponent<Props> = ({
disabled,
}: Props) => {
const [editorItems, setEditorItems] = useState<DropdownItem[]>([])
const [defaultEditorIdentifier, setDefaultEditorIdentifier] = useState<string>(PlainEditorType)
const [defaultEditorIdentifier, setDefaultEditorIdentifier] = useState<EditorIdentifier>(
FeatureIdentifier.PlainEditor,
)
const [newNoteTitleFormat, setNewNoteTitleFormat] = useState<NewNoteTitleFormat>(
NewNoteTitleFormat.CurrentDateAndTime,
)
const [customNoteTitleFormat, setCustomNoteTitleFormat] = useState('')
const getGlobalEditorDefault = useCallback((): SNComponent | undefined => {
return application.componentManager.componentsForArea(ComponentArea.Editor).filter((e) => e.isDefaultEditor())[0]
const getGlobalEditorDefaultIdentifier = useCallback((): string => {
return application.geDefaultEditorIdentifier()
}, [application])
const reloadPreferences = useCallback(() => {
if (mode === 'tag' && selectedTag.preferences?.editorIdentifier) {
setDefaultEditorIdentifier(selectedTag.preferences?.editorIdentifier)
} else {
const globalDefault = getGlobalEditorDefault()
setDefaultEditorIdentifier(globalDefault?.identifier || PlainEditorType)
const globalDefault = getGlobalEditorDefaultIdentifier()
setDefaultEditorIdentifier(globalDefault)
}
if (mode === 'tag' && selectedTag.preferences?.newNoteTitleFormat) {
@@ -82,7 +59,14 @@ const NewNotePreferences: FunctionComponent<Props> = ({
application.getPreference(PrefKey.NewNoteTitleFormat, PrefDefaults[PrefKey.NewNoteTitleFormat]),
)
}
}, [mode, selectedTag, application, getGlobalEditorDefault, setDefaultEditorIdentifier, setNewNoteTitleFormat])
}, [
mode,
selectedTag,
application,
getGlobalEditorDefaultIdentifier,
setDefaultEditorIdentifier,
setNewNoteTitleFormat,
])
useEffect(() => {
if (mode === 'tag' && selectedTag.preferences?.customNoteTitleFormat) {
@@ -107,52 +91,22 @@ const NewNotePreferences: FunctionComponent<Props> = ({
}
}
const removeEditorGlobalDefault = (application: WebApplication, component: SNComponent) => {
application.mutator
.changeAndSaveItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.defaultEditor = false
})
.catch(console.error)
}
const makeEditorGlobalDefault = (
application: WebApplication,
component: SNComponent,
currentDefault?: SNComponent,
) => {
if (currentDefault) {
removeEditorGlobalDefault(application, currentDefault)
}
application.mutator
.changeAndSaveItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.defaultEditor = true
})
.catch(console.error)
}
useEffect(() => {
setEditorItems(getDropdownItemsForAllEditors(application))
}, [application])
const setDefaultEditor = (value: string) => {
setDefaultEditorIdentifier(value as FeatureIdentifier)
const setDefaultEditor = useCallback(
(value: EditorOption['value']) => {
setDefaultEditorIdentifier(value as FeatureIdentifier)
if (mode === 'global') {
const editors = application.componentManager.componentsForArea(ComponentArea.Editor)
const currentDefault = getGlobalEditorDefault()
if (value !== PlainEditorType) {
const editorComponent = editors.filter((e) => e.package_info.identifier === value)[0]
makeEditorGlobalDefault(application, editorComponent, currentDefault)
} else if (currentDefault) {
removeEditorGlobalDefault(application, currentDefault)
if (mode === 'global') {
void application.setPreference(PrefKey.DefaultEditorIdentifier, value)
} else {
void changePreferencesCallback({ editorIdentifier: value })
}
} else {
void changePreferencesCallback({ editorIdentifier: value })
}
}
},
[application, changePreferencesCallback, mode],
)
const debounceTimeoutRef = useRef<number>()
@@ -187,7 +141,7 @@ const NewNotePreferences: FunctionComponent<Props> = ({
label="Select the default note type"
items={editorItems}
value={defaultEditorIdentifier}
onChange={setDefaultEditor}
onChange={(value) => setDefaultEditor(value as EditorOption['value'])}
/>
</div>
</div>
@@ -211,7 +165,10 @@ const NewNotePreferences: FunctionComponent<Props> = ({
<div className="mt-2">
<input
disabled={disabled}
className="w-full min-w-55 rounded border border-solid border-passive-3 bg-default px-2 py-1.5 text-sm focus-within:ring-2 focus-within:ring-info"
className={classNames(
'w-full min-w-55 rounded border border-solid border-passive-3 bg-default px-2 py-1.5 text-sm',
'focus-within:ring-2 focus-within:ring-info',
)}
placeholder="e.g. YYYY-MM-DD"
value={customNoteTitleFormat}
onChange={handleCustomFormatInputChange}
@@ -0,0 +1,20 @@
import { NewNoteTitleFormat } from '@standardnotes/snjs'
export const NoteTitleFormatOptions = [
{
label: 'Current date and time',
value: NewNoteTitleFormat.CurrentDateAndTime,
},
{
label: 'Current note count',
value: NewNoteTitleFormat.CurrentNoteCount,
},
{
label: 'Custom format',
value: NewNoteTitleFormat.CustomFormat,
},
{
label: 'Empty',
value: NewNoteTitleFormat.Empty,
},
]
@@ -25,9 +25,6 @@ const ListItemNotePreviewText: FunctionComponent<Props> = ({ item, hidePreview,
{!item.preview_html && item.preview_plain && (
<div className={`leading-1.3 line-clamp-${lineLimit} mt-1 overflow-hidden`}>{item.preview_plain}</div>
)}
{!item.preview_html && !item.preview_plain && item.text && (
<div className={`leading-1.3 line-clamp-${lineLimit} mt-1 overflow-hidden`}>{item.text}</div>
)}
</div>
)
}
@@ -1,4 +1,3 @@
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
import { isFile, SNNote } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useRef } from 'react'
@@ -36,9 +35,8 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
const listItemRef = useRef<HTMLDivElement>(null)
const editorForNote = application.componentManager.editorForNote(item as SNNote)
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME
const [icon, tint] = getIconAndTintForNoteType(editorForNote?.package_info.note_type)
const noteType = item.noteType || application.componentManager.editorForNote(item)?.package_info.note_type
const [icon, tint] = getIconAndTintForNoteType(noteType)
const hasFiles = application.items.itemsReferencingItem(item).filter(isFile).length > 0
const openNoteContextMenu = (posX: number, posY: number) => {
@@ -93,7 +91,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
>
{!hideIcon ? (
<div className="mr-0 flex flex-col items-center justify-between p-4 pr-4">
<Icon ariaLabel={`Icon for ${editorName}`} type={icon} className={`text-accessory-tint-${tint}`} />
<Icon type={icon} className={`text-accessory-tint-${tint}`} />
</div>
) : (
<div className="pr-4" />
@@ -13,11 +13,19 @@ const UpgradeNow = ({ application, featuresController, subscriptionContoller }:
const shouldShowCTA = !featuresController.hasFolders
const hasAccount = subscriptionContoller.hasAccount
const onClick = () => {
if (application.isNativeIOS()) {
application.showPremiumModal()
} else {
application.openPurchaseFlow()
}
}
return shouldShowCTA ? (
<div className="flex h-full items-center px-2">
<button
className="rounded bg-info py-0.5 px-1.5 text-sm font-bold uppercase text-info-contrast hover:brightness-125 lg:text-xs"
onClick={() => application.openPurchaseFlow()}
onClick={onClick}
>
{hasAccount ? 'Unlock features' : 'Sign up to sync'}
</button>
@@ -14,22 +14,27 @@ const NoSubscriptionBanner = ({
title: string
message: string
className?: string
}) => (
<div className={classNames('grid grid-cols-1 rounded-md border border-border p-4', className)}>
<div className="flex items-center">
<Icon className={classNames('mr-1 -ml-1 h-5 w-5', PremiumFeatureIconClass)} type={PremiumFeatureIconName} />
<h1 className="sk-h3 m-0 text-sm font-semibold">{title}</h1>
}) => {
const onClick = () => {
if (application.isNativeIOS()) {
application.showPremiumModal()
} else {
application.openPurchaseFlow()
}
}
return (
<div className={classNames('grid grid-cols-1 rounded-md border border-border p-4', className)}>
<div className="flex items-center">
<Icon className={classNames('mr-1 -ml-1 h-5 w-5', PremiumFeatureIconClass)} type={PremiumFeatureIconName} />
<h1 className="sk-h3 m-0 text-sm font-semibold">{title}</h1>
</div>
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">{message}</p>
<Button primary small className="col-start-1 col-end-3 mt-3 justify-self-start uppercase" onClick={onClick}>
Upgrade Features
</Button>
</div>
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">{message}</p>
<Button
primary
small
className="col-start-1 col-end-3 mt-3 justify-self-start uppercase"
onClick={() => application.openPurchaseFlow()}
>
Upgrade Features
</Button>
</div>
)
)
}
export default NoSubscriptionBanner
@@ -1,4 +1,4 @@
import { FileItem, FileViewController, NoteViewController } from '@standardnotes/snjs'
import { FileItem } from '@standardnotes/snjs'
import { AbstractComponent } from '@/Components/Abstract/PureComponent'
import { WebApplication } from '@/Application/Application'
import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes'
@@ -8,6 +8,8 @@ import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import ResponsivePaneContent from '../ResponsivePane/ResponsivePaneContent'
import FileView from '../FileView/FileView'
import NoteView from '../NoteView/NoteView'
import { NoteViewController } from '../NoteView/Controller/NoteViewController'
import { FileViewController } from '../NoteView/Controller/FileViewController'
type State = {
showMultipleSelectedNotes: boolean
@@ -0,0 +1,5 @@
export const EditorSaveTimeoutDebounce = {
Desktop: 350,
ImmediateChange: 100,
NativeMobileWeb: 700,
}
@@ -1,6 +1,6 @@
import { FileItem } from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
import { SNApplication } from '../Application/Application'
import { SNApplication } from '@standardnotes/snjs'
import { ItemViewControllerInterface } from './ItemViewControllerInterface'
export class FileViewController implements ItemViewControllerInterface {
@@ -1,32 +1,18 @@
import { FileItem, PrefKey, SNNote } from '@standardnotes/models'
import { WebApplication } from '@/Application/Application'
import { removeFromArray } from '@standardnotes/utils'
import { ApplicationEvent } from '@standardnotes/services'
import { SNApplication } from '../Application/Application'
import { FileItem, SNNote } from '@standardnotes/snjs'
import { NoteViewController } from './NoteViewController'
import { FileViewController } from './FileViewController'
import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerOptions'
type ItemControllerGroupChangeCallback = (activeController: NoteViewController | FileViewController | undefined) => void
type CreateItemControllerOptions = FileItem | SNNote | TemplateNoteViewControllerOptions
export class ItemGroupController {
public itemControllers: (NoteViewController | FileViewController)[] = []
private addTagHierarchy: boolean
changeObservers: ItemControllerGroupChangeCallback[] = []
eventObservers: (() => void)[] = []
constructor(private application: SNApplication) {
this.addTagHierarchy = application.getPreference(PrefKey.NoteAddToParentFolders, true)
this.eventObservers.push(
application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
this.addTagHierarchy = application.getPreference(PrefKey.NoteAddToParentFolders, true)
}),
)
}
constructor(private application: WebApplication) {}
public deinit(): void {
;(this.application as unknown) = undefined
@@ -44,26 +30,30 @@ export class ItemGroupController {
this.itemControllers.length = 0
}
async createItemController(options: CreateItemControllerOptions): Promise<NoteViewController | FileViewController> {
async createItemController(context: {
file?: FileItem
note?: SNNote
templateOptions?: TemplateNoteViewControllerOptions
}): Promise<NoteViewController | FileViewController> {
if (this.activeItemViewController) {
this.closeItemController(this.activeItemViewController, { notify: false })
}
let controller!: NoteViewController | FileViewController
if (options instanceof FileItem) {
const file = options
controller = new FileViewController(this.application, file)
} else if (options instanceof SNNote) {
const note = options
controller = new NoteViewController(this.application, note)
if (context.file) {
controller = new FileViewController(this.application, context.file)
} else if (context.note) {
controller = new NoteViewController(this.application, context.note)
} else if (context.templateOptions) {
controller = new NoteViewController(this.application, undefined, context.templateOptions)
} else {
controller = new NoteViewController(this.application, undefined, options)
throw Error('Invalid input to createItemController')
}
this.itemControllers.push(controller)
await controller.initialize(this.addTagHierarchy)
await controller.initialize()
this.notifyObservers()
@@ -0,0 +1,83 @@
import { WebApplication } from '@/Application/Application'
import { ContentType } from '@standardnotes/common'
import {
MutatorService,
SNComponentManager,
SNComponent,
SNTag,
ItemsClientInterface,
SNNote,
} from '@standardnotes/snjs'
import { FeatureIdentifier, NoteType } from '@standardnotes/features'
import { NoteViewController } from './NoteViewController'
describe('note view controller', () => {
let application: WebApplication
let componentManager: SNComponentManager
beforeEach(() => {
application = {} as jest.Mocked<WebApplication>
application.streamItems = jest.fn()
application.getPreference = jest.fn().mockReturnValue(true)
Object.defineProperty(application, 'items', { value: {} as jest.Mocked<ItemsClientInterface> })
componentManager = {} as jest.Mocked<SNComponentManager>
componentManager.legacyGetDefaultEditor = jest.fn()
Object.defineProperty(application, 'componentManager', { value: componentManager })
const mutator = {} as jest.Mocked<MutatorService>
mutator.createTemplateItem = jest.fn().mockReturnValue({} as SNNote)
Object.defineProperty(application, 'mutator', { value: mutator })
})
it('should create notes with plaintext note type', async () => {
application.geDefaultEditorIdentifier = jest.fn().mockReturnValue(FeatureIdentifier.PlainEditor)
const controller = new NoteViewController(application)
await controller.initialize()
expect(application.mutator.createTemplateItem).toHaveBeenCalledWith(
ContentType.Note,
expect.objectContaining({ noteType: NoteType.Plain }),
expect.anything(),
)
})
it('should create notes with markdown note type', async () => {
componentManager.legacyGetDefaultEditor = jest.fn().mockReturnValue({
identifier: FeatureIdentifier.MarkdownProEditor,
} as SNComponent)
componentManager.componentWithIdentifier = jest.fn().mockReturnValue({
identifier: FeatureIdentifier.MarkdownProEditor,
} as SNComponent)
application.geDefaultEditorIdentifier = jest.fn().mockReturnValue(FeatureIdentifier.MarkdownProEditor)
const controller = new NoteViewController(application)
await controller.initialize()
expect(application.mutator.createTemplateItem).toHaveBeenCalledWith(
ContentType.Note,
expect.objectContaining({ noteType: NoteType.Markdown }),
expect.anything(),
)
})
it('should add tag to note if default tag is set', async () => {
application.geDefaultEditorIdentifier = jest.fn().mockReturnValue(FeatureIdentifier.PlainEditor)
const tag = {
uuid: 'tag-uuid',
} as jest.Mocked<SNTag>
application.items.findItem = jest.fn().mockReturnValue(tag)
application.items.addTagToNote = jest.fn()
const controller = new NoteViewController(application, undefined, { tag: tag.uuid })
await controller.initialize()
expect(controller['defaultTag']).toEqual(tag)
expect(application.items.addTagToNote).toHaveBeenCalledWith(expect.anything(), tag, expect.anything())
})
})
@@ -1,4 +1,5 @@
import { NoteType } from '@standardnotes/features'
import { WebApplication } from '@/Application/Application'
import { noteTypeForEditorIdentifier } from '@standardnotes/features'
import { InfoStrings } from '@standardnotes/services'
import {
NoteMutator,
@@ -7,13 +8,15 @@ import {
NoteContent,
DecryptedItemInterface,
PayloadEmitSource,
PrefKey,
} from '@standardnotes/models'
import { UuidString } from '@standardnotes/snjs'
import { removeFromArray } from '@standardnotes/utils'
import { ContentType } from '@standardnotes/common'
import { UuidString } from '@Lib/Types/UuidString'
import { SNApplication } from '../Application/Application'
import { ItemViewControllerInterface } from './ItemViewControllerInterface'
import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerOptions'
import { EditorSaveTimeoutDebounce } from './EditorSaveTimeoutDebounce'
import { log, LoggingDomain } from '@/Logging'
export type EditorValues = {
title: string
@@ -23,25 +26,20 @@ export type EditorValues = {
const StringEllipses = '...'
const NotePreviewCharLimit = 160
const SaveTimeoutDebounc = {
Desktop: 350,
ImmediateChange: 100,
NativeMobileWeb: 700,
}
export class NoteViewController implements ItemViewControllerInterface {
public item!: SNNote
public dealloced = false
private innerValueChangeObservers: ((note: SNNote, source: PayloadEmitSource) => void)[] = []
private removeStreamObserver?: () => void
private disposers: (() => void)[] = []
public isTemplateNote = false
private saveTimeout?: ReturnType<typeof setTimeout>
private defaultTagUuid: UuidString | undefined
private defaultTag?: SNTag
public runtimeId = `${Math.random()}`
public needsInit = true
constructor(
private application: SNApplication,
private application: WebApplication,
item?: SNNote,
public templateNoteOptions?: TemplateNoteViewControllerOptions,
) {
@@ -60,8 +58,10 @@ export class NoteViewController implements ItemViewControllerInterface {
deinit(): void {
this.dealloced = true
this.removeStreamObserver?.()
;(this.removeStreamObserver as unknown) = undefined
for (const disposer of this.disposers) {
disposer()
}
this.disposers.length = 0
;(this.application as unknown) = undefined
;(this.item as unknown) = undefined
@@ -70,22 +70,30 @@ export class NoteViewController implements ItemViewControllerInterface {
this.saveTimeout = undefined
}
async initialize(addTagHierarchy: boolean): Promise<void> {
if (!this.item) {
const editorIdentifier =
this.defaultTag?.preferences?.editorIdentifier ||
this.application.componentManager.getDefaultEditor()?.identifier
async initialize(): Promise<void> {
if (!this.needsInit) {
throw Error('NoteViewController already initialized')
}
const defaultEditor = editorIdentifier
? this.application.componentManager.componentWithIdentifier(editorIdentifier)
: undefined
log(LoggingDomain.NoteView, 'Initializing NoteViewController')
this.needsInit = false
const addTagHierarchy = this.application.getPreference(PrefKey.NoteAddToParentFolders, true)
if (!this.item) {
log(LoggingDomain.NoteView, 'Initializing as template note')
const editorIdentifier = this.application.geDefaultEditorIdentifier(this.defaultTag)
const noteType = noteTypeForEditorIdentifier(editorIdentifier)
const note = this.application.mutator.createTemplateItem<NoteContent, SNNote>(
ContentType.Note,
{
text: '',
title: this.templateNoteOptions?.title || '',
noteType: defaultEditor?.noteType || NoteType.Plain,
noteType: noteType,
editorIdentifier: editorIdentifier,
references: [],
},
@@ -119,9 +127,8 @@ export class NoteViewController implements ItemViewControllerInterface {
return
}
this.removeStreamObserver = this.application.streamItems<SNNote>(
ContentType.Note,
({ changed, inserted, source }) => {
this.disposers.push(
this.application.streamItems<SNNote>(ContentType.Note, ({ changed, inserted, source }) => {
if (this.dealloced) {
return
}
@@ -137,11 +144,12 @@ export class NoteViewController implements ItemViewControllerInterface {
this.item = matchingNote
this.notifyObservers(matchingNote, source)
}
},
}),
)
}
public insertTemplatedNote(): Promise<DecryptedItemInterface> {
log(LoggingDomain.NoteView, 'Inserting template note')
this.isTemplateNote = false
return this.application.mutator.insertItem(this.item)
}
@@ -163,29 +171,55 @@ export class NoteViewController implements ItemViewControllerInterface {
}
}
/**
* @param bypassDebouncer Calling save will debounce by default. You can pass true to save
* immediately.
* @param isUserModified This field determines if the item will be saved as a user
* modification, thus updating the user modified date displayed in the UI
* @param dontUpdatePreviews Whether this change should update the note's plain and HTML
* preview.
* @param customMutate A custom mutator function.
*/
public async save(dto: {
editorValues: EditorValues
public async saveAndAwaitLocalPropagation(params: {
title?: string
text?: string
isUserModified: boolean
bypassDebouncer?: boolean
isUserModified?: boolean
dontUpdatePreviews?: boolean
dontGeneratePreviews?: boolean
previews?: { previewPlain: string; previewHtml?: string }
customMutate?: (mutator: NoteMutator) => void
}): Promise<void> {
const title = dto.editorValues.title
const text = dto.editorValues.text
if (this.needsInit) {
throw Error('NoteViewController not initialized')
}
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
const noDebounce = params.bypassDebouncer || this.application.noAccount()
const syncDebouceMs = noDebounce
? EditorSaveTimeoutDebounce.ImmediateChange
: this.application.isNativeMobileWeb()
? EditorSaveTimeoutDebounce.NativeMobileWeb
: EditorSaveTimeoutDebounce.Desktop
return new Promise((resolve) => {
this.saveTimeout = setTimeout(() => {
void this.undebouncedSave({ ...params, onLocalPropagationComplete: resolve })
}, syncDebouceMs)
})
}
private async undebouncedSave(params: {
title?: string
text?: string
bypassDebouncer?: boolean
isUserModified?: boolean
dontGeneratePreviews?: boolean
previews?: { previewPlain: string; previewHtml?: string }
customMutate?: (mutator: NoteMutator) => void
onLocalPropagationComplete?: () => void
onRemoteSyncComplete?: () => void
}): Promise<void> {
log(LoggingDomain.NoteView, 'Saving note', params)
const isTemplate = this.isTemplateNote
if (typeof document !== 'undefined' && document.hidden) {
void this.application.alertService.alert(InfoStrings.SavingWhileDocumentHidden)
return
}
if (isTemplate) {
@@ -201,41 +235,37 @@ export class NoteViewController implements ItemViewControllerInterface {
this.item,
(mutator) => {
const noteMutator = mutator as NoteMutator
if (dto.customMutate) {
dto.customMutate(noteMutator)
if (params.customMutate) {
params.customMutate(noteMutator)
}
noteMutator.title = title
noteMutator.text = text
if (!dto.dontUpdatePreviews) {
const noteText = text || ''
if (params.title != undefined) {
noteMutator.title = params.title
}
if (params.text != undefined) {
noteMutator.text = params.text
}
if (params.previews) {
noteMutator.preview_plain = params.previews.previewPlain
noteMutator.preview_html = params.previews.previewHtml
} else if (!params.dontGeneratePreviews && params.text != undefined) {
const noteText = params.text || ''
const truncate = noteText.length > NotePreviewCharLimit
const substring = noteText.substring(0, NotePreviewCharLimit)
const previewPlain = substring + (truncate ? StringEllipses : '')
// eslint-disable-next-line camelcase
noteMutator.preview_plain = previewPlain
// eslint-disable-next-line camelcase
noteMutator.preview_html = undefined
}
},
dto.isUserModified,
params.isUserModified,
)
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
params.onLocalPropagationComplete?.()
const noDebounce = dto.bypassDebouncer || this.application.noAccount()
const syncDebouceMs = noDebounce
? SaveTimeoutDebounc.ImmediateChange
: this.application.isNativeMobileWeb()
? SaveTimeoutDebounc.NativeMobileWeb
: SaveTimeoutDebounc.Desktop
this.saveTimeout = setTimeout(() => {
void this.application.sync.sync()
}, syncDebouceMs)
void this.application.sync.sync().then(() => {
params.onRemoteSyncComplete?.()
})
}
}
@@ -1,4 +1,4 @@
import { UuidString } from '@Lib/Types/UuidString'
import { UuidString } from '@standardnotes/snjs'
export type TemplateNoteViewControllerOptions = {
title?: string
@@ -8,12 +8,12 @@ import { NotesController } from '@/Controllers/NotesController'
import {
ApplicationEvent,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
NoteViewController,
SNNote,
NoteType,
PayloadEmitSource,
} from '@standardnotes/snjs'
import NoteView from './NoteView'
import { NoteViewController } from './Controller/NoteViewController'
describe('NoteView', () => {
let noteViewController: NoteViewController
@@ -16,30 +16,24 @@ import {
ComponentArea,
ComponentViewerInterface,
ContentType,
EditorFontSize,
EditorLineHeight,
isPayloadSourceInternalChange,
isPayloadSourceRetrieved,
NoteType,
NoteViewController,
PayloadEmitSource,
PrefKey,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
SNComponent,
SNNote,
WebAppEvent,
} from '@standardnotes/snjs'
import { confirmDialog, KeyboardKey, KeyboardModifier } from '@standardnotes/ui-services'
import { ChangeEventHandler, createRef, KeyboardEventHandler, RefObject } from 'react'
import { EditorEventSource } from '../../Types/EditorEventSource'
import { BlockEditor } from '../BlockEditor/BlockEditorComponent'
import { SuperEditor } from './SuperEditor/SuperEditor'
import IndicatorCircle from '../IndicatorCircle/IndicatorCircle'
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton'
import EditingDisabledBanner from './EditingDisabledBanner'
import { reloadFont } from './FontFunctions'
import { getPlaintextFontSize } from '../../Utils/getPlaintextFontSize'
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
import NoteViewFileDropTarget from './NoteViewFileDropTarget'
import { NoteViewProps } from './NoteViewProps'
@@ -48,9 +42,10 @@ import {
transactionForDisassociateComponentWithCurrentNote,
} from './TransactionFunctions'
import { SuperEditorContentId } from '@standardnotes/blocks-editor'
import { NoteViewController } from './Controller/NoteViewController'
import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor'
const MinimumStatusDuration = 400
const TextareaDebounce = 100
const NoteEditingDisabledText = 'Note editing disabled.'
function sortAlphabetically(array: SNComponent[]): SNComponent[] {
@@ -63,7 +58,6 @@ type State = {
editorComponentViewerDidAlreadyReload?: boolean
editorStateDidLoad: boolean
editorTitle: string
editorText: string
isDesktop?: boolean
lockText: string
marginResizersEnabled?: boolean
@@ -75,21 +69,14 @@ type State = {
spellcheck: boolean
stackComponentViewers: ComponentViewerInterface[]
syncTakingTooLong: boolean
/** Setting to true then false will allow the main content textarea to be destroyed
* then re-initialized. Used when reloading spellcheck status. */
textareaUnloading: boolean
plaintextEditorFocused?: boolean
monospaceFont?: boolean
plainEditorFocused?: boolean
leftResizerWidth: number
leftResizerOffset: number
rightResizerWidth: number
rightResizerOffset: number
monospaceFont?: boolean
lineHeight?: EditorLineHeight
fontSize?: EditorFontSize
updateSavingIndicator?: boolean
editorFeatureIdentifier?: string
noteType?: NoteType
}
@@ -98,23 +85,17 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
readonly controller!: NoteViewController
private statusTimeout?: NodeJS.Timeout
private lastEditorFocusEventSource?: EditorEventSource
onEditorComponentLoad?: () => void
private removeTrashKeyObserver?: () => void
private removeTabObserver?: () => void
private removeComponentStreamObserver?: () => void
private removeComponentManagerObserver?: () => void
private removeInnerNoteObserver?: () => void
private removeWebAppEventObserver: () => void
private needsAdjustMobileCursor = false
private isAdjustingMobileCursor = false
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null
private noteViewElementRef: RefObject<HTMLDivElement>
private editorContentRef: RefObject<HTMLDivElement>
private plainEditorRef?: RefObject<PlainEditorInterface>
constructor(props: NoteViewProps) {
super(props, props.application)
@@ -130,18 +111,9 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.debounceReloadEditorComponent = debounce(this.debounceReloadEditorComponent.bind(this), 25)
this.textAreaChangeDebounceSave = debounce(this.textAreaChangeDebounceSave, TextareaDebounce)
this.removeWebAppEventObserver = props.application.addWebEventObserver((event) => {
if (event === WebAppEvent.MobileKeyboardWillChangeFrame) {
this.scrollMobileCursorIntoViewAfterWebviewResize()
}
})
this.state = {
availableStackComponents: [],
editorStateDidLoad: false,
editorText: '',
editorTitle: '',
isDesktop: isDesktopApplication(),
lockText: NoteEditingDisabledText,
@@ -152,7 +124,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
spellcheck: true,
stackComponentViewers: [],
syncTakingTooLong: false,
textareaUnloading: false,
leftResizerWidth: 0,
leftResizerOffset: 0,
rightResizerWidth: 0,
@@ -165,16 +136,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.editorContentRef = createRef<HTMLDivElement>()
}
scrollMobileCursorIntoViewAfterWebviewResize() {
if (this.needsAdjustMobileCursor) {
this.needsAdjustMobileCursor = false
this.isAdjustingMobileCursor = true
document.getElementById('note-text-editor')?.blur()
document.getElementById('note-text-editor')?.focus()
this.isAdjustingMobileCursor = false
}
}
override deinit() {
super.deinit()
;(this.controller as unknown) = undefined
@@ -194,28 +155,20 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.clearNoteProtectionInactivityTimer()
;(this.ensureNoteIsInsertedBeforeUIAction as unknown) = undefined
this.removeWebAppEventObserver?.()
;(this.removeWebAppEventObserver as unknown) = undefined
this.removeTabObserver?.()
this.removeTabObserver = undefined
this.onEditorComponentLoad = undefined
this.statusTimeout = undefined
;(this.onPanelResizeFinish as unknown) = undefined
;(this.authorizeAndDismissProtectedWarning as unknown) = undefined
;(this.editorComponentViewerRequestsReload as unknown) = undefined
;(this.onTextAreaChange as unknown) = undefined
;(this.onTitleEnter as unknown) = undefined
;(this.onTitleChange as unknown) = undefined
;(this.onContentFocus as unknown) = undefined
;(this.onPanelResizeFinish as unknown) = undefined
;(this.stackComponentExpanded as unknown) = undefined
;(this.toggleStackComponent as unknown) = undefined
;(this.onSystemEditorRef as unknown) = undefined
;(this.debounceReloadEditorComponent as unknown) = undefined
;(this.textAreaChangeDebounceSave as unknown) = undefined
;(this.editorContentRef as unknown) = undefined
;(this.plainEditorRef as unknown) = undefined
}
getState() {
@@ -271,9 +224,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
if (this.controller.isTemplateNote) {
setTimeout(() => {
if (this.controller.templateNoteOptions?.autofocusBehavior === 'editor') {
this.focusEditor()
} else {
if (this.controller.templateNoteOptions?.autofocusBehavior === 'title') {
this.focusTitle()
}
})
@@ -296,34 +247,22 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
throw Error('Editor received changes for non-current note')
}
let title = this.state.editorTitle,
text = this.state.editorText
let title = this.state.editorTitle
if (isPayloadSourceRetrieved(source)) {
title = note.title
text = note.text
}
if (!this.state.editorTitle) {
title = note.title
}
if (!this.state.editorText) {
text = note.text
}
if (title !== this.state.editorTitle) {
this.setState({
editorTitle: title,
})
}
if (text !== this.state.editorText) {
this.setState({
editorText: text,
})
}
if (note.locked !== this.state.noteLocked) {
this.setState({
noteLocked: note.locked,
@@ -334,7 +273,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.setState({
editorFeatureIdentifier: note.editorIdentifier,
noteType: note.noteType,
editorText: note.text,
editorTitle: note.title,
})
@@ -625,36 +563,13 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
}
}
onTextAreaChange: ChangeEventHandler<HTMLTextAreaElement> = ({ currentTarget }) => {
const text = currentTarget.value
this.setState({
editorText: text,
})
this.textAreaChangeDebounceSave()
}
textAreaChangeDebounceSave = () => {
log(LoggingDomain.NoteView, 'Performing save after debounce')
this.controller
.save({
editorValues: {
title: this.state.editorTitle,
text: this.state.editorText,
},
isUserModified: true,
})
.catch(console.error)
}
onTitleEnter: KeyboardEventHandler<HTMLInputElement> = ({ key, currentTarget }) => {
if (key !== KeyboardKey.Enter) {
return
}
currentTarget.blur()
this.focusEditor()
this.plainEditorRef?.current?.focus()
}
onTitleChange: ChangeEventHandler<HTMLInputElement> = ({ currentTarget }) => {
@@ -667,49 +582,18 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
})
this.controller
.save({
editorValues: {
title: title,
text: this.state.editorText,
},
.saveAndAwaitLocalPropagation({
title: title,
isUserModified: true,
dontUpdatePreviews: true,
dontGeneratePreviews: true,
})
.catch(console.error)
}
focusEditor() {
const element = document.getElementById(ElementIds.NoteTextEditor)
if (element) {
this.lastEditorFocusEventSource = EditorEventSource.Script
element.focus()
}
}
focusTitle() {
document.getElementById(ElementIds.NoteTitleEditor)?.focus()
}
onContentFocus = () => {
if (!this.isAdjustingMobileCursor) {
this.needsAdjustMobileCursor = true
}
if (this.lastEditorFocusEventSource) {
this.application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: this.lastEditorFocusEventSource })
}
this.lastEditorFocusEventSource = undefined
this.setState({ plaintextEditorFocused: true })
}
onContentBlur = () => {
if (this.lastEditorFocusEventSource) {
this.application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: this.lastEditorFocusEventSource })
}
this.lastEditorFocusEventSource = undefined
this.setState({ plaintextEditorFocused: false })
}
setShowProtectedOverlay(show: boolean) {
this.viewControllerManager.notesController.setShowProtectedWarning(show)
}
@@ -737,13 +621,11 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.performNoteDeletion(this.note)
} else {
this.controller
.save({
editorValues: {
title: this.state.editorTitle,
text: this.state.editorText,
},
.saveAndAwaitLocalPropagation({
title: this.state.editorTitle,
bypassDebouncer: true,
dontUpdatePreviews: true,
dontGeneratePreviews: true,
isUserModified: true,
customMutate: (mutator) => {
mutator.trashed = true
},
@@ -773,15 +655,9 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
async reloadSpellcheck() {
const spellcheck = this.viewControllerManager.notesController.getSpellcheckStateForNote(this.note)
if (spellcheck !== this.state.spellcheck) {
this.setState({ textareaUnloading: true })
this.setState({ textareaUnloading: false })
reloadFont(this.state.monospaceFont)
this.setState({
spellcheck,
})
this.setState({ spellcheck })
}
}
@@ -797,10 +673,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
PrefDefaults[PrefKey.EditorResizersEnabled],
)
const lineHeight = this.application.getPreference(PrefKey.EditorLineHeight, PrefDefaults[PrefKey.EditorLineHeight])
const fontSize = this.application.getPreference(PrefKey.EditorFontSize, PrefDefaults[PrefKey.EditorFontSize])
const updateSavingIndicator = this.application.getPreference(
PrefKey.UpdateSavingStatusIndicator,
PrefDefaults[PrefKey.UpdateSavingStatusIndicator],
@@ -811,8 +683,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.setState({
monospaceFont,
marginResizersEnabled,
lineHeight,
fontSize,
updateSavingIndicator,
})
@@ -904,82 +775,20 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
})
}
onSystemEditorRef = (ref: HTMLTextAreaElement | null) => {
if (this.removeTabObserver || !ref) {
return
}
log(LoggingDomain.NoteView, 'On system editor ref')
/**
* Insert 4 spaces when a tab key is pressed,
* only used when inside of the text editor.
* If the shift key is pressed first, this event is
* not fired.
*/
const editor = document.getElementById(ElementIds.NoteTextEditor) as HTMLInputElement
if (!editor) {
console.error('Editor is not yet mounted; unable to add tab observer.')
return
}
this.removeTabObserver = this.application.io.addKeyObserver({
element: editor,
key: KeyboardKey.Tab,
onKeyDown: (event) => {
if (document.hidden || this.note.locked || event.shiftKey) {
return
}
event.preventDefault()
/** Using document.execCommand gives us undo support */
const insertSuccessful = document.execCommand('insertText', false, '\t')
if (!insertSuccessful) {
/** document.execCommand works great on Chrome/Safari but not Firefox */
const start = editor.selectionStart || 0
const end = editor.selectionEnd || 0
const spaces = ' '
/** Insert 4 spaces */
editor.value = editor.value.substring(0, start) + spaces + editor.value.substring(end)
/** Place cursor 4 spaces away from where the tab key was pressed */
editor.selectionStart = editor.selectionEnd = start + 4
}
this.setState({
editorText: editor.value,
})
this.controller
.save({
editorValues: {
title: this.state.editorTitle,
text: this.state.editorText,
},
bypassDebouncer: true,
})
.catch(console.error)
},
})
const observer = new MutationObserver((records) => {
for (const record of records) {
record.removedNodes.forEach((node) => {
if (node === editor) {
this.removeTabObserver?.()
this.removeTabObserver = undefined
}
})
}
})
observer.observe(editor.parentElement as HTMLElement, { childList: true })
}
ensureNoteIsInsertedBeforeUIAction = async () => {
if (this.controller.isTemplateNote) {
await this.controller.insertTemplatedNote()
}
}
onPlainFocus = () => {
this.setState({ plainEditorFocused: true })
}
onPlainBlur = () => {
this.setState({ plainEditorFocused: false })
}
override render() {
if (this.controller.dealloced) {
return null
@@ -996,12 +805,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
)
}
const renderHeaderOptions = isMobileScreen() ? !this.state.plaintextEditorFocused : true
const renderHeaderOptions = isMobileScreen() ? !this.state.plainEditorFocused : true
const editorMode =
this.note.noteType === NoteType.Blocks
? 'blocks'
: this.state.editorStateDidLoad && !this.state.editorComponentViewer && !this.state.textareaUnloading
this.note.noteType === NoteType.Super
? 'super'
: this.state.editorStateDidLoad && !this.state.editorComponentViewer
? 'plain'
: this.state.editorComponentViewer
? 'component'
@@ -1095,7 +904,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
</div>
)}
</div>
{editorMode !== 'blocks' && (
{editorMode !== 'super' && (
<LinkedItemBubblesContainer linkingController={this.viewControllerManager.linkingController} />
)}
</div>
@@ -1103,7 +912,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
<div
id={ElementIds.EditorContent}
className={`${ElementIds.EditorContent} z-editor-content overflow-scroll`}
className={`${ElementIds.EditorContent} z-editor-content overflow-auto`}
ref={this.editorContentRef}
>
{this.state.marginResizersEnabled && this.editorContentRef.current ? (
@@ -1133,34 +942,26 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
)}
{editorMode === 'plain' && (
<textarea
autoComplete="off"
dir="auto"
id={ElementIds.NoteTextEditor}
onChange={this.onTextAreaChange}
onFocus={this.onContentFocus}
onBlur={this.onContentBlur}
readOnly={this.state.noteLocked}
ref={(ref) => ref && this.onSystemEditorRef(ref)}
spellCheck={this.state.spellcheck}
value={this.state.editorText}
className={classNames(
'editable font-editor flex-grow',
this.state.lineHeight && `leading-${this.state.lineHeight.toLowerCase()}`,
this.state.fontSize && getPlaintextFontSize(this.state.fontSize),
)}
></textarea>
<PlainEditor
application={this.application}
spellcheck={this.state.spellcheck}
ref={this.plainEditorRef}
controller={this.controller}
locked={this.state.noteLocked}
onFocus={this.onPlainFocus}
onBlur={this.onPlainBlur}
/>
)}
{editorMode === 'blocks' && (
{editorMode === 'super' && (
<div className={classNames('blocks-editor w-full flex-grow overflow-hidden overflow-y-scroll')}>
<BlockEditor
<SuperEditor
key={this.note.uuid}
application={this.application}
note={this.note}
linkingController={this.viewControllerManager.linkingController}
filesController={this.viewControllerManager.filesController}
spellcheck={this.state.spellcheck}
controller={this.controller}
/>
</div>
)}
@@ -1,6 +1,5 @@
import { NoteViewController } from '@standardnotes/snjs'
import { WebApplication } from '@/Application/Application'
import { NoteViewController } from './Controller/NoteViewController'
export interface NoteViewProps {
application: WebApplication
@@ -0,0 +1,253 @@
import { WebApplication } from '@/Application/Application'
import { usePrevious } from '@/Components/ContentListView/Calendar/usePrevious'
import { ElementIds } from '@/Constants/ElementIDs'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import { log, LoggingDomain } from '@/Logging'
import { Disposer } from '@/Types/Disposer'
import { EditorEventSource } from '@/Types/EditorEventSource'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { getPlaintextFontSize } from '@/Utils/getPlaintextFontSize'
import {
ApplicationEvent,
EditorFontSize,
EditorLineHeight,
isPayloadSourceRetrieved,
PrefKey,
WebAppEvent,
} from '@standardnotes/snjs'
import { KeyboardKey } from '@standardnotes/ui-services'
import { ChangeEventHandler, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { NoteViewController } from '../Controller/NoteViewController'
type Props = {
application: WebApplication
spellcheck: boolean
controller: NoteViewController
locked: boolean
onFocus: () => void
onBlur: () => void
}
export type PlainEditorInterface = {
focus: () => void
}
export const PlainEditor = forwardRef<PlainEditorInterface, Props>(
({ application, spellcheck, controller, locked, onFocus, onBlur }: Props, ref) => {
const [editorText, setEditorText] = useState<string | undefined>()
const [textareaUnloading, setTextareaUnloading] = useState(false)
const [lineHeight, setLineHeight] = useState<EditorLineHeight | undefined>()
const [fontSize, setFontSize] = useState<EditorFontSize | undefined>()
const previousSpellcheck = usePrevious(spellcheck)
const lastEditorFocusEventSource = useRef<EditorEventSource | undefined>()
const needsAdjustMobileCursor = useRef(false)
const isAdjustingMobileCursor = useRef(false)
const note = useRef(controller.item)
const tabObserverDisposer = useRef<Disposer>()
useImperativeHandle(ref, () => ({
focus() {
focusEditor()
},
}))
useEffect(() => {
const disposer = controller.addNoteInnerValueChangeObserver((updatedNote, source) => {
if (updatedNote.uuid !== note.current.uuid) {
throw Error('Editor received changes for non-current note')
}
if (
isPayloadSourceRetrieved(source) ||
editorText == undefined ||
updatedNote.editorIdentifier !== note.current.editorIdentifier ||
updatedNote.noteType !== note.current.noteType
) {
setEditorText(updatedNote.text)
}
note.current = updatedNote
})
return disposer
}, [controller, editorText, controller.item.uuid, controller.item.editorIdentifier, controller.item.noteType])
const onTextAreaChange: ChangeEventHandler<HTMLTextAreaElement> = ({ currentTarget }) => {
const text = currentTarget.value
setEditorText(text)
void controller.saveAndAwaitLocalPropagation({ text: text, isUserModified: true })
}
const onContentFocus = useCallback(() => {
if (!isAdjustingMobileCursor.current) {
needsAdjustMobileCursor.current = true
}
if (lastEditorFocusEventSource.current) {
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
}
lastEditorFocusEventSource.current = undefined
onFocus()
}, [application, isAdjustingMobileCursor, lastEditorFocusEventSource, onFocus])
const onContentBlur = useCallback(() => {
if (lastEditorFocusEventSource.current) {
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
}
lastEditorFocusEventSource.current = undefined
onBlur()
}, [application, lastEditorFocusEventSource, onBlur])
const scrollMobileCursorIntoViewAfterWebviewResize = useCallback(() => {
if (needsAdjustMobileCursor.current) {
needsAdjustMobileCursor.current = false
isAdjustingMobileCursor.current = true
document.getElementById('note-text-editor')?.blur()
document.getElementById('note-text-editor')?.focus()
isAdjustingMobileCursor.current = false
}
}, [needsAdjustMobileCursor])
useEffect(() => {
const disposer = application.addWebEventObserver((event) => {
if (event === WebAppEvent.MobileKeyboardWillChangeFrame) {
scrollMobileCursorIntoViewAfterWebviewResize()
}
})
return disposer
}, [application, scrollMobileCursorIntoViewAfterWebviewResize])
const focusEditor = useCallback(() => {
const element = document.getElementById(ElementIds.NoteTextEditor)
if (element) {
lastEditorFocusEventSource.current = EditorEventSource.Script
element.focus()
}
}, [])
useEffect(() => {
if (controller.isTemplateNote && controller.templateNoteOptions?.autofocusBehavior === 'editor') {
setTimeout(() => {
focusEditor()
})
}
}, [controller, focusEditor])
const reloadPreferences = useCallback(() => {
const lineHeight = application.getPreference(PrefKey.EditorLineHeight, PrefDefaults[PrefKey.EditorLineHeight])
const fontSize = application.getPreference(PrefKey.EditorFontSize, PrefDefaults[PrefKey.EditorFontSize])
setLineHeight(lineHeight)
setFontSize(fontSize)
}, [application])
useEffect(() => {
reloadPreferences()
return application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
reloadPreferences()
})
}, [reloadPreferences, application])
useEffect(() => {
if (spellcheck !== previousSpellcheck) {
setTextareaUnloading(true)
setTimeout(() => {
setTextareaUnloading(false)
}, 0)
}
}, [spellcheck, previousSpellcheck])
const onRef = (ref: HTMLTextAreaElement | null) => {
if (tabObserverDisposer.current || !ref) {
return
}
log(LoggingDomain.NoteView, 'On system editor ref')
/**
* Insert 4 spaces when a tab key is pressed, only used when inside of the text editor.
* If the shift key is pressed first, this event is not fired.
*/
const editor = document.getElementById(ElementIds.NoteTextEditor) as HTMLInputElement
if (!editor) {
console.error('Editor is not yet mounted; unable to add tab observer.')
return
}
tabObserverDisposer.current = application.io.addKeyObserver({
element: editor,
key: KeyboardKey.Tab,
onKeyDown: (event) => {
if (document.hidden || note.current.locked || event.shiftKey) {
return
}
event.preventDefault()
/** Using document.execCommand gives us undo support */
const insertSuccessful = document.execCommand('insertText', false, '\t')
if (!insertSuccessful) {
/** document.execCommand works great on Chrome/Safari but not Firefox */
const start = editor.selectionStart || 0
const end = editor.selectionEnd || 0
const spaces = ' '
/** Insert 4 spaces */
editor.value = editor.value.substring(0, start) + spaces + editor.value.substring(end)
/** Place cursor 4 spaces away from where the tab key was pressed */
editor.selectionStart = editor.selectionEnd = start + 4
}
setEditorText(editor.value)
void controller.saveAndAwaitLocalPropagation({
text: editor.value,
bypassDebouncer: true,
isUserModified: true,
})
},
})
const observer = new MutationObserver((records) => {
for (const record of records) {
record.removedNodes.forEach((node) => {
if (node === editor) {
tabObserverDisposer.current?.()
tabObserverDisposer.current = undefined
}
})
}
})
observer.observe(editor.parentElement as HTMLElement, { childList: true })
}
if (textareaUnloading) {
return null
}
return (
<textarea
autoComplete="off"
dir="auto"
id={ElementIds.NoteTextEditor}
onChange={onTextAreaChange}
onFocus={onContentFocus}
onBlur={onContentBlur}
readOnly={locked}
ref={(ref) => ref && onRef(ref)}
spellCheck={spellcheck}
value={editorText}
className={classNames(
'editable font-editor flex-grow',
lineHeight && `leading-${lineHeight.toLowerCase()}`,
fontSize && getPlaintextFontSize(fontSize),
)}
></textarea>
)
},
)
@@ -0,0 +1,26 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect } from 'react'
export type ChangeEditorFunction = (jsonContent: string) => void
type ChangeEditorFunctionProvider = (changeEditorFunction: ChangeEditorFunction) => void
export function ChangeContentCallbackPlugin({
providerCallback,
}: {
providerCallback: ChangeEditorFunctionProvider
}): JSX.Element | null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
const changeContents: ChangeEditorFunction = (jsonContent: string) => {
editor.update(() => {
const editorState = editor.parseEditorState(jsonContent)
editor.setEditorState(editorState)
})
}
providerCallback(changeContents)
}, [editor, providerCallback])
return null
}

Some files were not shown because too many files have changed in this diff Show More