playroom 0.28.2 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@ jobs:
16
16
  uses: actions/checkout@v3
17
17
 
18
18
  - name: Set up pnpm
19
- uses: pnpm/action-setup@v2.2.2
19
+ uses: pnpm/action-setup@v2.2.4
20
20
 
21
21
  - name: Set up Node.js
22
22
  uses: actions/setup-node@v3
@@ -19,7 +19,7 @@ jobs:
19
19
  fetch-depth: 0
20
20
 
21
21
  - name: Set up pnpm
22
- uses: pnpm/action-setup@v2.2.2
22
+ uses: pnpm/action-setup@v2.2.4
23
23
 
24
24
  - name: Set up Node.js
25
25
  uses: actions/setup-node@v3
@@ -17,7 +17,7 @@ jobs:
17
17
  token: ${{ secrets.GITHUB_TOKEN }}
18
18
 
19
19
  - name: Set up pnpm
20
- uses: pnpm/action-setup@v2.2.2
20
+ uses: pnpm/action-setup@v2.2.4
21
21
 
22
22
  - name: Set up Node.js
23
23
  uses: actions/setup-node@v3
@@ -4,8 +4,11 @@ on: [push, pull_request]
4
4
 
5
5
  jobs:
6
6
  test:
7
+ strategy:
8
+ matrix:
9
+ os: [ubuntu-latest, macos-latest]
7
10
  name: Lint & Test
8
- runs-on: ubuntu-latest
11
+ runs-on: ${{ matrix.os }}
9
12
  env:
10
13
  CI: true
11
14
  steps:
@@ -13,7 +16,7 @@ jobs:
13
16
  uses: actions/checkout@v3
14
17
 
15
18
  - name: Set up pnpm
16
- uses: pnpm/action-setup@v2.2.2
19
+ uses: pnpm/action-setup@v2.2.4
17
20
 
18
21
  - name: Set up Node.js
19
22
  uses: actions/setup-node@v3
@@ -35,6 +38,7 @@ jobs:
35
38
  ${{ runner.os }}-pnpm-store-
36
39
 
37
40
  - name: Cache Cypress binary
41
+ id: cypress-cache
38
42
  uses: actions/cache@v3
39
43
  with:
40
44
  path: ~/.cache/Cypress
@@ -43,7 +47,9 @@ jobs:
43
47
  cypress-${{ runner.os }}-cypress-
44
48
 
45
49
  - name: Install Dependencies
46
- run: pnpm i
50
+ run: |
51
+ pnpm install
52
+ pnpm exec cypress install
47
53
 
48
54
  - name: Lint
49
55
  run: pnpm lint
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # playroom
2
2
 
3
+ ## 0.30.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b247e88: Adds multi-cursor support.
8
+
9
+ The keyboard shortcuts added in the previous version (swap/duplicate line up/down) now support multiple cursors being on screen.
10
+ "Select next occurrence" and "add cursor up/down" have also been implemented.
11
+
12
+ | Keybinding | Action |
13
+ | -------------------------------------------------------------- | ----------------------- |
14
+ | <kbd><kbd>Alt</kbd> + <kbd>Up</kbd></kbd> | Swap line up |
15
+ | <kbd><kbd>Alt</kbd> + <kbd>Down</kbd></kbd> | Swap line down |
16
+ | <kbd><kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>Up</kbd></kbd> | Duplicate line up |
17
+ | <kbd><kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>Down</kbd></kbd> | Duplicate line down |
18
+ | <kbd><kbd>Cmd</kbd> + <kbd>Alt</kbd> + <kbd>Up</kbd></kbd> | Add cursor to prev line |
19
+ | <kbd><kbd>Cmd</kbd> + <kbd>Alt</kbd> + <kbd>Down</kbd></kbd> | Add cursor to next line |
20
+ | <kbd><kbd>Cmd</kbd> + <kbd>D</kbd></kbd> | Select next occurrence |
21
+
22
+ ## 0.29.0
23
+
24
+ ### Minor Changes
25
+
26
+ - 9fc8c0d: Adds VSCode-style keybindings for move line up/down and copy line up/down.
27
+ Works for selections as well as single lines.
28
+
29
+ See the VSCode keyboard shortcut reference for details ([Mac]/[Windows]).
30
+
31
+ [mac]: https://code.visualstudio.com/shortcuts/keyboard-shortcuts-macos.pdf
32
+ [windows]: https://code.visualstudio.com/shortcuts/keyboard-shortcuts-windows.pdf
33
+
3
34
  ## 0.28.2
