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.
- package/.github/workflows/preview-site.yml +1 -1
- package/.github/workflows/release.yml +1 -1
- package/.github/workflows/snapshot.yml +1 -1
- package/.github/workflows/validate.yml +9 -3
- package/CHANGELOG.md +31 -0
- package/cypress/e2e/keymaps.cy.js +185 -0
- package/cypress/e2e/snippets.cy.js +4 -3
- package/cypress/support/utils.js +24 -1
- package/package.json +2 -1
- package/scripts/postCommitStatus.js +17 -1
- package/src/Playroom/CodeEditor/CodeEditor.tsx +22 -34
- package/src/Playroom/CodeEditor/keymaps/complete.ts +34 -0
- package/src/Playroom/CodeEditor/keymaps/cursors.ts +113 -0
- package/src/Playroom/CodeEditor/keymaps/lines.ts +192 -0
- package/src/Playroom/CodeEditor/keymaps/types.ts +5 -0
|
@@ -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:
|
|
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.
|
|
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:
|
|
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(`${
|
|
71
|
+
typeCode(`${isMac() ? '{cmd}' : '{ctrl}'}k`);
|
|
71
72
|
assertSnippetsListIsVisible();
|
|
72
73
|
assertCodePaneLineCount(8);
|
|
73
74
|
filterSnippets('{esc}');
|
|
74
75
|
assertCodePaneLineCount(1);
|
|
75
|
-
typeCode(`${
|
|
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(`${
|
|
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);
|
package/cypress/support/utils.js
CHANGED
|
@@ -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(`${
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
};
|