playroom 0.34.2 → 0.36.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.
Files changed (38) hide show
  1. package/.github/workflows/preview-site.yml +3 -3
  2. package/.github/workflows/release.yml +3 -3
  3. package/.github/workflows/snapshot.yml +3 -3
  4. package/.github/workflows/validate.yml +5 -5
  5. package/.nvmrc +1 -1
  6. package/CHANGELOG.md +55 -0
  7. package/README.md +6 -0
  8. package/bin/cli.cjs +2 -1
  9. package/cypress/e2e/editor.cy.js +1 -1
  10. package/cypress/e2e/keymaps.cy.js +1507 -11
  11. package/cypress/e2e/scope.cy.js +1 -1
  12. package/cypress/e2e/smoke.cy.js +2 -2
  13. package/cypress/e2e/toolbar.cy.js +1 -2
  14. package/cypress/e2e/urlHandling.cy.js +4 -5
  15. package/cypress/support/utils.js +62 -54
  16. package/lib/makeWebpackConfig.js +0 -3
  17. package/lib/provideDefaultConfig.js +13 -2
  18. package/package.json +18 -17
  19. package/src/Playroom/CatchErrors/CatchErrors.tsx +5 -6
  20. package/src/Playroom/CodeEditor/CodeEditor.tsx +11 -0
  21. package/src/Playroom/CodeEditor/keymaps/comment.ts +326 -0
  22. package/src/Playroom/CodeEditor/keymaps/wrap.ts +4 -1
  23. package/src/Playroom/Frame.tsx +9 -5
  24. package/src/Playroom/FramesPanel/FramesPanel.css.ts +19 -0
  25. package/src/Playroom/FramesPanel/FramesPanel.tsx +89 -46
  26. package/src/Playroom/Preview.tsx +12 -3
  27. package/src/Playroom/PreviewPanel/PreviewPanel.tsx +1 -1
  28. package/src/Playroom/SettingsPanel/SettingsPanel.tsx +11 -7
  29. package/src/Playroom/Stack/Stack.css.ts +4 -35
  30. package/src/Playroom/Stack/Stack.tsx +2 -9
  31. package/src/Playroom/Toolbar/Toolbar.tsx +2 -2
  32. package/src/Playroom/sprinkles.css.ts +1 -0
  33. package/src/StoreContext/StoreContext.tsx +31 -6
  34. package/src/index.d.ts +2 -0
  35. package/src/utils/params.ts +5 -8
  36. package/src/utils/usePreviewUrl.ts +2 -1
  37. package/utils/index.d.ts +3 -0
  38. package/utils/index.js +21 -7
