├── index.d.ts ├── .dockerignore ├── src ├── index.ts ├── react-app-env.d.ts ├── plugins │ ├── todo │ │ ├── index.sass │ │ └── index.tsx │ ├── text_formatting │ │ ├── index.sass │ │ └── index.tsx │ ├── examples │ │ └── index.ts │ ├── index.ts │ ├── debug_mode │ │ └── index.tsx │ ├── bracket_completion │ │ └── index.ts │ ├── html │ │ └── index.tsx │ ├── recursive_expand │ │ └── index.tsx │ ├── latex │ │ └── index.tsx │ ├── easy_motion │ │ └── index.tsx │ └── clone_tags │ │ └── index.tsx ├── shared │ ├── server_config.ts │ ├── data_backend.ts │ └── utils │ │ ├── logger.ts │ │ └── errors.ts └── assets │ ├── ts │ ├── definitions │ │ ├── index.ts │ │ ├── meta.ts │ │ ├── zoom.ts │ │ ├── indent.ts │ │ ├── history.ts │ │ └── menu.tsx │ ├── constants.ts │ ├── config.ts │ ├── components │ │ ├── spinner.tsx │ │ ├── fileInput.tsx │ │ ├── breadcrumbs.tsx │ │ ├── settings │ │ │ └── behaviorSettings.tsx │ │ ├── hotkeysTable.tsx │ │ └── menu.tsx │ ├── types.ts │ ├── utils │ │ ├── text.ts │ │ ├── functional.ts │ │ ├── queue.ts │ │ ├── browser.ts │ │ └── eventEmitter.ts │ ├── menu.ts │ ├── path.ts │ ├── keyMappings.ts │ ├── keyEmitter.ts │ ├── register.ts │ ├── themes.ts │ ├── keyBindings.ts │ └── keyDefinitions.ts │ └── css │ ├── mixins.sass │ ├── view.sass │ ├── utils.sass │ └── index.sass ├── docs ├── vimflowy.png ├── vim_inconsistencies.md ├── storage │ ├── README.md │ ├── SQLite.md │ └── Firebase.md ├── FAQ.md ├── dev_setup.md └── deployment.md ├── public ├── robots.txt ├── images │ ├── vimflowy.pxd │ ├── vimflowy-128.png │ ├── vimflowy-32.png │ └── vimflowy-512.png ├── manifest.json └── index.html ├── test ├── mocha.opts └── tests │ ├── insert_mode_actions.ts │ ├── todo.ts │ ├── visual.ts │ ├── collapse.ts │ ├── move_siblings.ts │ ├── swap_case.ts │ ├── join.ts │ ├── delete_home_end.ts │ ├── find.ts │ ├── goparent.ts │ ├── visible_ends.ts │ ├── repeat.ts │ ├── tags_clone.ts │ ├── macros.ts │ ├── backspace.ts │ ├── tags.ts │ └── enter.ts ├── server ├── constants.ts ├── data_backends.ts ├── prod.ts └── socket_server.ts ├── .gitignore ├── tsconfig.json ├── Dockerfile ├── LICENSE ├── .travis.yml ├── README.md ├── package.json └── tslint.json /index.d.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | test/** 2 | node_modules/** 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './assets/ts/app'; 2 | 3 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /docs/vimflowy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WuTheFWasThat/vimflowy/HEAD/docs/vimflowy.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/plugins/todo/index.sass: -------------------------------------------------------------------------------- 1 | .strikethrough 2 | text-decoration: line-through 3 | opacity: 0.5 4 | -------------------------------------------------------------------------------- /public/images/vimflowy.pxd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WuTheFWasThat/vimflowy/HEAD/public/images/vimflowy.pxd -------------------------------------------------------------------------------- /public/images/vimflowy-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WuTheFWasThat/vimflowy/HEAD/public/images/vimflowy-128.png -------------------------------------------------------------------------------- /public/images/vimflowy-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WuTheFWasThat/vimflowy/HEAD/public/images/vimflowy-32.png -------------------------------------------------------------------------------- /public/images/vimflowy-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WuTheFWasThat/vimflowy/HEAD/public/images/vimflowy-512.png -------------------------------------------------------------------------------- /src/shared/server_config.ts: -------------------------------------------------------------------------------- 1 | // server-side configuration of client code 2 | export type ServerConfig = { 3 | socketserver?: boolean, 4 | }; 5 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ts-node/register 2 | --require ignore-styles 3 | --watch-extensions tsx,ts 4 | --timeout 60000 5 | test/tests/*.ts 6 | -------------------------------------------------------------------------------- /src/assets/ts/definitions/index.ts: -------------------------------------------------------------------------------- 1 | import './motions'; 2 | import './basics'; 3 | import './history'; 4 | import './indent'; 5 | import './menu'; 6 | import './meta'; 7 | import './zoom'; 8 | -------------------------------------------------------------------------------- /src/assets/ts/constants.ts: -------------------------------------------------------------------------------- 1 | import { ServerConfig } from '../../shared/server_config'; 2 | 3 | export const SERVER_CONFIG: ServerConfig = JSON.parse(process.env.REACT_APP_SERVER_CONFIG || '{}'); 4 | 5 | -------------------------------------------------------------------------------- /src/plugins/text_formatting/index.sass: -------------------------------------------------------------------------------- 1 | .bold 2 | font-weight: bold 3 | 4 | .italic 5 | font-style: italic 6 | 7 | .underline 8 | text-decoration: underline 9 | 10 | .code 11 | color: #333; 12 | background: #eee; -------------------------------------------------------------------------------- /server/constants.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | export const defaultSrcDir = path.join(__dirname, '../src'); 4 | export const defaultStaticDir = path.join(__dirname, '../', 'public'); 5 | export const defaultBuildDir = path.join(__dirname, '../build'); 6 | 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /build/ 3 | .sass-cache 4 | 5 | node_modules/ 6 | npm-debug.log 7 | 8 | # vim swap files 9 | *.sw* 10 | # spacemacs files 11 | .#* 12 | 13 | /todo 14 | .DS_Store 15 | .vscode/* 16 | 17 | # profiling stuff 18 | *-v8.log 19 | 20 | .eslintcache 21 | -------------------------------------------------------------------------------- /src/assets/ts/definitions/meta.ts: -------------------------------------------------------------------------------- 1 | import keyDefinitions, { Action, SequenceAction } from '../keyDefinitions'; 2 | 3 | keyDefinitions.registerAction(new Action( 4 | 'export-file', 5 | 'Export as Json file', 6 | async function({ session }) { 7 | await session.exportFile('json'); 8 | }, 9 | { 10 | sequence: SequenceAction.DROP, 11 | }, 12 | )); 13 | 14 | -------------------------------------------------------------------------------- /src/assets/ts/config.ts: -------------------------------------------------------------------------------- 1 | import { ModeId, SerializedBlock } from './types'; 2 | import KeyMappings from './keyMappings'; 3 | 4 | // TODO: key mappings 5 | // TODO: starting mode 6 | // TODO: starting text (i.e. constants.default_data) 7 | 8 | type Config = { 9 | defaultMode: ModeId; 10 | getDefaultData: () => Array; 11 | // NOTE: can be mutated when there's a mode registered 12 | defaultMappings: KeyMappings; 13 | }; 14 | export default Config; 15 | -------------------------------------------------------------------------------- /src/plugins/examples/index.ts: -------------------------------------------------------------------------------- 1 | import { registerPlugin } from '../../assets/ts/plugins'; 2 | 3 | registerPlugin({ 4 | name: 'Hello World example', 5 | version: 1, 6 | author: 'Jeff Wu', 7 | description: ` 8 | Dummy example plugin for developers. 9 | Prints \'Hello World\' when the plugin is loaded 10 | `, 11 | }, function (api) { 12 | api.session.showMessage('Example plugin: Hello world!'); 13 | }, function (api) { 14 | api.session.showMessage('Example plugin: Goodbye world!'); 15 | }); 16 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import './easy_motion'; 2 | import './html'; 3 | import './latex'; 4 | import './marks'; 5 | import './text_formatting'; 6 | import './time_tracking'; 7 | import './todo'; 8 | import './recursive_expand'; 9 | import './daily_notes'; 10 | import './deadlines'; 11 | import './tags'; 12 | import './clone_tags'; 13 | import './bracket_completion'; 14 | 15 | // for developers: uncomment the following lines 16 | /* 17 | import './debug_mode'; 18 | import './examples'; 19 | */ 20 | // TODO: make this automatically happen in webpack dev? 21 | -------------------------------------------------------------------------------- /src/assets/ts/components/spinner.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | type Props = { 4 | fontSize?: number; 5 | loadingText?: string; 6 | }; 7 | export default class Spinner extends React.PureComponent { 8 | public render() { 9 | const style = { 10 | fontSize: this.props.fontSize || 20, 11 | marginRight: this.props.loadingText ? 10 : 0, 12 | }; 13 | 14 | return ( 15 | 16 | 19 | {this.props.loadingText} 20 | 21 | ); 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "images/vimflowy-128.png", 12 | "type": "image/png", 13 | "sizes": "128x128" 14 | }, 15 | { 16 | "src": "images/vimflowy-512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Vimflowy 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/plugins/debug_mode/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; // tslint:disable-line no-unused-variable 2 | 3 | import { registerPlugin } from '../../assets/ts/plugins'; 4 | 5 | registerPlugin({ 6 | name: 'ID Debug Mode', 7 | author: 'Zachary Vance', 8 | description: 'Display internal IDs for each node (for debugging for developers)', 9 | version: 1, 10 | }, (api => 11 | api.registerHook('session', 'renderAfterLine', function(pathElements, { path }) { 12 | pathElements.push( 13 | 14 | {' ' + path.getAncestry().join(', ')} 15 | 16 | ); 17 | 18 | return pathElements; 19 | }) 20 | ), (api => api.deregisterAll()) 21 | ); 22 | -------------------------------------------------------------------------------- /src/plugins/bracket_completion/index.ts: -------------------------------------------------------------------------------- 1 | import { registerPlugin } from '../../assets/ts/plugins'; 2 | import { Char } from '../../assets/ts/types'; 3 | 4 | const completions: { [key: string]: Char } = {'(': ')', '{': '}', '[': ']', '"': '"'}; 5 | 6 | registerPlugin({ 7 | name: 'Bracket Completion', 8 | version: 1, 9 | author: 'Victor Tao', 10 | description: ` 11 | Auto completes ${Object.keys(completions).join(', ')} in insert mode 12 | `, 13 | }, function (api) { 14 | api.registerHook('session', 'charInserted', async (_struct, { key }) => { 15 | if (key in completions) { 16 | await api.session.addCharsAtCursor([completions[key]]); 17 | await api.session.cursor.left(); 18 | } 19 | }); 20 | }, 21 | (api => api.deregisterAll()), 22 | ); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "lib": [ 5 | "dom", 6 | "es2015", 7 | "esnext.asynciterable" 8 | ], 9 | "jsx": "react-jsx", 10 | "types": [], 11 | "moduleResolution": "node", 12 | "allowJs": false, 13 | "strict": true, 14 | "experimentalDecorators": true, 15 | "esModuleInterop": true, 16 | "noUnusedParameters": true, 17 | "noUnusedLocals": false, 18 | "noImplicitReturns": true, 19 | "skipLibCheck": true, 20 | "allowSyntheticDefaultImports": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "module": "esnext", 24 | "resolveJsonModule": true, 25 | "isolatedModules": true, 26 | "noEmit": true 27 | }, 28 | "include": [ 29 | "src" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-stretch AS build 2 | LABEL maintainer="will.price94@gmail.com" 3 | LABEL version="0.0.1" 4 | # Prevent npm from spamming 5 | ENV NPM_CONFIG_LOGLEVEL=warn 6 | RUN npm config set progress=false 7 | WORKDIR /app/ 8 | COPY package.json package-lock.json ./ 9 | RUN npm install 10 | COPY . . 11 | RUN REACT_APP_SERVER_CONFIG='{"socketserver": true}' npm run build 12 | 13 | FROM node:10-alpine 14 | WORKDIR /app 15 | COPY --from=build /app/package.json /app/package-lock.json ./ 16 | RUN npm install --production 17 | RUN mkdir -p /app/build 18 | COPY --from=build /app/build/ /app/build 19 | VOLUME /app/db 20 | EXPOSE 3000 21 | ENV VIMFLOWY_PASSWORD= 22 | ENTRYPOINT npm run startprod -- \ 23 | --host 0.0.0.0 \ 24 | --port 3000 \ 25 | --staticDir /app/build \ 26 | --db sqlite \ 27 | --dbfolder /app/db \ 28 | --password $VIMFLOWY_PASSWORD 29 | -------------------------------------------------------------------------------- /src/assets/css/mixins.sass: -------------------------------------------------------------------------------- 1 | @mixin calc($property, $expression) 2 | #{$property}: -moz-calc(#{$expression}) 3 | #{$property}: -webkit-calc(#{$expression}) 4 | #{$property}: calc(#{$expression}) 5 | 6 | @mixin linear-gradient($from, $to) 7 | background: $from 8 | background: -moz-linear-gradient(#{$from}, #{$to}) 9 | background: -webkit-linear-gradient(#{$from}, #{$to}) 10 | background: -o-linear-gradient(#{$from}, #{$to}) 11 | 12 | @mixin transition($transition) 13 | -moz-transition: $transition 14 | -o-transition: $transition 15 | -webkit-transition: $transition 16 | transition: $transition 17 | 18 | @mixin box-shadow($shadow) 19 | -moz-box-shadow: $shadow 20 | -webkit-box-shadow: $shadow 21 | box-shadow: $shadow 22 | 23 | @mixin border-radius($radius) 24 | -webkit-border-radius: $radius 25 | -moz-border-radius: $radius 26 | border-radius: $radius 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2017 Jeff Wu 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /src/assets/css/view.sass: -------------------------------------------------------------------------------- 1 | #view 2 | table 3 | border-collapse: collapse 4 | td, th 5 | padding: 5px 6 | 7 | .block 8 | $indent-padding: 50px 9 | padding-left: $indent-padding 10 | 11 | .bullet 12 | float: left 13 | position: relative 14 | display: inline-block 15 | margin-left: - $indent-padding / 2 - 5 16 | 17 | &.clone-icon 18 | margin-left: - $indent-padding - 5 19 | 20 | 21 | .node 22 | $node-min-height: 20px 23 | 24 | // min height should be at least font size 25 | min-height: $node-min-height 26 | font-size: 14px 27 | 28 | .node-text 29 | margin-left: 10px 30 | display: block 31 | overflow-wrap: break-word 32 | white-space: pre-wrap 33 | min-height: $node-min-height 34 | 35 | .searchBox 36 | white-space: pre-wrap 37 | padding: 10px 38 | margin-bottom: 20px 39 | 40 | .hotkey 41 | padding: 2px 42 | border-radius: 5px 43 | 44 | @keyframes blink 45 | 50% 46 | visibility: hidden 47 | 48 | .blink-background 49 | animation: blink .5s step-end infinite alternate 50 | -------------------------------------------------------------------------------- /src/assets/ts/types.ts: -------------------------------------------------------------------------------- 1 | // TODO: enum for export mimetypes/extensions 2 | 3 | // keyboard key 4 | export type Key = string; 5 | 6 | export type Macro = Array; 7 | export type MacroMap = {[key: string]: Macro}; 8 | 9 | export type CursorOptions = { 10 | // means whether we're on the column or past it. 11 | // generally true when in insert mode but not in normal mode 12 | // effectively decides whether we can go past last column or not 13 | pastEnd?: boolean, 14 | 15 | // whether we consider the end of a word to be after the last letter 16 | // is true in normal mode (for de), false in visual (for vex) 17 | pastEndWord?: boolean, 18 | }; 19 | 20 | export type Char = string; 21 | export type Chars = Array; 22 | export type Line = Chars; 23 | export type SerializedLine = { 24 | text: string, 25 | collapsed?: boolean, 26 | plugins?: any, 27 | }; 28 | export type SerializedBlock = { 29 | text: string, 30 | collapsed?: boolean, 31 | id?: Row, 32 | plugins?: any, 33 | children?: Array 34 | } | { clone: Row } | string; 35 | 36 | export type Row = number; 37 | export type Col = number; 38 | export type SerializedPath = Array; 39 | 40 | export type ModeId = string; 41 | -------------------------------------------------------------------------------- /src/assets/css/utils.sass: -------------------------------------------------------------------------------- 1 | .unselectable 2 | -moz-user-select: -moz-none 3 | -khtml-user-select: none 4 | -webkit-user-select: none 5 | // Introduced in IE 10. See http://ie.microsoft.com/testdrive/HTML5/msUserSelect/ 6 | -ms-user-select: none 7 | user-select: none 8 | 9 | .center 10 | text-align: center 11 | margin-left: auto 12 | margin-right: auto 13 | 14 | .text-center 15 | text-align: center 16 | 17 | .hidden 18 | display: none 19 | visibility: hidden 20 | 21 | .offscreen 22 | position: absolute 23 | left: -999999em 24 | 25 | // http://stackoverflow.com/questions/218760/how-do-you-keep-parents-of-floated-elements-from-collapsing 26 | .clearfix:after 27 | content: " " 28 | display: block 29 | height: 0 30 | clear: both 31 | 32 | .tooltip 33 | position: relative 34 | cursor: default 35 | 36 | &:hover 37 | color: #c00 38 | text-decoration: none 39 | 40 | &:hover:after 41 | background: #111 42 | background: rgba(0,0,0,.6) 43 | border-radius: .5em 44 | bottom: 1em 45 | color: #fff 46 | content: attr(title) 47 | display: block 48 | text-align: center 49 | padding: .3em 1em 50 | position: absolute 51 | width: '100%' 52 | z-index: 98 53 | font-size: 12px 54 | 55 | .text-success 56 | color: green 57 | 58 | .text-error 59 | color: red 60 | -------------------------------------------------------------------------------- /src/assets/ts/utils/text.ts: -------------------------------------------------------------------------------- 1 | // TODO: is quite silly to consider undefined as whitespace... 2 | export function isWhitespace(chr: string | undefined) { 3 | return (chr === ' ') || (chr === '\n') || (chr === undefined); 4 | } 5 | 6 | // NOTE: currently unused 7 | export function isPunctuation(chr: string) { 8 | return chr === '.' || chr === ',' || chr === '!' || chr === '?'; 9 | } 10 | 11 | const urlRegex = /^https?:\/\/([^\s]+\.[^\s]+$|localhost)/; 12 | export function isLink(text: string): boolean { 13 | return urlRegex.test(text); 14 | } 15 | 16 | // Helpers to build up regex that finds the start of a word 17 | // Ignore the groups, for the sake of matching (TODO: do that in the caller?) 18 | // 19 | // Allow whitespace or beginning of line, then open paren 20 | export const silentWordStartRegex = '(?:\\s|^)(?:\\()*'; 21 | // Allow end parens, punctuation, then whitespace or end of line 22 | export const silentWordEndRegex = '(?:\\))*(?:\\.|,|!|\\?|\\:|\\;)*(?:\\s|$)'; 23 | 24 | // Returns regex whose first match is a "word" with certain properties (according to a regex string). 25 | // Note the word needn't be a word in the sense of containing no whitespace - that is up to the regex_str to decide. 26 | // It just needs to start on something like a word-start and end on something like a word-end. 27 | export function matchWordRegex(regex_str: string) { 28 | return new RegExp(silentWordStartRegex + '(' + regex_str + ')' + silentWordEndRegex); 29 | } 30 | -------------------------------------------------------------------------------- /test/tests/insert_mode_actions.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | // TODO: this file needs more tests! 5 | // tests ctrl+e, ctrl+w, ctrl+u, ctrl+k, ctrl+y 6 | 7 | describe('delete to end', () => 8 | it('keeps the cursor past the end', async function() { 9 | let t = new TestCase(['happy ending']); 10 | t.sendKeys('i'); 11 | t.sendKey('alt+f'); 12 | t.sendKey('ctrl+k'); 13 | t.sendKeys('feet'); 14 | t.sendKey('esc'); 15 | t.expect(['happy feet']); 16 | await t.done(); 17 | }) 18 | ); 19 | 20 | describe('insert mode actions', () => 21 | it('works in tricky case redoing actions in normal mode', async function() { 22 | let t = new TestCase(['bug reproduce']); 23 | t.sendKeys('i'); 24 | t.sendKey('ctrl+e'); // put cursor at end, this will be remembered by the cursor 25 | t.sendKey('ctrl+w'); 26 | t.expect(['bug ']); 27 | t.sendKey('ctrl+w'); 28 | t.expect(['']); 29 | t.sendKey('ctrl+y'); 30 | t.expect(['bug ']); 31 | t.sendKey('ctrl+y'); 32 | t.expect(['bug bug ']); 33 | t.sendKey('esc'); 34 | t.sendKey('u'); 35 | t.expect(['bug ']); 36 | t.sendKey('u'); 37 | t.expect(['']); 38 | t.sendKey('u'); 39 | t.expect(['bug ']); 40 | t.sendKey('u'); 41 | t.expect(['bug reproduce']); 42 | // even though we remembered cursor to be past e, it gets moved back, 43 | // since we're now in normal mode 44 | t.sendKey('x'); 45 | t.expect(['bug reproduc']); 46 | await t.done(); 47 | }) 48 | 49 | ); 50 | 51 | -------------------------------------------------------------------------------- /src/assets/ts/utils/functional.ts: -------------------------------------------------------------------------------- 1 | // grab bag of random functions, basically 2 | 3 | import * as _ from 'lodash'; 4 | 5 | export function id(x: T): T { return x; } 6 | 7 | // gets slice of an array, *inclusive* 8 | export function getSlice(array: Array, min: number, max: number): Array { 9 | if (max === -1) { 10 | if (array.length === 0) { 11 | return []; 12 | } 13 | max = array.length - 1; 14 | } 15 | return array.slice(min, max + 1); 16 | } 17 | 18 | // NOTE: fn should not have side effects, 19 | // since we parallelize the calls 20 | export async function asyncFilter( 21 | arr: Array, fn: (el: T) => Promise 22 | ) { 23 | const result: Array<{ el: T, i: number }> = []; 24 | await Promise.all( 25 | arr.map(async (el, i) => { 26 | if (await fn(el)) { 27 | result.push({ el, i }); 28 | } 29 | }) 30 | ); 31 | return _.sortBy(result, (x) => x.i).map((x) => x.el); 32 | } 33 | 34 | export function promiseDebounce(fn: (...args: Array) => Promise) { 35 | let running = false; 36 | let pending = false; 37 | const run = (...args: Array) => { 38 | running = true; 39 | fn(...args).then(() => { 40 | if (pending) { 41 | pending = false; 42 | run(...args); 43 | } else { 44 | running = false; 45 | } 46 | }); 47 | }; 48 | return (...args: Array) => { 49 | if (!running) { 50 | run(...args); 51 | } else { 52 | pending = true; 53 | } 54 | }; 55 | }; 56 | 57 | export async function timeout(ns: number) { 58 | await new Promise((resolve) => setTimeout(resolve, ns)); 59 | } 60 | -------------------------------------------------------------------------------- /src/shared/data_backend.ts: -------------------------------------------------------------------------------- 1 | import * as errors from './utils/errors'; 2 | 3 | /* 4 | DataBackend abstracts the data layer, so that it can be swapped out. 5 | To implement a new backend, one simply has to implement a simple key-value store 6 | with the two methods get and set. 7 | 8 | Note that the backend may want to protect against multiple clients writing/reading. 9 | */ 10 | 11 | export default class DataBackend { 12 | public async get(_key: string): Promise { 13 | throw new errors.NotImplemented(); 14 | } 15 | 16 | public async set(_key: string, _value: string): Promise { 17 | throw new errors.NotImplemented(); 18 | } 19 | } 20 | 21 | export class SynchronousDataBackend { 22 | public get(_key: string): string | null { 23 | throw new errors.NotImplemented(); 24 | } 25 | 26 | public set(_key: string, _value: string): void { 27 | throw new errors.NotImplemented(); 28 | } 29 | } 30 | 31 | export class SynchronousInMemory extends SynchronousDataBackend { 32 | private cache: {[key: string]: any} = {}; 33 | // constructor() { 34 | // super(); 35 | // } 36 | 37 | public get(key: string): string | null { 38 | if (key in this.cache) { 39 | return this.cache[key]; 40 | } 41 | return null; 42 | } 43 | 44 | public set(key: string, value: string): void { 45 | this.cache[key] = value; 46 | } 47 | } 48 | 49 | export class InMemory extends DataBackend { 50 | private sync_backend: SynchronousInMemory; 51 | constructor() { 52 | super(); 53 | this.sync_backend = new SynchronousInMemory(); 54 | } 55 | 56 | public async get(key: string): Promise { 57 | return this.sync_backend.get(key); 58 | } 59 | 60 | public async set(key: string, value: string): Promise { 61 | this.sync_backend.set(key, value); 62 | } 63 | } 64 | 65 | -------------------------------------------------------------------------------- /src/plugins/html/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; // tslint:disable-line no-unused-variable 2 | 3 | import { Token, RegexTokenizerSplitter, EmitFn, Tokenizer } from '../../assets/ts/utils/token_unfolder'; 4 | import { registerPlugin } from '../../assets/ts/plugins'; 5 | 6 | const htmlTypes: Array = [ 7 | 'div', 8 | 'span', 9 | 'img', 10 | 'table' 11 | ]; 12 | 13 | const htmlRegexParts: Array = []; 14 | htmlTypes.forEach((htmltype) => { 15 | htmlRegexParts.push( 16 | `<${htmltype}(.|\\n)*>(.|\\n)*` 17 | ); 18 | // self-closing 19 | htmlRegexParts.push( 20 | `<${htmltype}(.|\\n)*/>` 21 | ); 22 | }); 23 | const htmlRegex = '(' + htmlRegexParts.map((part) => '(' + part + ')').join('|') + ')'; 24 | 25 | registerPlugin( 26 | { 27 | name: 'HTML', 28 | author: 'Jeff Wu', 29 | description: ` 30 | Lets you inline the following html tags: 31 | ${ htmlTypes.map((htmltype) => '<' + htmltype + '>').join(' ') } 32 | `, 33 | }, 34 | function(api) { 35 | api.registerHook('session', 'renderLineTokenHook', (tokenizer, info) => { 36 | if (info.has_cursor) { 37 | return tokenizer; 38 | } 39 | if (info.has_highlight) { 40 | return tokenizer; 41 | } 42 | return tokenizer.then(RegexTokenizerSplitter( 43 | new RegExp(htmlRegex), 44 | (token: Token, emit: EmitFn, wrapped: Tokenizer) => { 45 | try { 46 | emit(); 50 | } catch (e) { 51 | api.session.showMessage(e.message, { text_class: 'error' }); 52 | emit(...wrapped.unfold(token)); 53 | } 54 | } 55 | )); 56 | }); 57 | }, 58 | (api => api.deregisterAll()), 59 | ); 60 | -------------------------------------------------------------------------------- /src/plugins/recursive_expand/index.tsx: -------------------------------------------------------------------------------- 1 | import { registerPlugin } from '../../assets/ts/plugins'; 2 | import Session from '../../assets/ts/session'; 3 | import logger from '../../shared/utils/logger'; 4 | import Path from '../../assets/ts/path'; 5 | 6 | export const pluginName = 'Recursive-Expand'; 7 | 8 | registerPlugin( 9 | { 10 | name: pluginName, 11 | author: 'Nikhil Sonti', 12 | description: `Lets you recursively expand or collapse a node`, 13 | }, 14 | function (api) { 15 | 16 | async function toggleRecursiveCollapse(session: Session, path: Path, collapse: boolean) { 17 | logger.debug('Toggle state: ' + collapse + ' row = ' + path.row); 18 | await session.document.setCollapsed(path.row, collapse); 19 | 20 | if (await session.document.hasChildren(path.row)) { 21 | let children = await session.document.getChildren(path); 22 | logger.debug('No of children: ' + children.length); 23 | 24 | children.forEach(async (child_path) => { 25 | await toggleRecursiveCollapse(session, child_path, collapse); 26 | }); 27 | } 28 | } 29 | 30 | api.registerAction( 31 | 'toggle-expand', 32 | 'Toggle expand/collapse recursively', 33 | async function ({ session }) { 34 | let is_collapsed = await session.document.collapsed(session.cursor.row); 35 | await toggleRecursiveCollapse(session, session.cursor.path, !is_collapsed); 36 | await session.document.forceLoadTree(session.cursor.row, false); 37 | }, 38 | ); 39 | 40 | api.registerDefaultMappings( 41 | 'NORMAL', 42 | { 43 | 'toggle-expand': [['Z']], 44 | }, 45 | ); 46 | }, 47 | (api => api.deregisterAll()), 48 | ); 49 | -------------------------------------------------------------------------------- /docs/vim_inconsistencies.md: -------------------------------------------------------------------------------- 1 | # Known inconsistencies with vim # 2 | 3 | There are some obvious huge differences like search and marks behavior. 4 | This catalogs some more subtle differences. Most have been entirely intentional. 5 | 6 | If you feel any of them make vimflowy feel significantly less familiar, contact me and I'll take it into consideration. 7 | And feel free to report more! 8 | 9 | - When using `dd` to delete blocks, the line(s) are "cloned" rather than copied, for efficiency reasons. 10 | The typical use case for dd is to move things around, in which case it doesn't matter. It only matters when pasting twice. 11 | For example, if you delete a block, paste it once, change the pasted block, and paste again, the second paste will contain the modifications 12 | and be synced with the first paste. In cases where you need a copy, use yank instead. 13 | - 5$ doesn't work 14 | - I goes to the beginning of the line, irrespective of whitespace 15 | 16 | - undoing operations always puts your cursor where it was. (This is not true in vim: try going to the middle of a line and typing d0u) 17 | - in insert mode, not everything is a single action with respect to undo/redo. motions, indenting, and splitting lines make for new items in history (i.e. for undo/redo) 18 | - cw works as expected - in vim, cw works like ce, which is inconsistent/counterintuitive 19 | - Y works as expected (like y$) - in vim, Y works like yy, which is inconsistent/counterintuitive 20 | - 100rx will replace as many as it can 21 | - t and T work when you use them repeatedly 22 | - yank (y) never moves the cursor (in vim, yb and yh move the cursor to the start of the yank region) 23 | - e/b/w skip lines with only whitespace 24 | - macros 25 | - redo operator (.) redoes entire macros rather than the last sequence (easy to fix, but not desired IMO) 26 | - not implemented using registers 27 | 28 | - behavior of control+o is different 29 | -------------------------------------------------------------------------------- /test/tests/todo.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | import * as Todo from '../../src/plugins/todo'; 4 | import '../../src/assets/ts/plugins'; 5 | 6 | const toggleStrikethroughKey = 'ctrl+enter'; 7 | 8 | describe('todo', function() { 9 | it('works in basic case', async function() { 10 | let t = new TestCase([ 11 | 'a line', 12 | 'another line', 13 | ], {plugins: [Todo.pluginName]}); 14 | t.sendKey(toggleStrikethroughKey); 15 | t.expect([ 16 | '~~a line~~', 17 | 'another line', 18 | ]); 19 | 20 | t.sendKey(toggleStrikethroughKey); 21 | t.expect([ 22 | 'a line', 23 | 'another line', 24 | ]); 25 | 26 | t.sendKey('u'); 27 | t.expect([ 28 | '~~a line~~', 29 | 'another line', 30 | ]); 31 | 32 | t.sendKey('u'); 33 | t.expect([ 34 | 'a line', 35 | 'another line', 36 | ]); 37 | await t.done(); 38 | }); 39 | 40 | it('works in visual line', async function() { 41 | let t = new TestCase([ 42 | 'a line', 43 | '~~another line~~', 44 | ], {plugins: [Todo.pluginName]}); 45 | t.sendKeys('Vj'); 46 | t.sendKey(toggleStrikethroughKey); 47 | t.expect([ 48 | '~~a line~~', 49 | '~~another line~~', 50 | ]); 51 | 52 | t.sendKeys('Vk'); 53 | t.sendKey(toggleStrikethroughKey); 54 | t.expect([ 55 | 'a line', 56 | 'another line', 57 | ]); 58 | 59 | t.sendKeys('Vj'); 60 | t.sendKey(toggleStrikethroughKey); 61 | t.expect([ 62 | '~~a line~~', 63 | '~~another line~~', 64 | ]); 65 | 66 | t.sendKey('u'); 67 | t.expect([ 68 | 'a line', 69 | 'another line', 70 | ]); 71 | 72 | t.sendKey('u'); 73 | t.expect([ 74 | '~~a line~~', 75 | '~~another line~~', 76 | ]); 77 | 78 | t.sendKey('u'); 79 | t.expect([ 80 | 'a line', 81 | '~~another line~~', 82 | ]); 83 | await t.done(); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /server/data_backends.ts: -------------------------------------------------------------------------------- 1 | import * as sqlite from 'sqlite3'; 2 | 3 | import DataBackend from '../src/shared/data_backend'; 4 | 5 | export class SQLiteBackend extends DataBackend { 6 | // init is basically like async constructor 7 | private db!: sqlite.Database; 8 | private setStatement!: sqlite.Statement; 9 | private getStatement!: sqlite.Statement; 10 | 11 | private tableName: string = 'vimflowy'; 12 | 13 | constructor() { 14 | super(); 15 | } 16 | 17 | public async init(filename: string): Promise { 18 | await new Promise((resolve, reject) => { 19 | this.db = new sqlite.Database(filename, (err) => { 20 | if (err) { reject(err); } else { resolve(); } 21 | }); 22 | }); 23 | 24 | await new Promise((resolve, reject) => { 25 | this.db.run( 26 | `CREATE TABLE IF NOT EXISTS ${this.tableName} (id string PRIMARY KEY, value string)`, 27 | (err) => { 28 | if (err) { reject(err); } else { resolve(); } 29 | } 30 | ); 31 | }); 32 | 33 | this.getStatement = this.db.prepare( 34 | `SELECT value FROM ${this.tableName} WHERE id = (?)` 35 | ); 36 | 37 | this.setStatement = this.db.prepare( 38 | `INSERT OR REPLACE INTO ${this.tableName} ("id", "value") VALUES (?, ?)` 39 | ); 40 | } 41 | 42 | public async get(key: string): Promise { 43 | return await new Promise((resolve, reject) => { 44 | this.getStatement.get([key], (err: string, result: any) => { 45 | if (err) { return reject(err); } 46 | if (!result) { 47 | resolve(null); 48 | } else { 49 | resolve(result.value); 50 | } 51 | }); 52 | }); 53 | } 54 | 55 | public async set(key: string, value: string): Promise { 56 | await new Promise((resolve, reject) => { 57 | this.setStatement.run([key, value], (err: string) => { 58 | if (err) { return reject(err); } 59 | resolve(); 60 | }); 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/assets/ts/definitions/zoom.ts: -------------------------------------------------------------------------------- 1 | import keyDefinitions, { Action, SequenceAction } from '../keyDefinitions'; 2 | 3 | keyDefinitions.registerAction(new Action( 4 | 'zoom-prev-sibling', 5 | 'Zoom to view root\'s previous sibling', 6 | async function({ session }) { 7 | await session.zoomUp(); 8 | }, 9 | { sequence: SequenceAction.DROP }, 10 | )); 11 | 12 | keyDefinitions.registerAction(new Action( 13 | 'zoom-next-sibling', 14 | 'Zoom to view root\'s next sibling', 15 | async function({ session }) { 16 | await session.zoomDown(); 17 | }, 18 | { sequence: SequenceAction.DROP }, 19 | )); 20 | 21 | keyDefinitions.registerAction(new Action( 22 | 'zoom-in', 23 | 'Zoom in by one level', 24 | async function({ session }) { 25 | await session.zoomIn(); 26 | }, 27 | { sequence: SequenceAction.DROP }, 28 | )); 29 | 30 | keyDefinitions.registerAction(new Action( 31 | 'zoom-out', 32 | 'Zoom out by one level', 33 | async function({ session }) { 34 | await session.zoomOut(); 35 | }, 36 | { sequence: SequenceAction.DROP }, 37 | )); 38 | 39 | keyDefinitions.registerAction(new Action( 40 | 'zoom-cursor', 41 | 'Zoom in onto cursor', 42 | async function({ session }) { 43 | await session.zoomInto(session.cursor.path); 44 | }, 45 | { sequence: SequenceAction.DROP }, 46 | )); 47 | 48 | keyDefinitions.registerAction(new Action( 49 | 'zoom-root', 50 | 'Zoom out to document root', 51 | async function({ session }) { 52 | await session.zoomInto(session.document.root); 53 | }, 54 | { sequence: SequenceAction.DROP }, 55 | )); 56 | 57 | keyDefinitions.registerAction(new Action( 58 | 'jump-prev', 59 | 'Jump to previously visited location', 60 | async function({ session }) { 61 | await session.jumpPrevious(); 62 | }, 63 | { sequence: SequenceAction.DROP }, 64 | )); 65 | 66 | keyDefinitions.registerAction(new Action( 67 | 'jump-next', 68 | 'Jump to next location', 69 | async function({ session }) { 70 | await session.jumpNext(); 71 | }, 72 | { sequence: SequenceAction.DROP }, 73 | )); 74 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: true 2 | dist: trusty 3 | language: node_js 4 | 5 | # The specification of multiple node versions 6 | # adds implicit jobs to the test stage (so we don't have to explicitly 7 | # add them), see 8 | # https://docs.travis-ci.com/user/build-stages/#Build-Stages-and-Build-Matrix-Expansion 9 | # for details 10 | node_js: 11 | # NOTE: node v12 doesnt work https://stackoverflow.com/questions/55960396/how-to-fix-npm-install-sqlite-build-fail 12 | # - 'lts/*' 13 | - '10' 14 | 15 | cache: 16 | directories: 17 | - node_modules 18 | jobs: 19 | include: 20 | - stage: compile 21 | script: 22 | - npm run lint 23 | - npm run typecheck 24 | - npm run build 25 | 26 | - stage: deploy 27 | install: skip 28 | before_install: 29 | - sudo apt-get update 30 | - sudo apt-get -y -o Dpkg::Options::="--force-confnew" install docker-ce 31 | script: 32 | - docker build -t vimflowy/vimflowy . 33 | - docker images 34 | - docker tag "vimflowy/vimflowy:latest" "vimflowy/vimflowy:$(jq -r .version package.json)" 35 | after_success: | 36 | if [[ "$TRAVIS_BRANCH" = "master" ]]; then 37 | docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" 38 | docker push vimflowy/vimflowy 39 | fi 40 | stages: 41 | - compile 42 | - test 43 | - deploy 44 | env: 45 | global: 46 | - DOCKER_USERNAME=vimflowytravis 47 | - secure: RfPC7UfHy73t2nRXTMKNfksRJVNvl1khYS8DUHb5E+E2/lySBDCjMr6VQMiNpUVR3ookmTNKb4K37HFWUtu71zWK/wlH+iGC51v8n6MLvquuY3jxc/bhHtvWJtmD/iLjjQIqn6SIGGyWBXYJcIntG2QnlG+q8DqvWb2jf9/fWsfB83ENVRIYNDdovJPUf047KHfpfBC2qy6WUbWvQzux9R2HNbtIjdJBbh+77/Ngi51//9SuywM/MjTaswv11l7HEGBUF9SPdnM00rArkLMHugaUXCt6LpSkSvzUZQ8psCnI7/0T//z9V3PtuMlTREXAUrmzVOeruS9IE5Ce8UxOngESJHcrrJRYpsUyfigm4rEU76hCe3nY749YnejKFnk4tCq5imr3meJ6VrBdv2+yiRo1NRQM7/N5NqrEJVww8ZjnlocZOZDH/Wzh38jf7w3qIa/hieYcc+bXS1mbHU+0GpdVeOzXBgs/BCGxsZmNWEv/xVGlLrHh0tZcSzQ/K5cMDXRhaWQ3NQyagO1w5V96O3ihVX6EOMtO7CvHMH2FEi0ntbUqaMAzB+gHF2mOG+Pvw2pZ3oIWIMo9JsbKXuBOKLx7uLdsF7X8jXly8Lef0+DoXX90ZC6cWhcHj3ZB/5AEjlm6OPWZJx5sCvshThYbsBNOAYaxDMxBMeVoEzcme14= 48 | -------------------------------------------------------------------------------- /docs/storage/README.md: -------------------------------------------------------------------------------- 1 | # Vimflowy data storage 2 | 3 | Vimflowy was designed to work with multiple storage backends. 4 | 5 | ## Local vs. Remote 6 | 7 | First, the data can either be local on your computer, or hosted elsewhere. 8 | 9 | By default, the app is entirely local meaning: 10 | - Your data is never sent over the internet, so you can only use it in one browser on one device 11 | - Vimflowy works offline 12 | 13 | If you enable a remote storage backend, then: 14 | - You can access your document from multiple devices 15 | - You cannot edit offline 16 | 17 | Keep in mind that even with remote storage backends, you won't be able to use vimflowy collaboratively - only one person can view/edit at a time. 18 | 19 | ## Options 20 | 21 | ### HTML5 localStorage (local) 22 | 23 | This is the default option, storing data using modern browsers' HTML5 localStorage API. 24 | - If you're going to have a very large document, use a browser with large localStorage limits, e.g. Firefox 25 | - Be warned that if you don't set up remote storage, *clearing localStorage will result in you losing all your data!* 26 | 27 | ### Firebase (Google hosting) 28 | 29 | With Firebase, you can let Google host your data for you remotely. 30 | Firebase is free, but you have to pay once your document is huge, or if you want automated backups. 31 | 32 | See [here](Firebase.md) for details on how to set this up. 33 | 34 | ### SQLite (self-hosting) 35 | 36 | You can run a custom vimflowy backend server that stores the data. 37 | You can choose to host it on your own computer, or on a remote server (if you want to access it from multiple places). 38 | 39 | Currently, the vimflowy server stores the data in SQLite (which stores data in a file), but other methods could be added in the future. 40 | 41 | The easiest way to set this up is to [use docker](/docs/deployment.md). 42 | However, it is not hard to set up manually. See [here](SQLite.md) for details. 43 | 44 | ## Other backends 45 | 46 | Please contact the dev team if you are interested in other storage backends. 47 | It's relatively easy to add new ones! 48 | -------------------------------------------------------------------------------- /docs/FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ (AKA questions I imagine people would ask me) 2 | 3 | ### Why vim? 4 | 5 | This is a productivity tool, and I find vim productive (once you get past the learning curve). 6 | Vim isn't for everyone, but if you edit text a lot, I recommend giving it a try. 7 | 8 | ### Why workflowy? 9 | 10 | I like the tree of bullets format, and the lack of clutter. 11 | 12 | ### Why did you make Vimflowy? 13 | 14 | I started working on Vimflowy after a conversation with a friend. 15 | I'd seen many outlining apps, but the vim part was important to me. 16 | My friend, who also uses vim keybindings for everything, had a *huge* document on Workflowy which was very laggy to load. 17 | Vimflowy lazy loads, and deleting and pasting large subtrees is efficient, which went well with the vim concept. 18 | 19 | So it started out being a tool mainly for me and my friend, but I do hope others also find it useful! 20 | I've especially tried to make it so that developers can write plugins to customize it, without much trouble. 21 | I would consider making a second set of non-vim bindings out of the box if enough people seem to like it. 22 | 23 | ### I do like this. What else should I consider? 24 | 25 | If you like both vim and workflowy, the best alternative I know of is spacemacs with the org layer (i.e. emacs org mode with vim keybindings). 26 | Org mode is extremely powerful. I recommend trying it out. 27 | 28 | There are pros and cons compared to vimflowy, which is more tailored for my particular workflow. 29 | I'm curious how they compare for others, so if you try both, let me know what you think! 30 | 31 | ### Why doesn't *mumble* work like vim? 32 | 33 | My goal is to make Vimflowy feel like home to vim users. However: 34 | - Vim has a lot of commands, so there are some still missing. 35 | - Vimflowy intentionally differs in few ways, partially due to its Workflowy-inspired half. 36 | Some known inconsistencies with vim are documented [here](vim_inconsistencies.md). 37 | 38 | If you find that something is incongruous with your vim use, whether a bug or missing feature, let me know via a github issue. Or better yet, a pull request! 39 | -------------------------------------------------------------------------------- /test/tests/visual.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | describe('visual mode', function() { 5 | it('works with basic motions', async function() { 6 | let t = new TestCase(['hello world']); 7 | t.sendKeys('vwx'); 8 | t.expect(['orld']); 9 | await t.done(); 10 | 11 | t = new TestCase(['hello world']); 12 | t.sendKeys('vex'); 13 | t.expect([' world']); 14 | await t.done(); 15 | 16 | t = new TestCase(['hello world']); 17 | t.sendKeys('v$x'); 18 | t.expect(['']); 19 | await t.done(); 20 | 21 | t = new TestCase(['hello world']); 22 | t.sendKeys('wv3lx'); 23 | t.expect(['hello d']); 24 | await t.done(); 25 | }); 26 | 27 | it('keeps cursor after canceling', async function() { 28 | let t = new TestCase(['hello world']); 29 | t.sendKeys('vw'); 30 | t.sendKey('esc'); 31 | t.sendKeys('x'); 32 | t.expect(['hello orld']); 33 | await t.done(); 34 | }); 35 | 36 | it('allows cursor swap', async function() { 37 | let t = new TestCase(['hello world']); 38 | t.sendKeys('wv3lo3hx'); 39 | t.expect(['held']); 40 | t.sendKeys('u'); 41 | t.expect(['hello world']); 42 | await t.done(); 43 | }); 44 | 45 | it('moves cursor back if needed', async function() { 46 | let t = new TestCase(['hello world']); 47 | t.sendKeys('v$'); 48 | t.sendKey('esc'); 49 | t.sendKeys('x'); 50 | t.expect(['hello worl']); 51 | t.sendKeys('u'); 52 | t.expect(['hello world']); 53 | await t.done(); 54 | }); 55 | 56 | it('pastes', async function() { 57 | let t = new TestCase([ 'hello world' ]); 58 | t.sendKeys('wv$y'); 59 | t.sendKeys('P'); 60 | t.expect([ 'hello worlworldd' ]); 61 | t.sendKeys('u'); 62 | t.expect(['hello world']); 63 | await t.done(); 64 | }); 65 | 66 | it('changes', async function() { 67 | let t = new TestCase(['hello world']); 68 | t.sendKeys('vec'); 69 | t.sendKeys('hi'); 70 | t.sendKey('esc'); 71 | t.expect(['hi world']); 72 | t.sendKeys('u'); 73 | t.expect(['hello world']); 74 | await t.done(); 75 | }); 76 | }); 77 | 78 | -------------------------------------------------------------------------------- /src/plugins/latex/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; // tslint:disable-line no-unused-variable 2 | import * as katex from 'katex'; 3 | import 'katex/dist/katex.min.css'; 4 | 5 | import { Tokenizer, Token, RegexTokenizerSplitter, EmitFn } from '../../assets/ts/utils/token_unfolder'; 6 | import { registerPlugin } from '../../assets/ts/plugins'; 7 | import { matchWordRegex } from '../../assets/ts/utils/text'; 8 | 9 | registerPlugin( 10 | { 11 | name: 'LaTeX', 12 | author: 'Jeff Wu', 13 | description: ` 14 | Lets you inline LaTeX between $ delimiters, 15 | or add block LaTeX between $$ delimiters. 16 | Limited to what KaTeX supports. 17 | `, 18 | }, 19 | function(api) { 20 | api.registerHook('session', 'renderLineTokenHook', (tokenizer, info) => { 21 | if (info.has_cursor) { 22 | return tokenizer; 23 | } 24 | if (info.has_highlight) { 25 | return tokenizer; 26 | } 27 | return tokenizer.then(RegexTokenizerSplitter( 28 | matchWordRegex('\\$\\$(\\n|.)+?\\$\\$'), 29 | (token: Token, emit: EmitFn, wrapped: Tokenizer) => { 30 | try { 31 | const html = katex.renderToString(token.text.slice(2, -2), { displayMode: true }); 32 | emit(
); 33 | } catch (e) { 34 | api.session.showMessage(e.message, { text_class: 'error' }); 35 | emit(...wrapped.unfold(token)); 36 | } 37 | } 38 | )).then(RegexTokenizerSplitter( 39 | matchWordRegex('\\$(\\n|.)+?\\$'), 40 | (token: Token, emit: EmitFn, wrapped: Tokenizer) => { 41 | try { 42 | const html = katex.renderToString(token.text.slice(1, -1), { displayMode: false }); 43 | emit(); 44 | } catch (e) { 45 | api.session.showMessage(e.message, { text_class: 'error' }); 46 | emit(...wrapped.unfold(token)); 47 | } 48 | } 49 | )); 50 | }); 51 | }, 52 | (api => api.deregisterAll()), 53 | ); 54 | -------------------------------------------------------------------------------- /src/assets/ts/definitions/indent.ts: -------------------------------------------------------------------------------- 1 | import keyDefinitions, { Action } from '../keyDefinitions'; 2 | 3 | keyDefinitions.registerAction(new Action( 4 | 'indent-row', 5 | 'Indent row right', 6 | async function({ session }) { 7 | await session.indent(); 8 | }, 9 | )); 10 | // NOTE: this matches block indent behavior, in visual line 11 | keyDefinitions.registerAction(new Action( 12 | 'visual-line-indent', 13 | 'Indent blocks right', 14 | async function({ session, visual_line }) { 15 | if (visual_line == null) { 16 | throw new Error('Visual_line mode arguments missing'); 17 | } 18 | await session.indentBlocks(visual_line.start, visual_line.num_rows); 19 | await session.setMode('NORMAL'); 20 | }, 21 | )); 22 | 23 | keyDefinitions.registerAction(new Action( 24 | 'unindent-row', 25 | 'Unindent row', 26 | async function({ session }) { 27 | await session.unindent(); 28 | }, 29 | )); 30 | 31 | // NOTE: this matches block indent behavior, in visual line 32 | keyDefinitions.registerAction(new Action( 33 | 'visual-line-unindent', 34 | 'Unindent blocks', 35 | async function({ session, visual_line }) { 36 | if (visual_line == null) { 37 | throw new Error('Visual_line mode arguments missing'); 38 | } 39 | await session.unindentBlocks(visual_line.start, visual_line.num_rows); 40 | await session.setMode('NORMAL'); 41 | } 42 | )); 43 | 44 | keyDefinitions.registerAction(new Action( 45 | 'indent-blocks', 46 | 'Indent blocks right', 47 | async function({ session, repeat }) { 48 | await session.indentBlocks(session.cursor.path, repeat); 49 | }, 50 | )); 51 | 52 | keyDefinitions.registerAction(new Action( 53 | 'unindent-blocks', 54 | 'Move block left', 55 | async function({ session, repeat }) { 56 | await session.unindentBlocks(session.cursor.path, repeat); 57 | }, 58 | )); 59 | 60 | keyDefinitions.registerAction(new Action( 61 | 'swap-block-down', 62 | 'Move block down', 63 | async function({ session }) { 64 | await session.swapDown(); 65 | }, 66 | )); 67 | 68 | keyDefinitions.registerAction(new Action( 69 | 'swap-block-up', 70 | 'Move block up', 71 | async function({ session }) { 72 | await session.swapUp(); 73 | }, 74 | )); 75 | -------------------------------------------------------------------------------- /src/assets/ts/utils/queue.ts: -------------------------------------------------------------------------------- 1 | import { ExtendableError } from '../../../shared/utils/errors'; 2 | 3 | export class QueueStoppedError extends ExtendableError { 4 | constructor(m = '', name = 'QueueStoppedError') { 5 | super(m ? `Queue stopped: ${m}!` : 'Queue stopped!', name); 6 | } 7 | } 8 | 9 | // Simple queue class where you can 10 | // enqueue synchronously, and dequeue asynchronously 11 | // (waiting for the next enqueue if nothing is available) 12 | export default class Queue { 13 | private queue: Array; 14 | private resolveNext: ((val: T) => void) | null; 15 | private nextProm: Promise | null; 16 | private id: number; 17 | private stopped: boolean; 18 | 19 | constructor(vals: Array = []) { 20 | this.queue = []; 21 | this.resolveNext = null; 22 | this.nextProm = null; 23 | vals.forEach((val) => this.enqueue(val)); 24 | this.id = Math.random(); 25 | this.stopped = false; 26 | } 27 | 28 | public empty() { 29 | return this.queue.length === 0; 30 | } 31 | 32 | public stop() { 33 | this.stopped = true; 34 | } 35 | 36 | public dequeue(): Promise { 37 | if (!this.empty()) { 38 | const val = this.queue.shift(); 39 | if (val === undefined) { 40 | throw new Error('Unexpected empty queue'); 41 | } 42 | return Promise.resolve(val); 43 | } 44 | if (this.stopped) { 45 | throw new QueueStoppedError('queue is stopped!'); 46 | } 47 | 48 | if (this.nextProm != null) { 49 | throw new Error('Cannot have multiple waiting on queue'); 50 | // return this.nextProm; 51 | } 52 | this.nextProm = new Promise((resolve) => { 53 | let real_resolve = (val: T) => { 54 | resolve(val); 55 | }; 56 | this.resolveNext = real_resolve; 57 | }); 58 | return this.nextProm; 59 | } 60 | 61 | public enqueue(val: T) { 62 | if (this.stopped) { 63 | throw new QueueStoppedError('queue is stopped, cannot enqueue!'); 64 | } 65 | if (this.resolveNext != null) { 66 | this.resolveNext(val); 67 | this.nextProm = null; 68 | this.resolveNext = null; 69 | return true; 70 | } else { 71 | this.queue.push(val); 72 | return false; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/assets/ts/components/fileInput.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import $ from 'jquery'; 3 | 4 | type Props = { 5 | onSelect?: (filename: string) => void; 6 | onLoad?: (filename: string, contents: string) => void; 7 | onError?: (error: string) => void; 8 | style?: React.CSSProperties; 9 | }; 10 | 11 | export const load_file = function(file: File): Promise<{name: string, contents: string}> { 12 | return new Promise((resolve, reject) => { 13 | const reader = new FileReader(); 14 | reader.readAsText(file, 'UTF-8'); 15 | reader.onload = function(evt) { 16 | const content = (evt.target as any).result; 17 | return resolve({ 18 | name: file.name, 19 | contents: content, 20 | }); 21 | }; 22 | reader.onerror = function(err) { 23 | reject(`Failed to reading file: ${err}`); 24 | }; 25 | }); 26 | }; 27 | 28 | export default class FileInput extends React.Component { 29 | private id: string; 30 | 31 | constructor(props: Props) { 32 | super(props); 33 | this.id = `fileinput.${Math.random()}`; 34 | } 35 | 36 | private handleChange(e: React.FormEvent) { 37 | // TODO: what is the right type here? 38 | const file = (e.target as any).files[0]; 39 | if (!file) { return; } // do nothing, they canceled 40 | 41 | if (this.props.onSelect) { 42 | this.props.onSelect(file.name); 43 | } 44 | load_file(file).then(({ name, contents }) => { 45 | if (this.props.onLoad) { 46 | this.props.onLoad(name, contents); 47 | } 48 | $(`#${this.id}`).val(''); 49 | }).catch((err: string) => { 50 | if (this.props.onError) { 51 | this.props.onError(err); 52 | } 53 | $(`#${this.id}`).val(''); 54 | }); 55 | } 56 | 57 | public render() { 58 | return ( 59 |
62 | this.handleChange(e)} 71 | /> 72 | 73 | {this.props.children} 74 |
75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Vimflowy](/static/images/vimflowy-32.png?raw=true) Vimflowy 2 | 3 | [![Build Status](https://travis-ci.org/WuTheFWasThat/vimflowy.svg?branch=master)](https://travis-ci.org/WuTheFWasThat/vimflowy?branch=master) 4 | 5 | NOTE: I don't have time to maintain this anymore, sorry! 6 | 7 | This is a productivity tool which draws some inspiration from workflowy and vim. 8 | 9 | [Try it out now!](https://www.wuthejeff.com/vimflowy) 10 | 11 | [Deploy yourself with docker!](https://hub.docker.com/r/vimflowy/vimflowy/) 12 | 13 | ## FEATURES 14 | 15 | - Workflowy features 16 | - tree-like outlining 17 | - collapsing and zooming into bullets 18 | - basic text formatting, strike through task completion 19 | - Vim features 20 | - (configurable) vim keybindings 21 | - modal editing 22 | - undo history, location history, macros, etc. 23 | - Plugins system (see [plugins.md](docs/plugins.md)) 24 | - marks (not like vim's) 25 | - easy-motion for moving between bullets quickly 26 | - time tracking 27 | - LaTeX and HTML rendering 28 | - Other 29 | - data import from or export as text file (native Vimflowy format or Workflowy-compatible format) 30 | - loads data lazily (good for huge documents) 31 | - search (not like vim's) 32 | - cloning (bullets duplicated in multiple locations in a document) 33 | - customizable visual themes 34 | 35 | ## LIMITATIONS 36 | 37 | - No collaborative editing 38 | - Global search is slow for large documents (so you'll want to use marks) 39 | - You may need a relatively modern browser (minimally HTML5 LocalStorage and Flexbox). I test only in recent versions of Chrome and Firefox. 40 | 41 | ## DATA STORAGE 42 | 43 | Vimflowy was designed to work with multiple storage backends. 44 | 45 | By default, you own your own data, as it is stored locally on your computer. 46 | However, you can let Google host it for you, or host it yourself. 47 | 48 | [See here for more info](docs/storage/README.md). 49 | 50 | ### SELF-HOSTING 51 | 52 | See the [deployment documentation](docs/deployment.md) for details. 53 | You can deploy with docker, or build from source yourself. 54 | 55 | ## NOTES FOR DEVELOPERS 56 | 57 | Contributions are very welcome! 58 | See [dev_setup.md](docs/dev_setup.md) to see how to get started with a development setup. 59 | 60 | #### LICENSE 61 | 62 | MIT: https://wuthefwasthat.mit-license.org/ 63 | 64 | ## FAQ (AKA questions I imagine people would ask me) 65 | 66 | [see here](docs/FAQ.md) 67 | -------------------------------------------------------------------------------- /src/shared/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /* 2 | A straightforward class for configurable logging 3 | Log-levels and streams (currently only one stream at a time) 4 | */ 5 | 6 | export enum LEVEL { 7 | DEBUG = 0, 8 | INFO = 1, 9 | WARN = 2, 10 | ERROR = 3, 11 | FATAL = 4, 12 | } 13 | 14 | const LEVELS = [ 15 | 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL', 16 | ]; 17 | 18 | export enum STREAM { 19 | STDOUT, 20 | STDERR, 21 | QUEUE, 22 | } 23 | 24 | export class Logger { 25 | private stream!: STREAM; 26 | private level!: LEVEL; 27 | private queue: Array = []; 28 | 29 | // hack since we add the methods dynamically 30 | public info: any; 31 | public debug: any; 32 | public warn: any; 33 | public error: any; 34 | public fatal: any; 35 | 36 | constructor(level = LEVEL.INFO, stream = STREAM.STDOUT) { 37 | this.setLevel(level); 38 | this.setStream(stream); 39 | 40 | const register_loglevel = (name: string, value: number) => { 41 | return (this as any)[name.toLowerCase()] = function(this: Logger) { 42 | if (this.level <= value) { 43 | return this.log.apply(this, arguments as any); 44 | } 45 | }; 46 | }; 47 | 48 | LEVELS.forEach((name) => { 49 | const value: LEVEL = (LEVEL as any)[name]; 50 | register_loglevel(name, value); 51 | }); 52 | } 53 | 54 | // tslint:disable-next-line no-unused-variable 55 | public log() { 56 | if (this.stream === STREAM.STDOUT) { 57 | return console.log.apply(console, arguments as any); 58 | } else if (this.stream === STREAM.STDERR) { 59 | return console.error.apply(console, arguments as any); 60 | } else if (this.stream === STREAM.QUEUE) { 61 | return this.queue.push(arguments); 62 | } 63 | } 64 | 65 | public setLevel(level: LEVEL) { 66 | this.level = level; 67 | } 68 | 69 | public off() { 70 | this.level = Infinity; 71 | } 72 | 73 | public setStream(stream: STREAM) { 74 | this.stream = stream; 75 | if (this.stream === STREAM.QUEUE) { 76 | this.queue = []; 77 | } 78 | } 79 | 80 | // for queue 81 | 82 | public flush() { 83 | if (this.stream === STREAM.QUEUE) { 84 | this.queue.forEach((args) => { 85 | console.log.apply(console, args); 86 | }); 87 | return this.empty(); 88 | } 89 | } 90 | 91 | public empty() { 92 | this.queue = []; 93 | } 94 | } 95 | 96 | const logger = new Logger(LEVEL.INFO); 97 | export default logger; 98 | -------------------------------------------------------------------------------- /src/assets/ts/menu.ts: -------------------------------------------------------------------------------- 1 | import Session, { InMemorySession } from './session'; 2 | import { Line } from './types'; 3 | 4 | /* 5 | Represents the menu shown in menu mode. 6 | Functions for paging through and selecting results, and for rendering. 7 | Internally uses an entire session object (this is sorta weird..) 8 | */ 9 | 10 | export type MenuResult = { 11 | contents: Line; 12 | 13 | // called when selected 14 | fn: any; // TODO 15 | 16 | // props for rendering LineComponent 17 | renderOptions?: { 18 | accents: {[column: number]: boolean} 19 | }; 20 | 21 | // hook for rendering search result contents 22 | renderHook?: (line: any) => any; // TODO 23 | }; 24 | 25 | type Query = string; 26 | type SearchFn = (query: Query) => Promise>; 27 | 28 | export default class Menu { 29 | private searchFn: SearchFn; 30 | public results: Array; 31 | public selection: number; 32 | 33 | public session: Session; 34 | 35 | private lastQuery: Query | null = null; 36 | 37 | constructor(searchFn: SearchFn) { 38 | this.searchFn = searchFn; 39 | 40 | // a bit of a overkill-y hack, use an entire session object internally 41 | this.session = new InMemorySession({ initialMode: 'INSERT' }); 42 | this.selection = 1; 43 | 44 | this.results = []; 45 | } 46 | 47 | public up() { 48 | if (!this.results.length) { 49 | return; 50 | } 51 | if (this.selection <= 0) { 52 | this.selection = this.results.length - 1; 53 | } else { 54 | this.selection = this.selection - 1; 55 | } 56 | } 57 | 58 | public down() { 59 | if (!this.results.length) { 60 | return; 61 | } 62 | if (this.selection + 1 >= this.results.length) { 63 | this.selection = 0; 64 | } else { 65 | this.selection = this.selection + 1; 66 | } 67 | } 68 | 69 | public async update() { 70 | const query = await this.session.curText(); 71 | if ((JSON.stringify(query)) !== (JSON.stringify(this.lastQuery))) { 72 | this.lastQuery = query; 73 | // const t = Date.now(); 74 | // console.log('updating results'); 75 | this.results = await this.searchFn(query); 76 | // console.log('updating results took', Date.now() - t); 77 | this.selection = 0; 78 | } 79 | } 80 | 81 | public async select() { 82 | if (!this.results.length) { 83 | return; 84 | } 85 | const result = this.results[this.selection]; 86 | await result.fn(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /docs/dev_setup.md: -------------------------------------------------------------------------------- 1 | ## DEVELOPMENT SETUP 2 | 3 | For development, you'll want to run vimflowy locally. 4 | My aim is for setup to be painless, so let me know if anything goes awry. 5 | 6 | ### Installation 7 | 8 | With recent versions of node (6.x) and npm: 9 | 10 | git clone https://github.com/WuTheFWasThat/vimflowy.git 11 | cd vimflowy 12 | npm install 13 | 14 | ### Run 15 | 16 | Simply run: 17 | 18 | npm start 19 | 20 | After a short wait, you should see the app at `http://localhost:3000/` 21 | When source code changes, assets should be automatically (incrementally) recompiled. 22 | 23 | This uses [Create React App](https://create-react-app.dev/docs/getting-started), so most of that documentation applies. For example, to use a different port, you set the`PORT` environment variable. 24 | 25 | Note that you may make new documents simply by visiting 26 | `http://localhost:3000?doc=#` 27 | 28 | NOTE: You may notice that the development version is a bit slow. 29 | If you're looking to run vimflowy for personal usage (not development), you'll want to compile the assets in production mode: 30 | 31 | npm run build 32 | npm run startprod 33 | 34 | Notably, you can run a SQLite backend, for persistence to your server. 35 | [See here for more info](storage/SQLite.md). 36 | 37 | ### Tests 38 | 39 | To run a process that monitors and runs tests when files change: 40 | 41 | npm run watchtest 42 | 43 | To run unit tests manually once (and get a more detailed report): 44 | 45 | npm test 46 | 47 | #### Typechecking 48 | 49 | To manually run typescript checking: 50 | 51 | npm run typecheck 52 | 53 | #### Linting 54 | 55 | To manually lint the project: 56 | 57 | npm run lint 58 | 59 | ### Profiling 60 | 61 | For profiling, you should use browser profiling when possible. 62 | However, though the results will be less realistic, you can also profile unit tests. Something like: 63 | 64 | npm run profiletest 65 | node-tick-processor *-v8.log > processed_log 66 | less processed_log 67 | 68 | ## Guidelines to contributing 69 | 70 | Just send a pull request. Remember to write tests when appropriate! 71 | 72 | For any questions, don't hesitate to submit an issue or contact me at [githubusername]@gmail.com. Let me know especially if you plan on adding new features! I'm happy to chat about design, give pointers for where to start reading code, etc. 73 | 74 | I've marked a number of github issues with the label `small_task`, which could be good places to start. 75 | -------------------------------------------------------------------------------- /src/shared/utils/errors.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | export class ExtendableError extends Error { 4 | constructor(message: string, name?: string) { 5 | super(message); 6 | this.name = name || this.constructor.name; 7 | this.stack = (new Error(message)).stack; 8 | } 9 | } 10 | 11 | export class NotImplemented extends ExtendableError { 12 | constructor(m = '') { 13 | super(m ? `Not implemented: ${m}!` : 'Not implemented!'); 14 | } 15 | } 16 | 17 | export class UnexpectedValue extends ExtendableError { 18 | constructor(name: string, value: any) { 19 | super(`Unexpected value for \`${name}\`: ${value}`); 20 | } 21 | } 22 | 23 | export class GenericError extends ExtendableError { 24 | // constructor(m: string) { super(m); } 25 | } 26 | 27 | // error class for errors that we can reasonably expect to happen 28 | // e.g. bad user input, multiple users 29 | // is special because ignored by error handling in app.tsx 30 | export class ExpectedError extends ExtendableError { 31 | // constructor(m: string) { super(m); } 32 | } 33 | 34 | /////////// 35 | // asserts 36 | /////////// 37 | 38 | export class AssertionError extends ExtendableError { 39 | constructor(m: string) { 40 | super(`Assertion error: ${m}`); 41 | } 42 | } 43 | 44 | export function assert(a: boolean, message = 'assert error') { 45 | if (!a) { 46 | throw new AssertionError(`${message}\nExpected ${a} to be true`); 47 | } 48 | } 49 | 50 | export function assert_equals(a: any, b: any, message = 'assert_equals error') { 51 | if (a !== b) { throw new AssertionError(`${message}\nExpected ${a} == ${b}`); } 52 | } 53 | 54 | export function assert_not_equals(a: any, b: any, message = 'assert_not_equals error') { 55 | if (a === b) { throw new AssertionError(`${message}\nExpected ${a} != ${b}`); } 56 | } 57 | 58 | // for asserting object equality 59 | export function assert_deep_equals(a: any, b: any, message = 'assert_deep_equals error') { 60 | if (!_.isEqual(a, b)) { 61 | throw new AssertionError(`${message} 62 | \nExpected: 63 | \n${JSON.stringify(a, null, 2)} 64 | \nBut got: 65 | \n${JSON.stringify(b, null, 2)} 66 | `); 67 | } 68 | } 69 | 70 | export function assert_arrays_equal(arr_a: Array, arr_b: Array) { 71 | const a_minus_b = _.difference(arr_a, arr_b); 72 | if (a_minus_b.length) { throw new AssertionError(`Arrays not same, first contains: ${a_minus_b}`); } 73 | const b_minus_a = _.difference(arr_b, arr_a); 74 | if (b_minus_a.length) { throw new AssertionError(`Arrays not same, second contains: ${b_minus_a}`); } 75 | } 76 | -------------------------------------------------------------------------------- /test/tests/collapse.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | describe('collapse', () => { 5 | it('works in basic case', async function() { 6 | let t = new TestCase([ 7 | { text: 'first', children: [ 8 | 'second', 9 | ] }, 10 | 'third', 11 | ]); 12 | t.sendKeys('z'); 13 | t.expect([ 14 | { text: 'first', collapsed: true, children: [ 15 | 'second', 16 | ] }, 17 | 'third', 18 | ]); 19 | t.sendKeys('jx'); 20 | t.expect([ 21 | { text: 'first', collapsed: true, children: [ 22 | 'second', 23 | ] }, 24 | 'hird', 25 | ]); 26 | t.sendKeys('uu'); 27 | t.expect([ 28 | { text: 'first', children: [ 29 | 'second', 30 | ] }, 31 | 'third', 32 | ]); 33 | await t.done(); 34 | }); 35 | 36 | it('open and close work in insert mode', async function() { 37 | let t = new TestCase([ 38 | { text: 'first', collapsed: true, children: [ 39 | 'second', 40 | ] }, 41 | ]); 42 | t.sendKeys('if-'); 43 | t.sendKey('meta+down'); 44 | t.expect([ 45 | { text: 'f-first', children: [ 46 | 'second', 47 | ] }, 48 | ]); 49 | t.sendKey('meta+down'); 50 | t.expect([ 51 | { text: 'f-first', children: [ 52 | 'second', 53 | ] }, 54 | ]); 55 | t.sendKey('meta+up'); 56 | t.expect([ 57 | { text: 'f-first', collapsed: true, children: [ 58 | 'second', 59 | ] }, 60 | ]); 61 | t.sendKey('meta+up'); 62 | t.expect([ 63 | { text: 'f-first', collapsed: true, children: [ 64 | 'second', 65 | ] }, 66 | ]); 67 | t.sendKeys('f-'); 68 | t.expect([ 69 | { text: 'f-f-first', collapsed: true, children: [ 70 | 'second', 71 | ] }, 72 | ]); 73 | t.sendKey('ctrl+z'); 74 | t.expect([ 75 | { text: 'f-first', collapsed: true, children: [ 76 | 'second', 77 | ] }, 78 | ]); 79 | t.sendKey('ctrl+z'); 80 | t.expect([ 81 | { text: 'f-first', children: [ 82 | 'second', 83 | ] }, 84 | ]); 85 | t.sendKey('ctrl+z'); 86 | t.expect([ 87 | { text: 'f-first', collapsed: true, children: [ 88 | 'second', 89 | ] }, 90 | ]); 91 | t.sendKey('ctrl+z'); 92 | t.expect([ 93 | { text: 'first', collapsed: true, children: [ 94 | 'second', 95 | ] }, 96 | ]); 97 | await t.done(); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /server/prod.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import { AddressInfo } from 'net'; 5 | 6 | import express from 'express'; 7 | import minimist from 'minimist'; 8 | 9 | import logger from '../src/shared/utils/logger'; 10 | 11 | import makeSocketServer from './socket_server'; 12 | import { defaultBuildDir } from './constants'; 13 | 14 | async function main(args: any) { 15 | if (args.help || args.h) { 16 | process.stdout.write(` 17 | Usage: ./node_modules/.bin/ts-node ${process.argv[1]} 18 | -h, --help: help menu 19 | 20 | --host $hostname: Host to listen on 21 | --port $portnumber: Port to run on 22 | 23 | --db $dbtype: If a db is set, we will additionally run a socket server. 24 | Available options: 25 | - 'sqlite' to use sqlite backend 26 | Any other value currently defaults to an in-memory backend. 27 | --password: password to protect database with (defaults to empty) 28 | 29 | --dbfolder: For sqlite backend only. Folder for sqlite to store data 30 | (defaults to in-memory if unspecified) 31 | 32 | --buildDir: Where build assets should be served from. Defaults to the \`build\` 33 | folder at the repo root. 34 | 35 | `, () => { 36 | process.exit(0); 37 | }); 38 | return; 39 | } 40 | 41 | const buildDir = path.resolve(args.buildDir || defaultBuildDir); 42 | 43 | let port: number = args.port || 3000; 44 | let host: string = args.host || 'localhost'; 45 | 46 | if (!fs.existsSync(buildDir)) { 47 | logger.info(` 48 | No assets found at ${buildDir}! 49 | Try running \`npm run build -- --outdir ${buildDir}\` first. 50 | Or specify where they should be found with --buildDir $somedir. 51 | `); 52 | return; 53 | } 54 | logger.info('Starting production server'); 55 | const app = express(); 56 | app.use(express.static(buildDir)); 57 | const server = http.createServer(app as any); 58 | if (args.db) { 59 | const options = { 60 | db: args.db, 61 | dbfolder: args.dbfolder, 62 | password: args.password, 63 | path: '/socket', 64 | }; 65 | makeSocketServer(server, options); 66 | } 67 | server.listen(port, host, (err?: Error) => { 68 | if (err) { return logger.error(err); } 69 | const address_info: AddressInfo = server.address() as AddressInfo; 70 | logger.info('Listening on http://%s:%d', address_info.address, address_info.port); 71 | }); 72 | } 73 | 74 | main(minimist(process.argv.slice(2))); 75 | -------------------------------------------------------------------------------- /src/assets/css/index.sass: -------------------------------------------------------------------------------- 1 | @import mixins 2 | 3 | body, html 4 | margin: 0 5 | font-family: 'Monaco', monospace // Courier New? 6 | height: 100% 7 | 8 | $bottom-bar-height: 25px 9 | #bottom-bar 10 | z-index: 3 11 | position: absolute 12 | bottom: 0px 13 | height: $bottom-bar-height 14 | border-left : 0px !important 15 | border-right : 0px !important 16 | border-bottom : 0px !important 17 | width: 100% 18 | font-size: 12px 19 | 20 | & > div, & > a 21 | padding: 5px 15px 22 | 23 | .btn 24 | display: inline-block 25 | padding: 5px 26 | border-radius: 5px 27 | font-size: 11px 28 | cursor: pointer 29 | margin: 0px 5px 30 | &:focus 31 | outline: 0 32 | 33 | #settings 34 | position: absolute 35 | width: 100% 36 | @include calc(height, '100% - #{$bottom-bar-height}') 37 | z-index: 2 38 | overflow-y: auto 39 | 40 | .settings-content 41 | padding: 20px 0px 42 | 43 | .settings-header 44 | $header-padding: 5px 45 | @include calc(width, '100% - #{2 * $header-padding}') 46 | border-radius: 2px 47 | -moz-border-radius: 2px 48 | padding: $header-padding 49 | 50 | .transition-ease-width 51 | @include transition(width 0.2s ease-in-out) 52 | 53 | #contents 54 | display: flex 55 | flex-direction: row 56 | bottom: $bottom-bar-height 57 | position: absolute 58 | width: 100% 59 | @include calc(height, '100% - #{$bottom-bar-height}') 60 | 61 | #view, #menu 62 | $content-padding: 50px 63 | padding: $content-padding 64 | 65 | @include calc(height, '100% - #{2 * $content-padding}') 66 | width: 100% 67 | 68 | overflow-y: auto 69 | 70 | .tabs 71 | list-style: none 72 | position: relative 73 | z-index: 2 74 | $tab-height: 20px 75 | $tab-bar-height: 1px 76 | & > li 77 | display: inline-block 78 | cursor: pointer 79 | margin: 0 10px 80 | padding: 0 10px 81 | height: $tab-height 82 | position: relative 83 | &.active 84 | height: $tab-height + $tab-bar-height 85 | background-color: inherit !important 86 | font-weight: bold 87 | z-index: 4 88 | border-bottom: 0px !important 89 | border-color: inherit !important 90 | margin-top: $tab-bar-height 91 | &:after 92 | position: absolute 93 | content: "" 94 | width: 100% 95 | bottom: 0 96 | left: 0 97 | border-bottom: $tab-bar-height solid 98 | z-index: 3 99 | -------------------------------------------------------------------------------- /docs/deployment.md: -------------------------------------------------------------------------------- 1 | # DEPLOYMENT 2 | 3 | ## Docker 4 | 5 | Vimflowy supports deployment with docker. 6 | A docker image is hosted on [Docker hub](https://hub.docker.com/r/vimflowy/vimflowy/). 7 | Check out [the `Dockerfile`](/Dockerfile) for technical details. 8 | 9 | ### Example deployment 10 | 11 | First, we download the image: 12 | ``` 13 | docker pull vimflowy/vimflowy 14 | ``` 15 | 16 | Next, we create a volume called `vimflowy-db` (you can rename this to your liking) to hold the 17 | [SQLite](storage/SQLite.md) databases. 18 | 19 | ``` 20 | docker volume create vimflowy-db 21 | ``` 22 | 23 | Lastly, we run vimflowy container, mounting in the `vimflowy-db` volume 24 | 25 | ``` 26 | docker run -d \ 27 | -e VIMFLOWY_PASSWORD=supersecretpassword \ 28 | --name vimflowy \ 29 | --mount source=vimflowy-db,target=/app/db \ 30 | -p 3000:3000 \ 31 | --restart unless-stopped \ 32 | vimflowy/vimflowy 33 | ``` 34 | 35 | ### Docker Compose with automatic HTTPS 36 | 37 | We can use [Caddy](https://caddyserver.com) and Docker Compose to run Vimflowy behind HTTPS proxy. 38 | 39 | docker-compose.yml 40 | ``` 41 | version: '2' 42 | services: 43 | tasks: 44 | image: vimflowy/vimflowy 45 | container_name: tasks 46 | restart: always 47 | environment: 48 | VIMFLOWY_PASSWORD: 'supersecretpassword' 49 | ports: 50 | - "3000" 51 | volumes: 52 | - "vimflowy-db:/app/db" 53 | server: 54 | image: abiosoft/caddy 55 | container_name: caddy 56 | restart: always 57 | ports: 58 | - "80:80" 59 | - "443:443" 60 | links: 61 | - tasks 62 | volumes: 63 | - "./Caddyfile:/etc/Caddyfile" 64 | 65 | volumes: 66 | vimflowy-db: 67 | ``` 68 | 69 | Make sure that in the same directory `Caddyfile` exists. 70 | 71 | ``` 72 | yourdomain.com { 73 | proxy / http://tasks:3000/ { 74 | websocket 75 | } 76 | tls your@email.com 77 | } 78 | ``` 79 | 80 | ### Environment variables 81 | 82 | You can override certain aspects of the container through environment variables (specified in `-e` options in the `docker run` command). 83 | 84 | * `VIMFLOWY_PASSWORD`: The server password, specified by the user in *Settings > Data Storage > Vimflowy Server* 85 | 86 | ## From source 87 | 88 | Of course, you can also deploy from source yourself. 89 | 90 | - Build from our [`Dockerfile`](/Dockerfile), if you want to deploy in a container 91 | ``` 92 | docker build . -t vimflowy:dev 93 | ``` 94 | - Follow the [dev setup](/docs/dev_setup.md) instructions, otherwise. 95 | You will likely want to run the server enabling the [SQLite backend](storage/SQLite.md). 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vimflowy", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "./", 6 | "dependencies": { 7 | "@types/file-saver": "^2.0.1", 8 | "@types/jest": "^26.0.19", 9 | "@types/node": "^12.19.11", 10 | "@types/react-dom": "^16.9.10", 11 | "@types/express": "^4.16.0", 12 | "@types/sqlite3": "^2.2.33", 13 | "@types/ws": "0.0.41", 14 | "express": "^4.16.4", 15 | "file-saver": "^2.0.5", 16 | "firebase": "^8.2.1", 17 | "font-awesome": "^4.7.0", 18 | "jquery": "^3.5.1", 19 | "katex": "^0.12.0", 20 | "localforage": "^1.9.0", 21 | "lodash": "^4.17.20", 22 | "node-sass": "^4.14.1", 23 | "react": "^17.0.1", 24 | "react-color": "^2.19.3", 25 | "react-dom": "^17.0.1", 26 | "react-scripts": "4.0.1", 27 | "sqlite3": "^4.1.1", 28 | "ts-node": "^3.3.0", 29 | "typescript": "^4.1.3", 30 | "web-vitals": "^0.2.4", 31 | "ws": "^6.1.2" 32 | }, 33 | "scripts": { 34 | "lint": "tslint --exclude 'node_modules/**/*' '**/*.ts' '**/*.tsx'", 35 | "startprod": "ts-node --compilerOptions '{\"module\":\"commonjs\"}' server/prod.ts", 36 | "typecheck": "tsc -p . --noEmit", 37 | "verify": "npm run lint && npm run typecheck && npm test", 38 | "start": "react-scripts start", 39 | "build": "react-scripts build", 40 | "test": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' mocha --opts test/mocha.opts", 41 | "test-windows": "SET TS_NODE_COMPILER_OPTIONS={\"module\":\"commonjs\"}&& mocha --opts test/mocha.opts", 42 | "watchtest": "npm test -- --reporter dot --watch", 43 | "profiletest": "npm test -- --prof", 44 | "eject": "react-scripts eject" 45 | }, 46 | "eslintConfig": { 47 | "extends": [ 48 | "react-app", 49 | "react-app/jest" 50 | ] 51 | }, 52 | "browserslist": { 53 | "production": [ 54 | ">0.2%", 55 | "not dead", 56 | "not op_mini all" 57 | ], 58 | "development": [ 59 | "last 1 chrome version", 60 | "last 1 firefox version", 61 | "last 1 safari version" 62 | ] 63 | }, 64 | "devDependencies": { 65 | "@types/jquery": "^3.5.5", 66 | "@types/katex": "^0.11.0", 67 | "@types/lodash": "^4.14.166", 68 | "@types/minimist": "^1.2.0", 69 | "@types/mocha": "^2.2.48", 70 | "@types/react": "^16.14.2", 71 | "@types/react-color": "^3.0.4", 72 | "ignore-styles": "^5.0.1", 73 | "minimist": "^1.2.2", 74 | "mocha": "^5.2.0", 75 | "tslint": "^4.5.1" 76 | } 77 | } -------------------------------------------------------------------------------- /docs/storage/SQLite.md: -------------------------------------------------------------------------------- 1 | # SQLite backend for vimflowy 2 | 3 | ## Info 4 | 5 | While vimflowy is a server-less app, it's possible to also run a backend server to keep your data. 6 | The vimflowy backend server communicates with the browser via websockets, while storing the data in SQLite. 7 | While SQLite is the only option for now, more may be added in the future. 8 | 9 | You can run the vimflowy server on your personal computer, for a local and offline vimflowy. 10 | You can also host it on an external server, so you can access it from multiple devices. In that case, you can protect access with a password. 11 | 12 | ## Setup 13 | 14 | ### Run the vimflowy server 15 | 16 | All commands in this section are on your server/computer where you're hosting. 17 | 18 | First, install vimflowy 19 | 20 | git clone https://github.com/WuTheFWasThat/vimflowy.git 21 | cd vimflowy 22 | npm install 23 | 24 | Build assets 25 | 26 | npm run build 27 | 28 | Then, run the server. 29 | 30 | npm run startprod -- --db sqlite --dbfolder ${somefolder} --password ${somepassword} 31 | 32 | The `dbfolder` flag says where to store/load data. If left empty, an in-memory database is used. 33 | 34 | The `password` flag is optional. Of course, it's wise to set one if you're using a widely accessible server, but pointless if you're hosting on your personal computer. 35 | 36 | You can also change the port (from the default of 3000) with the `--port ${portnumber}` flag. 37 | 38 | ### Configure Vimflowy 39 | 40 | Now open Vimflowy in your browser. 41 | The server you ran will also host a version of the website (though you can also use https://wuthejeff.com/vimflowy). 42 | On the page, click Settings in the lower right. 43 | Select the `Vimflowy server` option under `Data Storage`. 44 | 45 | Under the `Server` form field, you'll want to enter an address to connect to. It should be `ws://localhost:3000`, if you ran locally, and something like `wss://yourwebsite.com:3000`, or `wss://54.0.0.1:3000` otherwise. 46 | Under `Password`, enter a password if you configured one. 47 | Under the `Document` form field, you can optionally enter the name of the document. 48 | 49 | Then hit `Load Data Settings`. 50 | This should refresh the page automatically. 51 | If you see no errors, everything should be successful! 52 | 53 | ### Verify 54 | 55 | Check the settings menu again and you should see that the `Vimflowy server` option is already selected. 56 | 57 | You should also inspect the SQLite file in the `dbfolder` specified, and make sure it contains data. 58 | 59 | ## Backups 60 | 61 | Data backups are done simply by backing up your SQLite backups (e.g. obtain db lock, then copy file). 62 | You could also consider simply keeping the SQLite file in Dropbox (although Dropbox warns against this, it should work as long as it's not written to from other machines). 63 | -------------------------------------------------------------------------------- /test/tests/move_siblings.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | let nextSiblingKey = '}'; 5 | let prevSiblingKey = '{'; 6 | 7 | describe('move siblings', function() { 8 | it('works', async function() { 9 | let t = new TestCase([ 10 | { text: 'one', children: [ 11 | 'uno', 12 | ] }, 13 | { text: 'two', children: [ 14 | 'dos', 15 | ] }, 16 | { text: 'tacos', children: [ 17 | 'tacos', 18 | ] }, 19 | ]); 20 | t.sendKeys('x'); 21 | t.sendKey(nextSiblingKey); 22 | t.sendKeys('x'); 23 | t.expect([ 24 | { text: 'ne', children: [ 25 | 'uno', 26 | ] }, 27 | { text: 'wo', children: [ 28 | 'dos', 29 | ] }, 30 | { text: 'tacos', children: [ 31 | 'tacos', 32 | ] }, 33 | ]); 34 | t.sendKey(nextSiblingKey); 35 | t.sendKeys('x'); 36 | t.sendKey(nextSiblingKey); 37 | t.sendKeys('x'); 38 | t.expect([ 39 | { text: 'ne', children: [ 40 | 'uno', 41 | ] }, 42 | { text: 'wo', children: [ 43 | 'dos', 44 | ] }, 45 | { text: 'cos', children: [ 46 | 'tacos', 47 | ] }, 48 | ]); 49 | t.sendKey(prevSiblingKey); 50 | t.sendKeys('x'); 51 | t.expect([ 52 | { text: 'ne', children: [ 53 | 'uno', 54 | ] }, 55 | { text: 'o', children: [ 56 | 'dos', 57 | ] }, 58 | { text: 'cos', children: [ 59 | 'tacos', 60 | ] }, 61 | ]); 62 | t.sendKey(prevSiblingKey); 63 | t.sendKeys('x'); 64 | t.expect([ 65 | { text: 'e', children: [ 66 | 'uno', 67 | ] }, 68 | { text: 'o', children: [ 69 | 'dos', 70 | ] }, 71 | { text: 'cos', children: [ 72 | 'tacos', 73 | ] }, 74 | ]); 75 | t.sendKey(prevSiblingKey); 76 | t.sendKeys('x'); 77 | t.expect([ 78 | { text: '', children: [ 79 | 'uno', 80 | ] }, 81 | { text: 'o', children: [ 82 | 'dos', 83 | ] }, 84 | { text: 'cos', children: [ 85 | 'tacos', 86 | ] }, 87 | ]); 88 | await t.done(); 89 | }); 90 | 91 | it('doesnt work at the top level', async function() { 92 | let t = new TestCase([ 93 | { text: 'one', children: [ 94 | 'uno', 95 | ] }, 96 | { text: 'two', children: [ 97 | 'dos', 98 | ] }, 99 | { text: 'tacos', children: [ 100 | 'tacos', 101 | ] }, 102 | ]); 103 | t.sendKey(nextSiblingKey); 104 | t.sendKey('enter'); 105 | t.expectViewRoot(3); 106 | t.expectCursor(3, 0); 107 | t.sendKey(prevSiblingKey); 108 | t.expectCursor(3, 0); 109 | await t.done(); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/assets/ts/definitions/history.ts: -------------------------------------------------------------------------------- 1 | import keyDefinitions, { Action, SequenceAction } from '../keyDefinitions'; 2 | import { Key, Macro } from '../types'; 3 | 4 | keyDefinitions.registerAction(new Action( 5 | 'undo', 6 | 'Undo', 7 | async function({ session, repeat }) { 8 | for (let j = 0; j < repeat; j++) { 9 | await session.undo(); 10 | } 11 | }, 12 | { sequence: SequenceAction.DROP }, 13 | )); 14 | 15 | keyDefinitions.registerAction(new Action( 16 | 'redo', 17 | 'Redo', 18 | async function({ session, repeat }) { 19 | for (let j = 0; j < repeat; j++) { 20 | await session.redo(); 21 | } 22 | }, 23 | { sequence: SequenceAction.DROP }, 24 | )); 25 | 26 | keyDefinitions.registerAction(new Action( 27 | 'replay-command', 28 | 'Replay last command', 29 | async function({ repeat, keyStream, keyHandler }) { 30 | for (let j = 0; j < repeat; j++) { 31 | await keyHandler.playRecording(keyStream.lastSequence); 32 | } 33 | }, 34 | { sequence: SequenceAction.DROP }, 35 | )); 36 | 37 | // TODO: store this on session? dont assume global 38 | let RECORDING: { 39 | macro: Macro, 40 | key: Key, 41 | } | null = null; 42 | const RECORDING_LISTENER = (key: Key) => { 43 | if (!RECORDING) { 44 | throw new Error('Recording listener on while there was no recording'); 45 | } 46 | // TODO avoid recording RECORD_MACRO itself? 47 | // current record_macro implementation pops itself off 48 | // and assumes it's only 1 key 49 | RECORDING.macro.push(key); 50 | }; 51 | 52 | keyDefinitions.registerAction(new Action( 53 | 'record-macro', 54 | 'Begin/stop recording a macro', 55 | async function({ keyStream, session }) { 56 | if (RECORDING === null) { 57 | const key = await keyStream.dequeue(); 58 | RECORDING = { 59 | macro: [], 60 | key: key, 61 | }; 62 | keyStream.on('dequeue', RECORDING_LISTENER); 63 | } else { 64 | // pop off the RECORD_MACRO itself 65 | RECORDING.macro.pop(); 66 | const macros = session.clientStore.getMacros(); 67 | const macro = RECORDING.macro; 68 | macros[RECORDING.key] = macro; 69 | session.clientStore.setMacros(macros); 70 | RECORDING = null; 71 | keyStream.off('dequeue', RECORDING_LISTENER); 72 | } 73 | }, 74 | { sequence: SequenceAction.DROP }, 75 | )); 76 | 77 | keyDefinitions.registerAction(new Action( 78 | 'play-macro', 79 | 'Play a macro', 80 | async function({ keyStream, keyHandler, repeat, session }) { 81 | const key = await keyStream.dequeue(); 82 | const macros = session.clientStore.getMacros(); 83 | const recording = macros[key]; 84 | if (recording == null) { 85 | keyStream.drop(); 86 | return; 87 | } 88 | for (let j = 0; j < repeat; j++) { 89 | await keyHandler.playRecording(recording); 90 | } 91 | }, 92 | )); 93 | -------------------------------------------------------------------------------- /test/tests/swap_case.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | describe('swapping case', function() { 5 | it('should swap case at cursor and moving cursor to the right', async function() { 6 | const t = new TestCase(['oo']); 7 | t.sendKeys('0'); 8 | t.sendKey('~'); 9 | t.expect(['Oo']); 10 | t.expectCursor(1, 1); 11 | 12 | t.sendKeys('0'); 13 | t.sendKey('~'); 14 | t.expect(['oo']); 15 | t.expectCursor(1, 1); 16 | 17 | await t.done(); 18 | }); 19 | 20 | it('should not move cursor if swapping at the end of line', async function() { 21 | const t = new TestCase(['oo']); 22 | t.sendKeys('$~'); 23 | t.expect(['oO']); 24 | t.expectCursor(1, 1); 25 | 26 | t.sendKeys('~'); 27 | t.expect(['oo']); 28 | t.expectCursor(1, 1); 29 | 30 | await t.done(); 31 | }); 32 | 33 | it('should swap case in visual mode', async function() { 34 | const t = new TestCase(['swapCaseHere']); 35 | t.sendKeys('0llvlll~'); 36 | t.expect(['swAPcAseHere']); 37 | t.expectCursor(1, 2); 38 | 39 | t.sendKeys('v0~'); 40 | t.expect(['SWaPcAseHere']); 41 | t.expectCursor(1, 0); 42 | 43 | await t.done(); 44 | }); 45 | 46 | it('should undo case swapping', async function() { 47 | const t = new TestCase(['swapCaseHere']); 48 | t.sendKeys('0~'); 49 | t.expect(['SwapCaseHere']); 50 | t.sendKeys('u'); 51 | t.expect(['swapCaseHere']); 52 | 53 | t.sendKeys('llvll~'); 54 | t.expect(['swAPcaseHere']); 55 | t.sendKeys('u'); 56 | t.expect(['swapCaseHere']); 57 | 58 | await t.done(); 59 | }); 60 | 61 | it('should swap case for multiple selected lines', async function() { 62 | const t = new TestCase(['swap', 'case', 'here']); 63 | t.sendKeys('0Vj~'); 64 | t.expect(['SWAP', 'CASE', 'here']); 65 | t.expectCursor(2, 0); 66 | 67 | await t.done(); 68 | }); 69 | 70 | it('should swap case for multiple selected nodes, without children', async function () { 71 | const t = new TestCase([ 72 | { 73 | text: 'swap', 74 | children: ['case'] 75 | }, 76 | 'here', 77 | 'not here' 78 | ]); 79 | t.sendKeys('0Vjj~'); 80 | t.expect([ 81 | { 82 | text: 'SWAP', 83 | children: ['case'] 84 | }, 85 | 'HERE', 86 | 'not here' 87 | ]); 88 | t.expectCursor(3, 0); 89 | 90 | await t.done(); 91 | }); 92 | 93 | it('should undo multiline case swapping', async function() { 94 | const t = new TestCase(['swap', 'case']); 95 | t.sendKeys('0V~'); 96 | t.expect(['SWAP', 'case']); 97 | t.expectCursor(1, 0); 98 | 99 | t.sendKeys('u'); 100 | t.expect(['swap', 'case']); 101 | t.expectCursor(1, 0); 102 | 103 | await t.done(); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /test/tests/join.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | let joinKey = 'J'; 5 | 6 | describe('join', function() { 7 | it('works in basic case', async function() { 8 | let t = new TestCase(['ab', 'cd']); 9 | t.sendKeys(joinKey); 10 | t.expect(['ab cd']); 11 | t.sendKeys('x'); 12 | t.expect(['abcd']); 13 | await t.done(); 14 | }); 15 | 16 | it('works with delimiter already there', async function() { 17 | let t = new TestCase(['ab', ' cd']); 18 | t.sendKeys(joinKey); 19 | t.expect(['ab cd']); 20 | t.sendKeys('x'); 21 | t.expect(['abcd']); 22 | await t.done(); 23 | }); 24 | 25 | it('works with child', async function() { 26 | let t = new TestCase([ 27 | { text: 'ab', children: [ 28 | 'cd', 29 | ] }, 30 | ]); 31 | t.sendKeys(joinKey); 32 | t.expect(['ab cd']); 33 | t.sendKeys('x'); 34 | t.expect(['abcd']); 35 | await t.done(); 36 | }); 37 | 38 | it('works where second line has child', async function() { 39 | let t = new TestCase([ 40 | 'ab', 41 | { text: 'cd', children: [ 42 | 'ef', 43 | 'gh', 44 | ] }, 45 | ]); 46 | t.sendKeys(joinKey); 47 | t.expect([ 48 | { text: 'ab cd', children: [ 49 | 'ef', 50 | 'gh', 51 | ] }, 52 | ]); 53 | t.sendKeys('x'); 54 | t.expect([ 55 | { text: 'abcd', children: [ 56 | 'ef', 57 | 'gh', 58 | ] }, 59 | ]); 60 | await t.done(); 61 | }); 62 | 63 | it('is undo and redo-able', async function() { 64 | let t = new TestCase([ 65 | 'ab', 66 | { text: 'cd', children: [ 67 | 'ef', 68 | ] }, 69 | ]); 70 | t.sendKeys(joinKey); 71 | t.expect([ 72 | { text: 'ab cd', children: [ 73 | 'ef', 74 | ] }, 75 | ]); 76 | t.sendKeys('x'); 77 | t.expect([ 78 | { text: 'abcd', children: [ 79 | 'ef', 80 | ] }, 81 | ]); 82 | t.sendKeys('uu'); 83 | t.expect([ 84 | 'ab', 85 | { text: 'cd', children: [ 86 | 'ef', 87 | ] }, 88 | ]); 89 | t.sendKey('ctrl+r'); 90 | t.expect([ 91 | { text: 'ab cd', children: [ 92 | 'ef', 93 | ] }, 94 | ]); 95 | await t.done(); 96 | }); 97 | 98 | it('works when second row is empty', async function() { 99 | let t = new TestCase(['empty', '']); 100 | t.sendKeys('J'); 101 | t.expect(['empty']); 102 | await t.done(); 103 | }); 104 | 105 | it('doesnt affect registers', async function() { 106 | let t = new TestCase(['af', 'as', 'df']); 107 | t.sendKeys('dd'); 108 | t.expect(['as', 'df']); 109 | t.sendKeys('J'); 110 | t.expect(['as df']); 111 | t.sendKeys('p'); 112 | t.expect(['as df', 'af']); 113 | await t.done(); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "align": [true, 4 | "parameters", 5 | "statements"], 6 | "ban": false, 7 | "class-name": true, 8 | "comment-format": [true, 9 | "check-space" 10 | ], 11 | "curly": true, 12 | "eofline": true, 13 | "forin": false, 14 | "indent": [true, "spaces"], 15 | "interface-name": false, 16 | "jsdoc-format": true, 17 | "label-position": true, 18 | "max-line-length": [true, 140], 19 | "member-access": true, 20 | "member-ordering": [true, 21 | "private-before-public", 22 | "static-before-instance", 23 | "variables-before-functions" 24 | ], 25 | "no-any": false, 26 | "no-arg": true, 27 | "no-bitwise": true, 28 | "no-conditional-assignment": true, 29 | "no-console": [false, 30 | "debug", 31 | "log", 32 | "info", 33 | "time", 34 | "timeEnd", 35 | "trace" 36 | ], 37 | "no-construct": true, 38 | "no-debugger": true, 39 | "no-shadowed-variable": true, 40 | "no-duplicate-variable": true, 41 | "no-empty": true, 42 | "no-eval": true, 43 | "no-inferrable-types": false, 44 | "no-internal-module": true, 45 | "no-require-imports": false, 46 | "no-string-literal": true, 47 | "no-switch-case-fall-through": true, 48 | "no-trailing-whitespace": true, 49 | "no-unused-expression": true, 50 | "no-use-before-declare": true, 51 | "no-var-keyword": true, 52 | "no-var-requires": true, 53 | "object-literal-sort-keys": false, 54 | "one-line": [true, 55 | "check-open-brace", 56 | "check-catch", 57 | "check-else", 58 | "check-whitespace" 59 | ], 60 | "quotemark": [true, "single", "avoid-escape"], 61 | "radix": true, 62 | "semicolon": true, 63 | "switch-default": true, 64 | "trailing-comma": [false, { 65 | "multiline": "always", 66 | "singleline": "never" 67 | }], 68 | "triple-equals": [true, "allow-null-check"], 69 | "typedef": [true, 70 | "property-declaration", 71 | "member-variable-declaration" 72 | ], 73 | "typedef-whitespace": [true, { 74 | "call-signature": "nospace", 75 | "index-signature": "nospace", 76 | "parameter": "nospace", 77 | "property-declaration": "nospace", 78 | "variable-declaration": "nospace" 79 | }], 80 | "variable-name": false, 81 | "whitespace": [true, 82 | "check-branch", 83 | "check-decl", 84 | "check-operator", 85 | "check-separator", 86 | "check-type" 87 | ] 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/assets/ts/components/breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import Path from '../path'; 4 | import Session from '../session'; 5 | import { getStyles } from '../themes'; 6 | 7 | type CrumbProps = { 8 | onClick: ((...args: any[]) => void) | undefined, 9 | session: Session, 10 | }; 11 | class CrumbComponent extends React.PureComponent { 12 | public render() { 13 | const style = {}; 14 | if (this.props.onClick) { 15 | Object.assign(style, getStyles(this.props.session.clientStore, ['theme-link'])); 16 | } 17 | return ( 18 | 19 | 20 | {this.props.children} 21 | 22 | 24 | 25 | ); 26 | } 27 | } 28 | 29 | type BreadcrumbsProps = { 30 | session: Session; 31 | viewRoot: Path; 32 | crumbContents: {[row: number]: string}; 33 | onCrumbClick: ((...args: any[]) => void) | undefined; 34 | }; 35 | type BreadcrumbsState = { 36 | loaded: boolean; 37 | }; 38 | export default class BreadcrumbsComponent extends React.Component { 39 | constructor(props: BreadcrumbsProps) { 40 | super(props); 41 | this.state = { 42 | loaded: false, 43 | }; 44 | } 45 | 46 | public render() { 47 | const session = this.props.session; 48 | 49 | const crumbNodes: Array = []; 50 | let path = this.props.viewRoot; 51 | if (path.parent == null) { 52 | throw new Error('Shouldn\'t render breadcrumbs at root'); 53 | } 54 | path = path.parent; 55 | while (path.parent != null) { 56 | const cachedRow = session.document.cache.get(path.row); 57 | if (!cachedRow) { 58 | throw new Error('Row wasnt cached despite being in crumbs'); 59 | } 60 | const hooksInfo = { 61 | path, 62 | pluginData: cachedRow.pluginData, 63 | }; 64 | 65 | crumbNodes.push( 66 | 69 | { 70 | session.applyHook( 71 | 'renderLineContents', 72 | [this.props.crumbContents[path.row]], 73 | hooksInfo 74 | ) 75 | } 76 | 77 | ); 78 | path = path.parent; 79 | } 80 | crumbNodes.push( 81 | 84 | 85 | 86 | ); 87 | crumbNodes.reverse(); 88 | 89 | return ( 90 |
91 | {crumbNodes} 92 |
93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/assets/ts/components/settings/behaviorSettings.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { ClientStore } from '../../datastore'; 4 | 5 | type Props = { 6 | clientStore: ClientStore; 7 | }; 8 | 9 | type State = { 10 | copyToClipboard: boolean; 11 | formattedCopy: boolean; 12 | }; 13 | 14 | export default class BehaviorSettingsComponent extends React.Component { 15 | constructor(props: Props) { 16 | super(props); 17 | 18 | const clientStore = props.clientStore; 19 | this.state = { 20 | copyToClipboard: clientStore.getClientSetting('copyToClipboard'), 21 | formattedCopy: clientStore.getClientSetting('formattedCopy'), 22 | }; 23 | this.setCopyToClipboard = this.setCopyToClipboard.bind(this); 24 | this.setFormattedCopy = this.setFormattedCopy.bind(this); 25 | } 26 | 27 | private setCopyToClipboard(copyToClipboard: boolean): boolean { 28 | this.setState({ copyToClipboard: copyToClipboard }); 29 | this.props.clientStore.setClientSetting('copyToClipboard', copyToClipboard); 30 | return this.state.copyToClipboard; 31 | } 32 | 33 | private setFormattedCopy(formattedCopy: boolean): boolean { 34 | this.setState({ formattedCopy: formattedCopy }); 35 | this.props.clientStore.setClientSetting('formattedCopy', formattedCopy); 36 | return this.state.formattedCopy; 37 | } 38 | 39 | public render() { 40 | return ( 41 |
42 | 48 | 54 |
55 | ); 56 | } 57 | } 58 | 59 | interface SettingsCheckboxProps { 60 | id: string; 61 | name: string; 62 | desc: string; 63 | fn: (val: boolean) => void; 64 | checked: boolean; 65 | } 66 | 67 | class SettingsCheckbox extends React.Component { 68 | // constructor(props: SettingsCheckboxProps) { 69 | // super(props); 70 | // } 71 | 72 | public render() { 73 | return ( 74 |
75 | { 76 | this.props.fn(e.target.checked); 77 | }} /> 78 | 82 |
83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /test/tests/delete_home_end.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | describe('delete to end', function() { 5 | it('works in basic case', async function() { 6 | let t = new TestCase(['some random text']); 7 | t.sendKeys('wD'); 8 | t.expect(['some ']); 9 | t.sendKeys('D'); 10 | t.expect(['some']); 11 | t.sendKeys('u'); 12 | t.expect(['some ']); 13 | t.sendKeys('u'); 14 | t.expect(['some random text']); 15 | await t.done(); 16 | }); 17 | 18 | it('works at the end of a line', async function() { 19 | let t = new TestCase(['some random text']); 20 | t.sendKeys('$D'); 21 | t.expect(['some random tex']); 22 | // paste should work 23 | t.sendKeys('P'); 24 | t.expect(['some random tetx']); 25 | await t.done(); 26 | }); 27 | }); 28 | 29 | describe('delete to home/end in insert mode', function() { 30 | it('works in basic cases', async function() { 31 | let t = new TestCase(['some random text']); 32 | t.sendKeys('wi'); 33 | t.sendKey('ctrl+k'); 34 | t.expect(['some ']); 35 | t.sendKey('ctrl+u'); 36 | t.expect(['']); 37 | t.sendKey('ctrl+y'); 38 | t.expect(['some ']); 39 | t.sendKey('esc'); 40 | t.sendKeys('u'); 41 | t.expect(['']); 42 | t.sendKeys('u'); 43 | t.expect(['some ']); 44 | t.sendKeys('u'); 45 | t.expect(['some random text']); 46 | await t.done(); 47 | 48 | t = new TestCase(['some random text']); 49 | t.sendKeys('wi'); 50 | t.sendKey('ctrl+u'); 51 | t.expect(['random text']); 52 | t.sendKey('ctrl+k'); 53 | t.expect(['']); 54 | t.sendKey('ctrl+y'); 55 | t.expect(['random text']); 56 | t.sendKey('esc'); 57 | t.sendKeys('u'); 58 | t.expect(['']); 59 | t.sendKeys('u'); 60 | t.expect(['random text']); 61 | t.sendKeys('u'); 62 | t.expect(['some random text']); 63 | await t.done(); 64 | }); 65 | 66 | it('in insert mode, ctrl+y brings you past end', async function() { 67 | let t = new TestCase(['some random text']); 68 | t.sendKeys('wi'); 69 | t.sendKey('ctrl+k'); 70 | t.expect(['some ']); 71 | t.sendKey('ctrl+y'); 72 | t.expect(['some random text']); 73 | t.sendKey('s'); 74 | t.expect(['some random texts']); 75 | await t.done(); 76 | }); 77 | 78 | it("doesn't cause an undoable mutation when nothing happens", async function() { 79 | let t = new TestCase(['some random text']); 80 | t.sendKeys('x'); 81 | t.expect(['ome random text']); 82 | t.sendKeys('$a'); 83 | t.sendKey('ctrl+k'); 84 | t.sendKey('esc'); 85 | t.expect(['ome random text']); 86 | t.sendKeys('u'); 87 | t.expect(['some random text']); 88 | await t.done(); 89 | 90 | t = new TestCase(['some random text']); 91 | t.sendKeys('$x'); 92 | t.expect(['some random tex']); 93 | t.sendKeys('0i'); 94 | t.sendKey('ctrl+u'); 95 | t.sendKey('esc'); 96 | t.expect(['some random tex']); 97 | t.sendKeys('u'); 98 | t.expect(['some random text']); 99 | await t.done(); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /test/tests/find.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | describe('find', function() { 5 | it('works in basic cases', async function() { 6 | const t = new TestCase(['Peter Piper picked a peck of pickled peppers']); 7 | t.sendKeys('fprd'); 8 | t.expect(['Peter Pider picked a peck of pickled peppers']); 9 | t.sendKeys('fprl'); 10 | t.expect(['Peter Pider licked a peck of pickled peppers']); 11 | t.sendKeys('5fpx'); 12 | t.expect(['Peter Pider licked a peck of pickled pepers']); 13 | t.sendKeys('u'); 14 | t.expect(['Peter Pider licked a peck of pickled peppers']); 15 | t.sendKeys('5fpx'); 16 | t.expect(['Peter Pider licked a peck of pickled pepers']); 17 | t.sendKeys('0tPx'); 18 | t.expect(['PeterPider licked a peck of pickled pepers']); 19 | await t.done(); 20 | }); 21 | 22 | it('works backwards in basic cases', async function() { 23 | const t = new TestCase(['Peter Piper picked a peck of pickled peppers']); 24 | t.sendKeys('$Fpx'); 25 | t.expect(['Peter Piper picked a peck of pickled pepers']); 26 | t.sendKeys('3FpTpra'); 27 | t.expect(['Peter Piper picked a pack of pickled pepers']); 28 | t.sendKeys('TpruFpal'); 29 | t.sendKey('esc'); 30 | t.expect(['Peter Piper plucked a pack of pickled pepers']); 31 | t.sendKeys('2TPae'); 32 | t.sendKey('esc'); 33 | t.expect(['Peeter Piper plucked a pack of pickled pepers']); 34 | await t.done(); 35 | }); 36 | 37 | it('works in edge cases', async function() { 38 | let t = new TestCase(['edge case']); 39 | t.sendKeys('fsx'); 40 | t.expect(['edge cae']); 41 | t.sendKeys('fex'); 42 | t.expect(['edge ca']); 43 | t.sendKeys('fex'); 44 | t.expect(['edge c']); 45 | await t.done(); 46 | 47 | t = new TestCase(['edge case']); 48 | t.sendKeys('2tex'); 49 | t.expect(['edge cae']); 50 | t.sendKeys('htex'); 51 | t.expect(['edge ce']); 52 | await t.done(); 53 | }); 54 | 55 | it('works in edge cases backwards', async function() { 56 | let t = new TestCase(['edge case']); 57 | t.sendKeys('$Fdx'); 58 | t.expect(['ege case']); 59 | t.sendKeys('Fex'); 60 | t.expect(['ge case']); 61 | t.sendKeys('Fex'); 62 | t.expect(['e case']); 63 | await t.done(); 64 | 65 | t = new TestCase(['edge case']); 66 | t.sendKeys('$2Tex'); 67 | t.expect(['ege case']); 68 | t.sendKeys('Tex'); 69 | t.expect(['ee case']); 70 | t.sendKeys('hTfx'); 71 | t.expect(['e case']); 72 | await t.done(); 73 | }); 74 | 75 | it('works with delete', async function() { 76 | let t = new TestCase(['awdf awdf awdf']); 77 | t.sendKeys('d2fa'); 78 | t.expect(['wdf']); 79 | await t.done(); 80 | 81 | t = new TestCase(['awdf awdf awdf']); 82 | t.sendKeys('d2ta'); 83 | t.expect(['awdf']); 84 | await t.done(); 85 | 86 | t = new TestCase(['awdf awdf awdf']); 87 | t.sendKeys('$d2Fa'); 88 | t.expect(['awdf f']); 89 | await t.done(); 90 | 91 | t = new TestCase(['awdf awdf awdf']); 92 | t.sendKeys('$d2Ta'); 93 | t.expect(['awdf af']); 94 | await t.done(); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/assets/ts/utils/browser.ts: -------------------------------------------------------------------------------- 1 | /* Utilities for stuff related to being in the browser */ 2 | import $ from 'jquery'; 3 | import { saveAs } from 'file-saver'; 4 | 5 | // needed for the browser checks 6 | declare var window: any; 7 | 8 | // TODO: get jquery typing to work? 9 | export function scrollDiv($elem: any, amount: number) { 10 | // # animate. seems to not actually be great though 11 | // $elem.stop().animate({ 12 | // scrollTop: $elem[0].scrollTop + amount 13 | // }, 50) 14 | return $elem.scrollTop($elem.scrollTop() + amount); 15 | } 16 | 17 | // TODO: get jquery typing to work? 18 | export function scrollIntoView(el: Element, $within: any, margin: number = 0) { 19 | const elemTop = el.getBoundingClientRect().top; 20 | const elemBottom = el.getBoundingClientRect().bottom; 21 | 22 | const top_margin = margin; 23 | const bottom_margin = margin + ($('#bottom-bar').height() as number); 24 | 25 | if (elemTop < top_margin) { 26 | // scroll up 27 | return scrollDiv($within, elemTop - top_margin); 28 | } else if (elemBottom > window.innerHeight - bottom_margin) { 29 | // scroll down 30 | return scrollDiv($within, elemBottom - window.innerHeight + bottom_margin); 31 | } 32 | } 33 | 34 | export function isScrolledIntoView($elem: any, $container: any) { 35 | const docViewTop = $container.offset().top; 36 | const docViewBottom = docViewTop + $container.outerHeight(); 37 | 38 | const elemTop = $elem.offset().top; 39 | const elemBottom = elemTop + $elem.height(); 40 | 41 | return ((elemBottom <= docViewBottom) && (elemTop >= docViewTop)); 42 | } 43 | 44 | export function getParameterByName(name: string) { 45 | name = name.replace(/[[\]]/g, '\\$&'); 46 | const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'); 47 | const results = regex.exec(window.location.href); 48 | if (!results) { return null; } 49 | if (!results[2]) { return ''; } 50 | return decodeURIComponent(results[2].replace(/\+/g, ' ')); 51 | } 52 | 53 | export function downloadFile(filename: string, content: string, mimetype: string) { 54 | const blob = new Blob([content], {type: `${mimetype};charset=utf-8`}); 55 | saveAs(blob, filename); 56 | } 57 | 58 | // SEE: http://stackoverflow.com/questions/9847580/how-to-detect-safari-chrome-ie-firefox-and-opera-browser 59 | export function isOpera(): boolean { 60 | return !!window.opera || navigator.userAgent.indexOf(' OPR/') >= 0; // Opera 8.0+ 61 | } 62 | export function isSafari(): boolean { 63 | return Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0; // Safari 3+ 64 | } 65 | export function isChrome(): boolean { 66 | return !!window.chrome && !isOpera; // Chrome 1+ 67 | } 68 | declare var InstallTrigger: any; 69 | export function isFirefox(): boolean { 70 | return typeof InstallTrigger !== 'undefined'; // Firefox 1.0+ 71 | } 72 | 73 | export function cancel(ev: Event) { 74 | ev.stopPropagation(); 75 | ev.preventDefault(); 76 | return false; 77 | } 78 | 79 | export function mimetypeLookup(filename: string): string | undefined { 80 | const parts = filename.split('.'); 81 | const extension = parts.length > 1 ? parts[parts.length - 1] : ''; 82 | const extensionLookup: {[key: string]: string} = { 83 | 'json': 'application/json', 84 | 'txt': 'text/plain', 85 | '': 'text/plain', 86 | }; 87 | return extensionLookup[extension.toLowerCase()]; 88 | } 89 | 90 | -------------------------------------------------------------------------------- /src/assets/ts/utils/eventEmitter.ts: -------------------------------------------------------------------------------- 1 | // based on https://gist.github.com/contra/2759355 2 | 3 | // TODO: split eventEmitter and hooks into separate classes 4 | // TODO: get rid of all the anys in this file. use new typescript feature, like 5 | // export default class EventEmitter { 6 | // private listeners: {[K in keyof LTypes]: Array<(...args: LTypes[K]) => any>}; 7 | // 8 | 9 | export type Listener = (...args: any[]) => any; 10 | export type Hook = (obj: any, info: any) => any; 11 | 12 | export default class EventEmitter { 13 | private listeners: {[key: string]: Array}; 14 | private hooks: {[key: string]: Array}; 15 | 16 | constructor() { 17 | // mapping from event to list of listeners 18 | this.listeners = {}; 19 | this.hooks = {}; 20 | } 21 | 22 | // emit an event and return all responses from the listeners 23 | public emit(event: string, ...args: Array) { 24 | return (this.listeners[event] || []).map((listener) => { 25 | return listener.apply(listener, args); 26 | }); 27 | } 28 | 29 | public emitAsync(event: string, ...args: Array) { 30 | return Promise.all((this.listeners[event] || []).map(async (listener) => { 31 | return await listener.apply(listener, args); 32 | })); 33 | } 34 | 35 | public addListener(event: string, listener: Listener) { 36 | this.emit('newListener', event, listener); 37 | if (!this.listeners[event]) { 38 | this.listeners[event] = []; 39 | } 40 | this.listeners[event].push(listener); 41 | return this; 42 | } 43 | 44 | public on(event: string, listener: Listener) { 45 | return this.addListener(event, listener); 46 | } 47 | 48 | public once(event: string, listener: Listener) { 49 | const fn = (...args: Array) => { 50 | this.removeListener(event, fn); 51 | return listener(args); 52 | }; 53 | this.on(event, fn); 54 | return this; 55 | } 56 | 57 | public removeListener(event: string, listener: Listener) { 58 | if (!this.listeners[event]) { return this; } 59 | this.listeners[event] = this.listeners[event].filter((l) => l !== listener); 60 | return this; 61 | } 62 | 63 | public removeAllListeners(event: string) { 64 | delete this.listeners[event]; 65 | return this; 66 | } 67 | 68 | public off(event: string, listener: Listener) { 69 | return this.removeListener(event, listener); 70 | } 71 | 72 | // ordered set of hooks for mutating 73 | // NOTE: a little weird for eventEmitter to be in charge of this 74 | 75 | public addHook(event: string, transform: Hook) { 76 | if (!this.hooks[event]) { 77 | this.hooks[event] = []; 78 | } 79 | this.hooks[event].push(transform); 80 | return this; 81 | } 82 | 83 | public removeHook(event: string, transform: Hook) { 84 | if (!this.hooks[event]) { return this; } 85 | this.hooks[event] = this.hooks[event].filter((t) => t !== transform); 86 | return this; 87 | } 88 | 89 | public applyHook(event: string, obj: any, info: any) { 90 | (this.hooks[event] || []).forEach((transform) => { 91 | obj = transform(obj, info); 92 | }); 93 | return obj; 94 | } 95 | 96 | public async applyHookAsync(event: string, obj: any, info: any) { 97 | const hooks = (this.hooks[event] || []); 98 | for (let i = 0; i < hooks.length; i++) { 99 | const transform = hooks[i]; 100 | obj = await transform(obj, info); 101 | } 102 | return obj; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /docs/storage/Firebase.md: -------------------------------------------------------------------------------- 1 | # Firebase for vimflowy 2 | 3 | ## Info 4 | 5 | Firebase is a cloud service ran by Google. 6 | Thus if you set up Vimflowy to use Firebase, your data will sit on Google's servers. 7 | 8 | Note: As with all remote data implementations, you will be unable to access Vimflowy offline. 9 | However, you will be able to access it from multiple devices. 10 | 11 | ## Setup 12 | 13 | ### Obtain a Firebase project 14 | 15 | To use the Firebase backend, you should first set up a Firebase instance. 16 | You can do this by clicking `CREATE NEW PROJECT` at https://console.firebase.google.com/. 17 | You should use the older "Realtime Datbase", *not* firestore. 18 | 19 | You should then be given your own Firebase project id (or "Realtime Database URL"), 20 | something like `something-fiery-2222`. 21 | You should be able to now visit your console at a link like 22 | https://console.firebase.google.com/project/${projectId}, e.g. 23 | https://console.firebase.google.com/project/something-fiery-2222. 24 | 25 | ### Set up authentication 26 | 27 | #### Add a user 28 | 29 | Visit the Authentication tab. 30 | 31 | - Under `Authentication > Sign-In Method` (https://console.firebase.google.com/project/${projectId}/authentication/providers), enable email/password. 32 | 33 | - Under `Authentication > Users` (https://console.firebase.google.com/project/${projectId}/authentication/users), click `Add User`. 34 | Pick an email and password, and enter it. 35 | Remember the email/password pair - you'll need them later! 36 | 37 | #### Set up database rules 38 | 39 | Visit the `Database > Rules` section (https://console.firebase.google.com/project/${projectId}/database/rules). 40 | The rules should look like this: 41 | 42 | ``` 43 | { 44 | "rules": { 45 | ".read": "auth != null", 46 | ".write": "auth != null" 47 | } 48 | } 49 | ``` 50 | 51 | This default should work fine, but will give you email warnings about security. You can silence these emails in settings, or add an extra layer of security: 52 | 53 | ``` 54 | { 55 | "rules": { 56 | ".read": "auth != null && auth.uid == ''", 57 | ".write": "auth != null && auth.uid == ''" 58 | } 59 | } 60 | ``` 61 | 62 | See [here](https://github.com/WuTheFWasThat/vimflowy/issues/370) for details 63 | 64 | ### Configure Vimflowy 65 | 66 | Now, in the general settings menu (https://console.firebase.google.com/project/${projectId}/settings/general/) 67 | find your API key. 68 | Together with the project ID, and user information from earlier, 69 | we have everything needed to configure Vimflowy to use Firebase. 70 | 71 | Simply go to your tab with Vimflowy, and click Settings in the lower right. 72 | Select the Firebase option under `Data Storage`. 73 | Then enter in the four fields into the corresponding input boxes, 74 | and hit `Load Data Settings`. 75 | This should refresh the page automatically. 76 | Assuming configuration was correct and no errors occurred, 77 | you should now be using Firebase! 78 | 79 | ### Verify 80 | 81 | To verify Vimflowy is properly working with Firebase, check the settings menu again and you should see that the Firebase is already selected. 82 | 83 | You may also want to verify that the security rules are working. 84 | To do this, try changing the email/password pair to something invalid. 85 | After the page refresh, you should immediately see an error alert. 86 | 87 | ## Backups 88 | 89 | You can pay Firebase for automated backups of your data. 90 | See https://console.firebase.google.com/project/${projectId}/database/backups 91 | -------------------------------------------------------------------------------- /test/tests/goparent.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | describe('go parent', function() { 5 | it('works', async function() { 6 | let t = new TestCase([ 7 | { text: 'top row', children: [ 8 | { text: 'middle row', children : [ 9 | 'bottom row', 10 | ] }, 11 | ] }, 12 | ]); 13 | t.sendKeys('Gx'); 14 | t.expect([ 15 | { text: 'top row', children: [ 16 | { text: 'middle row', children : [ 17 | 'ottom row', 18 | ] }, 19 | ] }, 20 | ]); 21 | t.sendKeys('gpx'); 22 | t.expect([ 23 | { text: 'top row', children: [ 24 | { text: 'iddle row', children : [ 25 | 'ottom row', 26 | ] }, 27 | ] }, 28 | ]); 29 | t.sendKeys('gpx'); 30 | t.expect([ 31 | { text: 'op row', children: [ 32 | { text: 'iddle row', children : [ 33 | 'ottom row', 34 | ] }, 35 | ] }, 36 | ]); 37 | // can't go past the root 38 | t.sendKeys('gpx'); 39 | t.expect([ 40 | { text: 'p row', children: [ 41 | { text: 'iddle row', children : [ 42 | 'ottom row', 43 | ] }, 44 | ] }, 45 | ]); 46 | await t.done(); 47 | }); 48 | 49 | it('causes a zoom out', async function() { 50 | let t = new TestCase([ 51 | { text: 'top row', children: [ 52 | { text: 'middle row', children : [ 53 | 'bottom row', 54 | ] }, 55 | ] }, 56 | ]); 57 | t.sendKeys('jj]]]x'); 58 | t.expectViewRoot(3); 59 | t.expect([ 60 | { text: 'top row', children: [ 61 | { text: 'middle row', children : [ 62 | 'ottom row', 63 | ] }, 64 | ] }, 65 | ]); 66 | t.sendKeys('gpx'); 67 | t.expectViewRoot(2); 68 | t.expect([ 69 | { text: 'top row', children: [ 70 | { text: 'iddle row', children : [ 71 | 'ottom row', 72 | ] }, 73 | ] }, 74 | ]); 75 | t.sendKeys('gpx'); 76 | t.expectViewRoot(1); 77 | t.expect([ 78 | { text: 'op row', children: [ 79 | { text: 'iddle row', children : [ 80 | 'ottom row', 81 | ] }, 82 | ] }, 83 | ]); 84 | t.sendKeys('Gx'); 85 | t.expect([ 86 | { text: 'op row', children: [ 87 | { text: 'iddle row', children : [ 88 | 'ttom row', 89 | ] }, 90 | ] }, 91 | ]); 92 | t.sendKeys('ggx'); // verify viewroot is now top row 93 | t.expect([ 94 | { text: 'p row', children: [ 95 | { text: 'iddle row', children : [ 96 | 'ttom row', 97 | ] }, 98 | ] }, 99 | ]); 100 | await t.done(); 101 | }); 102 | 103 | it('does nothing at view root', async function() { 104 | let t = new TestCase([ 105 | { text: 'top row', children: [ 106 | { text: 'middle row', children : [ 107 | 'bottom row', 108 | ] }, 109 | ] }, 110 | ]); 111 | t.expectViewRoot(0); 112 | t.sendKeys('x'); 113 | t.expect([ 114 | { text: 'op row', children: [ 115 | { text: 'middle row', children : [ 116 | 'bottom row', 117 | ] }, 118 | ] }, 119 | ]); 120 | t.sendKeys('gpx'); 121 | t.expectViewRoot(0); 122 | t.expect([ 123 | { text: 'p row', children: [ 124 | { text: 'middle row', children : [ 125 | 'bottom row', 126 | ] }, 127 | ] }, 128 | ]); 129 | await t.done(); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/plugins/text_formatting/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; // tslint:disable-line no-unused-variable 2 | 3 | import './index.sass'; 4 | 5 | import { hideBorderAndModify, RegexTokenizerModifier } from '../../assets/ts/utils/token_unfolder'; 6 | import { registerPlugin } from '../../assets/ts/plugins'; 7 | import { matchWordRegex } from '../../assets/ts/utils/text'; 8 | 9 | const boldClass = 'bold'; 10 | const italicsClass = 'italic'; 11 | const underlineClass = 'underline'; 12 | const codeClass = 'code'; 13 | 14 | registerPlugin( 15 | { 16 | name: 'Text Formatting', 17 | author: 'Jeff Wu', 18 | description: ( 19 |
20 | Lets you: 21 |
    22 |
  • italicize text by surrounding with *asterisks*
  • 23 |
  • bold text by surrounding with **double asterisks**
  • 24 |
  • underline text by surrounding with _underscores_
  • 25 |
  • code text by surrounding with `back-ticks`
  • 26 |
27 |
28 | ), 29 | }, 30 | function(api) { 31 | api.registerHook('session', 'renderLineTokenHook', (tokenizer, hooksInfo) => { 32 | if (hooksInfo.has_cursor) { 33 | return tokenizer; 34 | } 35 | if (hooksInfo.has_highlight) { 36 | return tokenizer; 37 | } 38 | return tokenizer.then(RegexTokenizerModifier( 39 | // triple asterisk means both bold and italic 40 | matchWordRegex('\\*\\*\\*(\\n|.)+?\\*\\*\\*'), 41 | hideBorderAndModify(3, 3, (char_info) => { 42 | char_info.renderOptions.classes[italicsClass] = true; 43 | char_info.renderOptions.classes[boldClass] = true; 44 | }) 45 | )).then(RegexTokenizerModifier( 46 | matchWordRegex('(?:(\\*\\*_)|(_\\*\\*))(\\n|.)+?(?:(\\*\\*_)|(_\\*\\*))'), 47 | hideBorderAndModify(3, 3, (char_info) => { 48 | char_info.renderOptions.classes[boldClass] = true; 49 | char_info.renderOptions.classes[underlineClass] = true; 50 | }) 51 | )).then(RegexTokenizerModifier( 52 | matchWordRegex('(?:(\\*_)|(_\\*))(\\n|.)+?(?:(\\*_)|(_\\*))'), 53 | hideBorderAndModify(2, 2, (char_info) => { 54 | char_info.renderOptions.classes[italicsClass] = true; 55 | char_info.renderOptions.classes[underlineClass] = true; 56 | }) 57 | )).then(RegexTokenizerModifier( 58 | // middle is either a single character, or both sides have a non-* character 59 | matchWordRegex('\\*((\\n|[^\\*])|[^\\*](\\n|.)+?[^\\*])?\\*'), 60 | hideBorderAndModify(1, 1, (char_info) => { 61 | char_info.renderOptions.classes[italicsClass] = true; 62 | }) 63 | )).then(RegexTokenizerModifier( 64 | matchWordRegex('\\*\\*(\\n|.)+?\\*\\*'), 65 | hideBorderAndModify(2, 2, (char_info) => { 66 | char_info.renderOptions.classes[boldClass] = true; 67 | }) 68 | )).then(RegexTokenizerModifier( 69 | matchWordRegex('(?:[\\*]*)_(\\n|.)+?_(?:[\\*]*)'), 70 | hideBorderAndModify(1, 1, (char_info) => { 71 | char_info.renderOptions.classes[underlineClass] = true; 72 | }) 73 | )).then(RegexTokenizerModifier( 74 | // code 75 | matchWordRegex('\\`(\\n|.)+?\\`'), 76 | hideBorderAndModify(1, 1, (char_info) => { 77 | char_info.renderOptions.classes[codeClass] = true; 78 | }) 79 | )); 80 | }); 81 | }, 82 | (api => api.deregisterAll()), 83 | ); 84 | -------------------------------------------------------------------------------- /test/tests/visible_ends.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | describe('go visible end/beginning', function() { 5 | it('take you to the first column', async function() { 6 | let t = new TestCase(['always to front']); 7 | t.sendKeys('$Gx'); 8 | t.expect(['lways to front']); 9 | await t.done(); 10 | 11 | t = new TestCase(['a', 'ab', 'abc']); 12 | t.sendKeys('$Gx'); 13 | t.expect(['a', 'ab', 'bc']); 14 | await t.done(); 15 | 16 | t = new TestCase(['always to front']); 17 | t.sendKeys('$ggx'); 18 | t.expect(['lways to front']); 19 | await t.done(); 20 | }); 21 | 22 | it('basically works, at root', async function() { 23 | let t = new TestCase([ 24 | 'ab', 25 | { text: 'bc', children: [ 26 | 'cd', 27 | ] }, 28 | ]); 29 | t.sendKeys('Gx'); 30 | t.expect([ 31 | 'ab', 32 | { text: 'bc', children: [ 33 | 'd', 34 | ] }, 35 | ]); 36 | t.sendKeys('ggx'); 37 | t.expect([ 38 | 'b', 39 | { text: 'bc', children: [ 40 | 'd', 41 | ] }, 42 | ]); 43 | await t.done(); 44 | 45 | t = new TestCase(['a', 'ab', 'abc']); 46 | t.sendKeys('jj$x'); 47 | t.expect(['a', 'ab', 'ab']); 48 | t.sendKeys('ggx'); 49 | t.expect(['', 'ab', 'ab']); 50 | await t.done(); 51 | }); 52 | 53 | it('ignores collapsed children', async function() { 54 | let t = new TestCase([ 55 | 'ab', 56 | { text: 'bc', collapsed: true, children: [ 57 | 'cd', 58 | ] }, 59 | ]); 60 | t.sendKeys('Gx'); 61 | t.expect([ 62 | 'ab', 63 | { text: 'c', collapsed: true, children: [ 64 | 'cd', 65 | ] }, 66 | ]); 67 | await t.done(); 68 | }); 69 | 70 | it('works zoomed in', async function() { 71 | let t = new TestCase([ 72 | 'ab', 73 | { text: 'bc', children: [ 74 | 'dc', 75 | 'cd', 76 | ] }, 77 | 'de', 78 | ]); 79 | t.sendKeys('j]Gx'); 80 | t.expect([ 81 | 'ab', 82 | { text: 'bc', children: [ 83 | 'dc', 84 | 'd', 85 | ] }, 86 | 'de', 87 | ]); 88 | t.sendKeys('ggx'); 89 | t.expect([ 90 | 'ab', 91 | { text: 'c', children: [ 92 | 'dc', 93 | 'd', 94 | ] }, 95 | 'de', 96 | ]); 97 | await t.done(); 98 | }); 99 | 100 | it('works zoomed in to collapsed', async function() { 101 | let t = new TestCase([ 102 | 'ab', 103 | { text: 'bc', collapsed: true, children: [ 104 | 'dc', 105 | 'cd', 106 | ] }, 107 | 'de', 108 | ]); 109 | t.sendKeys('j]Gx'); 110 | t.expect([ 111 | 'ab', 112 | { text: 'bc', collapsed: true, children: [ 113 | 'dc', 114 | 'd', 115 | ] }, 116 | 'de', 117 | ]); 118 | t.sendKeys('ggx'); 119 | t.expect([ 120 | 'ab', 121 | { text: 'c', collapsed: true, children: [ 122 | 'dc', 123 | 'd', 124 | ] }, 125 | 'de', 126 | ]); 127 | t.sendKeys('j]Gx'); 128 | t.expect([ 129 | 'ab', 130 | { text: 'c', collapsed: true, children: [ 131 | 'c', 132 | 'd', 133 | ] }, 134 | 'de', 135 | ]); 136 | t.sendKeys('ggx'); 137 | t.expect([ 138 | 'ab', 139 | { text: 'c', collapsed: true, children: [ 140 | '', 141 | 'd', 142 | ] }, 143 | 'de', 144 | ]); 145 | await t.done(); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /test/tests/repeat.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | describe('repeat', function() { 5 | 6 | it('works with insertion of text', async function() { 7 | const t = new TestCase(['']); 8 | t.sendKeys('....'); 9 | t.expect(['']); 10 | t.sendKeys('irainbow'); 11 | t.sendKey('esc'); 12 | t.sendKey('.'); 13 | t.expect(['rainborainboww']); 14 | t.sendKeys('x...'); 15 | t.expect(['rainborain']); 16 | await t.done(); 17 | }); 18 | 19 | it('works with deletion + motion', async function() { 20 | const t = new TestCase(['the quick brown fox jumped over the lazy dog']); 21 | t.sendKeys('dw'); 22 | t.expect(['quick brown fox jumped over the lazy dog']); 23 | t.sendKeys('..'); 24 | t.expect(['fox jumped over the lazy dog']); 25 | t.sendKeys('u.'); 26 | t.expect(['fox jumped over the lazy dog']); 27 | t.sendKeys('dy'); // nonsense 28 | t.expect(['fox jumped over the lazy dog']); 29 | t.sendKeys('..'); 30 | t.expect(['over the lazy dog']); 31 | t.sendKeys('rxll.w.e.$.'); 32 | t.expect(['xvxr xhx lazy dox']); 33 | t.sendKeys('cbxero'); 34 | t.sendKey('esc'); 35 | t.expect(['xvxr xhx lazy xerox']); 36 | t.sendKeys('b.'); 37 | t.expect(['xvxr xhx xeroxerox']); 38 | t.sendKeys('.'); 39 | t.expect(['xvxr xhx xerooxerox']); 40 | await t.done(); 41 | }); 42 | 43 | it('works with change (c)', async function() { 44 | const t = new TestCase(['vim is great']); 45 | t.sendKeys('ceblah'); 46 | t.sendKey('esc'); 47 | t.sendKeys('w.w.'); 48 | t.expect(['blah blah blah']); 49 | await t.done(); 50 | }); 51 | 52 | it('works with replace', async function() { 53 | const t = new TestCase(['obladi oblada']); 54 | t.sendKeys('eroehl.'); 55 | t.expect(['oblado oblado']); 56 | await t.done(); 57 | }); 58 | }); 59 | 60 | 61 | describe('tricky cases for repeat', function() { 62 | it('test repeating x on empty row', async function() { 63 | const t = new TestCase(['empty', '']); 64 | t.sendKeys('ru'); 65 | t.expect(['umpty', '']); 66 | t.sendKeys('jxk.'); 67 | t.expect(['mpty', '']); 68 | await t.done(); 69 | }); 70 | 71 | it('repeat of change', async function() { 72 | const t = new TestCase([ 73 | 'oh say can you see', 74 | 'and the home of the brave', 75 | ]); 76 | t.sendKeys('ceme'); 77 | t.sendKey('esc'); 78 | t.expect([ 79 | 'me say can you see', 80 | 'and the home of the brave', 81 | ]); 82 | t.sendKeys('j$b.'); 83 | t.expect([ 84 | 'me say can you see', 85 | 'and the home of the me', 86 | ]); 87 | await t.done(); 88 | }); 89 | 90 | it('repeat of paste, edge case with empty line', async function() { 91 | const t = new TestCase(['word']); 92 | t.sendKeys('de'); 93 | t.expect(['']); 94 | t.sendKeys('p'); 95 | t.expect(['word']); 96 | t.sendKeys('u'); 97 | t.expect(['']); 98 | // repeat still knows what to do 99 | t.sendKeys('.'); 100 | t.expect(['word']); 101 | t.sendKeys('.'); 102 | t.expect(['wordword']); 103 | await t.done(); 104 | }); 105 | 106 | it('works with visual mode', async function() { 107 | const t = new TestCase([ '1234567' ]); 108 | t.sendKeys('vllx'); 109 | t.expect([ '4567' ]); 110 | t.sendKeys('.'); 111 | t.expect([ '7' ]); 112 | await t.done(); 113 | }); 114 | 115 | it('doesnt repeat visual mode yank', async function() { 116 | const t = new TestCase([ '1234' ]); 117 | t.sendKeys('xvly'); 118 | t.expect([ '234' ]); 119 | t.sendKeys('.'); 120 | t.expect([ '24' ]); 121 | await t.done(); 122 | }); 123 | }); 124 | 125 | -------------------------------------------------------------------------------- /server/socket_server.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | 3 | import * as WebSocket from 'ws'; 4 | 5 | import DataBackend, { InMemory } from '../src/shared/data_backend'; 6 | import logger from '../src/shared/utils/logger'; 7 | 8 | import { SQLiteBackend } from './data_backends'; 9 | 10 | type SocketServerOptions = { 11 | db?: string, 12 | dbfolder?: string, 13 | password?: string, 14 | path?: string, 15 | }; 16 | 17 | export default function makeSocketServer(server: http.Server, options: SocketServerOptions) { 18 | const wss = new WebSocket.Server({ server, path: options.path }); 19 | 20 | const dbs: {[docname: string]: DataBackend} = {}; 21 | const clients: {[docname: string]: string} = {}; 22 | 23 | async function getBackend(docname: string): Promise { 24 | if (docname in dbs) { 25 | return dbs[docname]; 26 | } 27 | let db: DataBackend; 28 | if (options.db === 'sqlite') { 29 | let filename; 30 | if (options.dbfolder) { 31 | filename = `${options.dbfolder}/${docname || 'vimflowy'}.sqlite`; 32 | logger.info('Using sqlite database: ', filename); 33 | } else { 34 | filename = ':memory:'; 35 | logger.warn('Using in-memory sqlite database'); 36 | } 37 | const sql_db = new SQLiteBackend(); 38 | await sql_db.init(filename); 39 | db = sql_db; 40 | } else { 41 | logger.info('Using in-memory database'); 42 | db = new InMemory(); 43 | } 44 | dbs[docname] = db; 45 | return db; 46 | } 47 | 48 | function broadcast(message: Object): void { 49 | wss.clients.forEach(client => { 50 | client.send(JSON.stringify(message)); 51 | }); 52 | } 53 | 54 | wss.on('connection', function connection(ws) { 55 | logger.info('New socket connection!'); 56 | let authed = false; 57 | let docname: string | null = null; 58 | ws.on('message', async (msg_string) => { 59 | logger.debug('received message: %s', msg_string); 60 | const msg = JSON.parse(msg_string); 61 | 62 | function respond(result: { value?: any, error: string | null }) { 63 | ws.send(JSON.stringify({ 64 | type: 'callback', 65 | id: msg.id, 66 | result: result, 67 | })); 68 | } 69 | 70 | if (msg.type === 'join') { 71 | if (options.password) { 72 | if (msg.password !== options.password) { 73 | return respond({ error: 'Wrong password!' }); 74 | } 75 | } 76 | authed = true; 77 | docname = msg.docname; 78 | clients[msg.docname] = msg.clientId; 79 | // TODO: only broadcast to client on this document? 80 | broadcast({ 81 | type: 'joined', 82 | clientId: msg.clientId, 83 | docname: msg.docname, 84 | }); 85 | return respond({ error: null }); 86 | } 87 | 88 | if (!authed) { 89 | return respond({ error: 'Not authenticated!' }); 90 | } 91 | if (docname == null) { 92 | throw new Error('No docname!'); 93 | } 94 | if (msg.clientId !== clients[docname]) { 95 | return respond({ error: 'Other client connected!' }); 96 | } 97 | const db = await getBackend(docname); 98 | 99 | if (msg.type === 'get') { 100 | const value = await db.get(msg.key); 101 | logger.debug('got', msg.key, value); 102 | respond({ value: value, error: null }); 103 | } else if (msg.type === 'set') { 104 | await db.set(msg.key, msg.value); 105 | logger.debug('set', msg.key, msg.value); 106 | respond({ error: null }); 107 | } 108 | }); 109 | 110 | ws.on('close', () => { 111 | logger.info('Socket connection closed!'); 112 | // TODO: clean up stuff? 113 | }); 114 | }); 115 | return server; 116 | } 117 | -------------------------------------------------------------------------------- /src/assets/ts/path.ts: -------------------------------------------------------------------------------- 1 | import * as errors from '../../shared/utils/errors'; 2 | 3 | import { Row, SerializedPath } from './types'; 4 | 5 | // represents a tree-traversal starting from the root going down 6 | // should be immutable 7 | export default class Path { 8 | public readonly parent: Path | null; 9 | public readonly row: Row; 10 | 11 | public static rootRow(): Row { 12 | return 0; 13 | } 14 | 15 | public static root() { 16 | return new Path(null, Path.rootRow()); 17 | } 18 | 19 | public static loadFromAncestry(ancestry: SerializedPath): Path { 20 | if (ancestry.length === 0) { 21 | return Path.root(); 22 | } 23 | const row: Row = ancestry.pop() as Row; 24 | const parent = Path.loadFromAncestry(ancestry); 25 | return parent.child(row); 26 | } 27 | 28 | constructor(parent: Path | null, row: Row) { 29 | this.parent = parent; 30 | this.row = row; 31 | } 32 | 33 | public isRoot(): boolean { 34 | return this.row === Path.rootRow(); 35 | } 36 | 37 | // gets a list of IDs 38 | public getAncestry(): SerializedPath { 39 | if (this.parent == null) { // i.e. (this.isRoot()) 40 | return []; 41 | } 42 | const ancestors = this.parent.getAncestry(); 43 | ancestors.push(this.row); 44 | return ancestors; 45 | } 46 | 47 | // returns an array representing the ancestry of a row, 48 | // up until the ancestor specified by the `stop` parameter 49 | // i.e. [stop, stop's child, ... , row's parent , row] 50 | public getAncestryPaths(stop: Path = Path.root()): Array { 51 | const ancestors: Array = []; 52 | let path: Path = this; 53 | while (!path.is(stop)) { 54 | if (path.parent == null) { 55 | throw new Error(`Failed to get ancestry for ${this} going up until ${stop}`); 56 | } 57 | ancestors.push(path); 58 | path = path.parent; 59 | } 60 | ancestors.push(stop); 61 | ancestors.reverse(); 62 | return ancestors; 63 | } 64 | 65 | // length() { 66 | // if this.parent === null { 67 | // return 0; 68 | // } 69 | // return 1 + this.parent.length(); 70 | // } 71 | 72 | public child(row: Row): Path { 73 | errors.assert(row !== this.row); 74 | return new Path(this, row); 75 | } 76 | 77 | public isDescendant(other_path: Path): boolean { 78 | return this.walkFrom(other_path) !== null; 79 | } 80 | 81 | public walkFrom(ancestor: Path): null | Array { 82 | const my_ancestry = this.getAncestry(); 83 | const their_ancestry = ancestor.getAncestry(); 84 | if (my_ancestry.length < their_ancestry.length) { 85 | return null; 86 | } 87 | for (let i = 0; i < their_ancestry.length; i++) { 88 | if (my_ancestry[i] !== their_ancestry[i]) { 89 | return null; 90 | } 91 | } 92 | return my_ancestry.slice(their_ancestry.length); 93 | } 94 | 95 | public shedUntil(row: Row): [Array, Path] | null { 96 | let ancestor: Path = this; 97 | const path: Array = []; 98 | while (ancestor.row !== row) { 99 | if (!ancestor.parent) { 100 | return null; 101 | } 102 | path.push(ancestor.row); 103 | ancestor = ancestor.parent; 104 | } 105 | return [path.reverse(), ancestor]; 106 | } 107 | 108 | public extend(walk: Array): Path { 109 | let descendent: Path = this; 110 | walk.forEach((row) => { 111 | descendent = descendent.child(row); 112 | }); 113 | return descendent; 114 | } 115 | 116 | // Represents the exact same row 117 | public is(other: Path): boolean { 118 | if (other === undefined) { return false; } 119 | if (this.row !== other.row) { return false; } 120 | if (this.parent == null) { return other.parent == null; } 121 | if (other.parent == null) { return false; } 122 | return this.parent.is(other.parent); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/assets/ts/keyMappings.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | import EventEmitter from './utils/eventEmitter'; 4 | import logger from '../../shared/utils/logger'; 5 | import { Key } from './types'; 6 | 7 | export type HotkeyMapping = { 8 | [name: string]: Array> 9 | }; 10 | 11 | export type HotkeyMappingPerMode = {[mode: string]: HotkeyMapping}; 12 | 13 | // for each mode, keeps a set of hotkeys 14 | // simple wrapper class, no sanity checks 15 | export default class KeyMappings extends EventEmitter { 16 | public mappings: HotkeyMappingPerMode; 17 | 18 | public static merge(first: KeyMappings, second: KeyMappings) { 19 | const getMerged = () => { 20 | const mappings = _.cloneDeep(first.mappings); 21 | Object.keys(second.mappings).forEach((mode) => { 22 | mappings[mode] = Object.assign(mappings[mode] || {}, second.mappings[mode]); 23 | }); 24 | return mappings; 25 | }; 26 | const merged = new KeyMappings(getMerged()); 27 | 28 | first.on('update', () => merged.setMappings(getMerged())); 29 | second.on('update', () => merged.setMappings(getMerged())); 30 | return merged; 31 | } 32 | 33 | constructor(mappings: HotkeyMappingPerMode) { 34 | super(); 35 | this.mappings = _.cloneDeep(mappings); 36 | } 37 | 38 | public setMappings(mappings: HotkeyMappingPerMode) { 39 | this.mappings = mappings; 40 | this.emit('update'); 41 | } 42 | 43 | public serialize() { 44 | return _.cloneDeep(this.mappings); 45 | } 46 | 47 | private _registerMapping(mode: string, keySequence: Array, name: string) { 48 | let mappings_for_mode = this.mappings[mode]; 49 | if (!mappings_for_mode) { 50 | mappings_for_mode = {}; 51 | this.mappings[mode] = mappings_for_mode; 52 | } 53 | let sequences_for_name = mappings_for_mode[name]; 54 | if (!sequences_for_name) { 55 | sequences_for_name = []; 56 | mappings_for_mode[name] = sequences_for_name; 57 | } 58 | sequences_for_name.push(keySequence); 59 | } 60 | 61 | public registerMapping(mode: string, keySequence: Array, name: string) { 62 | this._registerMapping(mode, keySequence, name); 63 | this.emit('update'); 64 | } 65 | 66 | public registerModeMappings(mode: string, mappings: HotkeyMapping) { 67 | Object.keys(mappings).forEach((name) => { 68 | const keySequences = mappings[name]; 69 | keySequences.forEach((sequence) => this._registerMapping(mode, sequence, name)); 70 | }); 71 | this.emit('update'); 72 | } 73 | 74 | // TODO: future dont require name for this 75 | // also sanity check collisions in registration 76 | private _deregisterMapping(mode: string, keySequence: Array, name: string) { 77 | const mappings_for_mode = this.mappings[mode]; 78 | if (!mappings_for_mode) { 79 | logger.warn(`Nothing to deregister for mode ${mode}`); 80 | return; 81 | } 82 | let sequences_for_name = mappings_for_mode[name]; 83 | if (!(sequences_for_name && sequences_for_name.length)) { 84 | logger.warn(`No sequences to deregister for ${name} in mode ${mode}`); 85 | return; 86 | } 87 | sequences_for_name = sequences_for_name.filter((sequence) => { 88 | return JSON.stringify(sequence) !== JSON.stringify(keySequence); 89 | }); 90 | mappings_for_mode[name] = sequences_for_name; 91 | } 92 | 93 | public deregisterMapping(mode: string, keySequence: Array, name: string) { 94 | this._deregisterMapping(mode, keySequence, name); 95 | this.emit('update'); 96 | } 97 | 98 | public deregisterModeMappings(mode: string, mappings: HotkeyMapping) { 99 | Object.keys(mappings).forEach((name) => { 100 | const keySequences = mappings[name]; 101 | keySequences.forEach((sequence) => this._deregisterMapping(mode, sequence, name)); 102 | }); 103 | this.emit('update'); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/assets/ts/components/hotkeysTable.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { HotkeyMapping } from '../keyMappings'; 4 | import { KeyDefinitions, Motion, Action } from '../keyDefinitions'; 5 | import { getStyles } from '../themes'; 6 | import { ClientStore } from '../datastore'; 7 | 8 | type HotkeysTableProps = { 9 | clientStore: ClientStore; 10 | keyMap: HotkeyMapping | null; 11 | definitions: KeyDefinitions; 12 | ignoreEmpty?: boolean; 13 | }; 14 | 15 | export default class HotkeysTableComponent extends React.Component { 16 | public render() { 17 | const keyMap = this.props.keyMap; 18 | if (!keyMap) { 19 | return
No hotkeys!
; 20 | } 21 | const definitions = this.props.definitions; 22 | const ignoreEmpty = this.props.ignoreEmpty; 23 | 24 | const actionRows: Array = []; 25 | const motionRows: Array = []; 26 | 27 | Object.keys(keyMap).forEach((name) => { 28 | const registration = definitions.getRegistration(name); 29 | if (registration === null) { return; } 30 | const mappings_for_name = keyMap[name]; 31 | if (mappings_for_name.length === 0 && ignoreEmpty) { 32 | return; 33 | } 34 | 35 | const cellStyle = { fontSize: 10, border: '1px solid', padding: 5 }; 36 | 37 | const el = ( 38 | 39 | 40 | { registration.name } 41 | 43 | 44 | 45 | { 46 | mappings_for_name.map((sequence, i) => { 47 | return ( 48 | 49 | { 50 | (i > 0) 51 | ? OR 52 | : null 53 | } 54 | { 55 | sequence.map((key, j) => { 56 | return ( 57 | 65 | {key} 66 | 67 | ); 68 | }) 69 | } 70 | 71 | ); 72 | }) 73 | } 74 | 75 | 76 | 77 | ); 78 | 79 | if (registration instanceof Motion) { 80 | motionRows.push(el); 81 | } else if (registration instanceof Action) { 82 | actionRows.push(el); 83 | } else { 84 | throw new Error( 85 | `Unexpected: unknown registration type for ${registration}` 86 | ); 87 | } 88 | }); 89 | 90 | return ( 91 |
92 | { 93 | (() => { 94 | return [ 95 | {label: 'Motions', rows: motionRows}, 96 | {label: 'Actions', rows: actionRows}, 97 | ].map(({label, rows}) => { 98 | return [ 99 |
100 | {label} 101 |
102 | , 103 | 109 | 110 | {rows} 111 | 112 |
, 113 | ]; 114 | }); 115 | })() 116 | } 117 |
118 | ); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/assets/ts/keyEmitter.ts: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import * as _ from 'lodash'; 3 | 4 | import * as browser_utils from './utils/browser'; 5 | import EventEmitter from './utils/eventEmitter'; 6 | import logger from '../../shared/utils/logger'; 7 | import { Key } from './types'; 8 | 9 | /* 10 | KeyEmitter is an EventEmitter that emits keys 11 | A key corresponds to a keypress in the browser, including modifiers/special keys 12 | 13 | The core function is to take browser keypress events, and normalize the key to have a string representation. 14 | 15 | For more info, see its consumer, keyHandler.ts, as well as keyBindings.ts 16 | Note that one-character keys are treated specially, in that they are insertable in insert mode. 17 | */ 18 | 19 | const shiftMap: {[key: string]: Key} = { 20 | '`': '~', 21 | '1': '!', 22 | '2': '@', 23 | '3': '#', 24 | '4': '$', 25 | '5': '%', 26 | '6': '^', 27 | '7': '&', 28 | '8': '*', 29 | '9': '(', 30 | '0': ')', 31 | '-': '_', 32 | '=': '+', 33 | '[': '{', 34 | ']': '}', 35 | ';': ':', 36 | '\'': '"', 37 | '\\': '|', 38 | '.': '>', 39 | ',': '<', 40 | '/': '?', 41 | }; 42 | 43 | const ignoreMap: {[keyCode: number]: string} = { 44 | 16: 'shift alone', 45 | 17: 'ctrl alone', 46 | 18: 'alt alone', 47 | 91: 'left command alone', 48 | 93: 'right command alone', 49 | }; 50 | 51 | const keyCodeMap: {[keyCode: number]: Key} = { 52 | 8: 'backspace', 53 | 9: 'tab', 54 | 13: 'enter', 55 | 27: 'esc', 56 | 32: 'space', 57 | 58 | 33: 'page up', 59 | 34: 'page down', 60 | 35: 'end', 61 | 36: 'home', 62 | 37: 'left', 63 | 38: 'up', 64 | 39: 'right', 65 | 40: 'down', 66 | 67 | 46: 'delete', 68 | 69 | 48: '0', 70 | 49: '1', 71 | 50: '2', 72 | 51: '3', 73 | 52: '4', 74 | 53: '5', 75 | 54: '6', 76 | 55: '7', 77 | 56: '8', 78 | 57: '9', 79 | 80 | 186: ';', 81 | 187: '=', 82 | 188: ',', 83 | 189: '-', 84 | 190: '.', 85 | 191: '/', 86 | 192: '`', 87 | 88 | 219: '[', 89 | 220: '\\', 90 | 221: ']', 91 | 222: '\'', 92 | }; 93 | 94 | for (let j = 1; j <= 26; j++) { 95 | const keyCode = j + 64; 96 | const letter = String.fromCharCode(keyCode); 97 | const lower = letter.toLowerCase(); 98 | keyCodeMap[keyCode] = lower; 99 | shiftMap[lower] = letter; 100 | } 101 | 102 | if (browser_utils.isFirefox()) { 103 | keyCodeMap[173] = '-'; 104 | } 105 | 106 | export default class KeyEmitter extends EventEmitter { 107 | // constructor() { 108 | // super(); 109 | // } 110 | 111 | public listen() { 112 | // IME event 113 | $(document).on('compositionend', (e: any) => { 114 | e.originalEvent.data.split('').forEach((key: string) => { 115 | this.emit('keydown', key); 116 | }); 117 | }); 118 | 119 | return $(document).keydown(e => { 120 | // IME input keycode is 229 121 | if (e.keyCode === 229) { 122 | return false; 123 | } 124 | if (e.keyCode in ignoreMap) { 125 | return true; 126 | } 127 | let key; 128 | if (e.keyCode in keyCodeMap) { 129 | key = keyCodeMap[e.keyCode]; 130 | } else { 131 | // this is necessary for typing stuff.. 132 | key = String.fromCharCode(e.keyCode); 133 | } 134 | 135 | if (e.shiftKey) { 136 | if (key in shiftMap) { 137 | key = shiftMap[key]; 138 | } else { 139 | key = `shift+${key}`; 140 | } 141 | } 142 | 143 | if (e.altKey) { 144 | key = `alt+${key}`; 145 | } 146 | 147 | if (e.ctrlKey) { 148 | key = `ctrl+${key}`; 149 | } 150 | 151 | if (e.metaKey) { 152 | key = `meta+${key}`; 153 | } 154 | 155 | logger.debug('keycode', e.keyCode, 'key', key); 156 | const results = this.emit('keydown', key); 157 | // return false to stop propagation, if any handler handled the key 158 | if (_.some(results)) { 159 | e.stopPropagation(); 160 | e.preventDefault(); 161 | return false; 162 | // return browser_utils.cancel(e); 163 | } 164 | return true; 165 | }); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /test/tests/tags_clone.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | import * as Tags from '../../src/plugins/tags'; 4 | import * as Marks from '../../src/plugins/marks'; 5 | import * as TagsClone from '../../src/plugins/clone_tags'; 6 | import '../../src/assets/ts/plugins'; 7 | import { Row } from '../../src/assets/ts/types'; 8 | 9 | // Testing 10 | class TagsTestCase extends TestCase { 11 | public expectTags(expected: {[key: string]: Row[]}) { 12 | return this._chain(async () => { 13 | const tagsApi: Tags.TagsPlugin = this.pluginManager.getInfo(Tags.pluginName).value; 14 | const tags_to_rows: {[key: string]: Row[]} = await tagsApi._getTagsToRows(); 15 | this._expectDeepEqual(tags_to_rows, expected, 'Inconsistent rows_to_tags'); 16 | }); 17 | } 18 | } 19 | 20 | // These test mostly ensure adding clone tags doesnt break tagging, not much testing of clone tags itself 21 | describe('tags_clone', function() { 22 | it('works in basic cases', async function() { 23 | let t = new TagsTestCase([ 24 | 'a line', 25 | 'another line', 26 | ], {plugins: [Tags.pluginName, Marks.pluginName, TagsClone.pluginName]}); 27 | t.expectTags({}); 28 | t.sendKeys('#tagtest'); 29 | t.sendKey('enter'); 30 | t.expectTags({'tagtest': [1]}); 31 | t.expect([ 32 | { 33 | 'text': 'tagtest', 34 | 'collapsed': true, 35 | 'plugins': { 36 | 'mark': 'tagtest' 37 | }, 38 | 'children': [ 39 | { 40 | 'text': 'a line', 41 | 'plugins': { 42 | 'tags': [ 43 | 'tagtest' 44 | ] 45 | }, 46 | 'id': 1 47 | } 48 | ] 49 | }, 50 | { 51 | 'clone': 1 52 | }, 53 | 'another line' 54 | ] 55 | ); 56 | 57 | t.sendKeys('j#test2'); 58 | t.sendKey('enter'); 59 | t.expectTags({'tagtest': [1], 'test2': [2]}); 60 | 61 | t.sendKeys('#test3'); 62 | t.sendKey('enter'); 63 | t.expectTags({'tagtest': [1], 'test2': [2], 'test3': [2]}); 64 | 65 | // duplicate tags ignored 66 | t.sendKeys('#test3'); 67 | t.sendKey('enter'); 68 | t.expectTags({'tagtest': [1], 'test2': [2], 'test3': [2]}); 69 | 70 | // remove tags 71 | t.sendKeys('d#1'); 72 | t.expectTags({'tagtest': [1], 'test3': [2]}); 73 | 74 | t.sendKeys('kd#'); 75 | t.expectTags({'test3': [2]}); 76 | 77 | await t.done(); 78 | }); 79 | it('can be searched for', async function() { 80 | let t = new TagsTestCase([ 81 | { text: 'hi', plugins: {tags: ['tag', 'test3']} }, 82 | { text: 'dog', plugins: {tags: ['test2']} }, 83 | ], {plugins: [Tags.pluginName, Marks.pluginName, TagsClone.pluginName]}); 84 | t.sendKeys('-test3'); 85 | t.sendKey('enter'); 86 | t.sendKeys('x'); 87 | 88 | t.sendKeys('-ta'); 89 | t.sendKey('enter'); 90 | t.sendKeys('x'); 91 | 92 | t.sendKeys('-test2'); 93 | t.sendKey('enter'); 94 | t.sendKeys('x'); 95 | await t.done(); 96 | }); 97 | it('can repeat', async function() { 98 | let t = new TagsTestCase([ 99 | { text: 'hi', plugins: {tags: ['tag', 'test3']} }, 100 | { text: 'dog', plugins: {tags: ['test2']} }, 101 | ], {plugins: [Tags.pluginName, Marks.pluginName, TagsClone.pluginName]}); 102 | t.sendKeys('jjjd#1.j'); 103 | t.expectTags({'test2': [4]}); 104 | await t.done(); 105 | }); 106 | it('can undo', async function() { 107 | let t = new TagsTestCase([ 108 | 'a line', 109 | 'another line', 110 | ], {plugins: [Tags.pluginName, Marks.pluginName, TagsClone.pluginName]}); 111 | t.expectTags({}); 112 | t.sendKeys('#tagtest'); 113 | t.sendKey('enter'); 114 | t.expectTags({'tagtest': [1]}); 115 | 116 | t.sendKey('u'); 117 | t.expectTags({}); 118 | 119 | t.sendKey('ctrl+r'); 120 | t.expectTags({'tagtest': [1]}); 121 | 122 | t.sendKeys('d#'); 123 | t.expectTags({}); 124 | 125 | t.sendKey('u'); 126 | t.expectTags({'tagtest': [1]}); 127 | 128 | t.sendKey('ctrl+r'); 129 | t.expectTags({}); 130 | await t.done(); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /src/plugins/todo/index.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | import './index.sass'; 4 | 5 | import { hideBorderAndModify, RegexTokenizerModifier } from '../../assets/ts/utils/token_unfolder'; 6 | import { registerPlugin } from '../../assets/ts/plugins'; 7 | import { matchWordRegex } from '../../assets/ts/utils/text'; 8 | import { Row } from '../../assets/ts/types'; 9 | import Session from '../../assets/ts/session'; 10 | 11 | const strikethroughClass = 'strikethrough'; 12 | 13 | export const pluginName = 'Todo'; 14 | 15 | registerPlugin( 16 | { 17 | name: pluginName, 18 | author: 'Jeff Wu', 19 | description: `Lets you strike out bullets (by default with ctrl+enter)`, 20 | }, 21 | function(api) { 22 | api.registerHook('session', 'renderLineTokenHook', (tokenizer, hooksInfo) => { 23 | if (hooksInfo.has_cursor) { 24 | return tokenizer; 25 | } 26 | if (hooksInfo.has_highlight) { 27 | return tokenizer; 28 | } 29 | return tokenizer.then(RegexTokenizerModifier( 30 | matchWordRegex('\\~\\~(\\n|.)+?\\~\\~'), 31 | hideBorderAndModify(2, 2, (char_info) => { char_info.renderOptions.classes[strikethroughClass] = true; }) 32 | )); 33 | }); 34 | 35 | async function isStruckThrough(session: Session, row: Row) { 36 | // for backwards compatibility 37 | const isStruckThroughOldStyle = await session.document.store._isStruckThroughOldFormat(row); 38 | if (isStruckThroughOldStyle) { return true; } 39 | 40 | const text = await session.document.getText(row); 41 | return (text.slice(0, 2) === '~~') && (text.slice(-2) === '~~'); 42 | } 43 | 44 | async function addStrikeThrough(session: Session, row: Row) { 45 | await session.addChars(row, -1, ['~', '~']); 46 | await session.addChars(row, 0, ['~', '~']); 47 | } 48 | 49 | async function removeStrikeThrough(session: Session, row: Row) { 50 | await session.delChars(row, -2, 2); 51 | await session.delChars(row, 0, 2); 52 | } 53 | 54 | api.registerAction( 55 | 'toggle-strikethrough', 56 | 'Toggle strikethrough for a row', 57 | async function({ session }) { 58 | if (await isStruckThrough(session, session.cursor.row)) { 59 | await removeStrikeThrough(session, session.cursor.row); 60 | } else { 61 | await addStrikeThrough(session, session.cursor.row); 62 | } 63 | }, 64 | ); 65 | 66 | // TODO: this should maybe strikethrough children, since UI suggests it? 67 | api.registerAction( 68 | 'visual-line-toggle-strikethrough', 69 | 'Toggle strikethrough for rows', 70 | async function({ session, visual_line }) { 71 | if (visual_line == null) { 72 | throw new Error('Visual_line mode arguments missing'); 73 | } 74 | 75 | const is_struckthrough = await Promise.all( 76 | visual_line.selected.map(async (path) => { 77 | return await isStruckThrough(session, path.row); 78 | }) 79 | ); 80 | if (_.every(is_struckthrough)) { 81 | await Promise.all( 82 | visual_line.selected.map(async (path) => { 83 | await removeStrikeThrough(session, path.row); 84 | }) 85 | ); 86 | } else { 87 | await Promise.all( 88 | visual_line.selected.map(async (path, i) => { 89 | if (!is_struckthrough[i]) { 90 | await addStrikeThrough(session, path.row); 91 | } 92 | }) 93 | ); 94 | } 95 | await session.setMode('NORMAL'); 96 | }, 97 | ); 98 | 99 | api.registerDefaultMappings( 100 | 'NORMAL', 101 | { 102 | 'toggle-strikethrough': [['ctrl+enter']], 103 | }, 104 | ); 105 | 106 | api.registerDefaultMappings( 107 | 'INSERT', 108 | { 109 | 'toggle-strikethrough': [['ctrl+enter', 'meta+enter']], 110 | }, 111 | ); 112 | 113 | api.registerDefaultMappings( 114 | 'VISUAL_LINE', 115 | { 116 | 'visual-line-toggle-strikethrough': [['ctrl+enter']], 117 | }, 118 | ); 119 | 120 | // TODO for workflowy mode 121 | // NOTE: in workflowy, this also crosses out children 122 | // 'toggle-strikethrough': [['meta+enter']], 123 | }, 124 | (api => api.deregisterAll()), 125 | ); 126 | -------------------------------------------------------------------------------- /src/assets/ts/components/menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import LineComponent from './line'; 4 | import SpinnerComponent from './spinner'; 5 | import Session from '../session'; 6 | import Menu from '../menu'; 7 | import { Line } from '../types'; 8 | import { getStyles } from '../themes'; 9 | 10 | type Props = { 11 | session: Session; 12 | menu: Menu; 13 | }; 14 | type State = { 15 | query: Line | null; 16 | }; 17 | export default class MenuComponent extends React.Component { 18 | private updateFn?: () => Promise; 19 | constructor(props: Props) { 20 | super(props); 21 | this.state = { 22 | query: null, 23 | }; 24 | } 25 | 26 | public componentDidMount() { 27 | const menu = this.props.menu; 28 | this.updateFn = async () => { 29 | const query = await menu.session.curLine(); 30 | this.setState({ query }); 31 | }; 32 | this.props.session.on('handledKey', this.updateFn); 33 | this.updateFn(); 34 | } 35 | 36 | public componentWillUnmount() { 37 | if (!this.updateFn) { 38 | throw new Error('Unmounting before mounting!?'); 39 | } 40 | this.props.session.off('handledKey', this.updateFn); 41 | } 42 | 43 | public render() { 44 | const menu = this.props.menu; 45 | const query = this.state.query; 46 | const session = this.props.session; 47 | 48 | const searchBox = ( 49 |
52 | 53 | 54 | { 55 | (() => { 56 | if (query === null) { 57 | return ; 58 | } else { 59 | return ; 70 | } 71 | })() 72 | } 73 | 74 |
75 | ); 76 | 77 | let searchResults; 78 | 79 | if (menu.results.length === 0) { 80 | let message = ''; 81 | if (!(query && query.length)) { 82 | message = 'Type something to search!'; 83 | } else { 84 | message = 'No results! Try typing something else'; 85 | } 86 | searchResults = ( 87 |
88 | {message} 89 |
90 | ); 91 | } else { 92 | searchResults = menu.results.map((result, i) => { 93 | const selected = i === menu.selection; 94 | 95 | const renderOptions = result.renderOptions || {}; 96 | let contents = ( 97 | 105 | ); 106 | if (result.renderHook) { 107 | contents = result.renderHook(contents); 108 | } 109 | 110 | const style = { marginBottom: 10 }; 111 | if (selected) { 112 | Object.assign(style, getStyles(session.clientStore, ['theme-bg-highlight'])); 113 | } 114 | return ( 115 |
116 | 118 | {contents} 119 |
120 | ); 121 | }); 122 | } 123 | 124 | return ( 125 |
126 | {searchBox} 127 | {searchResults} 128 |
129 | ); 130 | } 131 | } 132 | 133 | -------------------------------------------------------------------------------- /src/assets/ts/definitions/menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import Session from '../session'; 4 | import Path from '../path'; 5 | import Menu from '../menu'; 6 | import keyDefinitions, { Action } from '../keyDefinitions'; 7 | 8 | async function start_search(searchRoot: Path, session: Session) { 9 | await session.setMode('SEARCH'); 10 | session.menu = new Menu(async (text) => { 11 | const results = await session.document.search(searchRoot, text); 12 | return Promise.all( 13 | results.map(async ({ path, matches }) => { 14 | const accents: {[column: number]: boolean} = {}; 15 | matches.forEach((i) => { 16 | accents[i] = true; 17 | }); 18 | return { 19 | contents: await session.document.getLine(path.row), 20 | renderHook(lineDiv: React.ReactElement) { 21 | const cachedRow = session.document.cache.get(path.row); 22 | if (!cachedRow) { 23 | throw new Error('Row wasnt cached despite search returning it'); 24 | } 25 | const hooksInfo = { 26 | path, 27 | pluginData: cachedRow.pluginData, 28 | }; 29 | return ( 30 | 31 | { 32 | session.applyHook( 33 | 'renderLineContents', 34 | [lineDiv], 35 | hooksInfo 36 | ) 37 | } 38 | 39 | ); 40 | }, 41 | renderOptions: { accents }, 42 | fn: async () => { 43 | await session.zoomInto(path); 44 | await session.cursor.setPath(path); 45 | }, 46 | }; 47 | }) 48 | ); 49 | }); 50 | } 51 | 52 | keyDefinitions.registerAction(new Action( 53 | 'search-local', 54 | 'Search within view root', 55 | async function({ session }) { 56 | await start_search(session.viewRoot, session); 57 | }, 58 | )); 59 | 60 | keyDefinitions.registerAction(new Action( 61 | 'search-global', 62 | 'Search within entire document', 63 | async function({ session }) { 64 | await start_search(session.document.root, session); 65 | }, 66 | )); 67 | 68 | keyDefinitions.registerAction(new Action( 69 | 'move-cursor-search', 70 | 'Move the cursor within the search box (according to the specified motion)', 71 | async function({ motion, session }) { 72 | if (motion == null) { 73 | throw new Error('Motion command was not passed a motion'); 74 | } 75 | if (session.menu == null) { 76 | throw new Error('Menu session missing'); 77 | } 78 | await motion(session.menu.session.cursor, {pastEnd: true}); 79 | }, 80 | { acceptsMotion: true }, 81 | )); 82 | 83 | keyDefinitions.registerAction(new Action( 84 | 'search-delete-char-after', 85 | 'Delete character after the cursor (i.e. del key)', 86 | async function({ session }) { 87 | if (session.menu == null) { 88 | throw new Error('Menu session missing'); 89 | } 90 | await session.menu.session.delCharsAfterCursor(1); 91 | }, 92 | )); 93 | 94 | keyDefinitions.registerAction(new Action( 95 | 'search-delete-char-before', 96 | 'Delete previous character (i.e. backspace key)', 97 | async function({ session }) { 98 | if (session.menu == null) { 99 | throw new Error('Menu session missing'); 100 | } 101 | await session.menu.session.deleteAtCursor(); 102 | }, 103 | )); 104 | 105 | keyDefinitions.registerAction(new Action( 106 | 'search-select', 107 | 'Select current menu selection', 108 | async function({ session }) { 109 | if (session.menu == null) { 110 | throw new Error('Menu session missing'); 111 | } 112 | await session.menu.select(); 113 | return await session.setMode('NORMAL'); 114 | }, 115 | )); 116 | 117 | keyDefinitions.registerAction(new Action( 118 | 'search-up', 119 | 'Select previous menu selection', 120 | async function({ session }) { 121 | if (session.menu == null) { 122 | throw new Error('Menu session missing'); 123 | } 124 | return session.menu.up(); 125 | }, 126 | )); 127 | 128 | keyDefinitions.registerAction(new Action( 129 | 'search-down', 130 | 'Select next menu selection', 131 | async function({ session }) { 132 | if (session.menu == null) { 133 | throw new Error('Menu session missing'); 134 | } 135 | return session.menu.down(); 136 | }, 137 | )); 138 | 139 | -------------------------------------------------------------------------------- /test/tests/macros.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | describe('macros', function() { 5 | it('basically work', async function() { 6 | let t = new TestCase([ 'banananana' ]); 7 | // does nothing since nothing has been recorded 8 | t.sendKeys('@q'); 9 | t.expect([ 'banananana' ]); 10 | t.sendKeys('qqxlq'); 11 | t.expect([ 'anananana' ]); 12 | t.sendKeys('4@q'); 13 | t.expect([ 'aaaaa' ]); 14 | t.sendKeys('u'); 15 | t.expect([ 'anananana' ]); 16 | t.sendKey('ctrl+r'); 17 | t.expect([ 'aaaaa' ]); 18 | t.sendKeys('u'); 19 | t.expect([ 'anananana' ]); 20 | t.sendKeys('l@q'); 21 | t.expect([ 'annanana' ]); 22 | t.sendKeys('3.'); 23 | t.expect([ 'annnn' ]); 24 | await t.done(); 25 | 26 | t = new TestCase([ 27 | '00000000', 28 | '00000000', 29 | '00000000', 30 | '00000000', 31 | '00000000', 32 | '00000000', 33 | '00000000', 34 | '00000000', 35 | ]); 36 | // does nothing since nothing has been recorded 37 | t.sendKeys('qmr1lr2jq'); 38 | t.sendKeys('7@m'); 39 | t.expect([ 40 | '12000000', 41 | '01200000', 42 | '00120000', 43 | '00012000', 44 | '00001200', 45 | '00000120', 46 | '00000012', 47 | '00000002', 48 | ]); 49 | t.sendKeys('qmxxq'); 50 | t.expect([ 51 | '12000000', 52 | '01200000', 53 | '00120000', 54 | '00012000', 55 | '00001200', 56 | '00000120', 57 | '00000012', 58 | '000000', 59 | ]); 60 | // overrides old macro 61 | t.sendKeys('@m'); 62 | t.expect([ 63 | '12000000', 64 | '01200000', 65 | '00120000', 66 | '00012000', 67 | '00001200', 68 | '00000120', 69 | '00000012', 70 | '0000', 71 | ]); 72 | // can repeat 73 | // should it only do one delete? (just need to enable save on recorded keystream) 74 | t.sendKeys('.'); 75 | t.expect([ 76 | '12000000', 77 | '01200000', 78 | '00120000', 79 | '00012000', 80 | '00001200', 81 | '00000120', 82 | '00000012', 83 | '00', 84 | ]); 85 | t.sendKeys('x'); 86 | t.expect([ 87 | '12000000', 88 | '01200000', 89 | '00120000', 90 | '00012000', 91 | '00001200', 92 | '00000120', 93 | '00000012', 94 | '0', 95 | ]); 96 | t.sendKeys('u'); 97 | t.expect([ 98 | '12000000', 99 | '01200000', 100 | '00120000', 101 | '00012000', 102 | '00001200', 103 | '00000120', 104 | '00000012', 105 | '00', 106 | ]); 107 | t.sendKeys('u'); 108 | t.expect([ 109 | '12000000', 110 | '01200000', 111 | '00120000', 112 | '00012000', 113 | '00001200', 114 | '00000120', 115 | '00000012', 116 | '0000', 117 | ]); 118 | await t.done(); 119 | }); 120 | 121 | it('work nested', async function() { 122 | // create a checkerboard! 123 | let t = new TestCase([ 124 | '00000000', 125 | '00000000', 126 | '00000000', 127 | '00000000', 128 | '00000000', 129 | '00000000', 130 | '00000000', 131 | '00000000', 132 | ]); 133 | // does nothing since nothing has been recorded 134 | t.sendKeys('qqr1llq'); 135 | t.expect([ 136 | '10000000', 137 | '00000000', 138 | '00000000', 139 | '00000000', 140 | '00000000', 141 | '00000000', 142 | '00000000', 143 | '00000000', 144 | ]); 145 | t.sendKeys('0'); 146 | t.sendKeys('qo4@qj0l4@qj0q'); 147 | t.expect([ 148 | '10101010', 149 | '01010101', 150 | '00000000', 151 | '00000000', 152 | '00000000', 153 | '00000000', 154 | '00000000', 155 | '00000000', 156 | ]); 157 | t.sendKeys('3@o'); 158 | t.expect([ 159 | '10101010', 160 | '01010101', 161 | '10101010', 162 | '01010101', 163 | '10101010', 164 | '01010101', 165 | '10101010', 166 | '01010101', 167 | ]); 168 | await t.done(); 169 | }); 170 | 171 | it('works even if sequence contains q', async function() { 172 | let t = new TestCase([ 173 | 'a q b q c q d q', 174 | ]); 175 | t.sendKeys('qxfqxq'); 176 | t.expect([ 177 | 'a b q c q d q', 178 | ]); 179 | t.sendKeys('@x'); 180 | t.expect([ 181 | 'a b c q d q', 182 | ]); 183 | t.sendKeys('2@x'); 184 | t.expect([ 185 | 'a b c d ', 186 | ]); 187 | await t.done(); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /src/assets/ts/register.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Represents a yank register. Holds saved data of one of several types - 3 | either nothing, a set of characters, a set of row ids, or a set of serialized rows 4 | Implements pasting for each of the types 5 | */ 6 | import Session from './session'; 7 | import { Line, SerializedBlock, Row } from './types'; 8 | 9 | export enum RegisterTypes { 10 | NONE = 0, 11 | CHARS = 1, 12 | SERIALIZED_ROWS = 2, 13 | CLONED_ROWS = 3, 14 | } 15 | 16 | export type RegisterValue = null | Line | Array | Array; 17 | export type SerializedRegister = { 18 | type: RegisterTypes, 19 | saved: RegisterValue, 20 | }; 21 | 22 | type PasteOptions = {before?: boolean}; 23 | 24 | export default class Register { 25 | private session: Session; 26 | private type: RegisterTypes = RegisterTypes.NONE; 27 | private saved: RegisterValue = null; 28 | 29 | constructor(session: Session) { 30 | this.session = session; 31 | this.saveNone(); 32 | return this; 33 | } 34 | 35 | public saveNone() { 36 | this.type = RegisterTypes.NONE; 37 | this.saved = null; 38 | } 39 | 40 | public saveChars(save: Line) { 41 | this.type = RegisterTypes.CHARS; 42 | this.saved = save; 43 | this.session.emit('yank', {type: this.type, saved: this.saved}); 44 | } 45 | 46 | public saveSerializedRows(save: Array) { 47 | this.type = RegisterTypes.SERIALIZED_ROWS; 48 | this.saved = save; 49 | this.session.emit('yank', {type: this.type, saved: this.saved}); 50 | } 51 | 52 | public saveClonedRows(save: Array) { 53 | this.type = RegisterTypes.CLONED_ROWS; 54 | this.saved = save; 55 | this.session.emit('yank', {type: this.type, saved: this.saved}); 56 | } 57 | 58 | public serialize() { 59 | return {type: this.type, saved: this.saved}; 60 | } 61 | 62 | public deserialize(serialized: SerializedRegister) { 63 | this.type = serialized.type; 64 | this.saved = serialized.saved; 65 | } 66 | 67 | // Pasting 68 | 69 | public async paste(options: PasteOptions = {}) { 70 | if (this.type === RegisterTypes.CHARS) { 71 | await this.pasteChars(options); 72 | } else if (this.type === RegisterTypes.SERIALIZED_ROWS) { 73 | await this.pasteSerializedRows(options); 74 | } else if (this.type === RegisterTypes.CLONED_ROWS) { 75 | await this.pasteClonedRows(options); 76 | } 77 | } 78 | 79 | public async pasteChars(options: PasteOptions = {}) { 80 | const chars = (this.saved as Line); 81 | if (options.before) { 82 | await this.session.addCharsAtCursor(chars); 83 | } else { 84 | await this.session.addCharsAfterCursor(chars); 85 | await this.session.cursor.setCol(this.session.cursor.col + chars.length); 86 | } 87 | } 88 | 89 | public async pasteSerializedRows(options: PasteOptions = {}) { 90 | const path = this.session.cursor.path; 91 | if (path.parent == null) { 92 | throw new Error('Cursor was at root'); 93 | } 94 | const parent = path.parent; 95 | const index = await this.session.document.indexInParent(path); 96 | 97 | const serialized_rows = (this.saved as Array); 98 | 99 | if (options.before) { 100 | await this.session.addBlocks(parent, index, serialized_rows, {setCursor: 'first'}); 101 | } else { 102 | if (path.is(this.session.viewRoot) || 103 | ((!await this.session.document.collapsed(path.row)) && 104 | (await this.session.document.hasChildren(path.row)))) { 105 | await this.session.addBlocks(path, 0, serialized_rows, {setCursor: 'first'}); 106 | } else { 107 | await this.session.addBlocks(parent, index + 1, serialized_rows, {setCursor: 'first'}); 108 | } 109 | } 110 | } 111 | 112 | public async pasteClonedRows(options: PasteOptions = {}) { 113 | const path = this.session.cursor.path; 114 | if (path.parent == null) { 115 | throw new Error('Cursor was at root'); 116 | } 117 | const parent = path.parent; 118 | const index = await this.session.document.indexInParent(path); 119 | 120 | const cloned_rows = (this.saved as Array); 121 | 122 | if (options.before) { 123 | await this.session.attachBlocks(parent, cloned_rows, index, {setCursor: 'first'}); 124 | } else { 125 | if (path.is(this.session.viewRoot) || 126 | ((!await this.session.document.collapsed(path.row)) && 127 | (await this.session.document.hasChildren(path.row)))) { 128 | await this.session.attachBlocks(path, cloned_rows, 0, {setCursor: 'first'}); 129 | } else { 130 | await this.session.attachBlocks(parent, cloned_rows, index + 1, {setCursor: 'first'}); 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /test/tests/backspace.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | 4 | describe('backspace', function() { 5 | it('works in simple case', async function() { 6 | const t = new TestCase(['abc']); 7 | t.sendKey('A'); 8 | t.sendKey('backspace'); 9 | t.sendKey('backspace'); 10 | t.expect(['a']); 11 | await t.done(); 12 | }); 13 | 14 | it('works deleting from second line', async function() { 15 | const t = new TestCase(['abc', 'def']); 16 | t.sendKeys('jli'); 17 | t.sendKey('backspace'); 18 | t.expect(['abc', 'ef']); 19 | t.sendKey('backspace'); 20 | t.expect(['abcef']); 21 | t.sendKey('backspace'); 22 | t.expect(['abef']); 23 | t.sendKey('backspace'); 24 | t.expect(['aef']); 25 | t.sendKey('backspace'); 26 | t.expect(['ef']); 27 | t.sendKey('backspace'); 28 | t.expect(['ef']); 29 | t.sendKey('esc'); 30 | t.sendKey('u'); 31 | t.expect(['abc', 'def']); 32 | await t.done(); 33 | }); 34 | 35 | it('works at end of line', async function() { 36 | const t = new TestCase(['ab', 'cd']); 37 | t.sendKeys('jA'); 38 | t.sendKey('backspace'); 39 | t.sendKey('backspace'); 40 | t.expect(['ab', '']); 41 | t.sendKey('backspace'); 42 | t.expect(['ab']); 43 | t.sendKey('backspace'); 44 | t.expect(['a']); 45 | await t.done(); 46 | }); 47 | 48 | it('works from children', async function() { 49 | const t = new TestCase([ 50 | { text: 'ab', children: [ 51 | 'bc', 52 | ] }, 53 | { text: 'cd', children: [ 54 | 'de', 55 | ] }, 56 | ]); 57 | t.sendKeys('jji'); 58 | t.sendKey('backspace'); 59 | // did nothing due to child of 'ab' 60 | t.expect([ 61 | { text: 'ab', children: [ 62 | 'bc', 63 | ] }, 64 | { text: 'cd', children: [ 65 | 'de', 66 | ] }, 67 | ]); 68 | t.sendKey('esc'); 69 | t.sendKeys('kddj'); 70 | t.expect([ 71 | 'ab', 72 | { text: 'cd', children: [ 73 | 'de', 74 | ] }, 75 | ]); 76 | t.sendKeys('i'); 77 | t.sendKey('backspace'); 78 | t.expect([ 79 | { text: 'abcd', children: [ 80 | 'de', 81 | ] }, 82 | ]); 83 | t.sendKey('backspace'); 84 | t.sendKey('backspace'); 85 | t.sendKey('backspace'); 86 | t.expect([ 87 | { text: 'cd', children: [ 88 | 'de', 89 | ] }, 90 | ]); 91 | t.sendKey('backspace'); 92 | t.expect([ 93 | { text: 'cd', children: [ 94 | 'de', 95 | ] }, 96 | ]); 97 | await t.done(); 98 | }); 99 | 100 | it('works with undo/redo', async function() { 101 | const t = new TestCase([ 102 | { text: 'ab', children: [ 103 | 'cd', 104 | ] }, 105 | ]); 106 | t.sendKeys('ji'); 107 | t.sendKey('backspace'); 108 | t.expect([ 109 | 'abcd', 110 | ]); 111 | t.expectCursor(1, 2); 112 | // t.sendKey('backspace'); 113 | // t.expect([ 114 | // 'acd' 115 | // ]); 116 | t.sendKey('esc'); 117 | t.expectCursor(1, 1); 118 | t.sendKeys('u'); 119 | t.expect([ 120 | { text: 'ab', children: [ 121 | 'cd', 122 | ] }, 123 | ]); 124 | t.sendKey('ctrl+r'); 125 | t.expect([ 126 | 'abcd', 127 | ]); 128 | t.expectCursor(1, 1); 129 | t.sendKey('x'); 130 | t.expect([ 131 | 'acd', 132 | ]); 133 | await t.done(); 134 | }); 135 | 136 | it('fails when both rows have children', async function() { 137 | const t = new TestCase([ 138 | { text: 'ab', children: [ 139 | 'cd', 140 | ] }, 141 | { text: 'ab', children: [ 142 | 'cd', 143 | ] }, 144 | ]); 145 | t.sendKeys('jji'); 146 | t.sendKey('backspace'); 147 | t.expect([ 148 | { text: 'ab', children: [ 149 | 'cd', 150 | ] }, 151 | { text: 'ab', children: [ 152 | 'cd', 153 | ] }, 154 | ]); 155 | t.sendKey('esc'); 156 | t.sendKeys('kdd'); 157 | t.expect([ 158 | 'ab', 159 | { text: 'ab', children: [ 160 | 'cd', 161 | ] }, 162 | ]); 163 | t.sendKeys('ji'); 164 | t.sendKey('backspace'); 165 | t.expect([ 166 | { text: 'abab', children: [ 167 | 'cd', 168 | ] }, 169 | ]); 170 | t.sendKey('backspace'); 171 | t.expect([ 172 | { text: 'aab', children: [ 173 | 'cd', 174 | ] }, 175 | ]); 176 | t.sendKey('esc'); 177 | t.sendKeys('u'); 178 | t.expect([ 179 | 'ab', 180 | { text: 'ab', children: [ 181 | 'cd', 182 | ] }, 183 | ]); 184 | await t.done(); 185 | }); 186 | }); 187 | 188 | 189 | describe('delete', () => 190 | it('works in basic case', async function() { 191 | const t = new TestCase(['ab', 'cd']); 192 | t.sendKeys('i'); 193 | t.sendKey('delete'); 194 | t.expect(['b', 'cd']); 195 | t.sendKey('delete'); 196 | t.expect(['', 'cd']); 197 | // doesn't do anything, for now 198 | t.sendKey('delete'); 199 | t.expect(['', 'cd']); 200 | t.sendKey('esc'); 201 | t.sendKey('u'); 202 | t.expect(['ab', 'cd']); 203 | await t.done(); 204 | }) 205 | ); 206 | 207 | -------------------------------------------------------------------------------- /src/plugins/easy_motion/index.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; // tslint:disable-line no-unused-variable 3 | import $ from 'jquery'; 4 | 5 | import Path from '../../assets/ts/path'; 6 | import { registerPlugin } from '../../assets/ts/plugins'; 7 | import * as browser_utils from '../../assets/ts/utils/browser'; 8 | import { getStyles } from '../../assets/ts/themes'; 9 | 10 | type EasyMotionMappings = { 11 | key_to_path: {[key: string]: Path}, 12 | path_to_key: {[serialized_path: string]: string}, 13 | }; 14 | 15 | async function getVisiblePaths() { 16 | const paths: Array = []; 17 | $.makeArray($('.bullet')).forEach((bullet) => { 18 | // TODO: more proper way to expose $('#view') in API 19 | if (!browser_utils.isScrolledIntoView($(bullet), $('#view'))) { 20 | return; 21 | } 22 | if ($(bullet).hasClass('fa-clone')) { 23 | return; 24 | } 25 | // NOTE: can't use $(x).data 26 | // http://stackoverflow.com/questions/25876274/jquery-data-not-working 27 | const ancestry = $(bullet).attr('data-ancestry'); 28 | if (!ancestry) { // as far as i know, this only happens because of menu mode 29 | return; 30 | } 31 | const path = Path.loadFromAncestry(JSON.parse(ancestry)); 32 | paths.push(path); 33 | }); 34 | return paths; 35 | } 36 | 37 | registerPlugin( 38 | { 39 | name: 'Easy motion', 40 | author: 'Jeff Wu', 41 | description: ( 42 |
43 | Lets you easily jump between rows, by default with space key. 44 | Based on this vim plugin 45 |
46 | ), 47 | }, 48 | function(api) { 49 | let EASY_MOTION_MAPPINGS: EasyMotionMappings | null = null; 50 | 51 | api.registerMotion( 52 | 'easy-motion', 53 | 'Jump to a visible row (based on EasyMotion)', 54 | async function({ session, keyStream, keyHandler }) { 55 | let paths: Array = (await getVisiblePaths()).filter( 56 | path => !path.is(session.cursor.path) 57 | ); 58 | 59 | let keys = [ 60 | 'Z', 'X', 'C', 'V', 61 | 'Q', 'W', 'E', 'R', 'T', 62 | 'A', 'S', 'D', 'F', 63 | 'z', 'x', 'c', 'v', 64 | 'q', 'w', 'e', 'r', 't', 65 | 'a', 's', 'd', 'f', 66 | 'g', 'h', 'j', 'k', 'l', 67 | 'y', 'u', 'i', 'o', 'p', 68 | 'b', 'n', 'm', 69 | 'G', 'H', 'J', 'K', 'L', 70 | 'Y', 'U', 'I', 'O', 'P', 71 | 'B', 'N', 'M', 72 | ]; 73 | 74 | let start; 75 | if (keys.length > paths.length) { 76 | start = (keys.length - paths.length) / 2; 77 | keys = keys.slice(start, start + paths.length); 78 | } else { 79 | start = (paths.length - keys.length) / 2; 80 | paths = paths.slice(start, start + paths.length); 81 | } 82 | 83 | let mappings: EasyMotionMappings = { 84 | key_to_path: {}, 85 | path_to_key: {}, 86 | }; 87 | // NOTE: _.zip has a stupid type definition 88 | _.zip(paths, keys).forEach((pair: any) => { 89 | const [path, jump_key]: [Path, string] = pair; 90 | mappings.key_to_path[jump_key] = path; 91 | mappings.path_to_key[JSON.stringify(path.getAncestry())] = jump_key; 92 | }); 93 | 94 | EASY_MOTION_MAPPINGS = mappings; 95 | 96 | await Promise.all(_.values(EASY_MOTION_MAPPINGS.key_to_path).map( 97 | async (path) => await api.updatedDataForRender(path.row) 98 | )); 99 | // TODO hacky way to trigger re-render 100 | keyHandler.emit('handledKey'); 101 | 102 | const key = await keyStream.dequeue(); 103 | 104 | return async function(cursor /*, options */) { 105 | if (EASY_MOTION_MAPPINGS === null) { 106 | throw new Error('Easy motion mappings were not set, as expected'); 107 | } 108 | if (key in EASY_MOTION_MAPPINGS.key_to_path) { 109 | let path = EASY_MOTION_MAPPINGS.key_to_path[key]; 110 | await cursor.setPosition(path, 0); 111 | } 112 | await Promise.all(_.values(EASY_MOTION_MAPPINGS.key_to_path).map( 113 | async (path) => await api.updatedDataForRender(path.row) 114 | )); 115 | EASY_MOTION_MAPPINGS = null; 116 | }; 117 | }, 118 | ); 119 | 120 | api.registerDefaultMappings( 121 | 'NORMAL', 122 | { 123 | 'easy-motion': [['space']], 124 | }, 125 | ); 126 | 127 | api.registerHook('session', 'renderBullet', function(bullet, info) { 128 | let ancestry_str = JSON.stringify(info.path.getAncestry()); 129 | if (EASY_MOTION_MAPPINGS !== null) { 130 | if (ancestry_str in EASY_MOTION_MAPPINGS.path_to_key) { 131 | bullet = ( 132 | 135 | {EASY_MOTION_MAPPINGS.path_to_key[ancestry_str]} 136 | 137 | ); 138 | } 139 | } 140 | return bullet; 141 | }); 142 | }, 143 | (api => api.deregisterAll()), 144 | ); 145 | -------------------------------------------------------------------------------- /src/plugins/clone_tags/index.tsx: -------------------------------------------------------------------------------- 1 | import { Logger } from '../../shared/utils/logger'; 2 | 3 | import { registerPlugin, PluginApi } from '../../assets/ts/plugins'; 4 | import Path from '../../assets/ts/path'; 5 | import { Row, SerializedBlock } from '../../assets/ts/types'; 6 | 7 | import { pluginName as marksPluginName, MarksPlugin } from '../marks'; 8 | import { pluginName as tagsPluginName, TagsPlugin } from '../tags'; 9 | 10 | type Tag = string; 11 | 12 | /* 13 | * ALGORITHMIC NOTE: maintaining the set of tags 14 | * Rather than trying to update the list 15 | * as rows get removed and added from the document (which is especially 16 | * tricky because of cloning), 17 | * we simply store all tags, even if attached to the document, 18 | * and then prune after looking them up. 19 | */ 20 | 21 | export class CloneTagsPlugin { 22 | private api: PluginApi; 23 | private logger: Logger; 24 | private tagRoot: {[tag: string]: Path | null}; 25 | private tagsPlugin: TagsPlugin; 26 | 27 | constructor(api: PluginApi) { 28 | this.api = api; 29 | this.logger = this.api.logger; 30 | this.tagRoot = {}; 31 | this.tagsPlugin = this.api.getPlugin(tagsPluginName) as TagsPlugin; 32 | } 33 | 34 | public async enable() { 35 | this.logger.debug('Enabling cloning tags'); 36 | 37 | this.api.registerHook('document', 'tagAdded', async (_struct, { tag, row }) => { 38 | await this.createClone(row, tag); 39 | }); 40 | 41 | this.api.registerHook('document', 'tagRemoved', async (_struct, { tag, row }) => { 42 | await this.deleteClone(row, tag); 43 | }); 44 | } 45 | 46 | public async inTagRoot(row: Row, tag: Tag) { 47 | // check if row is in top level of tag root 48 | const root = await this.getTagRoot(tag); 49 | const document = this.api.session.document; 50 | const info = await document.getInfo(row); 51 | const parents = info.parentRows; 52 | return parents.includes(root.row); 53 | } 54 | 55 | public async createClone(row: Row, tag: Tag) { 56 | const root = await this.getTagRoot(tag); 57 | if (!await this.inTagRoot(row, tag)) { 58 | await this.api.session.attachBlocks(root, [row], 0); 59 | await this.api.updatedDataForRender(row); 60 | } 61 | } 62 | 63 | public async deleteClone(row: Row, tag: Tag) { 64 | if (!await this.inTagRoot(row, tag)) { 65 | return; 66 | } 67 | const root = await this.getTagRoot(tag); 68 | const document = this.api.session.document; 69 | if (!root) { 70 | return; 71 | } 72 | await document._detach(row, root.row); 73 | await this.api.updatedDataForRender(row); 74 | } 75 | 76 | private async getMarkPath(mark: string): Promise { 77 | const marksPlugin = this.api.getPlugin(marksPluginName) as MarksPlugin; 78 | const marks = await marksPlugin.listMarks(); 79 | return marks[mark]; 80 | } 81 | 82 | public async getTagRoot(tag: Tag): Promise { 83 | let root = this.tagRoot[tag]; 84 | if (root && await this.api.session.document.isValidPath(root) && await this.api.session.document.isAttached(root.row)) { 85 | return root; 86 | } else { 87 | root = await this.getMarkPath(tag); 88 | if (!root) { 89 | await this.createTagRoot(tag); 90 | root = await this.getMarkPath(tag); 91 | if (!root) { 92 | throw new Error('Error while creating node'); 93 | } 94 | } 95 | this.tagRoot[tag] = root; 96 | return root; 97 | } 98 | } 99 | 100 | private async setMark(path: Path, mark: string) { 101 | const marksPlugin = this.api.getPlugin(marksPluginName) as MarksPlugin; 102 | await marksPlugin.setMark(path.row, mark); 103 | } 104 | 105 | private async createBlock(path: Path, text: string, isCollapsed: boolean = true, plugins?: any) { 106 | let serialzed_row: SerializedBlock = { 107 | text: text, 108 | collapsed: isCollapsed, 109 | plugins: plugins, 110 | children: [], 111 | }; 112 | const paths = await this.api.session.addBlocks(path, 0, [serialzed_row]); 113 | if (paths.length > 0) { 114 | await this.api.updatedDataForRender(path.row); 115 | return paths[0]; 116 | } else { 117 | throw new Error('Error while creating block'); 118 | } 119 | } 120 | 121 | private async createTagRoot(tag: Tag) { 122 | const path = await this.createBlock(this.api.session.document.root, tag); 123 | if (path) { 124 | await this.setMark(path, tag); 125 | } 126 | const tagsToRows = await this.tagsPlugin._getTagsToRows(); 127 | const document = this.api.session.document; 128 | for (let row of tagsToRows[tag]) { 129 | if (await document.isAttached(row)) { 130 | await this.createClone(row, tag); 131 | } 132 | } 133 | } 134 | } 135 | 136 | export const pluginName = 'Tag Clone'; 137 | 138 | registerPlugin( 139 | { 140 | name: pluginName, 141 | author: 'Victor Tao', 142 | description: 143 | `Creates a root node for every tag with mark [TAGNAME]. Tagged rows are cloned to to this node.`, 144 | version: 1, 145 | dependencies: [tagsPluginName, marksPluginName], 146 | }, 147 | async (api) => { 148 | const clonetagsPlugin = new CloneTagsPlugin(api); 149 | await clonetagsPlugin.enable(); 150 | return clonetagsPlugin; 151 | }, 152 | (api) => api.deregisterAll(), 153 | ); 154 | -------------------------------------------------------------------------------- /src/assets/ts/themes.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { ClientStore } from './datastore'; 4 | 5 | export type Theme = { 6 | 'theme-bg-primary': string, 7 | 'theme-bg-secondary': string, 8 | 'theme-bg-tertiary': string, 9 | 'theme-bg-highlight': string, 10 | 11 | 'theme-text-primary': string, 12 | 'theme-text-accent': string, 13 | 'theme-text-link': string, 14 | 15 | 'theme-trim': string, 16 | 'theme-trim-accent': string, 17 | 18 | 'theme-text-cursor': string, 19 | 'theme-bg-cursor': string, 20 | }; 21 | 22 | export type StyleProperty = 23 | 'theme-bg-primary' | 'theme-bg-secondary' | 'theme-bg-tertiary' | 'theme-bg-highlight' | 24 | 'theme-text-primary' | 'theme-text-accent' | 'theme-trim' | 'theme-trim-accent' | 25 | 'theme-link' | 'theme-cursor'; 26 | 27 | export function getStyles(clientStore: ClientStore, style_props: Array) { 28 | const style: React.CSSProperties = {}; 29 | style_props.forEach((style_prop) => { 30 | if (style_prop === 'theme-bg-primary') { 31 | style.backgroundColor = clientStore.getClientSetting('theme-bg-primary'); 32 | } else if (style_prop === 'theme-bg-secondary') { 33 | style.backgroundColor = clientStore.getClientSetting('theme-bg-secondary'); 34 | } else if (style_prop === 'theme-bg-tertiary') { 35 | style.backgroundColor = clientStore.getClientSetting('theme-bg-tertiary'); 36 | } else if (style_prop === 'theme-bg-highlight') { 37 | style.backgroundColor = clientStore.getClientSetting('theme-bg-highlight'); 38 | } else if (style_prop === 'theme-text-primary') { 39 | style.color = clientStore.getClientSetting('theme-text-primary'); 40 | } else if (style_prop === 'theme-text-accent') { 41 | style.color = clientStore.getClientSetting('theme-text-accent'); 42 | style.fontWeight = 'bold'; 43 | } else if (style_prop === 'theme-link') { 44 | style.color = clientStore.getClientSetting('theme-text-link'); 45 | style.cursor = 'pointer'; 46 | style.textDecoration = 'none'; 47 | } else if (style_prop === 'theme-trim') { 48 | style.border = `1px solid ${clientStore.getClientSetting('theme-trim')}`; 49 | } else if (style_prop === 'theme-trim-accent') { 50 | style.border = `2px solid ${clientStore.getClientSetting('theme-trim-accent')}`; 51 | } else if (style_prop === 'theme-cursor') { 52 | style.backgroundColor = clientStore.getClientSetting('theme-bg-cursor'); 53 | style.color = clientStore.getClientSetting('theme-text-cursor'); 54 | } 55 | }); 56 | return style; 57 | } 58 | 59 | const colors = { 60 | black: '#000000', 61 | white: '#ffffff', 62 | yellow: '#ffff00', 63 | solarized: { 64 | base03: '#002b36', 65 | base02: '#073642', 66 | base01: '#586e75', 67 | base00: '#657b83', 68 | base0: '#839496', 69 | base1: '#93a1a1', 70 | base2: '#eee8d5', 71 | base3: '#fdf6e3', 72 | yellow: '#b58900', 73 | orange: '#cb4b16', 74 | red: '#dc322f', 75 | magenta: '#d33682', 76 | violet: '#6c71c4', 77 | blue: '#268bd2', 78 | cyan: '#2aa198', 79 | green: '#859900', 80 | }, 81 | }; 82 | 83 | export const themes: {[key: string]: Theme} = { 84 | 'Default': { 85 | 'theme-bg-primary': colors.white, 86 | 'theme-bg-secondary': '#aaaaaa', 87 | 'theme-bg-tertiary': '#e0e0e0', 88 | 'theme-bg-highlight': '#ccc', // '#ffa' for yellowish 89 | 90 | 'theme-text-primary': colors.black, 91 | 'theme-text-accent': '#dd3388', 92 | 'theme-text-link': '#8888ff', 93 | 94 | 'theme-trim': colors.black, 95 | 'theme-trim-accent': '#dd3388', 96 | 97 | 'theme-text-cursor': colors.white, 98 | 'theme-bg-cursor': '#666', 99 | }, 100 | 'Dark': { 101 | 'theme-bg-primary': '#000000', 102 | 'theme-bg-secondary': '#333333', 103 | 'theme-bg-tertiary': '#404040', 104 | 'theme-bg-highlight': '#555', // '#770' for yellowish 105 | 106 | 'theme-text-primary': '#eeeeee', 107 | 'theme-text-accent': '#cccc00', 108 | 'theme-text-link': '#8888ff', 109 | 110 | 'theme-trim': '#eeeeee', 111 | 'theme-trim-accent': '#cccc00', 112 | 113 | 'theme-text-cursor': colors.black, 114 | 'theme-bg-cursor': '#ccc', 115 | }, 116 | 'Solarized Light': { 117 | 'theme-bg-primary': colors.solarized.base3, 118 | 'theme-bg-secondary': colors.solarized.base2, 119 | 'theme-bg-tertiary': '#e6dede', 120 | 'theme-bg-highlight': '#e2ebf3', 121 | 122 | 'theme-text-primary': colors.solarized.base00, 123 | 'theme-text-accent': colors.solarized.magenta, 124 | 'theme-text-link': colors.solarized.blue, 125 | 126 | 'theme-trim': colors.solarized.base01, 127 | 'theme-trim-accent': colors.solarized.magenta, 128 | 129 | 'theme-text-cursor': colors.solarized.base3, 130 | 'theme-bg-cursor': colors.solarized.cyan, 131 | }, 132 | 'Solarized Dark': { 133 | 'theme-bg-primary': colors.solarized.base02, 134 | 'theme-bg-secondary': colors.solarized.base03, 135 | 'theme-bg-tertiary': '#3C4446', 136 | 'theme-bg-highlight': '#384e55', 137 | 138 | 'theme-text-primary': colors.solarized.base1, 139 | 'theme-text-accent': colors.solarized.magenta, 140 | 'theme-text-link': colors.solarized.violet, 141 | 142 | 'theme-trim': colors.solarized.base1, 143 | 'theme-trim-accent': colors.solarized.yellow, 144 | 145 | 'theme-text-cursor': colors.solarized.base02, 146 | 'theme-bg-cursor': colors.solarized.base1, 147 | }, 148 | }; 149 | 150 | export const defaultTheme = themes.Default; 151 | -------------------------------------------------------------------------------- /src/assets/ts/keyBindings.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | import * as errors from '../../shared/utils/errors'; 4 | import EventEmitter from './utils/eventEmitter'; 5 | import logger from '../../shared/utils/logger'; 6 | import { KeyDefinitions, Motion, Action, motionKey } from './keyDefinitions'; 7 | import KeyMappings, { HotkeyMapping } from './keyMappings'; 8 | import { Key } from './types'; 9 | 10 | // one of these per mode 11 | export class KeyBindingsTree { 12 | private children: {[key: string]: KeyBindingsTree | Motion | Action}; 13 | public hasMotion: boolean; 14 | public hasAction: boolean; 15 | private definitions: KeyDefinitions; 16 | private lastAdded: [string, Motion | Action] | null; 17 | private path: Array; // sequence of keys to get here 18 | 19 | constructor(path: Array, definitions: KeyDefinitions) { 20 | this.children = {}; 21 | this.hasMotion = false; 22 | this.hasAction = false; 23 | this.lastAdded = null; 24 | this.path = path; 25 | this.definitions = definitions; 26 | } 27 | 28 | public print(tabs = 0) { 29 | const prefix = ' '.repeat(tabs * 2); 30 | Object.keys(this.children).forEach((key) => { 31 | const child: any = this.getKey(key); 32 | if (child == null) { return; } // this shouldn't happen 33 | if (child instanceof KeyBindingsTree) { 34 | console.log(prefix, key, ':'); // tslint:disable-line:no-console 35 | child.print(tabs + 1); 36 | } else { 37 | console.log(prefix, key, ':', child.name); // tslint:disable-line:no-console 38 | } 39 | }); 40 | } 41 | 42 | public getKey(key: Key): KeyBindingsTree | Motion | Action | null { 43 | return this.children[key] || null; 44 | } 45 | 46 | protected addMappingHelper( 47 | keys: Array, index: number, name: string, 48 | mapped: Action | Motion 49 | ) { 50 | const key = keys[index]; 51 | 52 | let child = this.children[key]; 53 | if (child instanceof Motion) { 54 | throw new Error( 55 | `Multiple registrations for key sequence ${keys.slice(0, index + 1)}: 56 | ${name} and ${child.name}` 57 | ); 58 | } 59 | if (child instanceof Action) { 60 | throw new Error( 61 | `Multiple registrations for key sequence ${keys.slice(0, index + 1)}: 62 | ${name} and ${child.name}` 63 | ); 64 | } 65 | 66 | if (key === motionKey) { 67 | if (mapped instanceof Motion) { 68 | throw new Error('Motions cannot accept motions in bindings'); 69 | } else { 70 | if (!mapped.metadata.acceptsMotion) { 71 | throw new Error(`Action ${mapped.name} does not accept motions`); 72 | } 73 | } 74 | } 75 | 76 | if (index === keys.length - 1) { 77 | if (child != null) { 78 | throw new errors.GenericError( 79 | `Multiple registrations for key sequence ${keys.slice(0, index)}: 80 | ${name} and ${child.lastAdded && child.lastAdded[0]}` 81 | ); 82 | } 83 | 84 | this.children[key] = mapped; 85 | } else { 86 | // need new variable for type safety 87 | let childBindings: KeyBindingsTree; 88 | if (child == null) { 89 | childBindings = new KeyBindingsTree(this.path.concat([key]), this.definitions); 90 | this.children[key] = childBindings; 91 | } else { 92 | childBindings = child; 93 | } 94 | childBindings.addMappingHelper(keys, index + 1, name, mapped); 95 | } 96 | 97 | if (mapped instanceof Motion) { 98 | this.hasMotion = true; 99 | } else { 100 | this.hasAction = true; 101 | } 102 | this.lastAdded = [name, mapped]; 103 | } 104 | 105 | public addMapping(name: string, keys: Array) { 106 | const mapped = this.definitions.getRegistration(name); 107 | if (mapped == null) { 108 | // Ignore mappings if there's no definition 109 | // This can happen if e.g. we saved keymappings but then turned off a plugin 110 | logger.warn(`Attempted to register hotkey for ${name}, but no definition found`); 111 | return; 112 | } 113 | 114 | this.addMappingHelper(keys, 0, name, mapped); 115 | } 116 | } 117 | 118 | function makeBindings(definitions: KeyDefinitions, mappings: KeyMappings) { 119 | const allBindings: {[mode: string]: KeyBindingsTree} = {}; 120 | _.map(mappings.mappings, (mapping: HotkeyMapping, mode: string) => { 121 | const bindings = new KeyBindingsTree([], definitions); 122 | _.map(mapping, (keySequences: Array>, command: string) => { 123 | keySequences.forEach((sequence) => { 124 | bindings.addMapping(command, sequence); 125 | }); 126 | }); 127 | allBindings[mode] = bindings; 128 | }); 129 | return allBindings; 130 | } 131 | 132 | export default class KeyBindings extends EventEmitter { 133 | public definitions: KeyDefinitions; 134 | public bindings: {[mode: string]: KeyBindingsTree} = {}; 135 | public mappings!: KeyMappings; 136 | 137 | private update: () => void; 138 | 139 | constructor(definitions: KeyDefinitions, mappings: KeyMappings) { 140 | super(); 141 | this.definitions = definitions; 142 | 143 | this.update = () => { 144 | this.bindings = makeBindings(this.definitions, this.mappings); 145 | this.emit('update'); 146 | }; 147 | 148 | this.definitions.on('update', this.update); 149 | this.setMappings(mappings); 150 | } 151 | 152 | public setMappings(mappings: KeyMappings) { 153 | if (this.mappings) { 154 | this.mappings.off('update', this.update); 155 | } 156 | this.mappings = mappings; 157 | this.mappings.on('update', this.update); 158 | this.update(); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/assets/ts/keyDefinitions.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from './utils/eventEmitter'; 2 | import Session from './session'; 3 | import KeyHandler, { KeyStream } from './keyHandler'; 4 | import Cursor from './cursor'; 5 | import Path from './path'; 6 | import { ModeId, CursorOptions } from './types'; 7 | 8 | // NOTE: this is a special key, which accepts any motion keys. 9 | // It causes definition functions to take an extra cursor argument. 10 | // For more info/context, see keyBindings.ts and definitions of CHANGE/DELETE/YANK 11 | export const motionKey = ''; 12 | 13 | type MotionName = string; 14 | export type MotionFn = (cursor: Cursor, options: CursorOptions) => Promise; 15 | // a motion is a function taking a cursor and moving it 16 | export type MotionDefinition = ( 17 | context: ActionContext 18 | ) => Promise; 19 | 20 | export class Motion { 21 | public name: MotionName; 22 | public description: string; 23 | public definition: MotionDefinition; 24 | constructor(name: MotionName, description: string, definition: MotionDefinition) { 25 | this.name = name; 26 | this.description = description; 27 | this.definition = definition; 28 | } 29 | } 30 | 31 | export enum SequenceAction { 32 | DROP, 33 | DROP_ALL, 34 | KEEP, 35 | }; 36 | export type ActionContext = { 37 | mode: ModeId; 38 | session: Session; 39 | repeat: number; 40 | keyStream: KeyStream; 41 | keyHandler: KeyHandler; 42 | motion?: MotionFn; 43 | visual_line?: { 44 | start_i: number, 45 | end_i: number, 46 | start: Path, 47 | end: Path, 48 | selected: Array, 49 | parent: Path, 50 | num_rows: number, 51 | }, 52 | }; 53 | export type ActionDefinition = (context: ActionContext) => Promise; 54 | export type ActionName = string; 55 | export type ActionMetadata = { 56 | sequence?: SequenceAction; 57 | acceptsMotion?: boolean; 58 | }; 59 | export class Action { 60 | public name: ActionName; 61 | public description: string; 62 | public definition: ActionDefinition; 63 | public metadata: ActionMetadata; 64 | constructor(name: MotionName, description: string, definition: ActionDefinition, metadata?: ActionMetadata) { 65 | this.name = name; 66 | this.description = description; 67 | this.definition = definition; 68 | this.metadata = metadata || {}; 69 | } 70 | } 71 | 72 | // NOTE: doesn't compose metadata 73 | export function composeActions( 74 | name: ActionName, description: string, 75 | parts: Array 76 | ): Action { 77 | const definition = async function(context: ActionContext) { 78 | let i = 0; 79 | while (i < parts.length) { 80 | const part = parts[i]; 81 | if (part instanceof Motion) { 82 | throw new Error('Cannot compose with motion without an action that accepts it'); 83 | } 84 | 85 | if (part.metadata.acceptsMotion) { 86 | i++; 87 | const motion = parts[i]; 88 | if (motion instanceof Action) { 89 | throw new Error( 90 | `Error while composing action ${name}: 91 | Action accepting motion was not followed by motion` 92 | ); 93 | } 94 | context.motion = await motion.definition.call(motion.definition, context); 95 | } 96 | await part.definition(context); 97 | i++; 98 | } 99 | }; 100 | return new Action(name, description, definition); 101 | } 102 | 103 | export class KeyDefinitions extends EventEmitter { 104 | private registry: {[name: string]: Action | Motion}; 105 | 106 | constructor() { 107 | super(); 108 | this.registry = {}; 109 | } 110 | 111 | public getRegistration(name: string): Action | Motion | null { 112 | return this.registry[name] || null; 113 | } 114 | 115 | public registerMotion(motion: Motion) { 116 | if (this.registry[motion.name]) { 117 | throw new Error(`${motion.name} already defined!`); 118 | } 119 | this.registry[motion.name] = motion; 120 | this.emit('update'); 121 | } 122 | 123 | public deregisterMotion(motionName: MotionName) { 124 | const motion = this.registry[motionName]; 125 | if (!motion) { 126 | throw new Error(`Tried to deregister motion ${motionName}, but it was not registered!`); 127 | } 128 | if (motion instanceof Action) { 129 | throw new Error(`Tried to deregister motion ${motionName}, but it was registered as an action!`); 130 | } 131 | delete this.registry[motionName]; 132 | this.emit('update'); 133 | } 134 | 135 | public registerComposedAction( 136 | name: ActionName, description: string, 137 | part_names: Array 138 | ) { 139 | this.registerAction(composeActions( 140 | name, description, 141 | part_names.map((part_name) => { 142 | const part = this.getRegistration(part_name); 143 | if (part == null) { 144 | throw new Error( 145 | `Could not compose action ${name} with unregistered part ${part_name}` 146 | ); 147 | } 148 | return part; 149 | }) 150 | )); 151 | } 152 | 153 | public registerAction(action: Action) { 154 | if (this.registry[action.name]) { 155 | throw new Error(`${action.name} already defined!`); 156 | } 157 | this.registry[action.name] = action; 158 | this.emit('update'); 159 | } 160 | 161 | public deregisterAction(actionName: ActionName) { 162 | const action = this.registry[actionName]; 163 | if (!action) { 164 | throw new Error(`Tried to deregister action ${actionName}, but it was not registered!`); 165 | } 166 | if (action instanceof Motion) { 167 | throw new Error(`Tried to deregister action ${actionName}, but it was registered as a motion!`); 168 | } 169 | delete this.registry[actionName]; 170 | this.emit('update'); 171 | } 172 | } 173 | 174 | export default new KeyDefinitions(); 175 | -------------------------------------------------------------------------------- /test/tests/tags.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | import * as Tags from '../../src/plugins/tags'; 4 | import '../../src/assets/ts/plugins'; 5 | import { Row } from '../../src/assets/ts/types'; 6 | 7 | // Testing 8 | class TagsTestCase extends TestCase { 9 | public expectTags(expected: {[key: string]: Row[]}) { 10 | return this._chain(async () => { 11 | const tagsApi: Tags.TagsPlugin = this.pluginManager.getInfo(Tags.pluginName).value; 12 | const tags_to_rows: {[key: string]: Row[]} = await tagsApi._getTagsToRows(); 13 | this._expectDeepEqual(tags_to_rows, expected, 'Inconsistent rows_to_tags'); 14 | }); 15 | } 16 | } 17 | 18 | describe('tags', function() { 19 | it('works in basic cases', async function() { 20 | let t = new TagsTestCase([ 21 | 'a line', 22 | 'another line', 23 | ], {plugins: [Tags.pluginName]}); 24 | t.expectTags({}); 25 | t.sendKeys('#tagtest'); 26 | t.sendKey('enter'); 27 | t.expectTags({'tagtest': [1]}); 28 | t.expect([ 29 | { text: 'a line', plugins: {tags: ['tagtest']} }, 30 | 'another line', 31 | ]); 32 | 33 | t.sendKeys('j#test2'); 34 | t.sendKey('enter'); 35 | t.expectTags({'tagtest': [1], 'test2': [2]}); 36 | t.expect([ 37 | { text: 'a line', plugins: {tags: ['tagtest']} }, 38 | { text: 'another line', plugins: {tags: ['test2']} }, 39 | ]); 40 | 41 | t.sendKeys('#test3'); 42 | t.sendKey('enter'); 43 | t.expectTags({'tagtest': [1], 'test2': [2], 'test3': [2]}); 44 | t.expect([ 45 | { text: 'a line', plugins: {tags: ['tagtest']} }, 46 | { text: 'another line', plugins: {tags: ['test2', 'test3']} }, 47 | ]); 48 | 49 | // duplicate tags ignored 50 | t.sendKeys('#test3'); 51 | t.sendKey('enter'); 52 | t.expectTags({'tagtest': [1], 'test2': [2], 'test3': [2]}); 53 | t.expect([ 54 | { text: 'a line', plugins: {tags: ['tagtest']} }, 55 | { text: 'another line', plugins: {tags: ['test2', 'test3']} }, 56 | ]); 57 | 58 | // remove tags 59 | t.sendKeys('d#1'); 60 | t.expectTags({'tagtest': [1], 'test3': [2]}); 61 | t.expect([ 62 | { text: 'a line', plugins: {tags: ['tagtest']} }, 63 | { text: 'another line', plugins: {tags: ['test3']} }, 64 | ]); 65 | 66 | t.sendKeys('kd#'); 67 | t.expectTags({'test3': [2]}); 68 | t.expect([ 69 | 'a line', 70 | { text: 'another line', plugins: {tags: ['test3']} }, 71 | ]); 72 | 73 | await t.done(); 74 | }); 75 | it('can be searched for', async function() { 76 | let t = new TagsTestCase([ 77 | { text: 'hi', plugins: {tags: ['tag', 'test3']} }, 78 | { text: 'dog', plugins: {tags: ['test2']} }, 79 | ], {plugins: [Tags.pluginName]}); 80 | t.sendKeys('-test3'); 81 | t.sendKey('enter'); 82 | t.sendKeys('x'); 83 | t.expect([ 84 | { text: 'i', plugins: {tags: ['tag', 'test3']} }, 85 | { text: 'dog', plugins: {tags: ['test2']} }, 86 | ]); 87 | 88 | t.sendKeys('-ta'); 89 | t.sendKey('enter'); 90 | t.sendKeys('x'); 91 | t.expect([ 92 | { text: '', plugins: {tags: ['tag', 'test3']} }, 93 | { text: 'dog', plugins: {tags: ['test2']} }, 94 | ]); 95 | 96 | t.sendKeys('-test2'); 97 | t.sendKey('enter'); 98 | t.sendKeys('x'); 99 | t.expect([ 100 | { text: '', plugins: {tags: ['tag', 'test3']} }, 101 | { text: 'og', plugins: {tags: ['test2']} }, 102 | ]); 103 | await t.done(); 104 | }); 105 | it('can repeat', async function() { 106 | let t = new TagsTestCase([ 107 | { text: 'hi', plugins: {tags: ['tag', 'test3']} }, 108 | { text: 'dog', plugins: {tags: ['test2']} }, 109 | ], {plugins: [Tags.pluginName]}); 110 | t.sendKeys('d#1.j'); 111 | t.expectTags({'test2': [2]}); 112 | t.expect([ 113 | 'hi', 114 | { text: 'dog', plugins: {tags: ['test2']} }, 115 | ]); 116 | await t.done(); 117 | }); 118 | it('can undo', async function() { 119 | let t = new TagsTestCase([ 120 | 'a line', 121 | 'another line', 122 | ], {plugins: [Tags.pluginName]}); 123 | t.expectTags({}); 124 | t.sendKeys('#tagtest'); 125 | t.sendKey('enter'); 126 | t.expectTags({'tagtest': [1]}); 127 | t.expect([ 128 | { text: 'a line', plugins: {tags: ['tagtest']} }, 129 | 'another line', 130 | ]); 131 | 132 | t.sendKey('u'); 133 | t.expectTags({}); 134 | t.expect([ 135 | 'a line', 136 | 'another line', 137 | ]); 138 | 139 | t.sendKey('ctrl+r'); 140 | t.expectTags({'tagtest': [1]}); 141 | t.expect([ 142 | { text: 'a line', plugins: {tags: ['tagtest']} }, 143 | 'another line', 144 | ]); 145 | 146 | t.sendKeys('d#'); 147 | t.expectTags({}); 148 | t.expect([ 149 | 'a line', 150 | 'another line', 151 | ]); 152 | 153 | t.sendKey('u'); 154 | t.expectTags({'tagtest': [1]}); 155 | t.expect([ 156 | { text: 'a line', plugins: {tags: ['tagtest']} }, 157 | 'another line', 158 | ]); 159 | 160 | t.sendKey('ctrl+r'); 161 | t.expectTags({}); 162 | t.expect([ 163 | 'a line', 164 | 'another line', 165 | ]); 166 | await t.done(); 167 | }); 168 | it('can be disabled', async function() { 169 | let t = new TagsTestCase([ 170 | { text: 'hi', plugins: {tags: ['tag', 'test3']} }, 171 | { text: 'dog', plugins: {tags: ['test2']} }, 172 | ], {plugins: [Tags.pluginName]}); 173 | 174 | t.disablePlugin(Tags.pluginName); 175 | t.expect([ 176 | 'hi', 177 | 'dog', 178 | ]); 179 | 180 | // RE-ENABLE WORKS 181 | t.enablePlugin(Tags.pluginName); 182 | t.expect([ 183 | { text: 'hi', plugins: {tags: ['tag', 'test3']} }, 184 | { text: 'dog', plugins: {tags: ['test2']} }, 185 | ]); 186 | await t.done(); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /test/tests/enter.ts: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | import TestCase from '../testcase'; 3 | import { RegisterTypes } from '../../src/assets/ts/register'; 4 | 5 | describe('enter', function() { 6 | it('works in basic case', async function() { 7 | let t = new TestCase(['']); 8 | t.sendKey('i'); 9 | t.sendKeys('hello'); 10 | t.sendKey('enter'); 11 | t.sendKeys('world'); 12 | t.sendKey('esc'); 13 | t.expect(['hello', 'world']); 14 | await t.done(); 15 | }); 16 | 17 | it('works with tabbing', async function() { 18 | let t = new TestCase(['']); 19 | t.sendKey('i'); 20 | t.sendKeys('hello'); 21 | t.sendKey('enter'); 22 | t.sendKeys('world'); 23 | t.sendKey('tab'); 24 | t.sendKey('esc'); 25 | t.expect([ 26 | { text: 'hello', children: [ 27 | 'world', 28 | ] }, 29 | ]); 30 | t.sendKey('u'); 31 | t.sendKey('u'); 32 | t.sendKey('u'); 33 | t.sendKey('u'); 34 | t.expect(['']); 35 | t.sendKey('ctrl+r'); 36 | t.sendKey('ctrl+r'); 37 | t.sendKey('ctrl+r'); 38 | t.sendKey('ctrl+r'); 39 | t.expect([ 40 | { text: 'hello', children: [ 41 | 'world', 42 | ] }, 43 | ]); 44 | t.sendKeys('a of'); 45 | t.sendKey('shift+tab'); 46 | t.sendKeys(' goo'); 47 | t.sendKey('esc'); 48 | t.expect(['hello', 'world of goo']); 49 | await t.done(); 50 | }); 51 | 52 | it('does not mess up registers', async function() { 53 | let t = new TestCase(['']); 54 | t.setRegister({type: RegisterTypes.CHARS, saved: ['unchanged']}); 55 | t.sendKey('i'); 56 | t.sendKeys('helloworld'); 57 | for (let i = 0; i < 5; i++) { 58 | t.sendKey('left'); 59 | } 60 | t.sendKey('enter'); 61 | t.sendKey('esc'); 62 | t.expect(['hello', 'world']); 63 | t.expectRegister({type: RegisterTypes.CHARS, saved: ['unchanged']}); 64 | await t.done(); 65 | }); 66 | 67 | it('works at the end of a line', async function() { 68 | let t = new TestCase(['']); 69 | t.sendKey('i'); 70 | t.sendKeys('hello'); 71 | t.sendKey('enter'); 72 | t.sendKey('esc'); 73 | t.expect(['hello', '']); 74 | t.sendKey('u'); 75 | t.expect(['hello']); 76 | t.sendKey('u'); 77 | t.expect(['']); 78 | await t.done(); 79 | }); 80 | 81 | it('works at the beginning of a line', async function() { 82 | let t = new TestCase(['']); 83 | t.sendKey('i'); 84 | t.sendKey('enter'); 85 | t.sendKeys('hello'); 86 | t.sendKey('esc'); 87 | t.expect(['', 'hello']); 88 | t.sendKey('u'); 89 | t.expect(['', '']); 90 | t.sendKey('u'); 91 | t.expect(['']); 92 | await t.done(); 93 | }); 94 | 95 | it('works on lines with children', async function() { 96 | let t = new TestCase(['']); 97 | t.sendKey('i'); 98 | t.sendKeys('helloworld'); 99 | t.sendKey('enter'); 100 | t.sendKeys('of goo'); 101 | t.sendKey('esc'); 102 | t.sendKey('tab'); 103 | t.expect([ 104 | { text: 'helloworld', children: [ 105 | 'of goo', 106 | ] }, 107 | ]); 108 | t.sendKey('up'); 109 | t.sendKey('I'); 110 | for (let i = 0; i < 5; i++) { 111 | t.sendKey('right'); 112 | } 113 | t.sendKey('enter'); 114 | t.sendKey('esc'); 115 | t.expect([ 116 | 'hello', 117 | { text: 'world', children: [ 118 | 'of goo', 119 | ] }, 120 | ]); 121 | await t.done(); 122 | }); 123 | 124 | it('preserves identity at the end of a line', async function() { 125 | let t = new TestCase([ 126 | { text: 'hey', id: 1 }, 127 | 'you', 128 | { clone: 1 }, 129 | ]); 130 | t.sendKey('A'); 131 | t.sendKey('enter'); 132 | t.sendKeys('i like'); 133 | t.expect([ 134 | { text: 'hey', id: 1 }, 135 | 'i like', 136 | 'you', 137 | { clone: 1 }, 138 | ]); 139 | await t.done(); 140 | }); 141 | 142 | it('doesnt preserve identity in the middle of a line', async function() { 143 | let t = new TestCase([ 144 | { text: 'hey', id: 1 }, 145 | 'you', 146 | { clone: 1 }, 147 | ]); 148 | t.sendKey('$'); 149 | t.sendKey('i'); 150 | t.sendKey('enter'); 151 | t.sendKeys('ya'); 152 | t.expect([ 153 | 'he', 154 | { text: 'yay', id: 1 }, 155 | 'you', 156 | { clone: 1 }, 157 | ]); 158 | await t.done(); 159 | }); 160 | 161 | it('handles case with children at end of line', async function() { 162 | let t = new TestCase([ 163 | { text: 'hey', id: 1, children: [ 164 | 'like', 165 | ] }, 166 | 'you', 167 | { clone: 1 }, 168 | ]); 169 | t.sendKey('A'); 170 | t.sendKey('enter'); 171 | t.sendKeys('i'); 172 | t.expect([ 173 | { text: 'hey', id: 1, children: [ 174 | 'i', 175 | 'like', 176 | ] }, 177 | 'you', 178 | { clone: 1 }, 179 | ]); 180 | await t.done(); 181 | }); 182 | 183 | it('handles collapsed case at end of line', async function() { 184 | let t = new TestCase([ 185 | { text: 'hey', id: 1, collapsed: true, children: [ 186 | 'like', 187 | ] }, 188 | 'you', 189 | { clone: 1 }, 190 | ]); 191 | t.sendKey('A'); 192 | t.sendKey('enter'); 193 | t.sendKeys('i'); 194 | t.expect([ 195 | { text: 'hey', id: 1, collapsed: true, children: [ 196 | 'like', 197 | ] }, 198 | 'i', 199 | 'you', 200 | { clone: 1 }, 201 | ]); 202 | await t.done(); 203 | }); 204 | 205 | it('when using o on a blank bullet, collapses parent', async function() { 206 | let t = new TestCase([ 207 | { text: 'hey', children: [ 208 | 'you', 209 | ] }, 210 | ]); 211 | t.sendKey('j'); 212 | t.sendKey('enter'); 213 | t.sendKeys('ook'); 214 | t.sendKey('esc'); 215 | t.expect([ 216 | { text: 'hey', children: [ 217 | { text: 'you', collapsed: true, children: [ 218 | 'ok', 219 | ] }, 220 | ] }, 221 | ]); 222 | t.sendKey('u'); 223 | t.expect([ 224 | { text: 'hey', children: [ 225 | 'you', 226 | ] }, 227 | ]); 228 | await t.done(); 229 | }); 230 | }); 231 | --------------------------------------------------------------------------------