From 94c75f86930122b34664a56eeec3f60027b8d22f Mon Sep 17 00:00:00 2001 From: Felix Habib <33821218+felixhabib@users.noreply.github.com> Date: Thu, 7 Mar 2024 15:15:11 +1100 Subject: [PATCH] Add "Find", "Find and replace", and "Jump to line" keyboard shortcuts (#343) Co-authored-by: Michael Taranto Co-authored-by: Adam Skoufis --- .changeset/good-starfishes-share.md | 11 ++ cypress/e2e/keymaps.cy.js | 169 ++++++++++++++++++- cypress/support/utils.js | 56 ++++++ src/Playroom/CodeEditor/CodeEditor.css.ts | 100 ++++++++++- src/Playroom/CodeEditor/CodeEditor.tsx | 24 +++ src/Playroom/SettingsPanel/SettingsPanel.tsx | 3 + src/Playroom/palettes.ts | 8 +- src/Playroom/sprinkles.css.ts | 1 + 8 files changed, 362 insertions(+), 10 deletions(-) create mode 100644 .changeset/good-starfishes-share.md diff --git a/.changeset/good-starfishes-share.md b/.changeset/good-starfishes-share.md new file mode 100644 index 00000000..5d664573 --- /dev/null +++ b/.changeset/good-starfishes-share.md @@ -0,0 +1,11 @@ +--- +'playroom': minor +--- + +Add "Find", "Find and replace", and "Jump to line" functionality. + +Keybindings for these new commands are: + +- `Cmd + F` / `Ctrl + F` - Find +- `Cmd + Option + F` / `Ctrl + Alt + F` - Find and replace +- `Cmd + G` / `Ctrl + G` - Jump to line diff --git a/cypress/e2e/keymaps.cy.js b/cypress/e2e/keymaps.cy.js index 1198ce4b..36114b82 100644 --- a/cypress/e2e/keymaps.cy.js +++ b/cypress/e2e/keymaps.cy.js @@ -3,6 +3,7 @@ import { typeCode, assertCodePaneContains, loadPlayroom, + cmdPlus, selectNextWords, selectNextLines, selectNextCharacters, @@ -11,14 +12,13 @@ import { moveToEndOfLine, moveBy, moveByWords, + assertCodePaneSearchMatchesCount, + findInCode, + replaceInCode, + jumpToLine, } from '../support/utils'; import { isMac } from '../../src/utils/formatting'; -const cmdPlus = (keyCombo) => { - const platformSpecificKey = isMac() ? 'cmd' : 'ctrl'; - return `${platformSpecificKey}+${keyCombo}`; -}; - describe('Keymaps', () => { describe('swapLine', () => { beforeEach(() => { @@ -350,6 +350,163 @@ describe('Keymaps', () => { }); }); + describe('find and replace', () => { + beforeEach(() => { + loadPlayroom(` +
First line
+
Second line
+
Third line
+ `); + }); + + it('should find all occurrences of search term', () => { + findInCode('div'); + + assertCodePaneSearchMatchesCount(6); + + cy.focused().type('{esc}'); + + typeCode('c'); + + assertCodePaneContains(dedent` + First line +
Second line
+
Third line
+ `); + }); + + it('should replace and skip occurrences of search term correctly', () => { + replaceInCode('div', 'span'); + + // replace occurrence + cy.get('.CodeMirror-dialog button').contains('Yes').click(); + + assertCodePaneContains(dedent` + First line +
Second line
+
Third line
+ `); + + // ignore occurrence + cy.get('.CodeMirror-dialog button').contains('No').click(); + + assertCodePaneContains(dedent` + First line +
Second line
+
Third line
+ `); + + // replace occurrence + cy.get('.CodeMirror-dialog button').contains('Yes').click(); + + assertCodePaneContains(dedent` + First line + Second line +
Third line
+ `); + + // replace all remaining occurrences + cy.get('.CodeMirror-dialog button').contains('All').click(); + + assertCodePaneContains(dedent` + First line + Second line + Third line + `); + + typeCode('c'); + + assertCodePaneContains(dedent` + First line + Second line + Third line + `); + }); + + it('should back out of replace correctly', () => { + replaceInCode('div'); + + typeCode('{esc}'); + + assertCodePaneContains(dedent` +
First line
+
Second line
+
Third line
+ `); + + typeCode('c'); + + assertCodePaneContains(dedent` + c
First line
+
Second line
+
Third line
+ `); + }); + }); + + describe('jump to line', () => { + beforeEach(() => { + loadPlayroom(` +
First line
+
Second line
+
Third line
+
Forth line
+
Fifth line
+
Sixth line
+
Seventh line
+ `); + }); + + it('should jump to line number correctly', () => { + const line = 6; + jumpToLine(line); + + typeCode('c'); + + assertCodePaneContains(dedent` +
First line
+
Second line
+
Third line
+
Forth line
+
Fifth line
+ c
Sixth line
+
Seventh line
+ `); + + typeCode('{backspace}'); + + const nextLine = 2; + jumpToLine(nextLine); + + typeCode('c'); + + assertCodePaneContains(dedent` +
First line
+ c
Second line
+
Third line
+
Forth line
+
Fifth line
+
Sixth line
+
Seventh line
+ `); + }); + + it('should jump to line and column number correctly', () => { + jumpToLine('6:10'); + typeCode('a'); + + assertCodePaneContains(dedent` +
First line
+
Second line
+
Third line
+
Forth line
+
Fifth line
+
Sixtha line
+
Seventh line
+ `); + }); + }); + describe('toggleComment', () => { const blockStarter = `
First line
@@ -1461,7 +1618,7 @@ describe('Keymaps', () => { }); }); - describe('for an selection beginning during opening comment syntax', () => { + describe('for a selection beginning during opening comment syntax', () => { it('block', () => { loadPlayroom(`
diff --git a/cypress/support/utils.js b/cypress/support/utils.js index 18986518..091d3686 100644 --- a/cypress/support/utils.js +++ b/cypress/support/utils.js @@ -5,6 +5,11 @@ import dedent from 'dedent'; import { createUrl } from '../../utils'; import { isMac } from '../../src/utils/formatting'; +export const cmdPlus = (keyCombo) => { + const platformSpecificKey = isMac() ? 'cmd' : 'ctrl'; + return `${platformSpecificKey}+${keyCombo}`; +}; + const getCodeEditor = () => cy.get('.CodeMirror-code').then((editor) => cy.wrap(editor)); @@ -182,3 +187,54 @@ export const loadPlayroom = (initialCode) => { indexedDB.deleteDatabase(storageKey); }); }; + +const typeInSearchField = (text) => + /* + force true is required because cypress incorrectly and intermittently + reports that search field is covered by another element + */ + cy.get('.CodeMirror-search-field').type(text, { force: true }); + +/** + * @param {string} term + */ +export const findInCode = (term) => { + // Wait necessary to ensure code pane is focussed + cy.wait(200); // eslint-disable-line @finsit/cypress/no-unnecessary-waiting + typeCode(`{${cmdPlus('f')}}`); + + typeInSearchField(`${term}{enter}`); +}; + +/** + * @param {string} term + * @param {string} [replaceWith] + */ +export const replaceInCode = (term, replaceWith) => { + // Wait necessary to ensure code pane is focussed + cy.wait(200); // eslint-disable-line @finsit/cypress/no-unnecessary-waiting + typeCode(`{${cmdPlus('alt+f')}}`); + typeInSearchField(`${term}{enter}`); + if (replaceWith) { + typeInSearchField(`${replaceWith}{enter}`); + } +}; + +/** + * @param {number} line + */ +export const jumpToLine = (line) => { + // Wait necessary to ensure code pane is focussed + cy.wait(200); // eslint-disable-line @finsit/cypress/no-unnecessary-waiting + typeCode(`{${cmdPlus('g')}}`); + typeCode(`${line}{enter}`); +}; + +/** + * @param {number} lines + */ +export const assertCodePaneSearchMatchesCount = (lines) => { + getCodeEditor().within(() => + cy.get('.cm-searching').should('have.length', lines) + ); +}; diff --git a/src/Playroom/CodeEditor/CodeEditor.css.ts b/src/Playroom/CodeEditor/CodeEditor.css.ts index e67ed1bb..8078b632 100644 --- a/src/Playroom/CodeEditor/CodeEditor.css.ts +++ b/src/Playroom/CodeEditor/CodeEditor.css.ts @@ -1,5 +1,6 @@ -import { style, globalStyle, keyframes } from '@vanilla-extract/css'; +import { style, globalStyle, keyframes, createVar } from '@vanilla-extract/css'; import { vars, colorPaletteVars, sprinkles } from '../sprinkles.css'; +import { toolbarItemSize } from '../ToolbarItem/ToolbarItem.css'; const minimumLineNumberWidth = '50px'; @@ -224,3 +225,100 @@ globalStyle('.cm-s-neo .cm-variable', { globalStyle('.cm-s-neo .cm-number', { color: colorPaletteVars.code.number, }); + +globalStyle('.CodeMirror-dialog', { + paddingLeft: vars.space.xlarge, + paddingRight: vars.space.xlarge, + minHeight: toolbarItemSize, + borderBottom: `1px solid ${colorPaletteVars.border.standard}`, + display: 'flex', + alignItems: 'center', +}); + +const searchOffset = createVar(); +globalStyle('.CodeMirror-scroll', { + transform: `translateY(${searchOffset})`, + transition: vars.transition.fast, +}); + +globalStyle('.dialog-opened .CodeMirror-scroll', { + vars: { + [searchOffset]: `${toolbarItemSize}px`, + }, +}); + +globalStyle('.dialog-opened .CodeMirror-lines', { + paddingBottom: searchOffset, +}); + +globalStyle('.CodeMirror-dialog input', { + font: vars.font.scale.large, + fontFamily: vars.font.family.code, + height: vars.touchableSize, + flexGrow: 1, +}); + +globalStyle('.CodeMirror-search-hint', { + display: 'none', +}); + +globalStyle('.CodeMirror-search-label', { + display: 'flex', + alignItems: 'center', + minHeight: vars.touchableSize, + font: vars.font.scale.large, + fontFamily: vars.font.family.code, +}); + +globalStyle('.CodeMirror-search-field', { + paddingLeft: vars.space.xlarge, +}); + +globalStyle('label.CodeMirror-search-label', { + flexGrow: 1, +}); + +globalStyle('.dialog-opened.cm-s-neo .CodeMirror-selected', { + background: colorPaletteVars.background.search, +}); + +globalStyle('.cm-overlay.cm-searching', { + paddingTop: 2, + paddingBottom: 2, + background: colorPaletteVars.background.selection, +}); + +globalStyle('.CodeMirror-dialog button:first-of-type', { + marginLeft: vars.space.xlarge, +}); + +globalStyle('.CodeMirror-dialog button', { + appearance: 'none', + font: vars.font.scale.standard, + fontFamily: vars.font.family.standard, + marginLeft: vars.space.medium, + paddingTop: vars.space.medium, + paddingBottom: vars.space.medium, + paddingLeft: vars.space.large, + paddingRight: vars.space.large, + alignSelf: 'center', + display: 'block', + background: 'none', + borderRadius: vars.radii.large, + cursor: 'pointer', + border: '1px solid currentColor', +}); + +globalStyle('.CodeMirror-dialog button:focus', { + color: colorPaletteVars.foreground.accent, + boxShadow: colorPaletteVars.shadows.focus, + outline: 'none', +}); + +globalStyle('.CodeMirror-dialog button:focus:hover', { + background: colorPaletteVars.background.selection, +}); + +globalStyle('.CodeMirror-dialog button:hover', { + background: colorPaletteVars.background.transparent, +}); diff --git a/src/Playroom/CodeEditor/CodeEditor.tsx b/src/Playroom/CodeEditor/CodeEditor.tsx index 3ef4b57e..3c869741 100644 --- a/src/Playroom/CodeEditor/CodeEditor.tsx +++ b/src/Playroom/CodeEditor/CodeEditor.tsx @@ -2,6 +2,7 @@ import { useRef, useContext, useEffect, useCallback } from 'react'; import { useDebouncedCallback } from 'use-debounce'; import type { Editor } from 'codemirror'; import 'codemirror/lib/codemirror.css'; +import 'codemirror/addon/dialog/dialog.css'; import 'codemirror/theme/neo.css'; import { @@ -26,6 +27,10 @@ import 'codemirror/addon/edit/closetag'; import 'codemirror/addon/edit/closebrackets'; import 'codemirror/addon/hint/show-hint'; import 'codemirror/addon/hint/xml-hint'; +import 'codemirror/addon/dialog/dialog'; +import 'codemirror/addon/search/jump-to-line'; +import 'codemirror/addon/search/search'; +import 'codemirror/addon/search/searchcursor'; import 'codemirror/addon/selection/active-line'; import 'codemirror/addon/fold/foldcode'; import 'codemirror/addon/fold/foldgutter'; @@ -117,6 +122,17 @@ export const CodeEditor = ({ code, onChange, previewCode, hints }: Props) => { e.preventDefault(); dispatch({ type: 'toggleToolbar', payload: { panel: 'snippets' } }); } + + // Prevent browser keyboard shortcuts when the search/replace input is focused + if ( + cmdOrCtrl && + document.activeElement?.classList.contains( + 'CodeMirror-search-field' + ) && + e.key === 'f' + ) { + e.preventDefault(); + } } }; @@ -259,6 +275,14 @@ export const CodeEditor = ({ code, onChange, previewCode, hints }: Props) => { [`${keymapModifierKey}-D`]: selectNextOccurrence, [`Shift-${keymapModifierKey}-,`]: wrapInTag, [`${keymapModifierKey}-/`]: toggleComment, + [`${keymapModifierKey}-F`]: 'findPersistent', + [`${keymapModifierKey}-Alt-F`]: 'replace', + [`${keymapModifierKey}-G`]: 'jumpToLine', + ['Alt-G']: false, // override default keybinding + ['Alt-F']: false, // override default keybinding + ['Shift-Ctrl-R']: false, // override default keybinding + ['Cmd-Option-F']: false, // override default keybinding + ['Shift-Cmd-Option-F']: false, // override default keybinding [`Shift-${keymapModifierKey}-C`]: () => { dispatch({ type: 'copyToClipboard', diff --git a/src/Playroom/SettingsPanel/SettingsPanel.tsx b/src/Playroom/SettingsPanel/SettingsPanel.tsx index ec81f9b5..3cd70809 100644 --- a/src/Playroom/SettingsPanel/SettingsPanel.tsx +++ b/src/Playroom/SettingsPanel/SettingsPanel.tsx @@ -25,12 +25,15 @@ const getKeyBindings = () => { const shiftKeySymbol = isMac() ? '⇧' : 'Shift'; return { + Find: [metaKeySymbol, 'F'], + 'Find and replace': [metaKeySymbol, altKeySymbol, 'F'], 'Toggle comment': [metaKeySymbol, '/'], 'Wrap selection in tag': [metaKeySymbol, shiftKeySymbol, ','], 'Format code': [metaKeySymbol, 'S'], 'Insert snippet': [metaKeySymbol, 'K'], 'Copy Playroom link': [metaKeySymbol, shiftKeySymbol, 'C'], 'Select next occurrence': [metaKeySymbol, 'D'], + 'Jump to line number': [metaKeySymbol, 'G'], 'Swap line up': [altKeySymbol, '↑'], 'Swap line down': [altKeySymbol, '↓'], 'Duplicate line up': [shiftKeySymbol, altKeySymbol, '↑'], diff --git a/src/Playroom/palettes.ts b/src/Playroom/palettes.ts index 4f33ecef..98d4a9c1 100644 --- a/src/Playroom/palettes.ts +++ b/src/Playroom/palettes.ts @@ -49,7 +49,8 @@ export const light = { neutral: originalPalette.gray2, surface: originalPalette.white, body: originalPalette.gray1, - selection: originalPalette.blue0, + selection: transparentize(0.85, originalPalette.blue1), + search: darken(0.15, originalPalette.blue0), }, border: { standard: originalPalette.gray2, @@ -143,14 +144,15 @@ export const dark = { positive: seekPalette.mint[500], }, background: { - transparent: 'rgba(0, 0, 0, .15)', + transparent: 'rgba(255, 255, 255, .07)', accent: seekPalette.blue[500], positive: mix(0.6, seekPalette.grey[900], seekPalette.mint[500]), critical: mix(0.7, seekPalette.grey[900], seekPalette.red[600]), neutral: seekPalette.grey[800], surface: seekPalette.grey[900], body: darken(0.03, seekPalette.grey[900]), - selection: transparentize(0.85, seekPalette.blue[600]), + selection: transparentize(0.75, seekPalette.blue[600]), + search: transparentize(0.25, seekPalette.blue[600]), }, border: { standard: seekPalette.grey[800], diff --git a/src/Playroom/sprinkles.css.ts b/src/Playroom/sprinkles.css.ts index 6914f517..8c8659b3 100644 --- a/src/Playroom/sprinkles.css.ts +++ b/src/Playroom/sprinkles.css.ts @@ -75,6 +75,7 @@ export const colorPaletteVars = createThemeContract({ surface: null, body: null, selection: null, + search: null, }, border: { standard: null,