4
35
 
5
36
  ### Patch Changes
@@ -0,0 +1,185 @@
1
+ import dedent from 'dedent';
2
+ import {
3
+ typeCode,
4
+ assertCodePaneContains,
5
+ loadPlayroom,
6
+ selectNextWords,
7
+ selectLines,
8
+ selectNextCharacters,
9
+ isMac,
10
+ } from '../support/utils';
11
+
12
+ const cmdPlus = (keyCombo) => {
13
+ const platformSpecificKey = isMac() ? 'cmd' : 'ctrl';
14
+ return `${platformSpecificKey}+${keyCombo}`;
15
+ };
16
+
17
+ const moveToStart = isMac() ? '{cmd+upArrow}' : '{ctrl+home}';
18
+
19
+ describe('Keymaps', () => {
20
+ beforeEach(() => {
21
+ loadPlayroom();
22
+
23
+ // The last closing div is automatically inserted by autotag
24
+ typeCode(dedent`
25
+ <div>First line</div>
26
+ <div>Second line</div>
27
+ <div>Third line
28
+ `);
29
+
30
+ // Reset the cursor to a reliable position at the beginning
31
+ typeCode(moveToStart);
32
+ });
33
+
34
+ describe('swapLine', () => {
35
+ it('should swap single lines up and down without a selection', () => {
36
+ // Move the first line down
37
+ typeCode('{alt+downArrow}');
38
+ assertCodePaneContains(dedent`
39
+ <div>Second line</div>
40
+ <div>First line</div>
41
+ <div>Third line</div>
42
+ `);
43
+
44
+ // Move the line back up
45
+ typeCode('{alt+upArrow}');
46
+ assertCodePaneContains(dedent`
47
+ <div>First line</div>
48
+ <div>Second line</div>
49
+ <div>Third line</div>
50
+ `);
51
+
52
+ // Move the bottom line to the top
53
+ typeCode('{downArrow}{downArrow}{alt+upArrow}{alt+upArrow}');
54
+ assertCodePaneContains(dedent`
55
+ <div>Third line</div>
56
+ <div>First line</div>
57
+ <div>Second line</div>
58
+ `);
59
+ });
60
+
61
+ it('should swap single lines up and down with a selection', () => {
62
+ typeCode('{rightArrow}');
63
+ selectNextWords(1);
64
+
65
+ // The q checks that the selection is maintained after a line shift
66
+ typeCode('{alt+downArrow}q');
67
+
68
+ assertCodePaneContains(dedent`
69
+ <div>Second line</div>
70
+ <q>First line</div>
71
+ <div>Third line</div>
72
+ `);
73
+ });
74
+
75
+ it('should swap multiple lines up and down with a selection', () => {
76
+ typeCode('{rightArrow}');
77
+ selectLines(1);
78
+ selectNextCharacters(3);
79
+
80
+ typeCode('{alt+downArrow}');
81
+
82
+ assertCodePaneContains(dedent`
83
+ <div>Third line</div>
84
+ <div>First line</div>
85
+ <div>Second line</div>
86
+ `);
87
+
88
+ // Check that the selection is maintained
89
+ typeCode('a');
90
+
91
+ assertCodePaneContains(dedent`
92
+ <div>Third line</div>
93
+ <a>Second line</div>
94
+ `);
95
+ });
96
+ });
97
+
98
+ describe('duplicateLine', () => {
99
+ it('should duplicate single lines up and down', () => {
100
+ // Duplicate the first line down
101
+ typeCode('{shift+alt+downArrow}a');
102
+ assertCodePaneContains(dedent`
103
+ <div>First line</div>
104
+ a<div>First line</div>
105
+ <div>Second line</div>
106
+ <div>Third line</div>
107
+ `);
108
+
109
+ // Duplicate the last line up
110
+ typeCode('{downArrow}{downArrow}{leftArrow}');
111
+ typeCode('{shift+alt+upArrow}a');
112
+ assertCodePaneContains(dedent`
113
+ <div>First line</div>
114
+ a<div>First line</div>
115
+ <div>Second line</div>
116
+ a<div>Third line</div>
117
+ <div>Third line</div>
118
+ `);
119
+ });
120
+ });
121
+
122
+ describe('selectNextOccurrence', () => {
123
+ const cmdPlusD = cmdPlus('D');
124
+
125
+ it('should select the current word on one use', () => {
126
+ typeCode(`{rightArrow}{${cmdPlusD}}`);
127
+
128
+ // Overwrite to check the selection
129
+ typeCode('a');
130
+
131
+ assertCodePaneContains(dedent`
132
+ <a>First line</div>
133
+ <div>Second line</div>
134
+ <div>Third line</div>
135
+ `);
136
+ });
137
+
138
+ it('should select the next instance of the word on two uses', () => {
139
+ typeCode(`{rightArrow}{${cmdPlusD}}{${cmdPlusD}}`);
140
+
141
+ // Overwrite to check the selection
142
+ typeCode('a');
143
+
144
+ assertCodePaneContains(dedent`
145
+ <a>First line</a>
146
+ <div>Second line</div>
147
+ <div>Third line</div>
148
+ `);
149
+ });
150
+
151
+ it('should select the all instances of the word when spamming the key', () => {
152
+ typeCode(`{rightArrow}${`{${cmdPlusD}}`.repeat(20)}`);
153
+
154
+ // Overwrite to check the selection and that multiple cursors were created
155
+ typeCode('span');
156
+
157
+ assertCodePaneContains(dedent`
158
+ <span>First line</span>
159
+ <span>Second line</span>
160
+ <span>Third line</span>
161
+ `);
162
+ });
163
+ });
164
+
165
+ describe('addCursor', () => {
166
+ it('should add a cursor on the next line', () => {
167
+ typeCode(`{${cmdPlus('alt+downArrow')}}a`);
168
+ assertCodePaneContains(dedent`
169
+ a<div>First line</div>
170
+ a<div>Second line</div>
171
+ <div>Third line</div>
172
+ `);
173
+ });
174
+
175
+ it('should add a cursor on the previous line', () => {
176
+ typeCode('{downArrow}{downArrow}');
177
+ typeCode(`{${cmdPlus('alt+upArrow')}}a`);
178
+ assertCodePaneContains(dedent`
179
+ <div>First line</div>
180
+ a<div>Second line</div>
181
+ a<div>Third line</div>
182
+ `);
183
+ });
184
+ });
185
+ });
@@ -11,6 +11,7 @@ import {
11
11
  assertSnippetsListIsVisible,
12
12
  mouseOverSnippet,
13
13
  loadPlayroom,
14
+ isMac,
14
15
  } from '../support/utils';
