-
-
Notifications
You must be signed in to change notification settings - Fork 173
hotkey fix for the editor #1290
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -54,107 +54,127 @@ export const useMarkdownHotkeys = ( | |||||||||||||||||||||||||||||||||||||
| const handleHotkey = useCallback( | ||||||||||||||||||||||||||||||||||||||
| (hotkey: Hotkey) => (e: KeyboardEvent) => { | ||||||||||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||||||||||
| if (textareaRef.current) { | ||||||||||||||||||||||||||||||||||||||
| const textarea = textareaRef.current; | ||||||||||||||||||||||||||||||||||||||
| const startPos = textarea.selectionStart; | ||||||||||||||||||||||||||||||||||||||
| const endPos = textarea.selectionEnd; | ||||||||||||||||||||||||||||||||||||||
| const currentValue = textarea.value; | ||||||||||||||||||||||||||||||||||||||
| const { markup, type } = hotkey; | ||||||||||||||||||||||||||||||||||||||
| let newText; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| switch (type) { | ||||||||||||||||||||||||||||||||||||||
| case "pre": | ||||||||||||||||||||||||||||||||||||||
| newText = `${markup}${currentValue.slice(startPos, endPos)}`; | ||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| case "wrap": | ||||||||||||||||||||||||||||||||||||||
| // check for codeBlock, url then default wrap | ||||||||||||||||||||||||||||||||||||||
| if (hotkey.key === "c" && hotkey.useShift) { | ||||||||||||||||||||||||||||||||||||||
| newText = `${markup}\n\n${markup}`; | ||||||||||||||||||||||||||||||||||||||
| } else if (hotkey.key === "u") { | ||||||||||||||||||||||||||||||||||||||
| newText = `${markup[0]}${currentValue.slice(startPos, endPos)}${ | ||||||||||||||||||||||||||||||||||||||
| markup[1] | ||||||||||||||||||||||||||||||||||||||
| }`; | ||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||
| newText = `${markup}${currentValue.slice( | ||||||||||||||||||||||||||||||||||||||
| startPos, | ||||||||||||||||||||||||||||||||||||||
| endPos, | ||||||||||||||||||||||||||||||||||||||
| )}${markup}`; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| case "blockQuote": | ||||||||||||||||||||||||||||||||||||||
| const lines = currentValue.slice(startPos, endPos).split("\n"); | ||||||||||||||||||||||||||||||||||||||
| const quotedLines = lines.map((line) => `${markup} ${line}`); | ||||||||||||||||||||||||||||||||||||||
| newText = quotedLines.join("\n"); | ||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| case "linkOrImage": | ||||||||||||||||||||||||||||||||||||||
| const selectedText = currentValue.slice(startPos, endPos); | ||||||||||||||||||||||||||||||||||||||
| if (!selectedText) return; // Do nothing if no text is selected | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const url = prompt("Enter the URL:"); | ||||||||||||||||||||||||||||||||||||||
| if (!url) return; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const tag = markup | ||||||||||||||||||||||||||||||||||||||
| .replace("text", selectedText) | ||||||||||||||||||||||||||||||||||||||
| .replace("url", url); | ||||||||||||||||||||||||||||||||||||||
| textarea.value = `${currentValue.slice( | ||||||||||||||||||||||||||||||||||||||
| 0, | ||||||||||||||||||||||||||||||||||||||
| e.stopPropagation(); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const textarea = textareaRef.current; | ||||||||||||||||||||||||||||||||||||||
| if (!textarea) return; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const startPos = textarea.selectionStart; | ||||||||||||||||||||||||||||||||||||||
| const endPos = textarea.selectionEnd; | ||||||||||||||||||||||||||||||||||||||
| const currentValue = textarea.value; | ||||||||||||||||||||||||||||||||||||||
| const { markup, type } = hotkey; | ||||||||||||||||||||||||||||||||||||||
| let newText; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| switch (type) { | ||||||||||||||||||||||||||||||||||||||
| case "pre": | ||||||||||||||||||||||||||||||||||||||
| newText = `${markup}${currentValue.slice(startPos, endPos)}`; | ||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| case "wrap": | ||||||||||||||||||||||||||||||||||||||
| // check for codeBlock, url then default wrap | ||||||||||||||||||||||||||||||||||||||
| if (hotkey.key === "c" && hotkey.useShift) { | ||||||||||||||||||||||||||||||||||||||
| newText = `${markup}\n\n${markup}`; | ||||||||||||||||||||||||||||||||||||||
| } else if (hotkey.key === "u") { | ||||||||||||||||||||||||||||||||||||||
| newText = `${markup[0]}${currentValue.slice(startPos, endPos)}${ | ||||||||||||||||||||||||||||||||||||||
| markup[1] | ||||||||||||||||||||||||||||||||||||||
| }`; | ||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||
| newText = `${markup}${currentValue.slice( | ||||||||||||||||||||||||||||||||||||||
| startPos, | ||||||||||||||||||||||||||||||||||||||
| )}${tag}${currentValue.slice(endPos)}`; | ||||||||||||||||||||||||||||||||||||||
| const cursorPos = startPos + tag.length; | ||||||||||||||||||||||||||||||||||||||
| textarea.setSelectionRange(cursorPos, cursorPos); | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| case "select": | ||||||||||||||||||||||||||||||||||||||
| let start = startPos - 1; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // Move left while the cursor is on whitespace | ||||||||||||||||||||||||||||||||||||||
| while (start >= 0 && /\s/.test(currentValue[start])) { | ||||||||||||||||||||||||||||||||||||||
| start--; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // Move left while the cursor is on non-whitespace | ||||||||||||||||||||||||||||||||||||||
| while (start >= 0 && /\S/.test(currentValue[start])) { | ||||||||||||||||||||||||||||||||||||||
| start--; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| start++; // Move to the beginning of the word | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // Trim right whitespace | ||||||||||||||||||||||||||||||||||||||
| let trimmedEnd = endPos; | ||||||||||||||||||||||||||||||||||||||
| while (/\s/.test(currentValue[trimmedEnd - 1])) { | ||||||||||||||||||||||||||||||||||||||
| trimmedEnd--; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| textarea.setSelectionRange(start, trimmedEnd); | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| default: | ||||||||||||||||||||||||||||||||||||||
| setSelectCount(0); | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| textarea.value = `${currentValue.slice( | ||||||||||||||||||||||||||||||||||||||
| 0, | ||||||||||||||||||||||||||||||||||||||
| startPos, | ||||||||||||||||||||||||||||||||||||||
| )}${newText}${currentValue.slice(endPos)}`; | ||||||||||||||||||||||||||||||||||||||
| const cursorPos = | ||||||||||||||||||||||||||||||||||||||
| type === "wrap" && hotkey.key === "c" && hotkey.useShift | ||||||||||||||||||||||||||||||||||||||
| ? startPos + markup.length + 1 | ||||||||||||||||||||||||||||||||||||||
| : startPos + newText.length; | ||||||||||||||||||||||||||||||||||||||
| textarea.setSelectionRange(cursorPos, cursorPos); | ||||||||||||||||||||||||||||||||||||||
| endPos, | ||||||||||||||||||||||||||||||||||||||
| )}${markup}`; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| case "blockQuote": | ||||||||||||||||||||||||||||||||||||||
| const lines = currentValue.slice(startPos, endPos).split("\n"); | ||||||||||||||||||||||||||||||||||||||
| const quotedLines = lines.map((line) => `${markup} ${line}`); | ||||||||||||||||||||||||||||||||||||||
| newText = quotedLines.join("\n"); | ||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+89
to
+94
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Case-level declarations leak across switch; wrap each case in a block. Biome’s noSwitchDeclarations errors are valid; variables declared in one case are visible to others. Apply this patch: - case "blockQuote":
- const lines = currentValue.slice(startPos, endPos).split("\n");
- const quotedLines = lines.map((line) => `${markup} ${line}`);
- newText = quotedLines.join("\n");
- break;
+ case "blockQuote": {
+ const lines = currentValue.slice(startPos, endPos).split("\n");
+ const quotedLines = lines.map((line) => `${markup} ${line}`);
+ newText = quotedLines.join("\n");
+ break;
+ }
@@
- case "linkOrImage":
- const selectedText = currentValue.slice(startPos, endPos);
- if (!selectedText) return; // Do nothing if no text is selected
-
- const url = prompt("Enter the URL:");
- if (!url) return;
-
- const tag = markup
- .replace("text", selectedText)
- .replace("url", url);
- textarea.value = `${currentValue.slice(
- 0,
- startPos,
- )}${tag}${currentValue.slice(endPos)}`;
- const cursorPos = startPos + tag.length;
- textarea.setSelectionRange(cursorPos, cursorPos);
- return;
+ case "linkOrImage": {
+ const selectedText = currentValue.slice(startPos, endPos);
+ if (!selectedText) return; // Do nothing if no text is selected
+ const url = prompt("Enter the URL:");
+ if (!url) return;
+ const tag = markup.replace("text", selectedText).replace("url", url);
+ const nextValue = `${currentValue.slice(0, startPos)}${tag}${currentValue.slice(endPos)}`;
+ const cursorPos = startPos + tag.length;
+ textarea.value = nextValue;
+ textarea.setSelectionRange(cursorPos, cursorPos);
+ return;
+ }
@@
- case "select":
- let start = startPos - 1;
-
- // Move left while the cursor is on whitespace
- while (start >= 0 && /\s/.test(currentValue[start])) {
- start--;
- }
-
- // Move left while the cursor is on non-whitespace
- while (start >= 0 && /\S/.test(currentValue[start])) {
- start--;
- }
-
- start++; // Move to the beginning of the word
-
- // Trim right whitespace
- let trimmedEnd = endPos;
- while (/\s/.test(currentValue[trimmedEnd - 1])) {
- trimmedEnd--;
- }
- textarea.setSelectionRange(start, trimmedEnd);
- return;
+ case "select": {
+ let start = startPos - 1;
+ while (start >= 0 && /\s/.test(currentValue[start])) start--;
+ while (start >= 0 && /\S/.test(currentValue[start])) start--;
+ start++;
+ let trimmedEnd = endPos;
+ while (/\s/.test(currentValue[trimmedEnd - 1])) trimmedEnd--;
+ textarea.setSelectionRange(start, trimmedEnd);
+ return;
+ }Based on static analysis hints. Also applies to: 95-112, 113-135 🧰 Tools🪛 Biome (2.1.2)[error] 90-90: Other switch clauses can erroneously access this declaration. The declaration is defined in this switch clause: Safe fix: Wrap the declaration in a block. (lint/correctness/noSwitchDeclarations) [error] 91-91: Other switch clauses can erroneously access this declaration. The declaration is defined in this switch clause: Safe fix: Wrap the declaration in a block. (lint/correctness/noSwitchDeclarations) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| case "linkOrImage": | ||||||||||||||||||||||||||||||||||||||
| const selectedText = currentValue.slice(startPos, endPos); | ||||||||||||||||||||||||||||||||||||||
| if (!selectedText) return; // Do nothing if no text is selected | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const url = prompt("Enter the URL:"); | ||||||||||||||||||||||||||||||||||||||
| if (!url) return; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const tag = markup | ||||||||||||||||||||||||||||||||||||||
| .replace("text", selectedText) | ||||||||||||||||||||||||||||||||||||||
| .replace("url", url); | ||||||||||||||||||||||||||||||||||||||
| textarea.value = `${currentValue.slice( | ||||||||||||||||||||||||||||||||||||||
| 0, | ||||||||||||||||||||||||||||||||||||||
| startPos, | ||||||||||||||||||||||||||||||||||||||
| )}${tag}${currentValue.slice(endPos)}`; | ||||||||||||||||||||||||||||||||||||||
| const cursorPos = startPos + tag.length; | ||||||||||||||||||||||||||||||||||||||
| textarea.setSelectionRange(cursorPos, cursorPos); | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| case "select": | ||||||||||||||||||||||||||||||||||||||
| let start = startPos - 1; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // Move left while the cursor is on whitespace | ||||||||||||||||||||||||||||||||||||||
| while (start >= 0 && /\s/.test(currentValue[start])) { | ||||||||||||||||||||||||||||||||||||||
| start--; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // Move left while the cursor is on non-whitespace | ||||||||||||||||||||||||||||||||||||||
| while (start >= 0 && /\S/.test(currentValue[start])) { | ||||||||||||||||||||||||||||||||||||||
| start--; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| start++; // Move to the beginning of the word | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // Trim right whitespace | ||||||||||||||||||||||||||||||||||||||
| let trimmedEnd = endPos; | ||||||||||||||||||||||||||||||||||||||
| while (/\s/.test(currentValue[trimmedEnd - 1])) { | ||||||||||||||||||||||||||||||||||||||
| trimmedEnd--; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| textarea.setSelectionRange(start, trimmedEnd); | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| default: | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| textarea.value = `${currentValue.slice( | ||||||||||||||||||||||||||||||||||||||
| 0, | ||||||||||||||||||||||||||||||||||||||
| startPos, | ||||||||||||||||||||||||||||||||||||||
| )}${newText}${currentValue.slice(endPos)}`; | ||||||||||||||||||||||||||||||||||||||
| const cursorPos = | ||||||||||||||||||||||||||||||||||||||
| type === "wrap" && hotkey.key === "c" && hotkey.useShift | ||||||||||||||||||||||||||||||||||||||
| ? startPos + markup.length + 1 | ||||||||||||||||||||||||||||||||||||||
| : startPos + newText.length; | ||||||||||||||||||||||||||||||||||||||
| textarea.setSelectionRange(cursorPos, cursorPos); | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| [], | ||||||||||||||||||||||||||||||||||||||
| [textareaRef], | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // Map each hotkey to its corresponding callback | ||||||||||||||||||||||||||||||||||||||
| Object.values(hotkeys).forEach((hotkey) => { | ||||||||||||||||||||||||||||||||||||||
| useHotkeys( | ||||||||||||||||||||||||||||||||||||||
| `${hotkey.key}${hotkey.useShift ? "+meta+shift" : "+meta"}`, | ||||||||||||||||||||||||||||||||||||||
| handleHotkey(hotkey), | ||||||||||||||||||||||||||||||||||||||
| { enableOnFormTags: true }, | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| // Use useEffect to bind event listeners directly to the textarea | ||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||
| const textarea = textareaRef.current; | ||||||||||||||||||||||||||||||||||||||
| if (!textarea) return; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const keydownHandler = (e: KeyboardEvent) => { | ||||||||||||||||||||||||||||||||||||||
| // Check if it's a meta/ctrl key combination | ||||||||||||||||||||||||||||||||||||||
| if (!e.metaKey && !e.ctrlKey) return; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // Find matching hotkey | ||||||||||||||||||||||||||||||||||||||
| const matchingHotkey = Object.values(hotkeys).find((hotkey) => { | ||||||||||||||||||||||||||||||||||||||
| const isCorrectKey = e.key === hotkey.key; | ||||||||||||||||||||||||||||||||||||||
| const hasCorrectShift = hotkey.useShift ? e.shiftKey : !e.shiftKey; | ||||||||||||||||||||||||||||||||||||||
| return isCorrectKey && hasCorrectShift; | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+162
to
+167
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SHIFT combos and punctuation won’t match reliably (use e.code/normalize). e.key is case- and layout-dependent (e.g., Shift+B → "B", Shift+. → ">"), so matching against literal "b" or "." fails. Normalize and fall back to e.code for digits/punctuation. Apply this patch: - const matchingHotkey = Object.values(hotkeys).find((hotkey) => {
- const isCorrectKey = e.key === hotkey.key;
- const hasCorrectShift = hotkey.useShift ? e.shiftKey : !e.shiftKey;
- return isCorrectKey && hasCorrectShift;
- });
+ const matchingHotkey = Object.values(hotkeys).find((hotkey) => {
+ const key = ((e.key as string) || "").toLowerCase();
+ const isCorrectKey =
+ key === hotkey.key ||
+ // punctuation/locale-safe for blockquote
+ (hotkey.key === "." && e.code === "Period") ||
+ // digits via code (handles Shift and various layouts)
+ (/^[1-6]$/.test(hotkey.key) && e.code === `Digit${hotkey.key}`);
+ const hasCorrectShift = hotkey.useShift ? e.shiftKey : !e.shiftKey;
+ return isCorrectKey && hasCorrectShift;
+ });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if (matchingHotkey) { | ||||||||||||||||||||||||||||||||||||||
| handleHotkey(matchingHotkey)(e); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| textarea.addEventListener('keydown', keydownHandler); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||||||||||||||||||
| textarea.removeEventListener('keydown', keydownHandler); | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
| }, [textareaRef, handleHotkey]); | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mutating textarea.value needs an 'input' event to keep React state in sync.
Without dispatching an input event, controlled textareas won’t observe the change.
Apply this patch:
Also applies to: 105-111, 140-148
🤖 Prompt for AI Agents