@@ -0,0 +1,326 @@
1
+ import type CodeMirror from 'codemirror';
2
+ import { type Editor, Pos } from 'codemirror';
3
+ import type { Selection } from './types';
4
+
5
+ const BLOCK_COMMENT_START = '{/*';
6
+ const BLOCK_COMMENT_END = '*/}';
7
+
8
+ const LINE_COMMENT_START = '//';
9
+
10
+ const BLOCK_COMMENT_OFFSET = BLOCK_COMMENT_START.length + 1;
11
+ const LINE_COMMENT_OFFSET = LINE_COMMENT_START.length + 1;
12
+
13
+ const OPENING_AND_CLOSING_BLOCK_COMMENT_SYNTAX = /\{\/\*\s?|\s?\*\/\}/g;
14
+ const OPENING_LINE_COMMENT_SYNTAX_WITH_LEADING_WHITESPACE = /^(\s*)\/\/\s?/gm;
15
+ const LINE_COMMENT_LEADING_WHITESPACE = '$1';
16
+
17
+ interface IsReverseSelectionOptions {
18
+ anchor: CodeMirror.Position;
19
+ head: CodeMirror.Position;
20
+ }
21
+
22
+ function isReverseSelection({
23
+ anchor,
24
+ head,
25
+ }: IsReverseSelectionOptions): boolean {
26
+ return (
27
+ anchor.line > head.line ||
28
+ (anchor.line === head.line && anchor.ch > head.ch)
29
+ );
30
+ }
31
+
32
+ function getCommentStartInfo(commentType: CommentType, fullContent: string) {
33
+ const commentStart =
34
+ commentType === 'block' ? BLOCK_COMMENT_START : LINE_COMMENT_START;
35
+
36
+ const commentStartWithSpace = `${commentStart} `;
37
+ const commentStartUsed =
38
+ fullContent.indexOf(commentStartWithSpace) === -1
39
+ ? commentStart
40
+ : commentStartWithSpace;
41
+
42
+ const commentStartIndex = fullContent.indexOf(commentStartUsed);
43
+
44
+ return {
45
+ commentStartUsed,
46
+ commentStartIndex,
47
+ };
48
+ }
49
+
50
+ function getSelectionPositionRelativeToCommentStart(
51
+ selectionEndpoint: CodeMirror.Position,
52
+ commentStartIndex: number,
53
+ commentStartUsed: string
54
+ ): 'before' | 'during' | 'after' {
55
+ if (selectionEndpoint.ch < commentStartIndex) {
56
+ return 'before';
57
+ }
58
+
59
+ if (selectionEndpoint.ch > commentStartIndex + commentStartUsed.length) {
60
+ return 'after';
61
+ }
62
+
63
+ return 'during';
64
+ }
65
+
66
+ interface GetSelectionFromOffsetOptions {
67
+ commentType: CommentType;
68
+ isAlreadyCommented: boolean;
69
+ fullContent: string;
70
+ from: CodeMirror.Position;
71
+ }
72
+
73
+ function getSelectionFromOffset({
74
+ commentType,
75
+ isAlreadyCommented,
76
+ fullContent,
77
+ from,
78
+ }: GetSelectionFromOffsetOptions) {
79
+ if (!isAlreadyCommented) {
80
+ const totalLeadingWhitespace =
81
+ fullContent.length - fullContent.trimStart().length;
82
+
83
+ const fromPositionBeforeCodeStart = from.ch < totalLeadingWhitespace;
84
+
85
+ if (fromPositionBeforeCodeStart) {
86
+ return 0;
87
+ }
88
+
89
+ return commentType === 'block' ? BLOCK_COMMENT_OFFSET : LINE_COMMENT_OFFSET;
90
+ }
91
+
92
+ const { commentStartUsed, commentStartIndex } = getCommentStartInfo(
93
+ commentType,
94
+ fullContent
95
+ );
96
+
97
+ const fromPositionRelativeToCommentStart =
98
+ getSelectionPositionRelativeToCommentStart(
99
+ from,
100
+ commentStartIndex,
101
+ commentStartUsed
102
+ );
103
+
104
+ switch (fromPositionRelativeToCommentStart) {
105
+ case 'before':
106
+ return 0;
107
+ case 'during':
108
+ return commentStartIndex - from.ch;
109
+ case 'after':
110
+ return -commentStartUsed.length;
111
+ }
112
+ }
113
+
114
+ interface GetSelectionToOffsetOptions {
115
+ to: CodeMirror.Position;
116
+ commentType: CommentType;
117
+ isAlreadyCommented: boolean;
118
+ isMultiLineSelection: boolean;
119
+ fullContent: string;
120
+ }
121
+
122
+ function getSelectionToOffset({
123
+ to,
124
+ commentType,
125
+ isAlreadyCommented,
126
+ isMultiLineSelection,
127
+ fullContent,
128
+ }: GetSelectionToOffsetOptions) {
129
+ const commentOffset =
130
+ commentType === 'block' ? BLOCK_COMMENT_OFFSET : LINE_COMMENT_OFFSET;
131
+
132
+ if (isMultiLineSelection && commentType === 'block') {
133
+ return 0;
134
+ }
135
+
136
+ const totalLeadingWhitespace =
137
+ fullContent.length - fullContent.trimStart().length;
138
+ const toPositionBeforeCodeStart = to.ch < totalLeadingWhitespace;
139
+
140
+ if (!isAlreadyCommented) {
141
+ if (!isMultiLineSelection && toPositionBeforeCodeStart) {
142
+ return 0;
143
+ }
144
+
145
+ return commentOffset;
146
+ }
147
+
148
+ const { commentStartUsed, commentStartIndex } = getCommentStartInfo(
149
+ commentType,
150
+ fullContent
151
+ );
152
+
153
+ const toPositionRelativeToCommentStart =
154
+ getSelectionPositionRelativeToCommentStart(
155
+ to,
156
+ commentStartIndex,
157
+ commentStartUsed
158
+ );
159
+
160
+ switch (toPositionRelativeToCommentStart) {
161
+ case 'before':
162
+ return 0;
163
+ case 'during':
164
+ return commentStartIndex - to.ch;
165
+ case 'after':
166
+ return -commentOffset;
167
+ }
168
+ }
169
+
170
+ function getUpdatedContent(existingContent: string, range: TagRange) {
171
+ if (range.isAlreadyCommented) {
172
+ const uncommentType: CommentType = existingContent
173
+ .trimStart()
174
+ .startsWith(BLOCK_COMMENT_START)
175
+ ? 'block'
176
+ : 'line';
177
+
178
+ const existingContentWithoutComment = existingContent.replace(
179
+ uncommentType === 'block'
180
+ ? OPENING_AND_CLOSING_BLOCK_COMMENT_SYNTAX
181
+ : OPENING_LINE_COMMENT_SYNTAX_WITH_LEADING_WHITESPACE,
182
+ uncommentType === 'block' ? '' : LINE_COMMENT_LEADING_WHITESPACE
183
+ );
184
+
185
+ return existingContentWithoutComment;
186
+ }
187
+
188
+ if (range.commentType === 'block') {
189
+ return `${BLOCK_COMMENT_START} ${existingContent} ${BLOCK_COMMENT_END}`;
190
+ }
191
+
192
+ if (range.multiLine) {
193
+ return existingContent.replace(/^(\s*)/gm, `$1${LINE_COMMENT_START} `);
194
+ }
195
+
196
+ return `${LINE_COMMENT_START} ${existingContent}`;
197
+ }
198
+
199
+ type CommentType = 'line' | 'block';
200
+
201
+ interface TagRange {
202
+ from: CodeMirror.Position;
203
+ to: CodeMirror.Position;
204
+ multiLine: boolean;
205
+ existingIndent: number;
206
+ isAlreadyCommented: boolean;
207
+ commentType: CommentType;
208
+ }
209
+
210
+ const determineCommentType = (
211
+ cm: Editor,
212
+ from: CodeMirror.Position,
213
+ to: CodeMirror.Position
214
+ ): CommentType => {
215
+ const lineTokens = cm.getLineTokens(from.line);
216
+
217
+ const containsTag = lineTokens.some((token) => token.type === 'tag');
218
+ const containsAttribute = lineTokens.some(
219
+ (token) => token.type === 'attribute'
220
+ );
221
+
222
+ const isJavaScriptMode = cm.getModeAt(from).name === 'javascript';
223
+ const isInlineComment = cm
224
+ .getLine(from.line)
225
+ .trimStart()
226
+ .startsWith(LINE_COMMENT_START);
227
+ const isBlockComment =
228
+ cm.getLine(from.line).trimStart().startsWith(BLOCK_COMMENT_START) &&
229
+ cm.getLine(to.line).trimEnd().endsWith(BLOCK_COMMENT_END);
230
+
231
+ if (isInlineComment) {
232
+ return 'line';
233
+ }
234
+
235
+ if (isJavaScriptMode && !isBlockComment && !containsTag) {
236
+ return 'line';
237
+ }
238
+
239
+ if (
240
+ (!isJavaScriptMode && !containsAttribute) ||
241
+ containsTag ||
242
+ isJavaScriptMode
243
+ ) {
244
+ return 'block';
245
+ }
246
+
247
+ return 'line';
248
+ };
249
+
250
+ export const toggleComment = (cm: Editor) => {
251
+ const newSelections: Selection[] = [];
252
+ const tagRanges: TagRange[] = [];
253
+
254
+ for (const range of cm.listSelections()) {
255
+ const from = range.from();
256
+ let to = range.to();
257
+
258
+ if (to.line !== from.line && to.ch === 0) {
259
+ to = new Pos(to.line - 1);
260
+ }
261
+
262
+ const commentType = determineCommentType(cm, from, to);
263
+
264
+ const fullContent = cm.getRange(new Pos(from.line, 0), new Pos(to.line));
265
+ const existingIndent = fullContent.length - fullContent.trimStart().length;
266
+
267
+ const trimmedContent = fullContent.trim();
268
+
269
+ const isAlreadyCommented =
270
+ (trimmedContent.startsWith(BLOCK_COMMENT_START) &&
271
+ trimmedContent.endsWith(BLOCK_COMMENT_END)) ||
272
+ trimmedContent.startsWith(LINE_COMMENT_START);
273
+
274
+ const isMultiLineSelection = to.line !== from.line;
275
+
276
+ tagRanges.push({
277
+ from,
278
+ to,
279
+ multiLine: isMultiLineSelection,
280
+ existingIndent,
281
+ isAlreadyCommented,
282
+ commentType,
283
+ });
284
+
285
+ const fromOffset = getSelectionFromOffset({
286
+ commentType,
287
+ isAlreadyCommented,
288
+ fullContent,
289
+ from,
290
+ });
291
+
292
+ const toOffset = getSelectionToOffset({
293
+ to,
294
+ commentType,
295
+ isAlreadyCommented,
296
+ isMultiLineSelection,
297
+ fullContent,
298
+ });
299
+
300
+ const newSelectionRangeFrom = new Pos(from.line, from.ch + fromOffset);
301
+ const newSelectionRangeTo = new Pos(to.line, to.ch + toOffset);
302
+
303
+ const newSelection = isReverseSelection(range)
304
+ ? { anchor: newSelectionRangeTo, head: newSelectionRangeFrom }
305
+ : { anchor: newSelectionRangeFrom, head: newSelectionRangeTo };
306
+
307
+ newSelections.push(newSelection);
308
+ }
309
+
310
+ cm.operation(() => {
311
+ for (const range of [...tagRanges].reverse()) {
312
+ const newRangeFrom = new Pos(range.from.line, range.existingIndent);
313
+ const newRangeTo = new Pos(range.to.line);
314
+
315
+ const existingContent = cm.getRange(newRangeFrom, newRangeTo);
316
+
317
+ cm.replaceRange(
318
+ getUpdatedContent(existingContent, range),
319
+ newRangeFrom,
320
+ newRangeTo
321
+ );
322
+ }
323
+
324
+ cm.setSelections(newSelections);
325
+ });
326
+ };
@@ -36,10 +36,13 @@ export const wrapInTag = (cm: Editor) => {
36
36
  existingIndent,
37
37
  });