15
16
 
16
17
  describe('Snippets', () => {
@@ -67,12 +68,12 @@ describe('Snippets', () => {
67
68
 
68
69
  it('driven with keyboard', () => {
69
70
  // Open and format for insertion point
70
- typeCode(`${navigator.platform.match('Mac') ? '{cmd}' : '{ctrl}'}k`);
71
+ typeCode(`${isMac() ? '{cmd}' : '{ctrl}'}k`);
71
72
  assertSnippetsListIsVisible();
72
73
  assertCodePaneLineCount(8);
73
74
  filterSnippets('{esc}');
74
75
  assertCodePaneLineCount(1);
75
- typeCode(`${navigator.platform.match('Mac') ? '{cmd}' : '{ctrl}'}k`);
76
+ typeCode(`${isMac() ? '{cmd}' : '{ctrl}'}k`);
76
77
  assertSnippetsListIsVisible();
77
78
  assertCodePaneLineCount(8);
78
79
 
@@ -91,7 +92,7 @@ describe('Snippets', () => {
91
92
  assertCodePaneLineCount(1);
92
93
 
93
94
  // Re-open and persist
94
- typeCode(`${navigator.platform.match('Mac') ? '{cmd}' : '{ctrl}'}k`);
95
+ typeCode(`${isMac() ? '{cmd}' : '{ctrl}'}k`);
95
96
  filterSnippets('{downarrow}{downarrow}{downarrow}{downarrow}{enter}');
96
97
  assertFirstFrameContains('Initial code\nBar\nBlue Bar');
97
98
  assertCodePaneLineCount(7);
@@ -8,6 +8,8 @@ export const getPreviewFrameNames = () => cy.get('[data-testid="frameName"]');
8
8
 
9
9
  export const getFirstFrame = () => getPreviewFrames().first();
10
10
 
11
+ export const isMac = () => navigator.platform.match('Mac');
12
+
11
13
  export const visit = (url) =>
12
14
  cy
13
15
  .visit(url)
@@ -28,7 +30,7 @@ export const typeCode = (code, { delay = 200 } = {}) =>
28
30
  export const formatCode = () =>
29
31
  getCodeEditor()
30
32
  .focused()
31
- .type(`${navigator.platform.match('Mac') ? '{cmd}' : '{ctrl}'}s`)
33
+ .type(`${isMac() ? '{cmd}' : '{ctrl}'}s`)
32
34
  .wait(WAIT_FOR_FRAME_TO_RENDER);
33
35
 
34
36
  export const selectWidthPreferenceByIndex = (index) =>
@@ -85,6 +87,27 @@ export const assertFirstFrameContains = (text) => {
85
87
  );
86
88
  };
87
89
 
90
+ export const selectNextCharacters = (numCharacters) => {
91
+ typeCode('{shift+rightArrow}'.repeat(numCharacters));
92
+ };
93
+
94
+ export const selectNextWords = (numWords) => {
95
+ const modifier = isMac() ? 'alt' : 'ctrl';
96
+ typeCode(`{shift+${modifier}+rightArrow}`.repeat(numWords));
97
+ };
98
+
99
+ /**
100
+ * @typedef {import('../../src/Playroom/CodeEditor/keymaps/types').Direction} Direction
101
+ */
102
+ /**
103
+ * @param {number} numLines
104
+ * @param {Direction} direction
105
+ */
106
+ export const selectLines = (numLines, direction = 'down') => {
107
+ const arrowCode = direction === 'down' ? 'downArrow' : 'upArrow';
108
+ typeCode(`{shift+${arrowCode}}`.repeat(numLines));
109
+ };
110
+
88
111
  export const assertCodePaneContains = (text) => {
89
112
  getCodeEditor().within(() => {
90
113
  const lines = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playroom",
3
- "version": "0.28.2",
3
+ "version": "0.30.0",
4
4
  "description": "Design with code, powered by your own component library",
5
5
  "main": "utils/index.js",
6
6
  "types": "utils/index.d.ts",
@@ -82,6 +82,7 @@
82
82
  "webpack-merge": "^5.8.0"
83
83
  },
84
84
  "devDependencies": {
85
+ "@actions/core": "^1.10.0",
85
86
  "@changesets/cli": "^2.25.2",
86
87
  "@octokit/rest": "^19.0.5",
87
88
  "@types/jest": "^29.2.4",
@@ -1,4 +1,13 @@
1
1
  /* eslint-disable no-console */
2
+ const core = require('@actions/core');
3
+
4
+ const writeSummary = async ({ title, link }) => {
5
+ core.summary.addHeading(title, 3);
6
+ core.summary.addLink(link, link);
7
+
8
+ await core.summary.write();
9
+ };
10
+
2
11
  (async () => {
3
12
  try {
4
13
  console.log('Posting commit status to GitHub...');
@@ -16,17 +25,24 @@
16
25
  auth: GITHUB_TOKEN,
17
26
  });
18
27
 
28
+ const previewUrl = `https://playroom--${GITHUB_SHA}.surge.sh`;
29
+
19
30
  await octokit.repos.createCommitStatus({
20
31
  owner: 'seek-oss',
21
32
  repo: 'playroom',
22
33
  sha: GITHUB_SHA,
23
34
  state: 'success',
24
35
  context: 'Preview Site',
25
- target_url: `https://playroom--${GITHUB_SHA}.surge.sh`,
36
+ target_url: previewUrl,
26
37
  description: 'The preview for this PR has been successfully deployed',
27
38
  });
28
39
 
29
40
  console.log('Successfully posted commit status to GitHub');
41
+
42
+ await writeSummary({
43
+ title: 'Preview published',
44
+ link: previewUrl,
45
+ });
30
46
  } catch (err) {
31
47
  console.error(err);
32
48
  process.exit(1); // eslint-disable-line no-process-exit
@@ -1,6 +1,6 @@
1
1
  import React, { useRef, useContext, useEffect, useCallback } from 'react';
2
2
  import { useDebouncedCallback } from 'use-debounce';
3
- import CodeMirror, { Editor } from 'codemirror';
3
+ import { Editor } from 'codemirror';
4
4
  import 'codemirror/lib/codemirror.css';
5
5
  import 'codemirror/theme/neo.css';
6
6
 
@@ -15,6 +15,13 @@ import {
15
15
  import * as styles from './CodeEditor.css';
16
16
 
17
17
  import { UnControlled as ReactCodeMirror } from './CodeMirror2';
18
+ import {
19
+ completeAfter,
20
+ completeIfAfterLt,
21
+ completeIfInTag,
22
+ } from './keymaps/complete';
23
+ import { duplicateLine, swapLineDown, swapLineUp } from './keymaps/lines';
24
+
18
25
  import 'codemirror/mode/jsx/jsx';
19
26
  import 'codemirror/addon/edit/closetag';
20
27
  import 'codemirror/addon/edit/closebrackets';
@@ -24,39 +31,11 @@ import 'codemirror/addon/selection/active-line';
24
31
  import 'codemirror/addon/fold/foldcode';
25
32
  import 'codemirror/addon/fold/foldgutter';
26
33
  import 'codemirror/addon/fold/brace-fold';
27
-
28
- const completeAfter = (cm: Editor, predicate?: () => boolean) => {
29
- if (!predicate || predicate()) {
30
- setTimeout(() => {
31
- if (!cm.state.completionActive) {
32
- cm.showHint({ completeSingle: false });
33
- }
34
- }, 100);
35
- }
36
-
37
- return CodeMirror.Pass;
38
- };
39
-
40
- const completeIfAfterLt = (cm: Editor) =>
41
- completeAfter(cm, () => {
42
- const cur = cm.getCursor();
43
- // eslint-disable-next-line new-cap
44
- return cm.getRange(CodeMirror.Pos(cur.line, cur.ch - 1), cur) === '<';
45
- });
46
-
47
- const completeIfInTag = (cm: Editor) =>
48
- completeAfter(cm, () => {
49
- const tok = cm.getTokenAt(cm.getCursor());
50
- if (
51
- tok.type === 'string' &&
52
- (!/['"]/.test(tok.string.charAt(tok.string.length - 1)) ||
53
- tok.string.length === 1)
54
- ) {
55
- return false;
56
- }
57
- const inner = CodeMirror.innerMode(cm.getMode(), tok.state).state;
58
- return inner.tagName;
59
- });
34
+ import {
35
+ addCursorToNextLine,
36
+ addCursorToPrevLine,
37
+ selectNextOccurrence,
38
+ } from './keymaps/cursors';
60
39
 
61
40
  const validateCode = (editorInstance: Editor, code: string) => {
62
41
  editorInstance.clearGutter('errorGutter');
@@ -225,6 +204,8 @@ export const CodeEditor = ({ code, onChange, previewCode, hints }: Props) => {
225
204
  }
226
205
  }, [highlightLineNumber]);
227
206
 
207
+ const keymapModifierKey = navigator.platform.match('Mac') ? 'Cmd' : 'Ctrl';
208
+
228
209
  return (
229
210
  <ReactCodeMirror
230
211
  editorDidMount={(editorInstance) => {
@@ -284,6 +265,13 @@ export const CodeEditor = ({ code, onChange, previewCode, hints }: Props) => {
284
265
  "'/'": completeIfAfterLt,
285
266
  "' '": completeIfInTag,
286
267
  "'='": completeIfInTag,
268
+ 'Alt-Up': swapLineUp,
269
+ 'Alt-Down': swapLineDown,
270
+ 'Shift-Alt-Up': duplicateLine('up'),
271
+ 'Shift-Alt-Down': duplicateLine('down'),
272
+ [`${keymapModifierKey}-Alt-Up`]: addCursorToPrevLine,
273
+ [`${keymapModifierKey}-Alt-Down`]: addCursorToNextLine,
274
+ [`${keymapModifierKey}-D`]: selectNextOccurrence,
287
275
  },
288
276
  }}
289
277
  />
@@ -0,0 +1,34 @@
1
+ import CodeMirror, { Editor } from 'codemirror';
2
+
3
+ export const completeAfter = (cm: Editor, predicate?: () => boolean) => {
4
+ if (!predicate || predicate()) {
5
+ setTimeout(() => {
6
+ if (!cm.state.completionActive) {
7
+ cm.showHint({ completeSingle: false });
8
+ }
9
+ }, 100);
10
+ }
11
+
12
+ return CodeMirror.Pass;
13
+ };
14
+
15
+ export const completeIfAfterLt = (cm: Editor) =>
16
+ completeAfter(cm, () => {
17
+ const cur = cm.getCursor();
18
+ // eslint-disable-next-line new-cap
19
+ return cm.getRange(CodeMirror.Pos(cur.line, cur.ch - 1), cur) === '<';
20
+ });
21
+
22
+ export const completeIfInTag = (cm: Editor) =>
23
+ completeAfter(cm, () => {
24
+ const tok = cm.getTokenAt(cm.getCursor());
25
+ if (
26
+ tok.type === 'string' &&
27
+ (!/['"]/.test(tok.string.charAt(tok.string.length - 1)) ||
28
+ tok.string.length === 1)
29
+ ) {
30
+ return false;
31
+ }
32
+ const inner = CodeMirror.innerMode(cm.getMode(), tok.state).state;
33
+ return inner.tagName;
34
+ });
@@ -0,0 +1,113 @@
1
+ import CodeMirror, { Editor, Pos } from 'codemirror';
2
+
3
+ import 'codemirror/addon/search/searchcursor';
4
+
5
+ import { Direction, Selection } from './types';
6
+
7
+ function wordAt(cm: Editor, pos: CodeMirror.Position) {
8
+ let start = pos.ch;
9
+ let end = start;
10
+ const line = cm.getLine(pos.line);
11
+
12
+ // Move `start` back to the beginning of the word
13
+ while (start && CodeMirror.isWordChar(line.charAt(start - 1))) {
14
+ --start;
15
+ }
16
+
17
+ // Move `end` to the end of the word
18
+ while (end < line.length && CodeMirror.isWordChar(line.charAt(end))) {
19
+ ++end;
20
+ }
21
+
22
+ return {
23
+ from: new Pos(pos.line, start),
24
+ to: new Pos(pos.line, end),
25
+ word: line.slice(start, end),
26
+ };
27
+ }
28
+
29
+ function rangeIsAlreadySelected(
30
+ ranges: CodeMirror.Range[],
31
+ checkRange: Pick<CodeMirror.Range, 'from' | 'to'>
32
+ ) {
33
+ for (const range of ranges) {
34
+ const startsFromStart =
35
+ CodeMirror.cmpPos(range.from(), checkRange.from()) === 0;
36
+ const endsAtEnd = CodeMirror.cmpPos(range.to(), checkRange.to()) === 0;
37
+
38
+ if (startsFromStart && endsAtEnd) {
39
+ return true;
40
+ }
41
+ }
42
+
43
+ return false;
44
+ }
45
+
46
+ export const selectNextOccurrence = (cm: Editor) => {
47
+ const from = cm.getCursor('from');
48
+ const to = cm.getCursor('to');
49
+
50
+ // If the selections are the same as last time this
51
+ // ran, we're still in full word mode
52
+ let fullWord = cm.state.selectNextFindFullWord === cm.listSelections();
53
+
54
+ // If this is just a cursor, rather than a selection
55
+ if (CodeMirror.cmpPos(from, to) === 0) {
56
+ const word = wordAt(cm, from);
57
+
58
+ // And there's no actual word at that cursor, then do nothing
59
+ if (!word.word) {
60
+ return;
61
+ }
62
+
63
+ // Otherwise select the word and enter full word mode
64
+ cm.setSelection(word.from, word.to);
65
+ fullWord = true;
66
+ } else {
67
+ const text = cm.getRange(from, to);
68
+ const query = fullWord ? new RegExp(`\\b${text}\\b`) : text;
69
+ let cur = cm.getSearchCursor(query, to);
70
+ let found = cur.findNext();
71
+
72
+ // If we didn't find any occurrence in the rest of the
73
+ // document, start again at the start
74
+ if (!found) {
75
+ cur = cm.getSearchCursor(query, new Pos(cm.firstLine(), 0));
76
+ found = cur.findNext();
77
+ }
78
+
79
+ // If we still didn't find anything, or we re-discover a selection
80
+ // we already have, then do nothing
81
+ if (!found || rangeIsAlreadySelected(cm.listSelections(), cur)) {
82
+ return;
83
+ }
84
+
85
+ cm.addSelection(cur.from(), cur.to());
86
+ }
87
+
88
+ if (fullWord) {
89
+ cm.state.selectNextFindFullWord = cm.listSelections();
90
+ }
91
+ };
92
+
93
+ function addCursorToSelection(cm: Editor, dir: Direction) {
94
+ const ranges = cm.listSelections();
95
+ const newRanges: Selection[] = [];
96
+
97
+ const linesToMove = dir === 'up' ? -1 : 1;
98
+
99
+ for (const range of ranges) {
100
+ const newAnchor = cm.findPosV(range.anchor, linesToMove, 'line');
101
+ const newHead = cm.findPosV(range.head, linesToMove, 'line');
102
+
103
+ newRanges.push(range);
104
+ newRanges.push({ anchor: newAnchor, head: newHead });
105
+ }
106
+
107
+ cm.setSelections(newRanges);
108
+ }
109
+
110
+ export const addCursorToPrevLine = (cm: Editor) =>
111
+ addCursorToSelection(cm, 'up');
112
+ export const addCursorToNextLine = (cm: Editor) =>
113
+ addCursorToSelection(cm, 'down');
@@ -0,0 +1,192 @@
1
+ import CodeMirror from 'codemirror';
2
+ import { Editor, Pos } from 'codemirror';
3
+ import { Direction, Selection } from './types';
4
+ type RangeMethod = Extract<keyof CodeMirror.Range, 'from' | 'to'>;
5
+
6
+ const directionToMethod: Record<Direction, RangeMethod> = {
7
+ up: 'to',
8
+ down: 'from',
9
+ };
10
+
11
+ type ContentUpdate = [string, [CodeMirror.Position, CodeMirror.Position?]];
12
+
13
+ const getNewPosition = (
14
+ range: CodeMirror.Range,
15
+ direction: Direction,
16
+ extraLines: number
17
+ ) => {
18
+ const currentLine = range[directionToMethod[direction]]().line;
19
+
20
+ const newLine = direction === 'up' ? currentLine + 1 : currentLine;
21
+ return new Pos(newLine + extraLines, 0);
22
+ };
23
+
24
+ const moveByLines = (range: CodeMirror.Range, lines: number) => {
25
+ const anchor = new Pos(range.anchor.line + lines, range.anchor.ch);
26
+ const head = new Pos(range.head.line + lines, range.head.ch);
27
+
28
+ return { anchor, head };
29
+ };
30
+
31
+ export const duplicateLine = (direction: Direction) => (cm: Editor) => {
32
+ const ranges = cm.listSelections();
33
+
34
+ const contentUpdates: ContentUpdate[] = [];
35
+ const newSelections: Selection[] = [];
36
+
37
+ // To keep the selections in the right spot, we need to track how many additional
38
+ // lines have been introduced to the document (in multicursor mode).
39
+ let newLinesSoFar = 0;
40
+
41
+ for (const range of ranges) {
42
+ const newLineCount = range.to().line - range.from().line + 1;
43
+ const existingContent = cm.getRange(
44
+ new Pos(range.from().line, 0),
45
+ new Pos(range.to().line)
46
+ );
47
+
48
+ const newContentParts = [existingContent, '\n'];
49
+
50
+ // Copy up on the last line has some unusual behaviour
51
+ if (range.to().line === cm.lastLine() && direction === 'up') {
52
+ newContentParts.reverse();
53
+ }
54
+
55
+ const newContent = newContentParts.join('');
56
+
57
+ contentUpdates.push([
58
+ newContent,
59
+ [getNewPosition(range, direction, newLinesSoFar)],
60
+ ]);
61
+
62
+ // Copy up doesn't always handle its cursors correctly
63
+ if (direction === 'up') {
64
+ newSelections.push(moveByLines(range, newLinesSoFar));
65
+ }
66
+
67
+ newLinesSoFar += newLineCount;
68
+ }
69
+
70
+ cm.operation(function () {
71
+ for (const [newContent, [start, end]] of contentUpdates) {
72
+ cm.replaceRange(newContent, start, end, '+swapLine');
73
+ }
74
+
75
+ // Shift the selection up by one line to match the moved content
76
+ cm.setSelections(newSelections);
77
+ });
78
+ };
79
+
80
+ export const swapLineUp = function (cm: Editor) {
81
+ if (cm.isReadOnly()) {
82
+ return CodeMirror.Pass;
83
+ }
84
+
85
+ // We need to keep track of the current bottom of the block
86
+ // to make sure we're not overwriting lines
87
+ let lastLine = cm.firstLine() - 1;
88
+
89
+ const rangesToMove: Array<{ from: number; to: number }> = [];
90
+ const newSels: Selection[] = [];
91
+
92
+ for (const range of cm.listSelections()) {
93
+ // Include one line above the current range
94
+ const from = range.from().line - 1;
95
+ let to = range.to().line;
96
+
97
+ // Shift the selection up by one line
98
+ newSels.push({
99
+ anchor: new Pos(range.anchor.line - 1, range.anchor.ch),
100
+ head: new Pos(range.head.line - 1, range.head.ch),
101
+ });
102
+
103
+ // If we've accidentally run over to the start of the
104
+ // next line, then go back up one
105
+ if (range.to().ch === 0 && !range.empty()) {
106
+ --to;
107
+ }
108
+
109
+ // If the one-line-before-current-range is after the last line, put
110
+ // the start and end lines in the list of lines to move
111
+ if (from > lastLine) {
112
+ rangesToMove.push({ from, to });
113
+ // If the ranges overlap, update the last range in the list
114
+ // to include both ranges
115
+ } else if (rangesToMove.length) {
116
+ rangesToMove[rangesToMove.length - 1].to = to;
117
+ }
118
+
119
+ // Move the last line to the end of the current range
120
+ lastLine = to;
121
+ }
122
+
123
+ cm.operation(function () {
124
+ for (const range of rangesToMove) {
125
+ const { from, to } = range;
126
+ const line = cm.getLine(from);
127
+ cm.replaceRange('', new Pos(from, 0), new Pos(from + 1, 0), '+swapLine');
128
+
129
+ if (to > cm.lastLine()) {
130
+ cm.replaceRange(
131
+ `\n${line}`,
132
+ new Pos(cm.lastLine()),
133
+ undefined,
134
+ '+swapLine'
135
+ );
136
+ } else {
137
+ cm.replaceRange(`${line}\n`, new Pos(to, 0), undefined, '+swapLine');
138
+ }
139
+ }
140
+
141
+ cm.setSelections(newSels);
142
+ cm.scrollIntoView(null);
143
+ });
144
+ };
145
+
146
+ export const swapLineDown = function (cm: Editor) {
147
+ if (cm.isReadOnly()) {
148
+ return CodeMirror.Pass;
149
+ }
150
+
151
+ const ranges = cm.listSelections();
152
+ const rangesToMove: Array<{ from: number; to: number }> = [];
153
+
154
+ let firstLine = cm.lastLine() + 1;
155
+
156
+ for (const range of [...ranges].reverse()) {
157
+ let from = range.to().line + 1;
158
+ const to = range.from().line;
159
+
160
+ if (range.to().ch === 0 && !range.empty()) {
161
+ from--;
162
+ }
163
+
164
+ if (from < firstLine) {
165
+ rangesToMove.push({ from, to });
166
+ } else if (rangesToMove.length) {
167
+ rangesToMove[rangesToMove.length - 1].to = to;
168
+ }
169
+
170
+ firstLine = to;
171
+ }
172
+
173
+ cm.operation(function () {
174
+ for (const range of rangesToMove) {
175
+ const { from, to } = range;
176
+ const line = cm.getLine(from);
177
+ if (from === cm.lastLine()) {
178
+ cm.replaceRange('', new Pos(from - 1), new Pos(from), '+swapLine');
179
+ } else {
180
+ cm.replaceRange(
181
+ '',
182
+ new Pos(from, 0),
183
+ new Pos(from + 1, 0),
184
+ '+swapLine'
185
+ );
186
+ }
187
+
188
+ cm.replaceRange(`${line}\n`, new Pos(to, 0), undefined, '+swapLine');
189
+ }
190
+ cm.scrollIntoView(null);
191
+ });
192
+ };
@@ -0,0 +1,5 @@
1
+ import { Editor } from 'codemirror';
2
+
3
+ export type Direction = 'up' | 'down';
4
+
5
+ export type Selection = Parameters<Editor['setSelections']>[0][number];