feat: implement tags folder as experimental feature (#788)

* feat: add tag folders support basics

* feat: add draggability to tags

* feat: add drag & drop draft

* feat: fold folders

* fix: do not select on fold / unfold tags

* style: clean the isValidTag call

* feat: add native folder toggle

* feat: add touch mobile support

* ui: add nicer design & icons

* style: render full-width tag items

* feat: nicer looking dropzone

* style: fix arguments

* fix: tag template rendering in list items

* feat: tag can be dragged over the whole item

* fix: cancel / reset title after save

* fix: disable drag completely when needed

* fix: invalid tag parents

* feat: add paying feature

* feat: with paid feature tooltip

* feat: tag has a plus feature

* feat: add premium modal

* style: simplif code

* refactor: extract feature_state & simplif code

* fix: icons and icons svg

* style: remove comment

* feat: tag folders naming

* feat: use the feature notification

* fix: tag folders copy

* style: variable names

* style: remove & clean comments

* refactor: remove is-mobile library

* feat: tags folder experimental (#10)

* feat: hide native folders behind experimental flag

* fix: better tags resizing

* fix: merge global window

* style: rename params

* refactor: remove level of indirection in feature toggle

* feat: recursively add tags to note on create (#9)

* fix: use add tags folder hierarchy & isTemplateItem (#13)

* fix: use new snjs add tag hierarchy

* fix: use new snjs isTemplateItem

* feat: tags folder premium (#774)

* feat: upgrade premium in tags section

refactor: move TagsSection to react

feat: show premium on Tag section

feat: keep drag and drop features always active

fix: drag & drop tweak with premium

feat: premium messages

fix: remove fill in svg icons

fix: change tag list color (temporary)

style: remove dead code

refactor: clarify names and modules

fix: draggable behind feature toggle

feat: add button in TagSection & design

* feat: fix features loading with app state (#775)

* fix: distinguish between app launch and start

* fix: update state for footer too

* fix: wait for application launch event

Co-authored-by: Laurent Senta <laurent@singulargarden.com>

* feat: tags folder with folder text design (#776)

* feat: add folder text

* fix: sn stylekit colors

* fix: root drop zone

* chore: upgrade stylekit

* fix: hide dropzone when feature is disabled

* chore: bump versions now that they are released

Co-authored-by: Mo <me@bitar.io>

* feat: tags folder design review (#785)

* fix: upgrade design after review

* fix: tweak dropzone

* fix: sync after assign parent

* fix: tsc error on build

* fix: vertical center the fold arrows

* fix: define our own hoist for react-dnd

* feat: hide fold when there are no folders

* fix: show children usability + resize UI

* fix: use old colors for now, theme compat

* fix: tweak alignment and add title

* fix: meta offset with folders

* fix: tweak tag size

* fix: observable setup

* fix: use link-off icon on dropzone

* fix: more tweak on text sizes

Co-authored-by: Mo <me@bitar.io>
This commit is contained in:
Laurent Senta
2021-12-23 14:34:02 +01:00
committed by GitHub
parent 4a5a202a37
commit 237cd91acd
33 changed files with 1048 additions and 221 deletions

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5999 8.00002C13.5999 8.44185 13.2417 8.80002 12.7999 8.80002H8.7999V12.8C8.7999 13.2419 8.44173 13.6 7.9999 13.6C7.55807 13.6 7.1999 13.2419 7.1999 12.8V8.80002H3.1999C2.75807 8.80002 2.3999 8.44185 2.3999 8.00002C2.3999 7.5582 2.75807 7.20002 3.1999 7.20002H7.1999V3.20002C7.1999 2.7582 7.55807 2.40002 7.9999 2.40002C8.44173 2.40002 8.7999 2.7582 8.7999 3.20002V7.20002H12.7999C13.2417 7.20002 13.5999 7.5582 13.5999 8.00002Z" />
</svg>

After

Width:  |  Height:  |  Size: 548 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6668 14.9999H3.33342V6.66658H16.6668V14.9999ZM16.6668 4.99992H10.0001L8.33342 3.33325H3.33342C2.40841 3.33325 1.66675 4.07492 1.66675 4.99992V14.9999C1.66675 15.4419 1.84234 15.8659 2.1549 16.1784C2.46746 16.491 2.89139 16.6666 3.33342 16.6666H16.6668C17.1088 16.6666 17.5327 16.491 17.8453 16.1784C18.1578 15.8659 18.3334 15.4419 18.3334 14.9999V6.66658C18.3334 5.74159 17.5834 4.99992 16.6668 4.99992Z"/>
</svg>

After

Width:  |  Height:  |  Size: 500 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.1667 5.83333H10.8334V7.41667H14.1667C15.5917 7.41667 16.7501 8.575 16.7501 10C16.7501 11.1917 15.9334 12.1917 14.8251 12.5L16.0417 13.7C17.4001 13.0083 18.3334 11.625 18.3334 10C18.3334 8.89493 17.8944 7.83512 17.113 7.05372C16.3316 6.27232 15.2718 5.83333 14.1667 5.83333ZM13.3334 9.16667H11.5084L13.1751 10.8333H13.3334V9.16667ZM1.66675 3.55833L4.25841 6.15C2.74175 6.76667 1.66675 8.25833 1.66675 10C1.66675 11.1051 2.10573 12.1649 2.88714 12.9463C3.66854 13.7277 4.72835 14.1667 5.83342 14.1667H9.16675V12.5833H5.83342C4.40841 12.5833 3.25008 11.425 3.25008 10C3.25008 8.675 4.25841 7.58333 5.55008 7.44167L7.27508 9.16667H6.66675V10.8333H8.94175L10.8334 12.725V14.1667H12.2751L15.6167 17.5L16.6667 16.45L2.72508 2.5L1.66675 3.55833Z"/>
</svg>

After

Width:  |  Height:  |  Size: 834 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.83325 4.16667H17.4999V5.83333H5.83325V4.16667ZM5.83325 10.8333V9.16667H17.4999V10.8333H5.83325ZM3.33325 3.75C3.66477 3.75 3.98272 3.8817 4.21714 4.11612C4.45156 4.35054 4.58325 4.66848 4.58325 5C4.58325 5.33152 4.45156 5.64946 4.21714 5.88388C3.98272 6.1183 3.66477 6.25 3.33325 6.25C3.00173 6.25 2.68379 6.1183 2.44937 5.88388C2.21495 5.64946 2.08325 5.33152 2.08325 5C2.08325 4.66848 2.21495 4.35054 2.44937 4.11612C2.68379 3.8817 3.00173 3.75 3.33325 3.75ZM3.33325 8.75C3.66477 8.75 3.98272 8.8817 4.21714 9.11612C4.45156 9.35054 4.58325 9.66848 4.58325 10C4.58325 10.3315 4.45156 10.6495 4.21714 10.8839C3.98272 11.1183 3.66477 11.25 3.33325 11.25C3.00173 11.25 2.68379 11.1183 2.44937 10.8839C2.21495 10.6495 2.08325 10.3315 2.08325 10C2.08325 9.66848 2.21495 9.35054 2.44937 9.11612C2.68379 8.8817 3.00173 8.75 3.33325 8.75ZM5.83325 15.8333V14.1667H17.4999V15.8333H5.83325ZM3.33325 13.75C3.66477 13.75 3.98272 13.8817 4.21714 14.1161C4.45156 14.3505 4.58325 14.6685 4.58325 15C4.58325 15.3315 4.45156 15.6495 4.21714 15.8839C3.98272 16.1183 3.66477 16.25 3.33325 16.25C3.00173 16.25 2.68379 16.1183 2.44937 15.8839C2.21495 15.6495 2.08325 15.3315 2.08325 15C2.08325 14.6685 2.21495 14.3505 2.44937 14.1161C2.68379 13.8817 3.00173 13.75 3.33325 13.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.83325 8.33337L9.99992 12.5L14.1666 8.33337H5.83325Z"/>
</svg>

After

Width:  |  Height:  |  Size: 147 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.91675 14.5834L12.0834 10.4167L7.91675 6.25004L7.91675 14.5834Z"/>
</svg>

After

Width:  |  Height:  |  Size: 158 B

View File

@@ -10,6 +10,13 @@ declare global {
_plans_url?: string;
// eslint-disable-next-line camelcase
_dashboard_url?: string;
// eslint-disable-next-line camelcase
_default_sync_server: string;
// eslint-disable-next-line camelcase
_enable_unfinished_features: boolean;
// eslint-disable-next-line camelcase
_websocket_url: string;
startApplication?: StartApplication;
}
}
@@ -82,6 +89,7 @@ import { ComponentViewDirective } from '@/components/ComponentView';
import { TagsListDirective } from '@/components/TagsList';
import { NotesViewDirective } from './components/NotesView';
import { PinNoteButtonDirective } from '@/components/PinNoteButton';
import { TagsSectionDirective } from './components/Tags/TagsSection';
function reloadHiddenFirefoxTab(): boolean {
/**
@@ -182,7 +190,8 @@ const startApplication: StartApplication = async function startApplication(
.directive('notesListOptionsMenu', NotesListOptionsDirective)
.directive('icon', IconDirective)
.directive('noteTagsContainer', NoteTagsContainerDirective)
.directive('tags', TagsListDirective)
.directive('tagsList', TagsListDirective)
.directive('tagsSection', TagsSectionDirective)
.directive('preferences', PreferencesDirective)
.directive('purchaseFlow', PurchaseFlowDirective)
.directive('notesView', NotesViewDirective)
@@ -216,11 +225,11 @@ const startApplication: StartApplication = async function startApplication(
if (IsWebPlatform) {
startApplication(
(window as any)._default_sync_server as string,
window._default_sync_server,
new BrowserBridge(AppVersion),
(window as any)._enable_unfinished_features as boolean,
(window as any)._websocket_url as string
window._enable_unfinished_features,
window._websocket_url
);
} else {
(window as any).startApplication = startApplication;
window.startApplication = startApplication;
}

View File

@@ -24,8 +24,10 @@ import MarkdownIcon from '../../icons/ic-markdown.svg';
import CodeIcon from '../../icons/ic-code.svg';
import AccessibilityIcon from '../../icons/ic-accessibility.svg';
import AddIcon from '../../icons/ic-add.svg';
import HelpIcon from '../../icons/ic-help.svg';
import KeyboardIcon from '../../icons/ic-keyboard.svg';
import ListBulleted from '../../icons/ic-list-bulleted.svg';
import ListedIcon from '../../icons/ic-listed.svg';
import SecurityIcon from '../../icons/ic-security.svg';
import SettingsIcon from '../../icons/ic-settings.svg';
@@ -53,11 +55,17 @@ import LockIcon from '../../icons/ic-lock.svg';
import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg';
import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg';
import WindowIcon from '../../icons/ic-window.svg';
import LinkOffIcon from '../../icons/ic-link-off.svg';
import MenuArrowDownAlt from '../../icons/ic-menu-arrow-down-alt.svg';
import MenuArrowRight from '../../icons/ic-menu-arrow-right.svg';
import { toDirective } from './utils';
import { FunctionalComponent } from 'preact';
const ICONS = {
'menu-arrow-down-alt': MenuArrowDownAlt,
'menu-arrow-right': MenuArrowRight,
'arrows-sort-up': ArrowsSortUpIcon,
'arrows-sort-down': ArrowsSortDownIcon,
lock: LockIcon,
@@ -94,8 +102,11 @@ const ICONS = {
more: MoreIcon,
tune: TuneIcon,
accessibility: AccessibilityIcon,
add: AddIcon,
help: HelpIcon,
keyboard: KeyboardIcon,
'list-bulleted': ListBulleted,
'link-off': LinkOffIcon,
listed: ListedIcon,
security: SecurityIcon,
settings: SettingsIcon,
@@ -111,7 +122,7 @@ const ICONS = {
'menu-arrow-down': MenuArrowDownIcon,
'menu-close': MenuCloseIcon,
window: WindowIcon,
'premium-feature': PremiumFeatureIcon
'premium-feature': PremiumFeatureIcon,
};
export type IconType = keyof typeof ICONS;

View File

@@ -0,0 +1 @@
export { usePremiumModal, PremiumModalProvider } from './usePremiumModal';

View File

@@ -0,0 +1,49 @@
import { FunctionalComponent } from 'preact';
import { useCallback, useContext, useState } from 'preact/hooks';
import { createContext } from 'react';
import { PremiumFeaturesModal } from '../PremiumFeaturesModal';
type PremiumModalContextData = {
activate: (featureName: string) => void;
};
const PremiumModalContext = createContext<PremiumModalContextData | null>(null);
const PremiumModalProvider_ = PremiumModalContext.Provider;
export const usePremiumModal = (): PremiumModalContextData => {
const value = useContext(PremiumModalContext);
if (!value) {
throw new Error('invalid PremiumModal context');
}
return value;
};
export const PremiumModalProvider: FunctionalComponent = ({ children }) => {
const [featureName, setFeatureName] = useState<null | string>(null);
const activate = setFeatureName;
const closeModal = useCallback(() => {
setFeatureName(null);
}, [setFeatureName]);
const showModal = !!featureName;
return (
<>
{showModal && (
<PremiumFeaturesModal
showModal={!!featureName}
featureName={featureName}
onClose={closeModal}
/>
)}
<PremiumModalProvider_ value={{ activate }}>
{children}
</PremiumModalProvider_>
</>
);
};

View File

@@ -2,7 +2,7 @@ import { WebApplication } from '@/ui_models/application';
import { FeatureIdentifier } from '@standardnotes/features';
import { FeatureStatus } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { useCallback, useState } from 'preact/hooks';
import { JSXInternal } from 'preact/src/jsx';
import { Icon } from '../Icon';
import { PremiumFeaturesModal } from '../PremiumFeaturesModal';
@@ -10,46 +10,48 @@ import { Switch } from '../Switch';
type Props = {
application: WebApplication;
closeQuickSettingsMenu: () => void;
focusModeEnabled: boolean;
setFocusModeEnabled: (enabled: boolean) => void;
onToggle: (value: boolean) => void;
onClose: () => void;
isEnabled: boolean;
};
export const FocusModeSwitch: FunctionComponent<Props> = ({
application,
closeQuickSettingsMenu,
focusModeEnabled,
setFocusModeEnabled,
onToggle,
onClose,
isEnabled,
}) => {
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const isEntitledToFocusMode =
const isEntitled =
application.getFeatureStatus(FeatureIdentifier.FocusMode) ===
FeatureStatus.Entitled;
const toggleFocusMode = (
e: JSXInternal.TargetedMouseEvent<HTMLButtonElement>
) => {
e.preventDefault();
if (isEntitledToFocusMode) {
setFocusModeEnabled(!focusModeEnabled);
closeQuickSettingsMenu();
} else {
setShowUpgradeModal(true);
}
};
const toggle = useCallback(
(e: JSXInternal.TargetedMouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (isEntitled) {
onToggle(!isEnabled);
onClose();
} else {
setShowUpgradeModal(true);
}
},
[isEntitled, isEnabled, onToggle, setShowUpgradeModal, onClose]
);
return (
<>
<button
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
onClick={toggleFocusMode}
onClick={toggle}
>
<div className="flex items-center">
<Icon type="menu-close" className="color-neutral mr-2" />
Focused Writing
</div>
{isEntitledToFocusMode ? (
<Switch className="px-0" checked={focusModeEnabled} />
{isEntitled ? (
<Switch className="px-0" checked={isEnabled} />
) : (
<div title="Premium feature">
<Icon type="premium-feature" />

View File

@@ -6,10 +6,10 @@ import {
DisclosurePanel,
} from '@reach/disclosure';
import {
ContentType,
SNTheme,
ComponentArea,
ContentType,
SNComponent,
SNTheme,
} from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
@@ -301,9 +301,9 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
))}
<FocusModeSwitch
application={application}
closeQuickSettingsMenu={closeQuickSettingsMenu}
focusModeEnabled={focusModeEnabled}
setFocusModeEnabled={setFocusModeEnabled}
onToggle={setFocusModeEnabled}
onClose={closeQuickSettingsMenu}
isEnabled={focusModeEnabled}
/>
<div className="h-1px my-2 bg-border"></div>
<button

View File

@@ -0,0 +1,64 @@
import {
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
} from '@/ui_models/app_state/features_state';
import { TagsState } from '@/ui_models/app_state/tags_state';
import { observer } from 'mobx-react-lite';
import { useDrop } from 'react-dnd';
import { Icon } from './Icon';
import { usePremiumModal } from './Premium';
import { DropItem, DropProps, ItemTypes } from './TagsListItem';
type Props = {
tagsState: TagsState;
featuresState: FeaturesState;
};
export const RootTagDropZone: React.FC<Props> = observer(
({ tagsState, featuresState }) => {
const premiumModal = usePremiumModal();
const isNativeFoldersEnabled = featuresState.enableNativeFoldersFeature;
const hasFolders = tagsState.hasFolders;
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({
accept: ItemTypes.TAG,
canDrop: () => {
return true;
},
drop: (item) => {
if (!hasFolders) {
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME);
return;
}
tagsState.assignParent(item.uuid, undefined);
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
}),
[tagsState, hasFolders, premiumModal]
);
if (!isNativeFoldersEnabled || !hasFolders) {
return null;
}
return (
<div
ref={dropRef}
className={`root-drop ${canDrop ? 'active' : ''} ${
isOver ? 'is-over' : ''
}`}
>
<Icon className="color-neutral" type="link-off" />
<p className="content">
Move the tag here to <br />
remove it from its folder.
</p>
</div>
);
}
);

View File

@@ -1,12 +1,12 @@
import { ComponentChildren, FunctionalComponent } from 'preact';
import { useState } from 'preact/hooks';
import { HTMLProps } from 'react';
import {
CustomCheckboxContainer,
CustomCheckboxInput,
CustomCheckboxInputProps,
} from '@reach/checkbox';
import '@reach/checkbox/styles.css';
import { ComponentChildren, FunctionalComponent } from 'preact';
import { useState } from 'preact/hooks';
import { HTMLProps } from 'react';
export type SwitchProps = HTMLProps<HTMLInputElement> & {
checked?: boolean;
@@ -23,6 +23,10 @@ export const Switch: FunctionalComponent<SwitchProps> = (
const [checkedState, setChecked] = useState(props.checked || false);
const checked = props.checked ?? checkedState;
const className = props.className ?? '';
const isDisabled = !!props.disabled;
const isActive = checked && !isDisabled;
return (
<label
className={`sn-component flex justify-between items-center cursor-pointer px-3 ${className}`}
@@ -35,7 +39,8 @@ export const Switch: FunctionalComponent<SwitchProps> = (
setChecked(event.target.checked);
props.onChange?.(event.target.checked);
}}
className={`sn-switch ${checked ? 'bg-info' : 'bg-neutral'}`}
className={`sn-switch ${isActive ? 'bg-info' : 'bg-neutral'}`}
disabled={props.disabled}
>
<CustomCheckboxInput
{...({

View File

@@ -0,0 +1,108 @@
import { TagsList } from '@/components/TagsList';
import { toDirective } from '@/components/utils';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import {
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
TAG_FOLDERS_FEATURE_TOOLTIP,
} from '@/ui_models/app_state/features_state';
import { Tooltip } from '@reach/tooltip';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback } from 'preact/hooks';
import { IconButton } from '../IconButton';
import { PremiumModalProvider, usePremiumModal } from '../Premium';
type Props = {
application: WebApplication;
appState: AppState;
};
const TagAddButton: FunctionComponent<{
appState: AppState;
features: FeaturesState;
}> = observer(({ appState, features }) => {
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
if (!isNativeFoldersEnabled) {
return null;
}
return (
<IconButton
icon="add"
title="Create a new tag"
focusable={true}
onClick={() => appState.createNewTag()}
/>
);
});
const TagTitle: FunctionComponent<{
features: FeaturesState;
}> = observer(({ features }) => {
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
const hasFolders = features.hasFolders;
const modal = usePremiumModal();
const showPremiumAlert = useCallback(() => {
modal.activate(TAG_FOLDERS_FEATURE_NAME);
}, [modal]);
if (!isNativeFoldersEnabled) {
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Tags</span>
</div>
</>
);
}
if (hasFolders) {
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Folders</span>
</div>
</>
);
}
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Tags</span>
<Tooltip label={TAG_FOLDERS_FEATURE_TOOLTIP}>
<label
className="ml-1 sk-bold color-grey-2 cursor-pointer"
onClick={showPremiumAlert}
>
Folders
</label>
</Tooltip>
</div>
</>
);
});
export const TagsSection: FunctionComponent<Props> = observer(
({ application, appState }) => {
return (
<PremiumModalProvider>
<section>
<div className="tags-title-section section-title-bar">
<div className="section-title-bar-header">
<TagTitle features={appState.features} />
<TagAddButton appState={appState} features={appState.features} />
</div>
</div>
<TagsList application={application} appState={appState} />
</section>
</PremiumModalProvider>
);
}
);
export const TagsSectionDirective = toDirective<Props>(TagsSection);

View File

@@ -1,12 +1,18 @@
import { PremiumModalProvider } from '@/components/Premium';
import { confirmDialog } from '@/services/alertService';
import { STRING_DELETE_TAG } from '@/strings';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { isMobile } from '@/utils';
import { SNTag, TagMutator } from '@standardnotes/snjs';
import { runInAction } from 'mobx';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback } from 'preact/hooks';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { TouchBackend } from 'react-dnd-touch-backend';
import { RootTagDropZone } from './RootTagDropZone';
import { TagsListItem } from './TagsListItem';
import { toDirective } from './utils';
@@ -28,8 +34,9 @@ const tagsWithOptionalTemplate = (
export const TagsList: FunctionComponent<Props> = observer(
({ application, appState }) => {
const templateTag = appState.templateTag;
const tags = appState.tags.tags;
const allTags = tagsWithOptionalTemplate(templateTag, tags);
const rootTags = appState.tags.rootTags;
const allTags = tagsWithOptionalTemplate(templateTag, rootTags);
const selectTag = useCallback(
(tag: SNTag) => {
@@ -106,29 +113,39 @@ export const TagsList: FunctionComponent<Props> = observer(
[appState]
);
const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend;
return (
<>
{allTags.length === 0 ? (
<div className="no-tags-placeholder">
No tags. Create one using the add button above.
</div>
) : (
<>
{allTags.map((tag) => {
return (
<TagsListItem
key={tag.uuid}
tag={tag}
selectTag={selectTag}
saveTag={saveTag}
removeTag={removeTag}
appState={appState}
/>
);
})}
</>
)}
</>
<PremiumModalProvider>
<DndProvider backend={backend}>
{allTags.length === 0 ? (
<div className="no-tags-placeholder">
No tags. Create one using the add button above.
</div>
) : (
<>
{allTags.map((tag) => {
return (
<TagsListItem
level={0}
key={tag.uuid}
tag={tag}
tagsState={appState.tags}
selectTag={selectTag}
saveTag={saveTag}
removeTag={removeTag}
appState={appState}
/>
);
})}
<RootTagDropZone
tagsState={appState.tags}
featuresState={appState.features}
/>
</>
)}
</DndProvider>
</PremiumModalProvider>
);
}
);

View File

@@ -1,26 +1,47 @@
import {
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
} from '@/ui_models/app_state/features_state';
import { TagsState } from '@/ui_models/app_state/tags_state';
import '@reach/tooltip/styles.css';
import { SNTag } from '@standardnotes/snjs';
import { computed, runInAction } from 'mobx';
import { observer } from 'mobx-react-lite';
import { FunctionComponent, JSX } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useDrag, useDrop } from 'react-dnd';
import { Icon } from './Icon';
import { usePremiumModal } from './Premium';
export enum ItemTypes {
TAG = 'TAG',
}
export type DropItemTag = { uuid: string };
export type DropItem = DropItemTag;
export type DropProps = { isOver: boolean; canDrop: boolean };
type Props = {
tag: SNTag;
tagsState: TagsState;
selectTag: (tag: SNTag) => void;
removeTag: (tag: SNTag) => void;
saveTag: (tag: SNTag, newTitle: string) => void;
appState: TagsListState;
level: number;
};
export type TagsListState = {
readonly selectedTag: SNTag | undefined;
tags: TagsState;
editingTag: SNTag | undefined;
features: FeaturesState;
};
export const TagsListItem: FunctionComponent<Props> = observer(
({ tag, selectTag, saveTag, removeTag, appState }) => {
({ tag, selectTag, saveTag, removeTag, appState, tagsState, level }) => {
const [title, setTitle] = useState(tag.title || '');
const inputRef = useRef<HTMLInputElement>(null);
@@ -28,10 +49,36 @@ export const TagsListItem: FunctionComponent<Props> = observer(
const isEditing = appState.editingTag === tag;
const noteCounts = computed(() => appState.tags.getNotesCount(tag));
const childrenTags = computed(() => tagsState.getChildren(tag)).get();
const hasChildren = childrenTags.length > 0;
const hasFolders = tagsState.hasFolders;
const isNativeFoldersEnabled = appState.features.enableNativeFoldersFeature;
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder;
const premiumModal = usePremiumModal();
const [showChildren, setShowChildren] = useState(hasChildren);
const [hadChildren, setHadChildren] = useState(hasChildren);
useEffect(() => {
if (!hadChildren && hasChildren) {
setShowChildren(true);
}
setHadChildren(hasChildren);
}, [hadChildren, hasChildren]);
useEffect(() => {
setTitle(tag.title || '');
}, [setTitle, tag]);
const toggleChildren = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
setShowChildren((x) => !x);
},
[setShowChildren]
);
const selectCurrentTag = useCallback(() => {
if (isEditing || isSelected) {
return;
@@ -41,7 +88,8 @@ export const TagsListItem: FunctionComponent<Props> = observer(
const onBlur = useCallback(() => {
saveTag(tag, title);
}, [tag, saveTag, title]);
setTitle(tag.title);
}, [tag, saveTag, title, setTitle]);
const onInput = useCallback(
(e: JSX.TargetedEvent<HTMLInputElement>) => {
@@ -81,56 +129,142 @@ export const TagsListItem: FunctionComponent<Props> = observer(
removeTag(tag);
}, [removeTag, tag]);
const [, dragRef] = useDrag(
() => ({
type: ItemTypes.TAG,
item: { uuid: tag.uuid },
canDrag: () => {
return isNativeFoldersEnabled;
},
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
}),
[tag, hasFolders]
);
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({
accept: ItemTypes.TAG,
canDrop: (item) => {
return tagsState.isValidTagParent(tag.uuid, item.uuid);
},
drop: (item) => {
if (!hasFolders) {
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME);
return;
}
tagsState.assignParent(item.uuid, tag.uuid);
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
}),
[tag, tagsState, hasFolders, premiumModal]
);
const readyToDrop = isOver && canDrop;
return (
<div
className={`tag ${isSelected ? 'selected' : ''}`}
onClick={selectCurrentTag}
>
{!tag.errorDecrypting ? (
<div className="tag-info">
<div className="tag-icon">#</div>
<input
className={`title ${isEditing ? 'editing' : ''}`}
id={`react-tag-${tag.uuid}`}
onBlur={onBlur}
onInput={onInput}
value={title}
onKeyUp={onKeyUp}
spellCheck={false}
ref={inputRef}
/>
<div className="count">{noteCounts.get()}</div>
</div>
) : null}
{tag.conflictOf && (
<div className="danger small-text font-bold">
Conflicted Copy {tag.conflictOf}
</div>
)}
{tag.errorDecrypting && !tag.waitingForKey && (
<div className="danger small-text font-bold">Missing Keys</div>
)}
{tag.errorDecrypting && tag.waitingForKey && (
<div className="info small-text font-bold">Waiting For Keys</div>
)}
{isSelected && (
<div className="menu">
{!isEditing && (
<a className="item" onClick={onClickRename}>
Rename
</a>
<>
<div
className={`tag ${isSelected ? 'selected' : ''} ${
readyToDrop ? 'is-drag-over' : ''
}`}
onClick={selectCurrentTag}
ref={dragRef}
style={{ paddingLeft: `${level * 21 + 10}px` }}
>
{!tag.errorDecrypting ? (
<div className="tag-info" title={title} ref={dropRef}>
{hasFolders && isNativeFoldersEnabled && hasAtLeastOneFolder && (
<div
className={`tag-fold ${showChildren ? 'opened' : 'closed'}`}
onClick={hasChildren ? toggleChildren : undefined}
>
<Icon
className={`color-neutral ${!hasChildren ? 'hidden' : ''}`}
type={
showChildren ? 'menu-arrow-down-alt' : 'menu-arrow-right'
}
/>
</div>
)}
<div
className={`tag-icon ${
isNativeFoldersEnabled ? 'draggable' : ''
} mr-1`}
ref={dragRef}
>
<Icon
type="hashtag"
className={`${isSelected ? 'color-info' : 'color-neutral'}`}
/>
</div>
<input
className={`title ${isEditing ? 'editing' : ''}`}
id={`react-tag-${tag.uuid}`}
onBlur={onBlur}
onInput={onInput}
value={title}
onKeyUp={onKeyUp}
spellCheck={false}
ref={inputRef}
/>
<div className="count">{noteCounts.get()}</div>
</div>
) : null}
<div className={`meta ${hasAtLeastOneFolder ? 'with-folders' : ''}`}>
{tag.conflictOf && (
<div className="danger small-text font-bold">
Conflicted Copy {tag.conflictOf}
</div>
)}
{isEditing && (
<a className="item" onClick={onClickSave}>
Save
</a>
{tag.errorDecrypting && !tag.waitingForKey && (
<div className="danger small-text font-bold">Missing Keys</div>
)}
{tag.errorDecrypting && tag.waitingForKey && (
<div className="info small-text font-bold">Waiting For Keys</div>
)}
{isSelected && (
<div className="menu">
{!isEditing && (
<a className="item" onClick={onClickRename}>
Rename
</a>
)}
{isEditing && (
<a className="item" onClick={onClickSave}>
Save
</a>
)}
<a className="item" onClick={onClickDelete}>
Delete
</a>
</div>
)}
<a className="item" onClick={onClickDelete}>
Delete
</a>
</div>
</div>
{showChildren && (
<>
{childrenTags.map((tag) => {
return (
<TagsListItem
level={level + 1}
key={tag.uuid}
tag={tag}
tagsState={tagsState}
selectTag={selectTag}
saveTag={saveTag}
removeTag={removeTag}
appState={appState}
/>
);
})}
</>
)}
</div>
</>
);
}
);

View File

@@ -34,7 +34,7 @@ export const PurchaseFlowView: FunctionComponent<PurchaseFlowViewProps> =
const { currentPane } = appState.purchaseFlow;
return (
<div className="flex items-center justify-center overflow-hidden h-full w-full absolute top-left-0 z-index-purchase-flow bg-grey-2">
<div className="flex items-center justify-center overflow-hidden h-full w-full absolute top-left-0 z-index-purchase-flow bg-grey-super-light">
<div className="relative fit-content">
<div className="relative p-12 xs:px-8 mb-4 bg-default border-1 border-solid border-main rounded xs:rounded-0">
<SNLogoFull className="mb-5" />

View File

@@ -0,0 +1,67 @@
/* eslint-disable @typescript-eslint/ban-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
// Type definitions for hoist-non-react-statics 3.3
// Project: https://github.com/mridgway/hoist-non-react-statics#readme
// Definitions by: JounQin <https://github.com/JounQin>, James Reggio <https://github.com/jamesreggio>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
// TypeScript Version: 2.8
// https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/hoist-non-react-statics
declare module 'hoist-non-react-statics' {
interface REACT_STATICS {
childContextTypes: true;
contextType: true;
contextTypes: true;
defaultProps: true;
displayName: true;
getDefaultProps: true;
getDerivedStateFromError: true;
getDerivedStateFromProps: true;
mixins: true;
propTypes: true;
type: true;
}
interface KNOWN_STATICS {
name: true;
length: true;
prototype: true;
caller: true;
callee: true;
arguments: true;
arity: true;
}
interface MEMO_STATICS {
$$typeof: true;
compare: true;
defaultProps: true;
displayName: true;
propTypes: true;
type: true;
}
interface FORWARD_REF_STATICS {
$$typeof: true;
render: true;
defaultProps: true;
displayName: true;
propTypes: true;
}
export type NonReactStatics<
S extends React.ComponentType<any>,
C extends {
[key: string]: true;
} = {}
> = {
[key in Exclude<
keyof S,
S extends React.MemoExoticComponent<any>
? keyof MEMO_STATICS | keyof C
: S extends React.ForwardRefExoticComponent<any>
? keyof FORWARD_REF_STATICS | keyof C
: keyof REACT_STATICS | keyof KNOWN_STATICS | keyof C
>]: S[key];
};
}

View File

@@ -22,6 +22,7 @@ import {
runInAction,
} from 'mobx';
import { ActionsMenuState } from './actions_menu_state';
import { FeaturesState } from './features_state';
import { NotesState } from './notes_state';
import { NotesViewState } from './notes_view_state';
import { NoteTagsState } from './note_tags_state';
@@ -57,8 +58,8 @@ export enum EventSource {
type ObserverCallback = (event: AppStateEvent, data?: any) => Promise<void>;
export class AppState {
readonly enableUnfinishedFeatures: boolean = (window as any)
?._enable_unfinished_features;
readonly enableUnfinishedFeatures: boolean =
window?._enable_unfinished_features;
$rootScope: ng.IRootScopeService;
$timeout: ng.ITimeoutService;
@@ -86,6 +87,7 @@ export class AppState {
readonly sync = new SyncState();
readonly searchOptions: SearchOptionsState;
readonly notes: NotesState;
readonly features: FeaturesState;
readonly tags: TagsState;
readonly notesView: NotesViewState;
isSessionsModalVisible = false;
@@ -115,7 +117,12 @@ export class AppState {
this,
this.appEventObserverRemovers
);
this.tags = new TagsState(application, this.appEventObserverRemovers);
this.features = new FeaturesState(application);
this.tags = new TagsState(
application,
this.appEventObserverRemovers,
this.features
);
this.noAccountWarning = new NoAccountWarningState(
application,
this.appEventObserverRemovers
@@ -187,6 +194,7 @@ export class AppState {
this.unsubApp = undefined;
this.observers.length = 0;
this.appEventObserverRemovers.forEach((remover) => remover());
this.features.deinit();
this.appEventObserverRemovers.length = 0;
if (this.rootScopeCleanup1) {
this.rootScopeCleanup1();
@@ -430,6 +438,10 @@ export class AppState {
}
public async createNewTag() {
if (this.templateTag) {
return;
}
const newTag = (await this.application.createTemplateItem(
ContentType.Tag
)) as SNTag;

View File

@@ -0,0 +1,87 @@
import {
ApplicationEvent,
FeatureIdentifier,
FeatureStatus,
} from '@standardnotes/snjs';
import { computed, makeObservable, observable, runInAction } from 'mobx';
import { WebApplication } from '../application';
export const TAG_FOLDERS_FEATURE_NAME = 'Tag folders';
export const TAG_FOLDERS_FEATURE_TOOLTIP =
'A Plus or Pro plan is required to enable Tag folders.';
/**
* Holds state for premium/non premium features for the current user features,
* and eventually for in-development features (feature flags).
*/
export class FeaturesState {
readonly enableUnfinishedFeatures: boolean =
window?._enable_unfinished_features;
_hasFolders = false;
private unsub: () => void;
constructor(private application: WebApplication) {
this._hasFolders = this.hasNativeFolders();
makeObservable(this, {
_hasFolders: observable,
hasFolders: computed,
enableNativeFoldersFeature: computed,
});
this.unsub = this.application.addEventObserver(async (eventName) => {
switch (eventName) {
case ApplicationEvent.FeaturesUpdated:
case ApplicationEvent.Launched:
runInAction(() => {
this._hasFolders = this.hasNativeFolders();
});
break;
default:
break;
}
});
}
public deinit() {
this.unsub();
}
public get enableNativeFoldersFeature(): boolean {
return this.enableUnfinishedFeatures;
}
public get hasFolders(): boolean {
return this._hasFolders;
}
public set hasFolders(hasFolders: boolean) {
if (!hasFolders) {
this._hasFolders = false;
return;
}
if (!this.hasNativeFolders()) {
this.application.alertService?.alert(
`${TAG_FOLDERS_FEATURE_NAME} requires at least a Plus Subscription.`
);
this._hasFolders = false;
return;
}
this._hasFolders = hasFolders;
}
private hasNativeFolders(): boolean {
if (!this.enableNativeFoldersFeature) {
return false;
}
const status = this.application.getFeatureStatus(
FeatureIdentifier.TagNesting
);
return status === FeatureStatus.Entitled;
}
}

View File

@@ -176,16 +176,9 @@ export class NoteTagsState {
async addTagToActiveNote(tag: SNTag): Promise<void> {
const { activeNote } = this;
if (activeNote) {
const parentChainTags = this.application.getTagParentChain(tag);
const tagsToAdd = [...parentChainTags, tag];
await Promise.all(
tagsToAdd.map(async (tag) => {
await this.application.changeItem(tag.uuid, (mutator) => {
mutator.addItemAsRelationship(activeNote);
});
})
);
await this.application.addTagHierarchyToNote(activeNote, tag);
this.application.sync();
this.reloadTags();
}

View File

@@ -1,13 +1,15 @@
import { ContentType, SNSmartTag, SNTag } from '@standardnotes/snjs';
import { ContentType, SNSmartTag, SNTag, UuidString } from '@standardnotes/snjs';
import {
action,
computed,
makeAutoObservable,
makeObservable,
observable,
runInAction,
runInAction
} from 'mobx';
import { WebApplication } from '../application';
import { FeaturesState } from './features_state';
export class TagsState {
tags: SNTag[] = [];
@@ -16,14 +18,20 @@ export class TagsState {
constructor(
private application: WebApplication,
appEventListeners: (() => void)[]
appEventListeners: (() => void)[],
private features: FeaturesState
) {
this.tagsCountsState = new TagsCountsState(this.application);
makeObservable(this, {
tags: observable,
smartTags: observable,
tags: observable.ref,
smartTags: observable.ref,
hasFolders: computed,
hasAtLeastOneFolder: computed,
assignParent: action,
rootTags: computed,
tagsCount: computed,
});
@@ -48,9 +56,68 @@ export class TagsState {
return this.tagsCountsState.counts[tag.uuid] || 0;
}
getChildren(tag: SNTag): SNTag[] {
if (!this.hasFolders) {
return [];
}
if (this.application.isTemplateItem(tag)) {
return [];
}
const children = this.application.getTagChildren(tag);
const childrenUuids = children.map((childTag) => childTag.uuid);
const childrenTags = this.tags.filter((tag) =>
childrenUuids.includes(tag.uuid)
);
return childrenTags;
}
isValidTagParent(parentUuid: UuidString, tagUuid: UuidString): boolean {
return this.application.isValidTagParent(parentUuid, tagUuid);
}
public async assignParent(
tagUuid: string,
parentUuid: string | undefined
): Promise<void> {
const tag = this.application.findItem(tagUuid) as SNTag;
const parent =
parentUuid && (this.application.findItem(parentUuid) as SNTag);
if (!parent) {
await this.application.unsetTagParent(tag);
} else {
await this.application.setTagParent(parent, tag);
}
await this.application.sync();
}
get rootTags(): SNTag[] {
if (!this.hasFolders) {
return this.tags;
}
return this.tags.filter((tag) => !this.application.getTagParent(tag));
}
get tagsCount(): number {
return this.tags.length;
}
public get hasFolders(): boolean {
return this.features.hasFolders;
}
public set hasFolders(hasFolders: boolean) {
this.features.hasFolders = hasFolders;
}
public get hasAtLeastOneFolder(): boolean {
return this.tags.some((tag) => !!this.application.getTagParent(tag));
}
}
/**

View File

@@ -1,14 +1,21 @@
import { SNNote, ContentType, PayloadSource, UuidString, TagMutator } from '@standardnotes/snjs';
import {
SNNote,
ContentType,
PayloadSource,
UuidString,
TagMutator,
SNTag,
} from '@standardnotes/snjs';
import { WebApplication } from './application';
import { NoteTagsState } from './app_state/note_tags_state';
export class Editor {
public note!: SNNote
private application: WebApplication
private _onNoteChange?: () => void
private _onNoteValueChange?: (note: SNNote, source?: PayloadSource) => void
private removeStreamObserver?: () => void
public isTemplateNote = false
public note!: SNNote;
private application: WebApplication;
private _onNoteChange?: () => void;
private _onNoteValueChange?: (note: SNNote, source?: PayloadSource) => void;
private removeStreamObserver?: () => void;
public isTemplateNote = false;
constructor(
application: WebApplication,
@@ -66,22 +73,15 @@ export class Editor {
* Reverts the editor to a blank state, removing any existing note from view,
* and creating a placeholder note.
*/
async reset(
noteTitle = '',
noteTag?: UuidString,
) {
const note = await this.application.createTemplateItem(
ContentType.Note,
{
text: '',
title: noteTitle,
references: []
}
) as SNNote;
async reset(noteTitle = '', noteTag?: UuidString) {
const note = (await this.application.createTemplateItem(ContentType.Note, {
text: '',
title: noteTitle,
references: [],
})) as SNNote;
if (noteTag) {
await this.application.changeItem<TagMutator>(noteTag, (m) => {
m.addItemAsRelationship(note);
});
const tag = this.application.findItem(noteTag) as SNTag;
await this.application.addTagHierarchyToNote(note, tag);
}
if (!this.isTemplateNote || this.note.title !== note.title) {
this.setNote(note as SNNote, true);
@@ -106,7 +106,9 @@ export class Editor {
* Register to be notified when the editor's note's values change
* (and thus a new object reference is created)
*/
public onNoteValueChange(callback: (note: SNNote, source?: PayloadSource) => void) {
public onNoteValueChange(
callback: (note: SNNote, source?: PayloadSource) => void
) {
this._onNoteValueChange = callback;
}

View File

@@ -1,6 +1,7 @@
import { Platform, platformFromString } from '@standardnotes/snjs';
import { IsDesktopPlatform, IsWebPlatform } from '@/version';
import { EMAIL_REGEX } from '@Views/constants';
export { isMobile } from './isMobile';
declare const process: {
env: {

View File

@@ -0,0 +1,28 @@
/**
* source: https://github.com/juliangruber/is-mobile
*
* (MIT)
* Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const mobileRE =
/(android|bb\d+|meego).+mobile|armv7l|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i;
const tabletRE = /android|ipad|playbook|silk/i;
export type Opts = {
tablet?: boolean;
};
export const isMobile = (opts: Opts = {}) => {
const ua = navigator.userAgent || navigator.vendor;
if (typeof ua !== 'string') {
return false;
}
return mobileRE.test(ua) || (!!opts.tablet && tabletRE.test(ua));
};

View File

@@ -3,7 +3,7 @@
)
#app.app(
ng-class='self.state.appClass',
ng-if='!self.state.needsUnlock && self.state.ready'
ng-if='!self.state.needsUnlock && self.state.launched'
)
tags-view(application='self.application')
notes-view(
@@ -13,7 +13,7 @@
editor-group-view.flex-grow(application='self.application')
footer-view(
ng-if='!self.state.needsUnlock && self.state.ready'
ng-if='!self.state.needsUnlock && self.state.launched'
application='self.application'
)

View File

@@ -3,25 +3,29 @@ import { WebDirective } from '@/types';
import { getPlatformString } from '@/utils';
import template from './application-view.pug';
import { AppStateEvent, PanelResizedData } from '@/ui_models/app_state';
import { ApplicationEvent, Challenge, removeFromArray } from '@standardnotes/snjs';
import {
PANEL_NAME_NOTES,
PANEL_NAME_TAGS
} from '@/views/constants';
import {
STRING_DEFAULT_FILE_ERROR
} from '@/strings';
ApplicationEvent,
Challenge,
removeFromArray,
} from '@standardnotes/snjs';
import { PANEL_NAME_NOTES, PANEL_NAME_TAGS } from '@/views/constants';
import { STRING_DEFAULT_FILE_ERROR } from '@/strings';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { alertDialog } from '@/services/alertService';
class ApplicationViewCtrl extends PureViewCtrl<unknown, {
ready?: boolean,
needsUnlock?: boolean,
appClass: string,
}> {
class ApplicationViewCtrl extends PureViewCtrl<
unknown,
{
started?: boolean;
launched?: boolean;
needsUnlock?: boolean;
appClass: string;
}
> {
public platformString: string;
private notesCollapsed = false;
private tagsCollapsed = false;
/**
* To prevent stale state reads (setState is async),
* challenges is a mutable array
@@ -76,7 +80,7 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
this.$timeout(() => {
this.challenges.push(challenge);
});
}
},
});
await this.application.launch();
}
@@ -90,14 +94,17 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
async onAppStart() {
super.onAppStart();
this.setState({
ready: true,
needsUnlock: this.application.hasPasscode()
started: true,
needsUnlock: this.application.hasPasscode(),
});
}
async onAppLaunch() {
super.onAppLaunch();
this.setState({ needsUnlock: false });
this.setState({
launched: true,
needsUnlock: false,
});
this.handleDemoSignInFromParams();
}
@@ -108,17 +115,14 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
/** @override */
async onAppEvent(eventName: ApplicationEvent) {
super.onAppEvent(eventName);
switch (eventName) {
case ApplicationEvent.LocalDatabaseReadError:
alertDialog({
text: 'Unable to load local database. Please restart the app and try again.'
});
break;
case ApplicationEvent.LocalDatabaseWriteError:
alertDialog({
text: 'Unable to write to local database. Please restart the app and try again.'
});
break;
if (eventName === ApplicationEvent.LocalDatabaseReadError) {
alertDialog({
text: 'Unable to load local database. Please restart the app and try again.',
});
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
alertDialog({
text: 'Unable to write to local database. Please restart the app and try again.',
});
}
}
@@ -132,9 +136,13 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
if (panel === PANEL_NAME_TAGS) {
this.tagsCollapsed = collapsed;
}
let appClass = "";
if (this.notesCollapsed) { appClass += "collapsed-notes"; }
if (this.tagsCollapsed) { appClass += " collapsed-tags"; }
let appClass = '';
if (this.notesCollapsed) {
appClass += 'collapsed-notes';
}
if (this.tagsCollapsed) {
appClass += ' collapsed-tags';
}
this.setState({ appClass });
} else if (eventName === AppStateEvent.WindowDidFocus) {
if (!(await this.application.isLocked())) {
@@ -163,7 +171,7 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
if (event.dataTransfer?.files.length) {
event.preventDefault();
void alertDialog({
text: STRING_DEFAULT_FILE_ERROR
text: STRING_DEFAULT_FILE_ERROR,
});
}
}
@@ -171,15 +179,12 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
async handleDemoSignInFromParams() {
if (
this.$location.search().demo === 'true' &&
!this.application.hasAccount()
!this.application.hasAccount()
) {
await this.application.setCustomHost(
'https://syncing-server-demo.standardnotes.com'
);
this.application.signIn(
'demo@standardnotes.org',
'password',
);
this.application.signIn('demo@standardnotes.org', 'password');
}
}
}
@@ -193,7 +198,7 @@ export class ApplicationView extends WebDirective {
this.controllerAs = 'self';
this.bindToController = true;
this.scope = {
application: '='
application: '=',
};
}
}

View File

@@ -29,11 +29,7 @@
.danger.small-text.font-bold(ng-show='tag.conflictOf') Conflicted Copy
.danger.small-text.font-bold(ng-show='tag.errorDecrypting && !tag.waitingForKey') Missing Keys
.info.small-text.font-bold(ng-show='tag.errorDecrypting && tag.waitingForKey') Waiting For Keys
.tags-title-section.section-title-bar
.section-title-bar-header
.sk-h3.title
span.sk-bold Tags
tags(
tags-section(
application='self.application',
app-state='self.appState'
)

View File

@@ -1,11 +1,10 @@
/* Components and utilities that are good candidates for extraction to StyleKit. */
:root {
--sn-stylekit-grey-2: #f8f9fc;
}
.bg-grey-2 {
background-color: var(--sn-stylekit-grey-2);
.bg-grey-super-light {
background-color: var(--sn-stylekit-grey-super-light);
}
.h-90vh {

View File

@@ -51,7 +51,35 @@
margin-top: -5px;
}
.root-drop {
width: '100%';
padding: 12px;
opacity: 0;
transition: opacity 0.3s ease-in;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.sn-icon {
margin-right: 0.5rem;
}
&.active {
opacity: 1;
&.is-over {
background-color: var(--sn-stylekit-white);
color: var(--sn-stylekit-secondary-contrast-foreground-color);
}
}
}
.tag {
font-size: 14px;
line-height: 18px;
min-height: 30px;
padding: 5px 12px;
cursor: pointer;
@@ -65,17 +93,42 @@
align-items: center;
justify-content: space-between;
.sn-icon {
display: block;
margin: 0 auto;
&.hidden {
visibility: hidden;
}
}
> .tag-fold {
width: 22px;
display: flex;
align-items: center;
height: 100%;
}
> .tag-icon {
width: 10px;
opacity: 0.2;
font-size: var(--sn-stylekit-font-size-h2);
font-weight: bold;
margin-right: 6px;
display: flex;
align-items: center;
height: 100%;
&.draggable {
cursor: move;
}
&.propose-folders {
cursor: help;
}
}
> .title {
@extend .focus\:outline-none;
@extend .focus\:shadow-none;
font-size: 14px;
line-height: 18px;
width: 80%;
background-color: transparent;
font-weight: 600;
@@ -84,6 +137,7 @@
cursor: pointer;
text-overflow: ellipsis;
width: 75%;
flex-grow: 1;
// Required for Safari to avoid highlighting when dragging panel resizers
// Make sure to undo if it's selected (for editing)
@@ -118,21 +172,29 @@
}
}
> .menu {
font-size: 11px;
.meta {
padding-left: 3px;
> .item {
margin-right: 2px;
&.with-folders {
padding-left: 25px;
}
opacity: 0.5;
font-weight: bold;
clear: both;
margin-top: 2px;
margin-bottom: 2px;
> .menu {
font-size: 11px;
&:hover {
opacity: 1;
> .item {
margin-right: 2px;
}
opacity: 0.5;
font-weight: bold;
clear: both;
margin-top: 2px;
margin-bottom: 2px;
&:hover {
opacity: 1;
}
}
}
@@ -145,7 +207,8 @@
}
&:hover:not(.selected),
&.selected {
&.selected,
&.is-drag-over {
background-color: var(--sn-stylekit-secondary-contrast-background-color);
color: var(--sn-stylekit-secondary-contrast-foreground-color);

View File

@@ -66,7 +66,7 @@
"pug-loader": "^2.4.0",
"sass-loader": "^12.2.0",
"serve-static": "^1.14.1",
"sn-stylekit": "5.2.17",
"sn-stylekit": "5.2.20",
"svg-jest": "^1.0.1",
"ts-jest": "^27.0.7",
"ts-loader": "^9.2.6",
@@ -85,12 +85,16 @@
"@reach/dialog": "^0.16.2",
"@reach/listbox": "^0.16.2",
"@standardnotes/features": "1.10.2",
"@reach/tooltip": "^0.16.2",
"@standardnotes/sncrypto-web": "1.5.3",
"@standardnotes/snjs": "2.25.0",
"mobx": "^6.3.5",
"mobx-react-lite": "^3.2.2",
"preact": "^10.5.15",
"qrcode.react": "^1.0.1"
"qrcode.react": "^1.0.1",
"react-dnd": "^14.0.4",
"react-dnd-html5-backend": "^14.0.2",
"react-dnd-touch-backend": "^14.1.1"
},
"lint-staged": {
"*.{js,ts,jsx,tsx}": "eslint --cache --fix",

View File

@@ -1872,6 +1872,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.9.2":
version "7.16.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.10.4", "@babel/template@^7.12.7":
version "7.12.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc"
@@ -2523,6 +2530,20 @@
tiny-warning "^1.0.3"
tslib "^2.3.0"
"@reach/tooltip@^0.16.2":
version "0.16.2"
resolved "https://registry.yarnpkg.com/@reach/tooltip/-/tooltip-0.16.2.tgz#8448cee341476e4f795fa7192f7a0864f06b8085"
integrity sha512-wtJPnbJ6l4pmudMpQHGU9v1NS4ncDgcwRNi9re9KsIdsM525zccZvHQLteBKYiaW4ib7k09t2dbwhyNU9oa0Iw==
dependencies:
"@reach/auto-id" "0.16.0"
"@reach/portal" "0.16.2"
"@reach/rect" "0.16.0"
"@reach/utils" "0.16.0"
"@reach/visually-hidden" "0.16.0"
prop-types "^15.7.2"
tiny-warning "^1.0.3"
tslib "^2.3.0"
"@reach/utils@0.15.2":
version "0.15.2"
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.15.2.tgz#a63a761eb36266bf33d65cb91520dd85a04ae116"
@@ -2547,6 +2568,21 @@
prop-types "^15.7.2"
tslib "^2.3.0"
"@react-dnd/asap@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.0.tgz#b300eeed83e9801f51bd66b0337c9a6f04548651"
integrity sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ==
"@react-dnd/invariant@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-2.0.0.tgz#09d2e81cd39e0e767d7da62df9325860f24e517e"
integrity sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==
"@react-dnd/shallowequal@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz#a3031eb54129f2c66b2753f8404266ec7bf67f0a"
integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==
"@sinonjs/commons@^1.7.0":
version "1.8.3"
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
@@ -4641,6 +4677,15 @@ dir-glob@^3.0.1:
dependencies:
path-type "^4.0.0"
dnd-core@14.0.1:
version "14.0.1"
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-14.0.1.tgz#76d000e41c494983210fb20a48b835f81a203c2e"
integrity sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==
dependencies:
"@react-dnd/asap" "^4.0.0"
"@react-dnd/invariant" "^2.0.0"
redux "^4.1.1"
dns-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d"
@@ -5748,6 +5793,13 @@ he@1.2.x, he@^1.2.0:
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
dependencies:
react-is "^16.7.0"
hosted-git-info@^2.1.4:
version "2.8.8"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
@@ -8482,6 +8534,32 @@ react-clientside-effect@^1.2.5:
dependencies:
"@babel/runtime" "^7.12.13"
react-dnd-html5-backend@^14.0.2:
version "14.0.2"
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.2.tgz#25019388f6abdeeda3a6fea835dff155abb2085c"
integrity sha512-QgN6rYrOm4UUj6tIvN8ovImu6uP48xBXF2rzVsp6tvj6d5XQ7OjHI4SJ/ZgGobOneRAU3WCX4f8DGCYx0tuhlw==
dependencies:
dnd-core "14.0.1"
react-dnd-touch-backend@^14.1.1:
version "14.1.1"
resolved "https://registry.yarnpkg.com/react-dnd-touch-backend/-/react-dnd-touch-backend-14.1.1.tgz#d8875ef1cf8dcbf1741a4e03dd5b147c4fbda5e4"
integrity sha512-ITmfzn3fJrkUBiVLO6aJZcnu7T8C+GfwZitFryGsXKn5wYcUv+oQBeh9FYcMychmVbDdeUCfvEtTk9O+DKmAaw==
dependencies:
"@react-dnd/invariant" "^2.0.0"
dnd-core "14.0.1"
react-dnd@^14.0.4:
version "14.0.4"
resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-14.0.4.tgz#ffb4ea0e2a3a5532f9c6294d565742008a52b8b0"
integrity sha512-AFJJXzUIWp5WAhgvI85ESkDCawM0lhoVvfo/lrseLXwFdH3kEO3v8I2C81QPqBW2UEyJBIPStOhPMGYGFtq/bg==
dependencies:
"@react-dnd/invariant" "^2.0.0"
"@react-dnd/shallowequal" "^2.0.0"
dnd-core "14.0.1"
fast-deep-equal "^3.1.3"
hoist-non-react-statics "^3.3.2"
react-focus-lock@^2.5.2:
version "2.5.2"
resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.5.2.tgz#f1e4db5e25cd8789351f2bd5ebe91e9dcb9c2922"
@@ -8494,7 +8572,7 @@ react-focus-lock@^2.5.2:
use-callback-ref "^1.2.5"
use-sidecar "^1.0.5"
react-is@^16.8.1:
react-is@^16.7.0, react-is@^16.8.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -8595,6 +8673,13 @@ redent@^3.0.0:
indent-string "^4.0.0"
strip-indent "^3.0.0"
redux@^4.1.1:
version "4.1.2"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.2.tgz#140f35426d99bb4729af760afcf79eaaac407104"
integrity sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==
dependencies:
"@babel/runtime" "^7.9.2"
regenerate-unicode-properties@^8.2.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
@@ -9086,10 +9171,10 @@ slice-ansi@^5.0.0:
ansi-styles "^6.0.0"
is-fullwidth-code-point "^4.0.0"
sn-stylekit@5.2.17:
version "5.2.17"
resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.17.tgz#5d8ceefacc044d24f71f99c343bc4e0a9f867e3b"
integrity sha512-YjIstWUjkRYc0zWKxTcLDk8ZKfb9nskQAbsNsihKbJsko1tQbDywrvoQff8TmsE8kXedTd7z/8yUYrYlqgKp6g==
sn-stylekit@5.2.20:
version "5.2.20"
resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.20.tgz#c18f40ff3aaf4c59af89152439a8efbdde35f2dd"
integrity sha512-JymHBiZOzQPfCqHYgnVPSA2PwJqiKR268qqQoEMqI85MMAWSG3WYzuKEbd0LgfIQAKLElCxJjeZkrhejyRg+2A==
dependencies:
"@reach/listbox" "^0.15.0"
"@reach/menu-button" "^0.15.1"