├── .babelrc ├── .gitignore ├── README.md ├── client ├── common │ ├── default.yaml │ ├── localStorage.ts │ ├── remoteStorage.ts │ └── settings.ts ├── components │ ├── ConnectRemoteStorage.tsx │ ├── ContextMenu.tsx │ ├── Editor.tsx │ ├── Header.tsx │ ├── Settings.tsx │ └── Tree.tsx ├── helpers │ ├── historyManager.ts │ ├── plainTextCacher.ts │ └── propertyChangedNotifier.ts ├── index.pug ├── index.styl ├── index.tsx ├── manifest.webmanifest └── styles │ ├── common.styl │ ├── editor.styl │ ├── header.styl │ ├── settings.styl │ └── tree.styl ├── now.json ├── package.json ├── server ├── helpers │ └── diffFinder.ts └── index.ts ├── shared ├── index.ts └── yaml.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "browsers": ["last 2 versions"] 6 | } 7 | }] 8 | ], 9 | 10 | "plugins": [ 11 | ["@babel/plugin-transform-react-jsx", { 12 | "pragma": "h" 13 | }], 14 | 15 | ["@babel/plugin-transform-runtime"] 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.cache/ 2 | /dist/ 3 | /lib/ 4 | /node_modules/ 5 | 6 | /*.lock 7 | /*.log 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Snowfall 2 | ========== 3 | 4 | Snowfall is an open-source [Workflowy](https://workflowy.com) clone that aims to 5 | be customisable, and to work everywhere. It is highly inspired by 6 | [Vimflowy](https://github.com/WuTheFWasThat/vimflowy), but it does support mobile devices. 7 | 8 | 9 | ## Features 10 | - Offline support. 11 | - Markdown support. 12 | - Designed for both desktop, and mobile. 13 | - Notes and their metadata are stored in human-editable YAML. 14 | - Built-in YAML editor on the web. 15 | - Note searching, with optional caching and fuzzy-finding. 16 | 17 | 18 | ## Roadmap 19 | - Add custom keybindings. 20 | - Add Vim keybindings. 21 | - Create an optional backend that can take care of calling webhooks and storing data. 22 | - Add undo / redo. 23 | 24 | 25 | ## Format 26 | 27 | #### `index.yaml` 28 | 29 | ```yaml 30 | notes: 31 | - text: Notes are stored in `.yaml` files. 32 | - text: >- 33 | All they need to be supported by Snowfall 34 | are the `text` field, and an optional `children` field. 35 | children: 36 | - text: Obviously, children can be 37 | children: 38 | - text: arbitrarily 39 | children: 40 | - text: nested 41 | - When there are no children, there is no need for an object. 42 | - text: If you want, you can even include other files! 43 | - !!include included.yaml 44 | ``` 45 | 46 | #### `included.yaml` 47 | 48 | ```yaml 49 | text: Included files can also have a `text` field. 50 | children: 51 | - But what we really want are sub notes, right? 52 | ``` 53 | -------------------------------------------------------------------------------- /client/common/default.yaml: -------------------------------------------------------------------------------- 1 | notes: 2 | - text: hello **world** 3 | - text: foo 4 | children: 5 | - text: bar 6 | - text: baz 7 | -------------------------------------------------------------------------------- /client/common/localStorage.ts: -------------------------------------------------------------------------------- 1 | import { FileSystem } from '../../shared/yaml' 2 | 3 | 4 | export class LocalStorageFileSystem implements FileSystem { 5 | read(filename: string) { 6 | return Promise.resolve(localStorage.getItem(filename)) 7 | } 8 | 9 | write(filename: string, contents: string) { 10 | localStorage.setItem(filename, contents) 11 | 12 | return Promise.resolve() 13 | } 14 | 15 | getFiles() { 16 | const files = [] 17 | 18 | for (let i = 0; i < localStorage.length; i++) { 19 | const key = localStorage.key(i) 20 | 21 | if (key.endsWith('.yaml')) 22 | files.push(key) 23 | } 24 | 25 | return Promise.resolve(files) 26 | } 27 | 28 | createFile(filename: string, contents: string = '') { 29 | if (localStorage.getItem(filename) != null) 30 | return Promise.reject('File already exists.') 31 | 32 | localStorage.setItem(filename, contents) 33 | 34 | return Promise.resolve() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/common/remoteStorage.ts: -------------------------------------------------------------------------------- 1 | import { FileSystem } from '../../shared/yaml' 2 | import RemoteStorage from 'remotestoragejs' 3 | 4 | 5 | // Initialize Remote Storage 6 | // See: https://remotestoragejs.readthedocs.io/en/latest/getting-started/initialize-and-configure.html 7 | export const remoteStorage = new RemoteStorage() 8 | 9 | remoteStorage.access.claim('snowfall', 'rw') 10 | remoteStorage.caching.enable('/snowfall/') 11 | 12 | 13 | export class RemoteStorageFileSystem implements FileSystem { 14 | client = remoteStorage.scope('/snowfall/') 15 | 16 | read(filename: string): Promise { 17 | return this.client.getFile(filename).then(file => file.data) 18 | } 19 | 20 | write(filename: string, contents: string): Promise { 21 | return this.client.storeFile('text/yaml', filename, contents) 22 | } 23 | 24 | getFiles(): Promise { 25 | return this.client.getListing('').then(Object.keys) 26 | } 27 | 28 | createFile(filename: string, contents: string = 'text: hello world'): Promise { 29 | return this.client.storeFile('text/yaml', filename, contents) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/common/settings.ts: -------------------------------------------------------------------------------- 1 | import { Component } from 'preact' 2 | 3 | import { notifyOnPropertyChange, PropertyChangedNotifier } from '../helpers/propertyChangedNotifier' 4 | import { FileSystem } from '../../shared/yaml' 5 | 6 | const opencolor = require('open-color/open-color.json') 7 | 8 | 9 | export class Settings extends PropertyChangedNotifier { 10 | public get all() { return this } 11 | 12 | public autosave = false 13 | public autosaveInterval = 0 14 | 15 | public autoDarkMode = false 16 | public darkMode = false 17 | 18 | public useFuzzySearch = false 19 | public cachePlainText = false 20 | 21 | public enableEditor = false 22 | 23 | public quickNavigationShorcut = 'Shift-Space' 24 | 25 | public useVimMode = false 26 | 27 | public hideCompleted = false 28 | 29 | public historySize = 100 30 | 31 | public activeFile = 'index.yaml' 32 | 33 | public storage: 'localStorage' | 'remoteStorage' = 'localStorage' 34 | 35 | dependOn(component: Component, ...props: (keyof this)[]) { 36 | for (const prop of props) 37 | this.listen(prop, () => component.forceUpdate()) 38 | } 39 | 40 | static load() { 41 | const json = localStorage.getItem('settings') 42 | 43 | if (json == null) 44 | return new Settings() 45 | 46 | return Object.assign(new Settings(), JSON.parse(json)) as Settings 47 | } 48 | 49 | save() { 50 | const listeners = this.listeners 51 | 52 | // @ts-ignore 53 | delete this.listeners 54 | 55 | localStorage.setItem('settings', JSON.stringify(this)) 56 | 57 | // @ts-ignore 58 | this.listeners = listeners 59 | } 60 | 61 | /** 62 | * Returns whether the given page is a 'main page', that is, a page 63 | * that enables the user to edit their list. 64 | */ 65 | isMainPage(page: string): boolean { 66 | return !(page == '/settings' || (this.enableEditor && page == '/edit')) 67 | } 68 | 69 | /** 70 | * Returns the `FileSystem` chosen by the user to store data. 71 | */ 72 | async getFileSystem(): Promise { 73 | if (this.storage == 'localStorage') 74 | return new (await import('./localStorage')).LocalStorageFileSystem() 75 | else if (this.storage == 'remoteStorage') 76 | return new (await import('./remoteStorage')).RemoteStorageFileSystem() 77 | } 78 | 79 | /** 80 | * Sets the accent color of the given element, given its depth. 81 | */ 82 | setElementAccent(element: ElementCSSInlineStyle, depth: number) { 83 | const accent: string[10] = opencolor[Object.keys(opencolor)[3 + (depth % 12)]] 84 | 85 | if (this.darkMode) { 86 | element.style.setProperty('--accent' , accent[5]) 87 | element.style.setProperty('--dim-accent', accent[7]) 88 | element.style.setProperty('--bg-accent' , accent[9]) 89 | } else { 90 | element.style.setProperty('--accent' , accent[6]) 91 | element.style.setProperty('--dim-accent', accent[2]) 92 | element.style.setProperty('--bg-accent' , accent[0]) 93 | } 94 | } 95 | } 96 | 97 | export const settings = notifyOnPropertyChange(Settings.load()) 98 | export const appSettings = settings 99 | -------------------------------------------------------------------------------- /client/components/ConnectRemoteStorage.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact' 2 | import Widget from 'remotestorage-widget' 3 | 4 | import { remoteStorage } from '../common/remoteStorage' 5 | 6 | 7 | export default class ConnectRemoteStorage extends Component<{ }> { 8 | shouldComponentUpdate() { 9 | return false 10 | } 11 | 12 | componentDidMount() { 13 | new Widget(remoteStorage).attach('rs-widget') 14 | } 15 | 16 | componentWillUnmount() { 17 | this.base.firstChild.remove() 18 | } 19 | 20 | render() { 21 | return
22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/components/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { h, render, Component } from 'preact' 2 | import { route } from 'preact-router' 3 | 4 | import { HtmlNodeState, Tree } from './tree' 5 | import { Node } from '../../shared' 6 | 7 | import Dialog from 'preact-material-components/Dialog' 8 | import IconButton from 'preact-material-components/IconButton' 9 | import List from 'preact-material-components/List' 10 | import Menu from 'preact-material-components/Menu' 11 | import TextField from 'preact-material-components/TextField' 12 | 13 | 14 | class MetadataEditorState { 15 | props: { key: string; value: string }[] 16 | newKey: string 17 | newValue: string 18 | } 19 | 20 | class MetadataEditorProps { 21 | node: Node 22 | } 23 | 24 | class MetadataEditor extends Component { 25 | private dialog = null 26 | 27 | componentDidMount() { 28 | this.dialog.MDComponent.show() 29 | } 30 | 31 | componentWillReceiveProps({ node }: MetadataEditorProps) { 32 | const data = node.dataOrText 33 | 34 | this.setState({ 35 | props: typeof data == 'string' 36 | ? [{ key: 'text', value: data }] 37 | : Object.keys(data).map(x => ({ key: x, value: data[x] })), 38 | newKey: '', 39 | newValue: '' 40 | }) 41 | } 42 | 43 | render() { 44 | if (this.state == null || this.state.props == null) 45 | this.componentWillReceiveProps(this.props) 46 | 47 | const saveButtonDisabled = this.state.props.find(x => x.key == '' || x.value == '') != null 48 | const accept = () => { 49 | return Promise.all( 50 | this.state.props.map(({ key, value }) => this.props.node.updateProperty(key, value)) 51 | ) 52 | } 53 | 54 | return ( 55 | 99 | ) 100 | } 101 | } 102 | 103 | function openMetadataEditor(tree: Tree, node: Node) { 104 | const element = document.body.appendChild(document.createElement('div')) 105 | 106 | render(, element) 107 | } 108 | 109 | export function openMenu(tree: Tree, node: Node) { 110 | const element = document.body.appendChild(document.createElement('div')) 111 | const actions: (() => void)[] = [ 112 | () => route(node.computeStringPath()), 113 | () => openMetadataEditor(tree, node), 114 | 115 | () => tree.insertNewChild(node), 116 | () => tree.insertNewSibling(node), 117 | () => node.remove() 118 | ] 119 | 120 | if (node.children.length > 0) 121 | actions.splice(0, 0, () => node.wrapperElement.classList.toggle('collapsed')) 122 | 123 | let menu = null 124 | 125 | render( menu = x}> 126 | { node.children.length > 0 && (node.wrapperElement.classList.contains('collapsed') 127 | ? expand_moreExpand 128 | : expand_lessCollapse 129 | )} 130 | { node.children.length > 0 && 131 | 132 | } 133 | 134 | fullscreenZoom in 135 | 136 | editEdit metadata 137 | 138 | 139 | playlist_addInsert child 140 | addInsert sibling 141 | deleteRemove 142 | , element) 143 | 144 | element.remove() 145 | menu = menu.MDComponent 146 | 147 | menu.setAnchorElement(node.bulletElement) 148 | menu.hoistMenuToBody() 149 | 150 | menu.listen('MDCMenu:selected', e => { 151 | menu.destroy() 152 | menu.root_.remove() 153 | 154 | actions[e.detail.index]() 155 | }) 156 | 157 | menu.open = true 158 | } 159 | -------------------------------------------------------------------------------- /client/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact' 2 | 3 | import CodeMirror from 'codemirror/lib/codemirror' 4 | 5 | import 'codemirror/lib/codemirror.css' 6 | import 'codemirror/mode/yaml/yaml' 7 | import 'codemirror/theme/material.css' 8 | 9 | import { settings } from '../common/settings' 10 | import { DefaultObserver, Node } from '../../shared' 11 | import { YamlStore, YamlStoreState } from '../../shared/yaml' 12 | 13 | import '../styles/editor.styl' 14 | 15 | 16 | const editor = CodeMirror(document.createElement('div'), { 17 | mode: 'yaml', 18 | theme: settings.darkMode ? 'material' : 'default', 19 | 20 | autofocus : true, 21 | lineNumbers: false 22 | }) 23 | 24 | export const createEditorObserver = (store: YamlStore) => new DefaultObserver({ 25 | saved: () => { 26 | const [file] = store.files.filter(x => x.filename == settings.activeFile) 27 | 28 | if (file) 29 | editor.setValue(file.contents) 30 | 31 | editor.setOption('readOnly', false) 32 | }, 33 | loaded: () => { 34 | const [file] = store.files.filter(x => x.filename == settings.activeFile) 35 | 36 | if (file) 37 | editor.setValue(file.contents) 38 | 39 | editor.setOption('readOnly', false) 40 | }, 41 | 42 | saving: () => { 43 | editor.setOption('readOnly', true) 44 | }, 45 | loading: () => { 46 | editor.setOption('readOnly', true) 47 | }, 48 | 49 | inserted: (node: Node) => { 50 | if (node.syntax.kind == 'file' && node.syntax.filename == settings.activeFile) 51 | editor.setValue(node.syntax.contents) 52 | } 53 | }) 54 | 55 | export class EditorComponent extends Component<{ store: YamlStore }, { changed: boolean }> { 56 | shouldComponentUpdate() { 57 | return false 58 | } 59 | 60 | componentDidMount() { 61 | this.base.appendChild(editor.getWrapperElement()) 62 | editor.refresh() 63 | } 64 | 65 | componentWillUnmount() { 66 | this.base.firstChild.remove() 67 | 68 | if (this.state.changed) 69 | this.props.store.load('index.yaml') 70 | } 71 | 72 | render({ store }: { store: YamlStore }) { 73 | editor.on('change', () => { 74 | const [file] = store.files.filter(x => x.filename == settings.activeFile) 75 | 76 | if (!file) { 77 | store.fs.write(settings.activeFile, editor.getValue()) 78 | return 79 | } 80 | 81 | file.isDirty = false 82 | file.contents = editor.getValue() 83 | 84 | store.fs.write(file.filename, file.contents) 85 | 86 | this.setState({ changed: true }) 87 | }) 88 | 89 | settings.listen('activeFile', activeFile => { 90 | const [file] = store.files.filter(x => x.filename == activeFile) 91 | 92 | if (!file) { 93 | store.fs.read(activeFile).then(editor.setValue.bind(editor)) 94 | return 95 | } 96 | 97 | editor.setValue(file.contents) 98 | }) 99 | 100 | return
101 | } 102 | } 103 | 104 | export const key = 'editor' 105 | -------------------------------------------------------------------------------- /client/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact' 2 | import { RouterOnChangeArgs } from 'preact-router' 3 | 4 | import Dialog from 'preact-material-components/Dialog' 5 | import Menu from 'preact-material-components/Menu' 6 | import TabBar from 'preact-material-components/TabBar' 7 | import TextField from 'preact-material-components/TextField' 8 | import TopAppBar from 'preact-material-components/TopAppBar' 9 | 10 | import { settings } from '../common/settings' 11 | import { HistoryManager } from '../helpers/historyManager' 12 | import { DefaultObserver, Node } from '../../shared' 13 | import { YamlStore, YamlStoreState, FileSystem } from '../../shared/yaml' 14 | import { HtmlNodeState } from './tree' 15 | 16 | import '../styles/header.styl' 17 | 18 | 19 | export class HeaderComponentState { 20 | route : string 21 | canSave: boolean 22 | 23 | canUndo: boolean 24 | canRedo: boolean 25 | 26 | historyManager: HistoryManager<{}> 27 | 28 | files: string[] 29 | } 30 | 31 | const stringIncludes = settings.useFuzzySearch 32 | ? require('fuzzysearch') 33 | : (needle: string, haystack: string) => haystack.includes(needle) 34 | 35 | export class HeaderComponent extends Component<{ store: YamlStore, fs: FileSystem }, HeaderComponentState> { 36 | private loaded = false 37 | 38 | private observer = new DefaultObserver({ 39 | loaded: () => { this.loaded = true }, 40 | saved : () => { this.loaded = false; this.setState({ canSave: false }) }, 41 | 42 | inserted: (node) => this.setState({ canSave: this.state.canSave || (this.loaded && !node.isRoot) }), 43 | removed : () => this.setState({ canSave: true }), 44 | moved : () => this.setState({ canSave: true }), 45 | propertyUpdated: () => this.setState({ canSave: true }) 46 | }) 47 | 48 | constructor() { 49 | super() 50 | 51 | this.setState({ files: [] }) 52 | } 53 | 54 | handleRouteChange(e: RouterOnChangeArgs) { 55 | this.setState({ route: e.url }) 56 | } 57 | 58 | handleSearchChange(e: UIEvent) { 59 | const query = (e.target as HTMLInputElement).value.toLowerCase() 60 | 61 | if (query.length == 0) { 62 | for (const element of document.querySelectorAll('.partial-match')) 63 | element.classList.remove('no-match', 'partial-match') 64 | 65 | return 66 | } 67 | 68 | const visit = (node: Node) => { 69 | // @ts-ignore 70 | const match = (typeof node.plainText == 'string' && stringIncludes(query, node.plainText)) 71 | || (node.displayElement && stringIncludes(query, node.displayElement.textContent.toLowerCase())) 72 | 73 | let partialMatch = match 74 | 75 | for (const child of node.children) 76 | // 'partialMatch' comes after since we want to visit the children either way 77 | // (unlike you, dad) 78 | partialMatch = visit(child) || partialMatch 79 | 80 | if (match) { 81 | node.wrapperElement.classList.remove('no-match', 'partial-match') 82 | } else if (partialMatch) { 83 | node.wrapperElement.classList.remove('no-match') 84 | node.wrapperElement.classList.add('partial-match') 85 | } else { 86 | node.wrapperElement.classList.add('no-match', 'partial-match') 87 | } 88 | 89 | return partialMatch 90 | } 91 | 92 | visit(this.props.store.root as any) 93 | } 94 | 95 | componentWillMount() { 96 | if (this.props.store.observers[key] == null) 97 | this.props.store.observers[key] = this.observer 98 | 99 | this.props.fs.getFiles().then(files => this.setState({ files })) 100 | 101 | const historyManager: HistoryManager<{}> = this.props.store.observers[HistoryManager.key] 102 | 103 | if (historyManager != null) { 104 | historyManager.listen('canUndo', canUndo => this.setState({ canUndo }), true) 105 | historyManager.listen('canRedo', canRedo => this.setState({ canRedo }), true) 106 | } 107 | 108 | this.setState({ historyManager }) 109 | } 110 | 111 | createFile(filename: string) { 112 | this.props.fs 113 | .createFile(filename) 114 | .catch(reason => alert('Could not create file: ' + reason)) 115 | .then(() => this.props.fs.getFiles()) 116 | .then(files => this.setState({ files })) 117 | } 118 | 119 | save() { 120 | this.props.store.save() 121 | } 122 | 123 | render({ store }: { store: YamlStore }) { 124 | if (store.observers[key] == null) 125 | store.observers[key] = this.observer 126 | 127 | const disabled = (disabled: boolean, classes: string) => { 128 | return disabled ? 'disabled ' + classes : classes 129 | } 130 | const aboveRoute = (route: string) => { 131 | const end = route.lastIndexOf('/') 132 | 133 | return end <= 0 ? '/' : route.substring(0, end) 134 | } 135 | 136 | const route = this.state.route 137 | const inHomeRoute = settings.isMainPage(route) 138 | 139 | let menu: Menu 140 | let createFileDialog: Dialog 141 | let fileInput: HTMLInputElement 142 | let acceptButton: HTMLButtonElement 143 | 144 | return ( 145 | 146 | 147 | 148 | 149 | home 151 | expand_less 153 | 154 | 155 | 156 | this.handleSearchChange(e as any)} /> 159 | 160 | 161 | 162 | 164 | 165 |
166 | 167 | 169 | 171 | 172 |
173 | 174 | 176 | 177 | { settings.enableEditor && 178 | edit 180 | } 181 | 182 | settings 184 | 185 | 187 | 188 | 189 | menu = x}> 190 | this.state.historyManager.undo()}>Undo 192 | this.state.historyManager.redo()}>Redo 194 | 195 | 196 | 197 | { settings.enableEditor && 198 | 199 | Editor 200 | 201 | } 202 | 203 | Settings 204 | 205 | 206 | 207 |
208 | 209 |
210 | 211 | { settings.enableEditor && route == '/edit' && 212 | 213 | {this.state.files.map((filename, i) => 214 | settings.activeFile = filename}> 216 | {filename} 217 | 218 | )} 219 | 220 | { 221 | e.stopPropagation() 222 | 223 | createFileDialog.MDComponent.show() 224 | 225 | setTimeout(() => fileInput.focus(), 200) 226 | }}> 227 | add 228 | 229 | 230 | } 231 | 232 | createFileDialog = x} 233 | onAccept={() => this.createFile(fileInput.value) as any || (fileInput.value = '')} 234 | onCancel={() => (fileInput.value = '')}> 235 | 236 | acceptButton.disabled = (fileInput.value.length == 0 || !fileInput.validity.valid)} 238 | helperText='This must be a valid .yaml file name.' 239 | helperTextValidationMsg 240 | ref={x => x && x.MDComponent && (fileInput = x.MDComponent.input_)} /> 241 | 242 | 243 | 244 | Cancel 245 | acceptButton = x.control} disabled>Create 246 | 247 | 248 |
249 | ) 250 | } 251 | } 252 | 253 | export const key = 'headerComponent' 254 | -------------------------------------------------------------------------------- /client/components/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact' 2 | 3 | import Card from 'preact-material-components/Card' 4 | import Select from 'preact-material-components/Select' 5 | import Slider from 'preact-material-components/Slider' 6 | import Switch from 'preact-material-components/Switch' 7 | import TextField from 'preact-material-components/TextField' 8 | import Typography from 'preact-material-components/Typography' 9 | 10 | import { settings, Settings } from '../common/settings' 11 | 12 | import '../styles/settings.styl' 13 | 14 | import ConnectRemoteStorage from './ConnectRemoteStorage' 15 | 16 | 17 | export class SettingsComponent extends Component<{}, Settings> { 18 | componentWillMount() { 19 | this.setState(settings) 20 | } 21 | 22 | componentWillUnmount() { 23 | Object.assign(settings, this.state) 24 | 25 | settings.save() 26 | } 27 | 28 | shouldComponentUpdate(nextProps, nextState: Settings) { 29 | if (nextState.autosaveInterval != this.state.autosaveInterval || 30 | nextState.historySize != this.state.historySize) 31 | // Do not re-render for an interval change 32 | return false 33 | 34 | return true 35 | } 36 | 37 | render() { 38 | return ( 39 |
40 | 41 |
42 | Autosave 43 | 44 | this.setState({ autosave: (e.target as HTMLInputElement).checked })} /> 47 |
48 | 49 | Autosave interval (in seconds) 50 |
51 | this.setState({ autosaveInterval: (e as any).detail.value })} /> 55 |
56 |
57 | 58 | 59 |
60 | Editor 61 |
62 | 63 |
64 | Enable text editor 65 | this.setState({ enableEditor: (e.target as HTMLInputElement).checked })} /> 68 |
69 |
70 | 71 | 72 |
73 | Theme 74 |
75 | 76 |
77 | Enable dark mode 78 | this.setState({ darkMode: (e.target as HTMLInputElement).checked })} /> 81 |
82 | 83 |
84 | Automatic dark mode 85 | this.setState({ autoDarkMode: (e.target as HTMLInputElement).checked })} /> 89 |
90 |
91 | Enables dark mode between 19:00 and 7:00. 92 |
93 | 94 | 95 |
96 | Searching 97 |
98 | 99 |
100 | Use fuzzy-searching 101 | this.setState({ useFuzzySearch: (e.target as HTMLInputElement).checked })} /> 104 |
105 | 106 |
107 | Cache plain text 108 | 109 | this.setState({ cachePlainText: (e.target as HTMLInputElement).checked })} /> 112 |
113 |
114 | Consumes more memory, but improves search speed. 115 |
116 | 117 | 118 |
119 | Quick navigation 120 |
121 | 122 | this.setState({ quickNavigationShorcut: (e.target as HTMLInputElement).value })} /> 125 |
126 | 127 | 128 |
129 | Vim mode 130 |
131 | 132 |
133 | Enable Vim mode 134 | this.setState({ useVimMode: (e.target as HTMLInputElement).checked })} /> 137 |
138 |
139 | 140 | 141 |
142 | History 143 |
144 | 145 | History size 146 |
147 | this.setState({ historySize: (e as any).detail.value })} /> 150 |
151 |
152 | 153 | 154 |
155 | Syncing 156 |
157 | 158 | 164 | 165 | { this.state.storage == 'remoteStorage' && 166 | 167 | } 168 |
169 | 170 |
171 |
172 | ) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /client/components/Tree.tsx: -------------------------------------------------------------------------------- 1 | import CodeMirror, { Pass } from 'codemirror' 2 | import MarkdownIt from 'markdown-it' 3 | import { h, Component } from 'preact' 4 | import { route, RouterOnChangeArgs } from 'preact-router' 5 | 6 | import 'codemirror/addon/display/autorefresh' 7 | import 'codemirror/lib/codemirror.css' 8 | import 'codemirror/mode/markdown/markdown' 9 | 10 | import { Node, NodeObserver } from '../../shared' 11 | import { settings } from '../common/settings' 12 | import { openMenu } from './ContextMenu' 13 | 14 | import '../styles/tree.styl' 15 | 16 | 17 | // Markdown renderer 18 | const md = new MarkdownIt({ 19 | breaks: true 20 | }) 21 | 22 | 23 | // CodeMirror editor 24 | const createCodeMirror = (tree: Tree) => CodeMirror(document.createElement('div'), { 25 | mode: 'markdown', 26 | 27 | autofocus : true, 28 | lineNumbers : false, 29 | lineWrapping : true, 30 | scrollbarStyle: 'null', 31 | viewportMargin: Infinity, 32 | 33 | extraKeys: { 34 | 'Enter' (cm) { 35 | const node = tree.activeNode 36 | 37 | CodeMirror.signal(cm, 'blur') 38 | 39 | if (node.children.length > 0 || node == tree.rootNode) 40 | tree.insertNewChild(node) 41 | else 42 | tree.insertNewSibling(node) 43 | }, 44 | 45 | 'Shift-Tab' (cm) { 46 | const node = tree.activeNode 47 | 48 | if (node.depth == 0 || node == tree.rootNode) 49 | return 50 | 51 | CodeMirror.signal(cm, 'blur') 52 | 53 | const promise = node.decreaseDepth() 54 | 55 | if (promise) 56 | promise.then(() => tree.focus(node, null)) 57 | }, 58 | 59 | 'Tab' (cm) { 60 | const node = tree.activeNode 61 | 62 | if (node == tree.rootNode) 63 | return 64 | 65 | const promise = node.increaseDepth() 66 | 67 | if (!promise) 68 | return Pass 69 | 70 | CodeMirror.signal(cm, 'blur') 71 | 72 | promise.then(() => tree.focus(node, null)) 73 | }, 74 | 75 | 'Up' (cm) { 76 | if (cm.getDoc().getCursor().line != 0) 77 | return Pass 78 | 79 | const action = tree.focusPrevious(tree.activeNode) 80 | 81 | if (!action) 82 | return 83 | 84 | CodeMirror.signal(cm, 'blur') 85 | action() 86 | }, 87 | 88 | 'Down' (cm) { 89 | const doc = cm.getDoc() 90 | 91 | if (doc.getCursor().line != doc.lastLine()) 92 | return Pass 93 | 94 | const action = tree.focusNext(tree.activeNode) 95 | 96 | if (!action) 97 | return 98 | 99 | CodeMirror.signal(cm, 'blur') 100 | action() 101 | }, 102 | 103 | 'Left' (cm) { 104 | const doc = cm.getDoc() 105 | const cursor = doc.getCursor() 106 | 107 | if (cursor.line != 0 || cursor.ch != 0) 108 | return Pass 109 | 110 | const node = tree.activeNode 111 | const action = tree.focusPrevious(node, Infinity) 112 | 113 | if (!action) 114 | return Pass 115 | 116 | CodeMirror.signal(cm, 'blur') 117 | 118 | action() 119 | }, 120 | 121 | 'Right' (cm) { 122 | const doc = cm.getDoc() 123 | const cursor = doc.getCursor() 124 | 125 | if (cursor.line != doc.lastLine() || cursor.ch != doc.getLine(cursor.line).length) 126 | return Pass 127 | 128 | const node = tree.activeNode 129 | const action = tree.focusNext(node, 0) 130 | 131 | if (!action) 132 | return Pass 133 | 134 | CodeMirror.signal(cm, 'blur') 135 | 136 | action() 137 | }, 138 | 139 | 'Backspace' (cm) { 140 | if (cm.getValue().length > 0) 141 | return Pass 142 | 143 | const node = tree.activeNode 144 | const action = tree.focusPrevious(node, Infinity) 145 | 146 | if (!action) 147 | return Pass 148 | 149 | CodeMirror.signal(cm, 'blur') 150 | 151 | action() 152 | node.remove() 153 | } 154 | } 155 | }) 156 | 157 | 158 | // Helpers 159 | function hh(tag: K, attrs: object | null, ...children: (string | HTMLElement)[]): HTMLElementTagNameMap[K] { 160 | const element = document.createElement(tag) 161 | 162 | if (attrs) { 163 | for (const key in attrs) 164 | element.setAttribute(key, attrs[key]) 165 | } 166 | 167 | for (const child of children) 168 | element.append(child) 169 | 170 | return element 171 | } 172 | 173 | export type HtmlNodeState = { 174 | wrapperElement : HTMLDivElement 175 | contentElement : HTMLDivElement 176 | childrenElement: HTMLUListElement 177 | displayElement : HTMLDivElement 178 | editElement : HTMLDivElement 179 | bulletElement : HTMLDivElement 180 | 181 | tokens: any[] 182 | markdown: string 183 | markdownSource: string 184 | 185 | hasFocus: boolean 186 | } 187 | 188 | 189 | let selectingMultiple = false 190 | let lastSelected = null 191 | 192 | document.addEventListener('mouseup', () => { 193 | if (!selectingMultiple) 194 | return 195 | 196 | selectingMultiple = false 197 | lastSelected = null 198 | 199 | document.addEventListener('mouseup', () => { 200 | for (const element of document.querySelectorAll('.selected')) 201 | element.classList.remove('selected') 202 | 203 | window.getSelection().collapseToStart() 204 | }, { once: true }) 205 | }) 206 | 207 | 208 | // Tree 209 | export class Tree implements NodeObserver { 210 | public url: string 211 | 212 | public cm: CodeMirror.Editor 213 | public rootNode: Node 214 | public activeNode: Node 215 | 216 | public rootElement = document.createElement('div') 217 | public rootNodeChanged: (node: Node) => void = null 218 | 219 | constructor() { 220 | this.cm = createCodeMirror(this) 221 | 222 | this.cm.on('blur', async () => { 223 | const node = this.activeNode 224 | 225 | node.wrapperElement.classList.remove('focused') 226 | node.hasFocus = false 227 | 228 | await node.updateProperty('text', this.cm.getValue()) 229 | 230 | // Update markdown render if needed 231 | this.updateMarkdownRender(node) 232 | }) 233 | } 234 | 235 | // Called when the root node is loaded 236 | loaded() { 237 | this.handleRouteChange(this) 238 | } 239 | 240 | handleRouteChange({ url }: { url: string }) { 241 | this.url = url 242 | 243 | if (this.rootNode == null || !settings.isMainPage(url)) 244 | return 245 | 246 | const node = this.rootNode.root.resolveWithStringPath(url) 247 | 248 | if (node == null) { 249 | route('/', true) 250 | return 251 | } 252 | 253 | if (node.isRoot) 254 | this.rootElement.classList.remove('zoom') 255 | else 256 | this.rootElement.classList.add('zoom') 257 | 258 | const oldRootNode = this.rootNode 259 | 260 | this.rootNode.wrapperElement.remove() 261 | this.rootNode = node 262 | this.rootElement.appendChild(node.wrapperElement) 263 | 264 | if (this.rootNodeChanged != null) 265 | this.rootNodeChanged(node) 266 | 267 | if (!oldRootNode.isRoot) 268 | this.reinsert(oldRootNode) 269 | } 270 | 271 | 272 | private updateStyle(node: Node) { 273 | settings.setElementAccent(node.wrapperElement, node.depth) 274 | } 275 | 276 | private updateMarkdownRender(node: Node) { 277 | if (node.text != node.markdownSource) { 278 | const env = {} 279 | 280 | node.tokens = md.parseInline(node.text, env) 281 | // @ts-ignore 282 | node.markdown = md.renderer.render(node.tokens, md.options, env) 283 | node.markdownSource = node.text 284 | } 285 | 286 | node.displayElement.innerHTML = node.markdown 287 | } 288 | 289 | private reinsert(node: Node) { 290 | const parent = node.parent 291 | 292 | if (!parent) 293 | return 294 | 295 | if (node.isCompleted && settings.hideCompleted) { 296 | node.wrapperElement.remove() 297 | 298 | if (node.parent.children.length == 0) 299 | node.parent.wrapperElement.classList.add('no-children') 300 | 301 | return 302 | } 303 | 304 | const index = node.index 305 | const nextSibling = parent.childrenElement.childNodes[index] 306 | 307 | parent.childrenElement.insertBefore(node.wrapperElement, nextSibling) 308 | 309 | this.updateStyle(node) 310 | this.updateMarkdownRender(node) 311 | 312 | parent.wrapperElement.classList.remove('no-children') 313 | } 314 | 315 | 316 | inserted(node: Node) { 317 | if (node.isRoot && this.rootNode == null) { 318 | this.rootNode = node 319 | 320 | if (this.rootNodeChanged != null) 321 | this.rootNodeChanged(node) 322 | } 323 | 324 | if (node == this.rootNode) { 325 | node.wrapperElement = hh('div', { class: 'node-wrapper' }, 326 | node.childrenElement = hh('ul', { class: 'node-children' }) 327 | ) 328 | 329 | this.rootElement.innerHTML = '' 330 | this.rootElement.appendChild(node.wrapperElement) 331 | 332 | return 333 | } 334 | 335 | node.wrapperElement = 336 | hh('div', { class: 'node-wrapper' }, 337 | hh('div', { class: 'node-border' }), 338 | hh('div', { class: 'node-content-line' }, 339 | node.bulletElement = hh('div', { class: 'node-bullet' }, hh('div', { class: 'node-inner-bullet' })), 340 | node.contentElement = hh('div', { class: 'node-content' }, 341 | node.displayElement = hh('div', { class: 'node-display' }), 342 | node.editElement = hh('div', { class: 'node-edit' }) 343 | ) 344 | ), 345 | node.childrenElement = hh('ul', { class: 'node-children' }) 346 | ) 347 | 348 | if (node.children.length == 0) 349 | node.wrapperElement.classList.add('no-children') 350 | 351 | node.displayElement.addEventListener('click', ev => { 352 | ev.stopImmediatePropagation() 353 | 354 | this.focus(node, ev) 355 | }) 356 | 357 | // If the user starts selecting the node, then moves to other nodes, we start 358 | // a multi-node selection 359 | let selecting = false 360 | let previouslySelected = null 361 | 362 | node.displayElement.addEventListener('mouseleave', () => { 363 | if (!selecting) 364 | return 365 | 366 | selectingMultiple = true 367 | selecting = false 368 | 369 | node.wrapperElement.classList.add('selected') 370 | }) 371 | 372 | node.displayElement.addEventListener('mouseenter', () => { 373 | if (!selectingMultiple) 374 | return 375 | 376 | node.wrapperElement.classList.add('selected') 377 | 378 | if (previouslySelected != null && previouslySelected != lastSelected) 379 | lastSelected.classList.remove('selected') 380 | 381 | previouslySelected = lastSelected 382 | lastSelected = node.wrapperElement 383 | }) 384 | 385 | node.displayElement.addEventListener('mousedown', () => { 386 | selecting = true 387 | }) 388 | 389 | document.addEventListener('mouseup', () => { 390 | selecting = false 391 | previouslySelected = null 392 | }) 393 | 394 | node.bulletElement.addEventListener('mouseup', e => { 395 | if (e.button == 1) 396 | node.remove() 397 | else 398 | openMenu(this, node) 399 | }) 400 | 401 | node.bulletElement.addEventListener('mouseenter', () => node.wrapperElement.classList.add('active')) 402 | node.bulletElement.addEventListener('mouseleave', () => node.wrapperElement.classList.remove('active')) 403 | 404 | this.reinsert(node) 405 | } 406 | 407 | removed(node: Node, oldParent: Node) { 408 | node.wrapperElement.remove() 409 | 410 | if (oldParent.children.length == 0) 411 | oldParent.wrapperElement.classList.add('no-children') 412 | } 413 | 414 | propertyUpdated(node: Node) { 415 | if (!node.hasFocus && !node.isRoot) 416 | this.updateMarkdownRender(node) 417 | } 418 | 419 | moved(node: Node) { 420 | this.reinsert(node) 421 | } 422 | 423 | 424 | focus(node: Node, ev: MouseEvent | number | null) { 425 | function focusEditor(offset: number, endOffset: number = 0) { 426 | // Goal: find character that corresponds to given offset in source text 427 | // 428 | // For instance, the first line should return the second line: 429 | // foo bar baz 430 | // ^ 431 | // foo **bar** _baz_ 432 | // ^ 433 | if (node.tokens.length == 0) 434 | return 435 | 436 | const tokens = node.tokens[0].children 437 | 438 | let skipOffset = 0 439 | let remainingOffset = offset 440 | 441 | for (const token of tokens) { 442 | skipOffset += token.markup.length 443 | remainingOffset -= token.content.length 444 | 445 | if (remainingOffset < 0) 446 | break 447 | } 448 | 449 | const doc = cm.getDoc() 450 | const startOffset = skipOffset + offset 451 | 452 | if (endOffset > offset) { 453 | let skipEndOffset = 0 454 | let remainingEndOffset = endOffset 455 | 456 | for (const token of tokens) { 457 | skipEndOffset += token.markup.length 458 | remainingEndOffset -= token.content.length 459 | 460 | if (remainingEndOffset < 0) 461 | break 462 | } 463 | 464 | doc.setSelection(doc.posFromIndex(startOffset), doc.posFromIndex(skipEndOffset + endOffset)) 465 | } else { 466 | doc.setCursor(doc.posFromIndex(startOffset)) 467 | } 468 | } 469 | 470 | function getOffsetRelativeToContent(focusNode: Element, focusOffset: number): number { 471 | // Find all nodes before the focused node, and add their length 472 | // to the full offset 473 | let fullOffset = focusOffset 474 | let parent = node.displayElement 475 | 476 | for (const child of parent.childNodes) { 477 | if (child.contains(focusNode)) 478 | break 479 | 480 | fullOffset += child.textContent.length 481 | } 482 | 483 | return fullOffset 484 | } 485 | 486 | const { anchorNode, anchorOffset, extentNode, extentOffset, isCollapsed } = window.getSelection() 487 | const startOffset = typeof ev == 'number' ? ev : getOffsetRelativeToContent(anchorNode as Element, anchorOffset) 488 | const endOffset = typeof ev == 'number' || isCollapsed ? -1 : getOffsetRelativeToContent(extentNode as Element, extentOffset) 489 | 490 | const cm = this.cm 491 | 492 | node.hasFocus = true 493 | 494 | node.wrapperElement.classList.add('focused') 495 | node.editElement.appendChild(cm.getWrapperElement()) 496 | 497 | cm.setValue(node.text) 498 | cm.focus() 499 | 500 | if (ev) { 501 | if (endOffset == -1) 502 | focusEditor(startOffset) 503 | else if (startOffset > endOffset) 504 | focusEditor(endOffset, startOffset) 505 | else 506 | focusEditor(startOffset, endOffset) 507 | } 508 | 509 | this.activeNode = node 510 | } 511 | 512 | insertNewChild(node: Node) { 513 | node 514 | .createChild(0) 515 | .then(child => this.focus(child, null)) 516 | } 517 | 518 | insertNewChildAtEnd(node: Node) { 519 | node 520 | .createChild(node.children.length) 521 | .then(child => this.focus(child, null)) 522 | } 523 | 524 | insertNewSibling(node: Node) { 525 | node.parent 526 | .createChild(node.index + 1) 527 | .then(child => this.focus(child, null)) 528 | } 529 | 530 | focusLast(node: Node, offset: number) { 531 | // Focus the last child of the given node 532 | while (node.children.length > 0) 533 | node = node.children[node.children.length - 1] 534 | 535 | this.focus(node, offset) 536 | } 537 | 538 | focusPrevious(node: Node, offset: number = this.cm.getDoc().getCursor().ch) { 539 | if (node == this.rootNode) 540 | return 541 | 542 | const index = node.index 543 | 544 | if (index > 0) 545 | return () => this.focusLast(node.siblings[index - 1], offset) 546 | else if (!node.parent.isRoot) 547 | return () => this.focus(node.parent, offset) 548 | else 549 | return null 550 | } 551 | 552 | focusNext(node: Node, offset: number = this.cm.getDoc().getCursor().ch) { 553 | if (node == this.rootNode) { 554 | if (node.children.length > 0) 555 | return () => this.focus(node.children[0], offset) 556 | else 557 | return null 558 | } 559 | 560 | const index = node.index 561 | 562 | if (node.children.length > 0) 563 | return () => this.focus(node.children[0], offset) 564 | else if (node.siblings.length > index + 1) 565 | return () => this.focus(node.siblings[index + 1], offset) 566 | else if (node.parent.siblings.length > node.parent.index + 1) 567 | return () => this.focus(node.parent.siblings[node.parent.index + 1], offset) 568 | else 569 | return null 570 | } 571 | } 572 | 573 | export default class TreeComponent extends Component<{ tree: Tree }> { 574 | private addElement: SVGSVGElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg') 575 | 576 | shouldComponentUpdate() { 577 | return false 578 | } 579 | 580 | componentDidMount() { 581 | this.addElement.setAttribute('viewBox', '0 0 24 24') 582 | this.addElement.classList.add('node-add') 583 | this.addElement.innerHTML = `` 584 | 585 | this.addElement.addEventListener('click', () => { 586 | this.props.tree.insertNewChildAtEnd(this.props.tree.rootNode) 587 | }) 588 | 589 | this.props.tree.rootNodeChanged = node => settings.setElementAccent(this.addElement, node.depth + 1) 590 | 591 | this.base.appendChild(this.props.tree.rootElement) 592 | this.base.appendChild(this.addElement) 593 | } 594 | 595 | componentWillUnmount() { 596 | while (this.base.firstChild) 597 | this.base.firstChild.remove() 598 | } 599 | 600 | render() { 601 | return
602 | } 603 | } 604 | -------------------------------------------------------------------------------- /client/helpers/historyManager.ts: -------------------------------------------------------------------------------- 1 | import { Node, NodeObserver, StoreObserver } from '../../shared' 2 | import { settings } from '../common/settings' 3 | import { PropertyChangedNotifier } from './propertyChangedNotifier' 4 | 5 | 6 | class HistoryItem = keyof NodeObserver> { 7 | constructor( 8 | public changeType: T, 9 | public node : Node<{}>, 10 | public details : NodeObserver[T] extends (_: any, ...args: infer Args) => any ? Args : never 11 | ) {} 12 | } 13 | 14 | /** 15 | * Manages history and allows undo / redo operations. 16 | */ 17 | export class HistoryManager extends PropertyChangedNotifier implements StoreObserver { 18 | public static readonly key = 'historyManager' 19 | 20 | private readonly history: HistoryItem[] = [] 21 | private readonly undos : HistoryItem[] = [] 22 | 23 | private ignore : boolean = true 24 | private undoing: boolean = false 25 | 26 | private insert(historyItem: HistoryItem) { 27 | if (this.undoing) { 28 | this.undos.push(historyItem) 29 | 30 | if (this.undos.length == 1) 31 | this.notifyPropertyChanged('canRedo', true) 32 | 33 | return 34 | } 35 | 36 | if (this.history.length == settings.historySize) 37 | this.history.shift() 38 | 39 | this.history.push(historyItem) 40 | this.undos.splice(0, this.undos.length) 41 | 42 | if (this.history.length == 1) 43 | this.notifyPropertyChanged('canUndo', true) 44 | } 45 | 46 | get canUndo() { 47 | return this.history.length > 0 48 | } 49 | 50 | get canRedo() { 51 | return this.undos.length > 0 52 | } 53 | 54 | loading() { 55 | this.ignore = true 56 | } 57 | 58 | loaded() { 59 | this.ignore = false 60 | } 61 | 62 | inserted(node: Node) { 63 | if (!this.ignore) 64 | this.insert(new HistoryItem('inserted', node, [])) 65 | } 66 | 67 | propertyUpdated(node: Node, propertyKey: string, newValue: any, oldValue: any) { 68 | this.insert(new HistoryItem('propertyUpdated', node, [propertyKey, newValue, oldValue])) 69 | } 70 | 71 | removed(node: Node, oldParent: Node, oldIndex: number) { 72 | this.insert(new HistoryItem('removed', node, [oldParent, oldIndex])) 73 | } 74 | 75 | moved(node: Node, oldParent: Node, oldIndex: number) { 76 | this.insert(new HistoryItem('moved', node, [oldParent, oldIndex])) 77 | } 78 | 79 | 80 | private applyOppositeChange(item: HistoryItem) { 81 | if (item.changeType == 'inserted') { 82 | item.node.remove() 83 | } else if (item.changeType == 'propertyUpdated') { 84 | item.node.updateProperty(item.details[0], item.details[2]) 85 | } else if (item.changeType == 'moved') { 86 | item.node.move(item.details[0], item.details[1]) 87 | } else { 88 | item.node.insert(item.details[0], item.details[1]) 89 | } 90 | } 91 | 92 | undo() { 93 | if (this.history.length == 0) 94 | return 95 | 96 | this.undoing = true 97 | 98 | this.applyOppositeChange(this.history.pop()) 99 | 100 | this.undoing = false 101 | 102 | if (this.history.length == 0) 103 | this.notifyPropertyChanged('canUndo', false) 104 | } 105 | 106 | redo() { 107 | if (this.undos.length == 0) 108 | return 109 | 110 | this.applyOppositeChange(this.undos.pop()) 111 | 112 | if (this.undos.length == 0) 113 | this.notifyPropertyChanged('canRedo', false) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /client/helpers/plainTextCacher.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it' 2 | 3 | import { Node, NodeObserver } from '../../shared' 4 | 5 | 6 | export class PlainTextCacher implements NodeObserver { 7 | public readonly md = new MarkdownIt() 8 | 9 | private getPlainText(node: Node<{}>): string { 10 | const tokens = this.md.parseInline(node.text, {}) 11 | let text = '' 12 | 13 | for (const blockToken of tokens) 14 | for (const token of blockToken.children) 15 | text += token.content 16 | 17 | return text.toLowerCase() 18 | } 19 | 20 | inserted(node: Node<{ plainText: string }>) { 21 | if (node.text) 22 | node.plainText = this.getPlainText(node) 23 | } 24 | 25 | propertyUpdated(node: Node<{ plainText: string }>, propertyKey: string) { 26 | if (propertyKey == 'text' || propertyKey == 'note') 27 | node.plainText = this.getPlainText(node) 28 | } 29 | 30 | removed() {} 31 | moved() {} 32 | } 33 | 34 | export const key = 'plainTextCacher' 35 | -------------------------------------------------------------------------------- /client/helpers/propertyChangedNotifier.ts: -------------------------------------------------------------------------------- 1 | 2 | export class PropertyChangedNotifier { 3 | protected readonly listeners: { 4 | [key in keyof this]?: ((value: this[key]) => void)[] 5 | } = {} 6 | 7 | protected notifyPropertyChanged

(prop: P, value: this[P]) { 8 | for (const listener of this.listeners[prop] || []) 9 | listener(value) 10 | for (const listener of this.listeners['all'] || []) 11 | listener(this) 12 | } 13 | 14 | listen

(prop: P, handler: (value: this[P]) => void, callNow = false) { 15 | if (this.listeners[prop] == null) 16 | this.listeners[prop] = [] 17 | 18 | this.listeners[prop].push(handler) 19 | 20 | if (callNow) 21 | handler(this[prop]) 22 | } 23 | } 24 | 25 | export function notifyOnPropertyChange(value: T): T { 26 | return new Proxy(value, { 27 | set: (settings, key, value) => { 28 | if (key == 'listeners') { 29 | // @ts-ignore 30 | settings['listeners'] = value 31 | return true 32 | } 33 | 34 | if (typeof key != 'string' || key == 'all' || settings[key] === undefined) 35 | return false 36 | 37 | if (settings[key] != value) 38 | settings[key] = value 39 | 40 | // @ts-ignore 41 | settings.notifyPropertyChanged(key, value) 42 | 43 | return true 44 | } 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /client/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | 3 | html(lang='en') 4 | head 5 | title Snowfall 6 | 7 | meta(charset='utf-8') 8 | meta(name='viewport' , content='width=device-width, initial-scale=1') 9 | meta(name='theme-color', content='#fff') 10 | meta(name='description', content='Snowfall list application.') 11 | 12 | link(rel='manifest' , href='./manifest.webmanifest') 13 | link(rel='stylesheet', href='./index.styl') 14 | 15 | body 16 | noscript 17 | style. 18 | body { 19 | overflow: hidden; 20 | } 21 | h1, p { 22 | font-family: Roboto, sans-serif; 23 | } 24 | a { 25 | text-decoration: underline; 26 | } 27 | 28 | h1 Sorry, but this application requires Javascript. 29 | p 30 | | If you'd like to know more about Snowfall before trying it out, 31 | | you can check out its #[a(href='https://github.com/71/snowfall') code] 32 | | on GitHub. 33 | 34 | 35 | #menu 36 | #header 37 | #app 38 | 39 | script(src='./index.tsx') 40 | -------------------------------------------------------------------------------- /client/index.styl: -------------------------------------------------------------------------------- 1 | @import 'styles/common.styl' 2 | 3 | .mobile 4 | +desktop() 5 | display: none!important 6 | 7 | .desktop 8 | +mobile() 9 | display: none!important 10 | 11 | 12 | body 13 | background: var(--background) 14 | color: var(--foreground) 15 | position: absolute 16 | margin: 0 17 | height: 100% 18 | width: 100% 19 | text-align: center 20 | overflow-x: hidden 21 | 22 | * 23 | box-sizing: border-box 24 | 25 | a, a:visited 26 | color: inherit 27 | text-decoration-color: var(--accent, initial) 28 | 29 | #header, #app 30 | text-align: left 31 | 32 | #app 33 | display: inline-block 34 | height: 100% 35 | width: 100% 36 | 37 | max-width: 800px 38 | 39 | padding: 0 1em 40 | padding-top: 5em 41 | -------------------------------------------------------------------------------- /client/index.tsx: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact' 2 | import AsyncRoute from 'preact-async-route' 3 | import { Router } from 'preact-router' 4 | 5 | import 'material-icons/iconfont/material-icons.css' 6 | import 'typeface-roboto-mono' 7 | import 'typeface-sarabun' 8 | 9 | import 'preact-material-components/style.css' 10 | 11 | import { settings } from './common/settings' 12 | 13 | import { NodeObservers } from '../shared' 14 | import { YamlStore } from '../shared/yaml' 15 | 16 | import { HeaderComponent as Header } from './components/Header' 17 | import Tree, { Tree as DomObserver } from './components/Tree' 18 | 19 | 20 | if (navigator.serviceWorker) { 21 | const serviceWorkerName = 'service-worker.js' 22 | 23 | navigator.serviceWorker 24 | .register(serviceWorkerName) 25 | .then(() => console.log('Service worker registered.')) 26 | .catch(err => console.warn('Service worker could not be registered.', err)) 27 | } 28 | 29 | 30 | (async function() { 31 | // Set up storage... 32 | const observers: NodeObservers = {} 33 | 34 | if (settings.cachePlainText) { 35 | const { PlainTextCacher, key } = await import('./helpers/plainTextCacher') 36 | 37 | observers[key] = new PlainTextCacher() 38 | } 39 | 40 | if (settings.historySize > 0) { 41 | const { HistoryManager } = await import('./helpers/historyManager') 42 | 43 | observers[HistoryManager.key] = new HistoryManager() 44 | } 45 | 46 | const fs = await settings.getFileSystem() 47 | const files = await fs.getFiles() 48 | 49 | if (!files.includes('index.yaml')) { 50 | await fs.createFile('index.yaml', require('fs').readFileSync(__dirname + '/common/default.yaml', 'utf8')) 51 | } 52 | 53 | const store = new YamlStore(fs, observers) 54 | 55 | // Set up view... 56 | const appElement = document.querySelector('#app') 57 | const headerElement = document.querySelector('#header') 58 | 59 | if (settings.darkMode || (settings.autoDarkMode && (new Date().getHours() > 18 || new Date().getHours() < 8))) 60 | document.body.classList.add('dark') 61 | 62 | // Clean up HTML from previous sessions... 63 | appElement.innerHTML = '' 64 | headerElement.innerHTML = '' 65 | 66 | const tree = new DomObserver() 67 | 68 | let header: Header 69 | 70 | const Main = () => ( 71 | { header.handleRouteChange(ev); tree.handleRouteChange(ev); }}> 72 | 73 | 74 | { settings.enableEditor 75 | ? import('./components/Editor').then(module => module.EditorComponent)} 77 | store={store} filename='index.yaml' /> 78 | :

79 | } 80 | import('./components/Settings').then(module => module.SettingsComponent)} /> 82 | 83 | ) 84 | 85 | render(
header = x} store={store} fs={fs} />, headerElement) 86 | render(
, appElement) 87 | 88 | 89 | // Load data... 90 | observers['treeComponent'] = tree 91 | 92 | if (settings.enableEditor) { 93 | const { createEditorObserver, key } = await import('./components/Editor') 94 | 95 | observers[key] = createEditorObserver(store) 96 | } 97 | 98 | await store.load('index.yaml') 99 | })() 100 | -------------------------------------------------------------------------------- /client/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Snowfall", 3 | "short_name": "Snowfall", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "orientation": "portrait", 7 | "background_color": "#fff", 8 | "theme_color": "#aaa", 9 | "icons": [] 10 | } 11 | -------------------------------------------------------------------------------- /client/styles/common.styl: -------------------------------------------------------------------------------- 1 | body 2 | --background: white 3 | --foreground: #111 4 | --dim-foreground: gray 5 | 6 | --accent: white 7 | 8 | body.dark 9 | --background: black 10 | --foreground: #eee 11 | --dim-foreground: white 12 | 13 | --accent: black 14 | 15 | --mdc-theme-primary: #bb92f7 16 | --mdc-theme-background: var(--background) 17 | --mdc-theme-surface: #060606 18 | --mdc-theme-on-surface: white 19 | --mdc-theme-text-primary-on-background: #eee 20 | 21 | .mdc-button:disabled 22 | color: white 23 | 24 | .mdc-text-field:not(.mdc-text-field--disabled) 25 | color: rgba(255, 255, 255, 0.87) 26 | 27 | & input::placeholder 28 | color: white 29 | 30 | &:not(.mdc-text-field--focused) .mdc-text-field__input:hover ~ .mdc-notched-outline .mdc-notched-outline__path 31 | color: rgba(255, 255, 255, 0.87) 32 | stroke: @color 33 | &:not(.mdc-text-field--focused) .mdc-text-field__input:hover ~ .mdc-notched-outline__idle 34 | color: rgba(255, 255, 255, 0.87) 35 | border-color: @color 36 | 37 | .mdc-text-field__input 38 | color: rgba(255, 255, 255, 0.87) 39 | .mdc-floating-label 40 | color: rgba(255, 255, 255, 0.87) 41 | .mdc-notched-outline__idle 42 | color: rgba(255, 255, 255, 0.87) 43 | border-color: @color 44 | .mdc-notched-outline__path 45 | color: rgba(255, 255, 255, 0.87) 46 | stroke: @color 47 | .mdc-text-field__icon 48 | color: rgba(255, 255, 255, 0.95) 49 | 50 | 51 | mobile() 52 | @media screen and (max-width: 720px) 53 | {block} 54 | 55 | desktop() 56 | @media screen and (min-width: 720px) 57 | {block} 58 | 59 | dark() 60 | /body.dark {selector()} 61 | {block} 62 | 63 | 64 | $monospace = 'Roboto Mono', monospace 65 | $sans-serif = Sarabun, Roboto, 'Segoe UI', sans-serif 66 | -------------------------------------------------------------------------------- /client/styles/editor.styl: -------------------------------------------------------------------------------- 1 | @import 'common.styl' 2 | 3 | .editor-root 4 | height: 100% 5 | padding-top: 3em 6 | 7 | .CodeMirror 8 | background: transparent 9 | font-family: $monospace 10 | font-size: .9em 11 | height: 99% 12 | 13 | +mobile() 14 | font-size: .5em 15 | -------------------------------------------------------------------------------- /client/styles/header.styl: -------------------------------------------------------------------------------- 1 | @import 'common.styl' 2 | 3 | body.dark #header > header 4 | --mdc-theme-on-primary: #ccc 5 | 6 | #header > header 7 | --mdc-theme-on-primary: #5f6368 8 | 9 | background-color: var(--background)!important 10 | border-bottom: 1px solid #dadce0 11 | 12 | .disabled, [disabled] 13 | opacity: .3 14 | pointer-events: none 15 | 16 | section.mdc-top-app-bar__section 17 | flex: 0 0 auto 18 | 19 | section.mdc-top-app-bar__section.search-input 20 | padding: 0 21 | flex: 1 1 auto 22 | justify-content: left 23 | 24 | .mdc-text-field 25 | margin: 0 5em 26 | max-width: 800px 27 | width: 100% 28 | 29 | +mobile() 30 | display: none 31 | 32 | +desktop() 33 | .search-button 34 | display: none 35 | 36 | .new-tab 37 | flex: 0 0 auto 38 | padding: 0 .9em 39 | 40 | .create-dialog 41 | color: grey 42 | 43 | .mdc-text-field 44 | width: 100% 45 | 46 | .mdc-dialog__surface 47 | min-width: 0 48 | width: 300px 49 | -------------------------------------------------------------------------------- /client/styles/settings.styl: -------------------------------------------------------------------------------- 1 | @import 'common.styl' 2 | 3 | .settings-root 4 | .mdc-card 5 | padding: 1em 6 | margin-top: 1em 7 | 8 | .card-header 9 | margin-bottom: 1em 10 | position: relative 11 | 12 | .mdc-switch.right-switch 13 | position: absolute 14 | margin-top: .5em 15 | right: 0 16 | -------------------------------------------------------------------------------- /client/styles/tree.styl: -------------------------------------------------------------------------------- 1 | @import 'common.styl' 2 | 3 | .tree-root 4 | & > .zoom > .node-wrapper:first-child 5 | & > .node-border 6 | display: none 7 | 8 | & > .node-content-line 9 | margin-bottom: 1em 10 | 11 | & > .node-bullet 12 | display: none 13 | 14 | & > .node-content, .CodeMirror 15 | font-size: 24px 16 | 17 | & > div > .node-wrapper:first-child > .node-children 18 | padding-left: 0 19 | margin-left: 0 20 | 21 | .node-add 22 | cursor: pointer 23 | width: 18px 24 | height: 18px 25 | margin-left: 10px 26 | transition: transform .2s, opacity .2s 27 | opacity: 0 28 | 29 | &:hover 30 | transform: scale(1.3) 31 | opacity: .9!important 32 | /#app:hover & 33 | opacity: .3 34 | 35 | 36 | .node-wrapper 37 | position: relative 38 | border-radius: .3em 39 | transition: all .1s ease-in-out 40 | margin-bottom: -0.3em 41 | 42 | // Node 43 | 44 | &.active 45 | background: var(--bg-accent) 46 | 47 | &.selected 48 | user-select: none 49 | 50 | & > .node-content-line > .node-bullet 51 | background: black 52 | 53 | &.no-match 54 | display: none 55 | &.partial-match > .node-content-line 56 | opacity: .6 57 | 58 | &.focused > .node-content-line > .node-content 59 | & > .node-display 60 | display: none 61 | 62 | & > .node-edit 63 | display: inherit 64 | 65 | .node-edit 66 | display: none 67 | 68 | .node-content-line 69 | display: flex 70 | 71 | .node-content 72 | display: inline-block 73 | flex-grow: 100 74 | margin: .5em 0 75 | 76 | 77 | // Bullet 78 | 79 | $inner-diameter = 9px 80 | 81 | .node-bullet 82 | $diameter = $inner-diameter * 3 83 | 84 | width: $diameter 85 | height: $diameter 86 | border-radius: $diameter 87 | background: transparent 88 | margin: .5em .3em 89 | cursor: pointer 90 | 91 | &:hover 92 | background: var(--dim-accent) 93 | 94 | .node-inner-bullet 95 | $diameter = $inner-diameter 96 | 97 | width: $diameter 98 | height: $diameter 99 | border-radius: $diameter 100 | background: var(--accent) 101 | 102 | margin: $inner-diameter 103 | 104 | /.collapsed& 105 | border: 2px solid var(--accent) 106 | background: transparent 107 | 108 | 109 | // Children 110 | 111 | box-sizing: border-box 112 | padding-bottom: 0 113 | 114 | .node-children 115 | display: block 116 | margin-left: 1.05em 117 | margin-top: -.5em 118 | padding-left: 1em 119 | 120 | .node-border 121 | border-left: solid var(--dim-accent) 1px 122 | top: 2.2em 123 | height: calc(100% - 2.4em) 124 | width: 1px 125 | left: 1.1em 126 | position: absolute 127 | 128 | &.no-children, &.collapsed 129 | .node-border, .node-children 130 | display: none 131 | 132 | 133 | 134 | // Render 135 | 136 | .node-display 137 | code 138 | background: var(--dim-accent) 139 | border-radius: .2em 140 | padding: .1em .3em 141 | font-family: $monospace 142 | font-size: .9em 143 | 144 | .node-display::before 145 | content: "\200B" 146 | 147 | .node-content, .CodeMirror 148 | font-family: $sans-serif 149 | font-size: 18px 150 | color: var(--foreground) 151 | 152 | 153 | // Editor 154 | 155 | .CodeMirror 156 | background: transparent 157 | height: auto 158 | 159 | .CodeMirror-lines, .CodeMirror pre 160 | padding: 0 161 | 162 | .CodeMirror-cursor 163 | border-left: 1px solid var(--accent) 164 | 165 | 166 | // Context menu and metadata editor 167 | 168 | .mdc-menu .mdc-list-item 169 | height: 40px 170 | 171 | .metadata-item 172 | display: flex 173 | align-items: center 174 | margin-top: 1em 175 | 176 | .metadata-key 177 | flex: 0 0 auto 178 | .metadata-value 179 | flex: 1 1 auto 180 | 181 | .mdc-text-field 182 | margin-right: 1em 183 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "alias": [ 4 | "snowfall" 5 | ], 6 | "builds": [ 7 | { 8 | "src": "package.json", 9 | "use": "@now/static-build" 10 | } 11 | ], 12 | "github": { 13 | "silent": true 14 | }, 15 | "routes": [ 16 | { "src": "/(?:(.+\\.)((?!js$|css$|webmanifest$|html$|woff2$|ttf$|woff$|ico$|)[^.]*)|[^.]+)$", "dest": "index.html" } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snowfall", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "repository": "git@github.com:71/snowfall.git", 6 | "author": "Gregoire Geis ", 7 | "license": "MIT", 8 | "private": false, 9 | "scripts": { 10 | "dev": "parcel client/index.pug --no-cache", 11 | "dev-client": "parcel client/index.pug", 12 | "dev-server": "parcel server/index.ts", 13 | "now-build": "parcel build client/index.pug" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.2.2", 17 | "@babel/plugin-transform-react-jsx": "^7.2.0", 18 | "@babel/plugin-transform-runtime": "^7.2.0", 19 | "@babel/preset-env": "^7.2.3", 20 | "@types/codemirror": "^0.0.71", 21 | "@types/markdown-it": "^0.0.7", 22 | "@types/yaml": "^1.0.1", 23 | "babel-plugin-transform-react-jsx": "^6.24.1", 24 | "babel-plugin-transform-runtime": "^6.23.0", 25 | "babel-preset-env": "^1.7.0", 26 | "codemirror": "^5.42.2", 27 | "fuzzysearch": "^1.0.3", 28 | "markdown-it": "^8.4.2", 29 | "material-icons": "^0.3.0", 30 | "open-color": "^1.6.3", 31 | "parcel-bundler": "^1.11.0", 32 | "parcel-plugin-sw-precache": "^1.0.3", 33 | "preact": "^8.4.2", 34 | "preact-async-route": "^2.2.1", 35 | "preact-material-components": "^1.5.5", 36 | "preact-router": "^2.6.1", 37 | "pug": "^2.0.3", 38 | "remotestorage-widget": "^1.3.0", 39 | "remotestoragejs": "^1.2.0", 40 | "rimraf": "^2.6.3", 41 | "stylus": "^0.54.5", 42 | "typeface-roboto-mono": "^0.0.54", 43 | "typeface-sarabun": "^0.0.71", 44 | "typescript": "^3.2.2", 45 | "yaml": "^1.1.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /server/helpers/diffFinder.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/71/snowfall/357af0d74076a64559f605c61c668c71009dd2d8/server/helpers/diffFinder.ts -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/71/snowfall/357af0d74076a64559f605c61c668c71009dd2d8/server/index.ts -------------------------------------------------------------------------------- /shared/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A node in the node tree. 3 | */ 4 | export class BaseNode { 5 | private _text: string 6 | private _data: object 7 | private _ids : { [id: string]: Node } 8 | private _parent: Node 9 | 10 | public readonly children: Node[] = [] 11 | 12 | private constructor( 13 | public readonly observers: NodeObservers, 14 | 15 | text: string, 16 | data: object = {} 17 | ) { 18 | this._text = text 19 | this._data = data 20 | } 21 | 22 | /** 23 | * Creates a new root node, given a list of its `NodeObserver`s. 24 | * 25 | * The list is not copied, so new observers can later be added or removed. 26 | */ 27 | static createRoot(observers: NodeObservers, ids: { [id: string]: Node } = {}) { 28 | const root = new BaseNode(observers, null, {}) as any as Node 29 | root._ids = ids 30 | return root 31 | } 32 | 33 | /** 34 | * Returns the underlying text of the node. 35 | */ 36 | get text(): string { 37 | return this._text 38 | } 39 | 40 | /** 41 | * Returns the underlying data object of the node, or its text if it does not have 42 | * an underlying object. 43 | */ 44 | get dataOrText(): object | string { 45 | return this._data || this._text 46 | } 47 | 48 | /** 49 | * Returns the root node of this tree. 50 | */ 51 | get root(): Node { 52 | let node = this as any as Node 53 | 54 | while (node._parent instanceof BaseNode) 55 | node = node._parent 56 | 57 | return node 58 | } 59 | 60 | /** 61 | * Returns the parent of the node, or `null` if it does not have one. 62 | */ 63 | get parent(): Node | null { 64 | return this._parent 65 | } 66 | 67 | /** 68 | * Gets the list of all the siblings of this node. 69 | */ 70 | get siblings(): Node[] { 71 | return this.isRoot ? [this as any as Node] : (this._parent as Node).children 72 | } 73 | 74 | /** 75 | * Returns the depth of the node in its tree. 76 | */ 77 | get depth() { 78 | let depth = -1 79 | let node = this._parent 80 | 81 | while (node) { 82 | depth++ 83 | node = node._parent 84 | } 85 | 86 | return depth 87 | } 88 | 89 | /** 90 | * Gets a (supposedly) unique identifier that can be used to quickly 91 | * refer to this node. 92 | */ 93 | get id(): string | undefined { 94 | return this.get('id') 95 | } 96 | 97 | /** 98 | * Gets or sets whether the node is completed, that is: 99 | * - its 'completed' property is `true`, or 100 | * - its text is surrounded by '~~'. 101 | */ 102 | get isCompleted(): boolean { 103 | return this.get('completed') === true || this._text.startsWith('~~') && this._text.endsWith('~~') 104 | } 105 | 106 | set isCompleted(value: boolean) { 107 | if (this._data != null) 108 | this.updateProperty('completed', value) 109 | else if (value) 110 | this.updateProperty('text', `~~${this._text}~~`) 111 | } 112 | 113 | get index() { 114 | return this.siblings.indexOf(this as any as Node) 115 | } 116 | 117 | get isRoot() { 118 | return this._parent == null 119 | } 120 | 121 | /** 122 | * Gets the metadata with the given key. 123 | */ 124 | get(key: string): any { 125 | return key == 'text' ? this._text : this._data != null ? this._data[key] : undefined 126 | } 127 | 128 | indexOf(node: Node): number { 129 | return this.children.indexOf(node) 130 | } 131 | 132 | /** 133 | * Returns a list of integers that represents the path from the root 134 | * of the tree to this node. 135 | * 136 | * This list can be used to serialize a node's position. 137 | */ 138 | computePath(): (number | string)[] { 139 | const path: (number | string)[] = [] 140 | let node: Node = this as any as Node 141 | 142 | while (node) { 143 | const id = node.id 144 | 145 | if (typeof id == 'string') { 146 | path.splice(0, 0, id) 147 | break 148 | } 149 | 150 | path.splice(0, 0, node.index) 151 | node = node.parent 152 | } 153 | 154 | return path 155 | } 156 | 157 | /** 158 | * Returns a string that represents the path from the root 159 | * of the tree to this node. 160 | */ 161 | computeStringPath(parentPath?: string): string { 162 | if (this.isRoot) 163 | return '' 164 | 165 | const id = this.id 166 | 167 | if (typeof id == 'string') 168 | // @ts-ignore 169 | return `/${id}` 170 | else 171 | return `${parentPath || this.parent.computeStringPath()}/${this.index}` 172 | } 173 | 174 | /** 175 | * Resolves the node that the given string path (generated by `computeStringPath`) 176 | * represents. 177 | */ 178 | resolveWithStringPath(path: string): Node | null { 179 | let node = this as any as Node 180 | 181 | for (const sub of path.split('/')) { 182 | if (!sub) 183 | continue 184 | 185 | const i = Number.parseInt(sub, 10) 186 | 187 | if (Number.isNaN(i)) { 188 | node = node.getById(sub) 189 | 190 | if (node == null) 191 | return null 192 | 193 | continue 194 | } 195 | 196 | if (i < 0 || i >= node.children.length) 197 | return null 198 | 199 | node = node.children[i] 200 | } 201 | 202 | return node 203 | } 204 | 205 | /** 206 | * Resolves the node that the given string path (generated by `computePath`) represents. 207 | */ 208 | resolveWithPath(path: (number | string)[]): Node | null { 209 | let node = this as any as Node 210 | 211 | for (const sub of path) { 212 | if (typeof sub == 'number') { 213 | if (sub < 0 || sub >= node.children.length) 214 | return null 215 | 216 | node = node.children[sub] 217 | } else { 218 | node = node.getById(sub) 219 | 220 | if (node == null) 221 | return null 222 | } 223 | } 224 | 225 | return node 226 | } 227 | 228 | /** 229 | * Returns the node that bears the given identifier. 230 | */ 231 | getById(id: string): Node | undefined { 232 | return this.root._ids[id] 233 | } 234 | 235 | protected notify(f: (store: NodeObserver) => Promise | void) { 236 | return Promise.all(Object.values(this.observers).map(f)) 237 | } 238 | 239 | /** 240 | * Creates a new child at the given index. 241 | */ 242 | async createChild(index: number, text: string = '', data: object = {}, init: (node: Node) => void = null) { 243 | const child = new BaseNode(this.observers, text, data) as unknown as Node 244 | 245 | if (init) 246 | init(child) 247 | 248 | await child.insert(this as unknown as Node, index) 249 | 250 | return child 251 | } 252 | 253 | /** 254 | * Inserts a new node as a child of the given parent. 255 | */ 256 | insert(parent: Node, index: number) { 257 | if (parent) 258 | parent.children.splice(index, 0, this as any) 259 | 260 | this._parent = parent 261 | 262 | return this.notify(store => store.inserted(this as any)) 263 | } 264 | 265 | /** 266 | * Removes the given node from the tree. 267 | */ 268 | remove() { 269 | if (this.isRoot) 270 | throw new Error('Cannot remove root node from the tree.') 271 | 272 | const index = this.index 273 | const oldParent = this._parent as Node 274 | 275 | oldParent.children.splice(index, 1) 276 | this._parent = null 277 | 278 | return this.notify(store => store.removed(this as any, oldParent, index)) 279 | } 280 | 281 | /** 282 | * Moves the given node to another parent. 283 | */ 284 | move(newParent: Node, index: number) { 285 | if (this.isRoot) 286 | throw new Error('Cannot move root node in the tree.') 287 | 288 | const oldParent = this._parent as Node 289 | const oldIndex = this.index 290 | 291 | newParent.children.splice(index, 0, this.parent.children.splice(this.index, 1)[0]) 292 | 293 | this._parent = newParent 294 | 295 | return this.notify(store => store.moved(this as any, oldParent, oldIndex)) 296 | } 297 | 298 | /** 299 | * Updates a property of the given node. 300 | * 301 | * If `newValue` is `undefined`, the property is removed. 302 | */ 303 | updateProperty(propertyKey: string, newValue: any) { 304 | if (this._data == null) { 305 | if (propertyKey == 'text') { 306 | // Simple, text-only node 307 | const oldValue = this._text 308 | 309 | if (oldValue == newValue) 310 | return 311 | 312 | this._text = newValue 313 | 314 | return this.notify(store => store.propertyUpdated(this as any, 'text', newValue, oldValue)) 315 | } 316 | 317 | this._data = { text: this._text } 318 | } 319 | 320 | if (this._data[propertyKey] === newValue) 321 | return 322 | 323 | const oldValue = this._data[propertyKey] 324 | 325 | if (newValue === undefined) 326 | delete this._data[propertyKey] 327 | else 328 | this._data[propertyKey] = newValue 329 | 330 | if (propertyKey == 'text') 331 | this._text = newValue 332 | if (propertyKey == 'id') 333 | this.root._ids[newValue] = this as any as Node 334 | 335 | return this.notify(store => store.propertyUpdated(this as any, propertyKey, newValue, oldValue)) 336 | } 337 | 338 | /** 339 | * Increases the depth of a node. 340 | * 341 | * If it is the first child of another node, nothing will be done. 342 | */ 343 | increaseDepth() { 344 | const index = this.index 345 | 346 | if (index == 0) 347 | return 348 | 349 | const oldParent = this._parent as Node 350 | const newParent = this._parent.children[index - 1] 351 | 352 | newParent.children.push(this as any) 353 | 354 | oldParent.children.splice(index, 1) 355 | this._parent = newParent 356 | 357 | return this.notify(store => store.moved(this as any, oldParent, index)) 358 | } 359 | 360 | /** 361 | * Decreases the depth of a node. 362 | * 363 | * If its depth is already 0, nothing will be done. 364 | */ 365 | decreaseDepth() { 366 | if (this.isRoot || this._parent.isRoot) 367 | return 368 | 369 | const index = this.index 370 | const oldParent = this._parent as Node 371 | const newParent = oldParent._parent as Node 372 | const siblings = oldParent.children 373 | 374 | newParent.children.splice(oldParent.index + 1, 0, this as any) 375 | 376 | for (let i = index + 1; i < siblings.length; i++) 377 | siblings[i]._parent = this as any 378 | 379 | this.children.push(...siblings.splice(index + 1)) 380 | this._parent = newParent 381 | 382 | siblings.pop() 383 | 384 | return this.notify(store => store.moved(this as any, oldParent, index)) 385 | } 386 | } 387 | 388 | /** 389 | * A node with additional `T` data. 390 | */ 391 | export type Node = T & BaseNode 392 | 393 | 394 | /** 395 | * Defines a structure that can watch a `Node`. 396 | */ 397 | export interface NodeObserver { 398 | /** 399 | * Initializes and inserts a new node as a child of the given parent. 400 | */ 401 | inserted(node: Node): Promise | void 402 | 403 | /** 404 | * Removes the given node from the tree. 405 | */ 406 | removed(node: Node, oldParent: Node, oldIndex: number): Promise | void 407 | 408 | /** 409 | * Moves the given node to another parent. 410 | */ 411 | moved(node: Node, oldParent: Node, oldIndex: number): Promise | void 412 | 413 | /** 414 | * Updates a property of the given node. 415 | * 416 | * If `newValue` is `undefined`, the property is removed. 417 | */ 418 | propertyUpdated(node: Node, propertyKey: string, newValue: any, oldValue: any): Promise | void 419 | } 420 | 421 | export type NodeObservers = object & { 422 | [key: number]: NodeObserver 423 | } 424 | 425 | export interface StoreObserver extends NodeObserver { 426 | loading?(): Promise | void 427 | loaded ?(): Promise | void 428 | 429 | saving ?(): Promise | void 430 | saved ?(): Promise | void 431 | } 432 | 433 | export class DefaultObserver implements StoreObserver { 434 | constructor(private callbacks: { 435 | inserted?: (node: Node) => void | Promise, 436 | removed ?: (node: Node, oldParent: Node, oldIndex: number) => void | Promise, 437 | moved ?: (node: Node, oldParent: Node, oldIndex: number) => void | Promise, 438 | propertyUpdated?: (node: Node, propertyKey: string, newValue: any, oldValue: any) => void | Promise, 439 | 440 | loading?: () => Promise | void, 441 | loaded ?: () => Promise | void, 442 | 443 | saving ?: () => Promise | void, 444 | saved ?: () => Promise | void, 445 | }) {} 446 | 447 | inserted(node: Node): void | Promise { 448 | if (this.callbacks.inserted) 449 | return this.callbacks.inserted(node) 450 | } 451 | removed(node: Node, oldParent: Node, oldIndex: number): void | Promise { 452 | if (this.callbacks.removed) 453 | return this.callbacks.removed(node, oldParent, oldIndex) 454 | } 455 | moved(node: Node, oldParent: Node, oldIndex: number): void | Promise { 456 | if (this.callbacks.moved) 457 | return this.callbacks.moved(node, oldParent, oldIndex) 458 | } 459 | propertyUpdated(node: Node, propertyKey: string, newValue: any, oldValue: any): void | Promise { 460 | if (this.callbacks.propertyUpdated) 461 | return this.callbacks.propertyUpdated(node, propertyKey, newValue, oldValue) 462 | } 463 | 464 | loading(): Promise | void { 465 | if (this.callbacks.loading) 466 | return this.callbacks.loading() 467 | } 468 | loaded(): Promise | void { 469 | if (this.callbacks.loaded) 470 | return this.callbacks.loaded() 471 | } 472 | saving(): Promise | void { 473 | if (this.callbacks.saving) 474 | return this.callbacks.saving() 475 | } 476 | saved(): Promise | void { 477 | if (this.callbacks.saved) 478 | return this.callbacks.saved() 479 | } 480 | } 481 | 482 | /** 483 | * Defines the backing store of a tree. 484 | * 485 | * Depending on the situation, the store will be very different: 486 | * - On the server, it is kept in memory, and syncs changes to the underlying 487 | * YAML files. 488 | * - On the browser, it can either be online or offline: 489 | * - Online, it sends changes to the server without worrying about the underlying 490 | * YAML files 491 | */ 492 | export interface Store extends NodeObserver { 493 | /** 494 | * Gets the observers of nodes of this store. 495 | */ 496 | readonly observers: NodeObservers 497 | 498 | /** 499 | * Gets the root node. 500 | */ 501 | readonly root: Node 502 | } 503 | 504 | 505 | /** 506 | * A queue that saves all of its changes in an array, in the order in which they 507 | * are performed. 508 | * 509 | * This store can be used to send changes to another instance of Snowfall for 510 | * synchronization purposes. 511 | */ 512 | export class ChangeQueue implements NodeObserver<{}> { 513 | public readonly changes: { type: string; payload: any[] }[] = [] 514 | public readonly observers: ((queue: ChangeQueue) => void)[] = [] 515 | 516 | private pushChange(type: string, ...payload: any[]) { 517 | this.changes.push({ type, payload }) 518 | this.observers.forEach(observer => observer(this)) 519 | } 520 | 521 | inserted(node: Node<{}>): void | Promise { 522 | this.pushChange('inserted', node.id) 523 | } 524 | 525 | removed(node: Node<{}>, oldParent: Node<{}>, oldIndex: number): void | Promise { 526 | this.pushChange('removed', node.id, oldParent.id, oldIndex) 527 | } 528 | 529 | moved(node: Node<{}>, oldParent: Node<{}>, oldIndex: number): void | Promise { 530 | this.pushChange('moved', node.id, oldParent.id, oldIndex) 531 | } 532 | 533 | propertyUpdated(node: Node<{}>, propertyKey: string, newValue: any, oldValue: any): void | Promise { 534 | this.pushChange('propertyUpdated', node.id, propertyKey, newValue, oldValue) 535 | } 536 | 537 | depthIncreased(node: Node<{}>, oldParent: Node<{}>): void | Promise { 538 | this.pushChange('depthIncreased', node.id, oldParent.id) 539 | } 540 | 541 | depthDecreased(node: Node<{}>, oldParent: Node<{}>): void | Promise { 542 | this.pushChange('depthDecreased', node.id, oldParent.id) 543 | } 544 | } 545 | -------------------------------------------------------------------------------- /shared/yaml.ts: -------------------------------------------------------------------------------- 1 | import { Node, Store, BaseNode, NodeObserver, StoreObserver, NodeObservers } from '.' 2 | import yaml from 'yaml' 3 | 4 | 5 | export interface FileSystem { 6 | read(filename: string): Promise 7 | 8 | write(filename: string, contents: string): Promise 9 | 10 | getFiles(): Promise 11 | 12 | createFile(filename: string, contents?: string): Promise 13 | } 14 | 15 | 16 | class NodeHelpers { 17 | static getValue(node: yaml.ast.MapBase, pred: (k: string, v: yaml.ast.Pair | yaml.ast.Merge) => boolean) { 18 | for (const item of node.items) { 19 | const key = item.key!!.toJSON() 20 | 21 | if (pred(key, item)) 22 | return item.value 23 | } 24 | 25 | return null 26 | } 27 | 28 | static setValue(node: yaml.ast.MapBase, key: string, value: yaml.ast.AstNode) { 29 | for (const item of node.items) { 30 | if (item.key.toJSON() == key) { 31 | item.value = value 32 | return 33 | } 34 | } 35 | 36 | node.items.push((yaml.createNode({ [key]: null })).items[0]) 37 | node.items[node.items.length - 1].value = value 38 | } 39 | 40 | static getText(node: yaml.ast.MapBase) { 41 | return NodeHelpers.getValue(node, k => k == 'text' || k == 'title' || k == 'note') 42 | } 43 | 44 | static getNotes(node: yaml.ast.MapBase) { 45 | const notes = NodeHelpers.getValue(node, 46 | k => k == 'notes' || k == 'items' || k == 'children') 47 | return notes as yaml.ast.Seq 48 | } 49 | } 50 | 51 | export abstract class YamlFileOrChildNode { 52 | public abstract kind: 'file' | 'child' 53 | public abstract file: YamlFileNode 54 | 55 | private _seq: yaml.ast.SeqBase 56 | private _map: yaml.ast.MapBase 57 | 58 | constructor(public node: yaml.ast.MapBase | yaml.ast.AstNode) { 59 | if (node.type == 'MAP') { 60 | this._map = node 61 | this._seq = NodeHelpers.getNotes(node) 62 | } 63 | } 64 | 65 | get seq() { 66 | if (this._seq) 67 | return this._seq 68 | 69 | // we don't have children / we're a string 70 | if (this.node.type != 'MAP') 71 | this.node = yaml.createNode({ text: this.node.toJSON(), children: [] }) 72 | else 73 | this.node.items.push((yaml.createNode({ children: [] })).items[0]) 74 | 75 | return this._seq = NodeHelpers.getNotes(this.node) 76 | } 77 | 78 | get map() { 79 | if (this._map) 80 | return this._map 81 | 82 | return this.node = this._map = yaml.createNode({ text: this.node.toJSON() }) 83 | } 84 | } 85 | 86 | /** 87 | * A YAML node stored in its own file. 88 | */ 89 | export class YamlFileNode extends YamlFileOrChildNode { 90 | public kind: 'file' = 'file' 91 | public isDirty: boolean = false 92 | 93 | constructor( 94 | public filename: string, 95 | public document: yaml.ast.Document, 96 | public contents: string 97 | ) { 98 | super(document.contents) 99 | } 100 | 101 | get file() { 102 | return this 103 | } 104 | } 105 | 106 | /** 107 | * A child YAML node. 108 | */ 109 | export class YamlChildNode extends YamlFileOrChildNode { 110 | public kind: 'child' = 'child' 111 | 112 | constructor( 113 | public node: yaml.ast.MapBase | yaml.ast.AstNode, 114 | public textKind: 'plain' | 'property' | 'included', 115 | public file: YamlFileNode, 116 | public includedFile?: IncludedFile 117 | ) { 118 | super(node) 119 | } 120 | } 121 | 122 | export class IncludedFile { 123 | public kind: 'included' = 'included' 124 | public isDirty: boolean = false 125 | public nextContents: string 126 | 127 | constructor( 128 | public filename: string, 129 | public contents: string 130 | ) {} 131 | } 132 | 133 | export type YamlStoreState = { syntax: YamlFileNode | YamlChildNode } 134 | 135 | 136 | class IncludeNode implements yaml.ast.Node { 137 | comment: string 138 | commentBefore: string 139 | cstNode?: yaml.cst.Node 140 | range: [number, number] 141 | tag: string 142 | 143 | type: 'INCLUDE' = 'INCLUDE' 144 | 145 | constructor(public filename: string) {} 146 | 147 | toJSON() { 148 | return { __include__: this.filename } 149 | } 150 | } 151 | 152 | const includeTag: yaml.Tag = { 153 | class : Object, 154 | default: true, 155 | tag : 'tag:yaml.org,2002:include', 156 | 157 | // @ts-ignore 158 | resolve: (doc, cstNode: yaml.cst.Node) => { 159 | if (cstNode.type != 'PLAIN') 160 | throw '' 161 | 162 | return new IncludeNode(cstNode.rawValue) 163 | }, 164 | 165 | stringify: (item, ctx) => { 166 | return `!!include ${(item as any).value.filename}` 167 | } 168 | } 169 | 170 | 171 | /** 172 | * A `Store` that uses the file system and YAML files as backend. 173 | */ 174 | export class YamlStore implements Store { 175 | private saveTimeout: NodeJS.Timeout 176 | 177 | public files: (YamlFileNode | IncludedFile)[] = [] 178 | public root: Node 179 | 180 | constructor( 181 | public fs : FileSystem, 182 | public observers: NodeObservers, 183 | public throttleMs = Infinity 184 | ) { 185 | observers['yamlStore'] = this 186 | } 187 | 188 | 189 | async load(filename: string): Promise { 190 | const errors: string[] = [] 191 | const ids = {} 192 | 193 | this.files.length = 0 194 | this.root = await BaseNode.createRoot(this.observers, ids) 195 | 196 | for (const observerKey in this.observers) { 197 | const observer = this.observers[observerKey] as any as StoreObserver 198 | 199 | if (typeof observer.loading == 'function') 200 | await observer.loading() 201 | } 202 | 203 | const content = await this.fs.read(filename) 204 | const document = yaml.parseDocument(content, { tags: [ includeTag ] }) 205 | const root = new YamlFileNode(filename, document, content) 206 | 207 | this.files.push(root) 208 | 209 | const visit = async (parent: Node, currentFile: YamlFileNode, items: any[], seq: yaml.ast.SeqBase) => { 210 | for (let i = 0; i < items.length; i++) 211 | { 212 | let item = items[i] 213 | let node = seq.items[i] 214 | 215 | if (typeof item == 'string') 216 | { 217 | await parent.createChild(i, item, null, child => { 218 | child.syntax = new YamlChildNode(node, 'plain', currentFile) 219 | }) 220 | } 221 | else if (typeof item == 'object') 222 | { 223 | let filename = null 224 | let contents = null 225 | let document = null 226 | 227 | if (typeof item.__include__ == 'string') 228 | { 229 | filename = item.__include__ 230 | 231 | if (filename == currentFile.filename) { 232 | errors.push(`Cannot recursively import file ${filename}.`) 233 | continue 234 | } 235 | 236 | if (!filename.endsWith('.yaml') && !filename.endsWith('.yml')) { 237 | errors.push(`File ${filename} is not a YAML file.`) 238 | continue 239 | } 240 | 241 | contents = await this.fs.read(filename) 242 | 243 | if (contents == null) { 244 | errors.push(`File ${filename} does not exist.`) 245 | continue 246 | } 247 | 248 | document = yaml.parseDocument(contents, { tags: [ includeTag ] }) 249 | 250 | if (!document.contents || document.contents.type != 'MAP') { 251 | errors.push(`File ${filename} has an invalid content.`) 252 | continue 253 | } 254 | 255 | node = document.contents 256 | item = node.toJSON() 257 | } 258 | 259 | let text = item['text'] 260 | 261 | if (typeof text.__include__ == 'string') { 262 | // Text is included from other file 263 | filename = text.__include__ 264 | text = await this.fs.read(filename) 265 | 266 | if (text == null) { 267 | errors.push(`File ${filename} does not exist.`) 268 | continue 269 | } 270 | } else if (typeof text != 'string') { 271 | errors.push(`A note does not have any text.`) 272 | continue 273 | } 274 | 275 | const child = await parent.createChild(i, text, item, child => { 276 | if (document != null) { 277 | // Own file 278 | child.syntax = new YamlFileNode(filename, document, contents) 279 | 280 | this.files.push(child.syntax) 281 | } else if (filename != null) { 282 | // Child, but with text imported from other file 283 | const file = new IncludedFile(filename, text) 284 | 285 | child.syntax = new YamlChildNode(node, 'included', currentFile, file) 286 | this.files.push(file) 287 | } else { 288 | // Regular child 289 | child.syntax = new YamlChildNode(node, 'property', currentFile) 290 | } 291 | }) 292 | 293 | const id = item['id'] 294 | 295 | if (typeof id == 'string') { 296 | ids[id] = child 297 | } 298 | 299 | const map = node 300 | 301 | if (item.notes) 302 | await visit(child, child.syntax.file, item.notes, NodeHelpers.getValue(map, k => k == 'notes') as yaml.ast.Seq) 303 | else if (item.items) 304 | await visit(child, child.syntax.file, item.items, NodeHelpers.getValue(map, k => k == 'items') as yaml.ast.Seq) 305 | else if (item.children) 306 | await visit(child, child.syntax.file, item.children, NodeHelpers.getValue(map, k => k == 'children') as yaml.ast.Seq) 307 | } 308 | else 309 | { 310 | errors.push(`Invalid YAML document.`) 311 | continue 312 | } 313 | } 314 | } 315 | 316 | if (!document.contents || document.contents.type != 'MAP') { 317 | errors.push(`Invalid YAML document.`) 318 | return errors 319 | } 320 | 321 | const items = NodeHelpers.getValue(document.contents, k => k == 'items' || k == 'notes') 322 | 323 | if (!items || items.type != 'SEQ') { 324 | errors.push(`Invalid YAML document.`) 325 | return errors 326 | } 327 | 328 | this.root.syntax = root 329 | 330 | await this.root.insert(null, 0) 331 | 332 | await visit(this.root, root, items.toJSON(), items) 333 | 334 | for (const observerKey in this.observers) { 335 | const observer = this.observers[observerKey] as any as StoreObserver 336 | 337 | if (typeof observer.loaded == 'function') 338 | await observer.loaded() 339 | } 340 | 341 | return errors 342 | } 343 | 344 | 345 | async save() { 346 | clearTimeout(this.saveTimeout) 347 | 348 | this.saveTimeout = null 349 | 350 | for (const observerKey in this.observers) { 351 | const observer = this.observers[observerKey] as any as StoreObserver 352 | 353 | if (typeof observer.saving == 'function') 354 | await observer.saving() 355 | } 356 | 357 | for (const file of this.files) { 358 | if (!file.isDirty) 359 | continue 360 | 361 | if (file.kind == 'file') 362 | file.contents = file.document.toString() 363 | else 364 | file.contents = file.nextContents 365 | 366 | await this.fs.write(file.filename, file.contents) 367 | 368 | file.isDirty = false 369 | } 370 | 371 | for (const observerKey in this.observers) { 372 | const observer = this.observers[observerKey] as any as StoreObserver 373 | 374 | if (typeof observer.saved == 'function') 375 | await observer.saved() 376 | } 377 | } 378 | 379 | 380 | private scheduleSave() { 381 | const throttle = this.throttleMs 382 | 383 | if (throttle == Infinity) 384 | return 385 | 386 | if (this.saveTimeout) 387 | clearTimeout(this.saveTimeout) 388 | 389 | this.saveTimeout = setTimeout(() => { 390 | this.save() 391 | }, throttle) 392 | } 393 | 394 | private markDirty(node: Node | IncludedFile) { 395 | if (node instanceof BaseNode) 396 | node.syntax.file.isDirty = true 397 | else 398 | node.isDirty = true 399 | 400 | this.scheduleSave() 401 | } 402 | 403 | 404 | inserted(node: Node) { 405 | if (node.syntax || !node.parent) 406 | // already initialized (or root), we don't care 407 | return 408 | 409 | node.syntax = new YamlChildNode(yaml.createNode(node.dataOrText) as any, typeof node.dataOrText == 'string' ? 'plain' : 'property', node.parent.syntax.file) 410 | node.parent.syntax.seq.items.splice(node.index, 0, node.syntax.map) 411 | 412 | this.markDirty(node) 413 | } 414 | 415 | removed(node: Node, oldParent: Node, oldIndex: number) { 416 | node.syntax = null 417 | oldParent.syntax.seq.items.splice(oldIndex, 1) 418 | 419 | this.markDirty(oldParent) 420 | } 421 | 422 | async propertyUpdated(node: Node, propertyKey: string, newValue: any) { 423 | if (propertyKey == 'text' && node.syntax.kind == 'child') { 424 | if (node.syntax.textKind == 'plain') { 425 | // Updating text only, no need to create a new map 426 | node.syntax.node = yaml.createNode(newValue) as yaml.ast.ScalarNode 427 | } else if (node.syntax.textKind == 'included') { 428 | // Edited text that comes from another file, so we write to the file itself 429 | node.syntax.node = yaml.createNode(newValue) as yaml.ast.ScalarNode 430 | node.syntax.includedFile.nextContents = newValue 431 | 432 | this.markDirty(node.syntax.includedFile) 433 | } else { 434 | NodeHelpers.setValue(node.syntax.map, 'text', yaml.createNode(newValue) as any) 435 | } 436 | } else { 437 | NodeHelpers.setValue(node.syntax.map, propertyKey, yaml.createNode(newValue) as any) 438 | } 439 | 440 | this.markDirty(node) 441 | } 442 | 443 | moved(node: Node, oldParent: Node, oldIndex: number) { 444 | if (node.syntax.kind == 'file') { 445 | console.log('fuck', node) 446 | } 447 | 448 | node.parent.syntax.seq.items.splice(node.index, 0, node.syntax.map) 449 | oldParent.syntax.seq.items.splice(oldIndex, 1) 450 | 451 | if (node.syntax.kind == 'child') 452 | // Update file, in case it changed from one to another 453 | node.syntax.file = node.parent.syntax.file 454 | 455 | this.markDirty(node) 456 | } 457 | } 458 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "jsx": "react", 5 | "jsxFactory": "h", 6 | "module": "commonjs", 7 | "target": "esnext" 8 | }, 9 | 10 | "parcelTsPluginOptions": { 11 | "transpileOnly": false 12 | } 13 | } 14 | --------------------------------------------------------------------------------