38
38
 
39
+ const startCursorCharacterPosition =
40
+ from.ch + 1 + (isMultiLineSelection ? existingIndent : 0);
39
41
  const newStartCursor = new Pos(
40
42
  from.line + linesAdded,
41
- from.ch + existingIndent + 1
43
+ startCursorCharacterPosition
42
44
  );
45
+
43
46
  const newEndCursor = isMultiLineSelection
44
47
  ? new Pos(to.line + linesAdded + 2, from.ch + existingIndent + 2)
45
48
  : new Pos(to.line + linesAdded, to.ch + 4);
@@ -19,11 +19,15 @@ export default function Frame({
19
19
  components,
20
20
  FrameComponent,
21
21
  }: FrameProps) {
22
- const { themeName, code } = useParams((rawParams) => ({
23
- themeName:
24
- typeof rawParams.themeName === 'string' ? rawParams.themeName : '',
25
- code: typeof rawParams.code === 'string' ? rawParams.code : '',
26
- }));
22
+ const { themeName, code } = useParams((rawParams) => {
23
+ const rawThemeName = rawParams.get('themeName');
24
+ const rawCode = rawParams.get('code');
25
+
26
+ return {
27
+ themeName: rawThemeName || '',
28
+ code: rawCode || '',
29
+ };
30
+ });
27
31
 
28
32
  const resolvedThemeName =
29
33
  themeName === '__PLAYROOM__NO_THEME__' ? null : themeName;
@@ -144,3 +144,22 @@ globalStyle(`${checkbox}:checked ~ ${fakeCheckbox} > svg `, {
144
144
  transform: 'none',
145
145
  transition: vars.transition.fast,
146
146
  });
147
+
148
+ export const textField = style([
149
+ sprinkles({
150
+ font: 'large',
151
+ width: 'full',
152
+ paddingX: 'large',
153
+ boxSizing: 'border-box',
154
+ borderRadius: 'medium',
155
+ }),
156
+ {
157
+ color: colorPaletteVars.foreground.neutral,
158
+ height: vars.touchableSize,
159
+ background: colorPaletteVars.background.surface,
160
+ '::placeholder': {
161
+ color: colorPaletteVars.foreground.neutralSoft,
162
+ },
163
+ border: `1px solid ${colorPaletteVars.border.standard}`,
164
+ },
165
+ ]);
@@ -7,6 +7,21 @@ import { Stack } from '../Stack/Stack';
7
7
  import { Text } from '../Text/Text';
8
8
 
9
9
  import * as styles from './FramesPanel.css';
10
+ import { Helmet } from 'react-helmet';
11
+
12
+ const getTitle = (title: string | undefined) => {
13
+ if (title) {
14
+ return `${title} | Playroom`;
15
+ }
16
+
17
+ const configTitle = window?.__playroomConfig__.title;
18
+
19
+ if (configTitle) {
20
+ return `${configTitle} | Playroom`;
21
+ }
22
+
23
+ return 'Playroom';
24
+ };
10
25
 
11
26
  interface FramesPanelProps {
12
27
  availableWidths: number[];
@@ -77,7 +92,7 @@ function FrameOption<Option>({
77
92
  }
78
93
 
79
94
  export default ({ availableWidths, availableThemes }: FramesPanelProps) => {
80
- const [{ visibleWidths = [], visibleThemes = [] }, dispatch] =
95
+ const [{ visibleWidths = [], visibleThemes = [], title }, dispatch] =
81
96
  useContext(StoreContext);
82
97
  const hasThemes =
83
98
  availableThemes.filter(
@@ -88,69 +103,97 @@ export default ({ availableWidths, availableThemes }: FramesPanelProps) => {
88
103
  const hasFilteredThemes =
89
104
  visibleThemes.length > 0 && visibleThemes.length <= availableThemes.length;
90
105
 
106
+ const displayedTitle = getTitle(title);
107
+
91
108
  return (
92
- <ToolbarPanel data-testid="frame-panel">
93
- <Stack space="large" dividers>
94
- <div data-testid="widthsPreferences">
95
- <FrameHeading
96
- showReset={hasFilteredWidths}
97
- onReset={() => dispatch({ type: 'resetVisibleWidths' })}
98
- >
99
- Widths
100
- </FrameHeading>
101
-
102
- {availableWidths.map((option) => (
103
- <FrameOption
104
- key={option}
105
- option={option}
106
- selected={hasFilteredWidths && visibleWidths.includes(option)}
107
- visible={visibleWidths}
108
- available={availableWidths}
109
- onChange={(newWidths) => {
110
- if (newWidths) {
109
+ <>
110
+ {title === undefined ? null : (
111
+ <Helmet>
112
+ <title>{displayedTitle}</title>
113
+ </Helmet>
114
+ )}
115
+ <ToolbarPanel data-testid="frame-panel">
116
+ <Stack space="xxxlarge">
117
+ <label>
118
+ <Stack space="medium">
119
+ <Heading level="3">Title</Heading>
120
+ <input
121
+ type="text"
122
+ id="playroomTitleField"
123
+ placeholder="Enter a title for this Playroom..."
124
+ className={styles.textField}
125
+ value={title}
126
+ onChange={(e) =>
111
127
  dispatch({
112
- type: 'updateVisibleWidths',
113
- payload: { widths: newWidths },
114
- });
115
- } else {
116
- dispatch({ type: 'resetVisibleWidths' });
128
+ type: 'updateTitle',
129
+ payload: { title: e.target.value },
130
+ })
117
131
  }
118
- }}
119
- />
120
- ))}
121
- </div>
132
+ />
133
+ </Stack>
134
+ </label>
122
135
 
123
- {hasThemes ? (
124
- <div data-testid="themePreferences">
136
+ <div data-testid="widthsPreferences">
125
137
  <FrameHeading
126
- showReset={hasFilteredThemes}
127
- onReset={() => dispatch({ type: 'resetVisibleThemes' })}
138
+ showReset={hasFilteredWidths}
139
+ onReset={() => dispatch({ type: 'resetVisibleWidths' })}
128
140
  >
129
- Themes
141
+ Widths
130
142
  </FrameHeading>
131
143
 
132
- {availableThemes.map((option) => (
144
+ {availableWidths.map((option) => (
133
145
  <FrameOption
134
146
  key={option}
135
147
  option={option}
136
- selected={hasFilteredThemes && visibleThemes.includes(option)}
137
- visible={visibleThemes}
138
- available={availableThemes}
139
- onChange={(newThemes) => {
140
- if (newThemes) {
148
+ selected={hasFilteredWidths && visibleWidths.includes(option)}
149
+ visible={visibleWidths}
150
+ available={availableWidths}
151
+ onChange={(newWidths) => {
152
+ if (newWidths) {
141
153
  dispatch({
142
- type: 'updateVisibleThemes',
143
- payload: { themes: newThemes },
154
+ type: 'updateVisibleWidths',
155
+ payload: { widths: newWidths },
144
156
  });
145
157
  } else {
146
- dispatch({ type: 'resetVisibleThemes' });
158
+ dispatch({ type: 'resetVisibleWidths' });
147
159
  }
148
160
  }}
149
161
  />
150
162
  ))}
151
163
  </div>
152
- ) : null}
153
- </Stack>
154
- </ToolbarPanel>
164
+
165
+ {hasThemes ? (
166
+ <div data-testid="themePreferences">
167
+ <FrameHeading
168
+ showReset={hasFilteredThemes}
169
+ onReset={() => dispatch({ type: 'resetVisibleThemes' })}
170
+ >
171
+ Themes
172
+ </FrameHeading>
173
+
174
+ {availableThemes.map((option) => (
175
+ <FrameOption
176
+ key={option}
177
+ option={option}
178
+ selected={hasFilteredThemes && visibleThemes.includes(option)}
179
+ visible={visibleThemes}
180
+ available={availableThemes}
181
+ onChange={(newThemes) => {
182
+ if (newThemes) {
183
+ dispatch({
184
+ type: 'updateVisibleThemes',
185
+ payload: { themes: newThemes },
186
+ });
187
+ } else {
188
+ dispatch({ type: 'resetVisibleThemes' });
189
+ }
190
+ }}
191
+ />
192
+ ))}
193
+ </div>
194
+ ) : null}
195
+ </Stack>
196
+ </ToolbarPanel>
197
+ </>
155
198
  );
156
199
  };
@@ -9,10 +9,12 @@ import CatchErrors from './CatchErrors/CatchErrors';
9
9
  import RenderCode from './RenderCode/RenderCode';
10
10
 
11
11
  import * as styles from './Preview.css';
12
+ import { Helmet } from 'react-helmet';
12
13
 
13
14
  interface PreviewState {
14
15
  code?: string;
15
16
  themeName?: string;
17
+ title?: string;
16
18
  }
17
19
 
18
20
  export interface PreviewProps {
@@ -25,15 +27,17 @@ export interface PreviewProps {
25
27
  }>;
26
28
  }
27
29
  export default ({ themes, components, FrameComponent }: PreviewProps) => {
28
- const { themeName, code } = useParams((rawParams): PreviewState => {
29
- if (rawParams.code) {
30
+ const { themeName, code, title } = useParams((rawParams): PreviewState => {
31
+ const rawCode = rawParams.get('code');
32
+ if (rawCode) {
30
33
  const result = JSON.parse(
31
- lzString.decompressFromEncodedURIComponent(String(rawParams.code)) ?? ''
34
+ lzString.decompressFromEncodedURIComponent(String(rawCode)) ?? ''
32
35
  );
33
36
 
34
37
  return {
35
38
  code: compileJsx(result.code),
36
39
  themeName: result.theme,
40
+ title: result.title,
37
41
  };
38
42
  }
39
43
 
@@ -44,6 +48,11 @@ export default ({ themes, components, FrameComponent }: PreviewProps) => {
44
48
 
45
49
  return (
46
50
  <CatchErrors code={code}>
51
+ <Helmet>
52
+ <title>
53
+ {title ? `${title} | Playroom Preview` : 'Playroom Preview'}
54
+ </title>
55
+ </Helmet>
47
56
  <div className={styles.renderContainer}>
48
57
  <FrameComponent
49
58
  themeName={themeName || '__PLAYROOM__NO_THEME__'}
@@ -29,7 +29,7 @@ export default ({ themes, visibleThemes }: PreviewPanelProps) => {
29
29
 
30
30
  return (
31
31
  <ToolbarPanel data-testid="preview-panel">
32
- <Stack space="medium">
32
+ <Stack space="xxlarge">
33
33
  <Heading as="h4" level="3">
34
34
  Preview
35
35
  </Heading>
@@ -22,17 +22,21 @@ import { isMac } from '../../utils/formatting';
22
22
  const getKeyBindings = () => {
23
23
  const metaKeySymbol = isMac() ? '⌘' : 'Ctrl';
24
24
  const altKeySymbol = isMac() ? '⌥' : 'Alt';
25
+ const shiftKeySymbol = isMac() ? '⇧' : 'Shift';
25
26
 
26
27
  return {
28
+ 'Toggle comment': [metaKeySymbol, '/'],
29
+ 'Wrap selection in tag': [metaKeySymbol, shiftKeySymbol, ','],
27
30
  'Format code': [metaKeySymbol, 'S'],
31
+ 'Insert snippet': [metaKeySymbol, 'K'],
32
+ 'Copy Playroom link': [metaKeySymbol, shiftKeySymbol, 'C'],
33
+ 'Select next occurrence': [metaKeySymbol, 'D'],
28
34
  'Swap line up': [altKeySymbol, '↑'],
29
35
  'Swap line down': [altKeySymbol, '↓'],
30
- 'Duplicate line up': ['⇧', altKeySymbol, '↑'],
31
- 'Duplicate line down': ['⇧', altKeySymbol, '↓'],
36
+ 'Duplicate line up': [shiftKeySymbol, altKeySymbol, '↑'],
37
+ 'Duplicate line down': [shiftKeySymbol, altKeySymbol, '↓'],
32
38
  'Add cursor to prev line': [metaKeySymbol, altKeySymbol, '↑'],
33
39
  'Add cursor to next line': [metaKeySymbol, altKeySymbol, '↓'],
34
- 'Select next occurrence': [metaKeySymbol, 'D'],
35
- 'Wrap selection in tag': [metaKeySymbol, '⇧', ','],
36
40
  };
37
41
  };
38
42
 
@@ -79,8 +83,8 @@ export default React.memo(() => {
79
83
  const keybindings = getKeyBindings();
80
84
 
81
85
  return (
82
- <ToolbarPanel data-testid="frame-panel">
83
- <Stack space="large" dividers>
86
+ <ToolbarPanel data-testid="settings-panel">
87
+ <Stack space="xxxlarge">
84
88
  <fieldset className={styles.fieldset}>
85
89
  <legend>
86
90
  <Heading level="3">Editor Position</Heading>
@@ -157,7 +161,7 @@ export default React.memo(() => {
157
161
  </div>
158
162
  </fieldset>
159
163
 
160
- <Stack space="medium">
164
+ <Stack space="xlarge">
161
165
  <Heading level="3">Keyboard Shortcuts</Heading>
162
166
  {Object.entries(keybindings).map(([description, keybinding]) => (
163
167
  <KeyboardShortcut