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