Skip to content
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 118 additions & 98 deletions markdoc/editor/hotkeys/hotkeys.markdoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Comment on lines +65 to +67
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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:

-      let newText;
+      const commit = (nextValue: string, pos: number) => {
+        textarea.value = nextValue;
+        textarea.setSelectionRange(pos, pos);
+        textarea.dispatchEvent(new Event("input", { bubbles: true }));
+      };
+      let newText;
@@
-          textarea.value = `${currentValue.slice(
-            0,
-            startPos,
-          )}${tag}${currentValue.slice(endPos)}`;
-          const cursorPos = startPos + tag.length;
-          textarea.setSelectionRange(cursorPos, cursorPos);
+          const nextValue = `${currentValue.slice(0, startPos)}${tag}${currentValue.slice(endPos)}`;
+          const cursorPos = startPos + tag.length;
+          commit(nextValue, cursorPos);
           return;
@@
-      textarea.value = `${currentValue.slice(
-        0,
-        startPos,
-      )}${newText}${currentValue.slice(endPos)}`;
-      const cursorPos =
+      const nextValue = `${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);
+      commit(nextValue, cursorPos);

Also applies to: 105-111, 140-148

🤖 Prompt for AI Agents
In markdoc/editor/hotkeys/hotkeys.markdoc.ts around lines 65-67 (and similarly
at 105-111 and 140-148), the code directly assigns to textarea.value which won't
update React-controlled state; after setting textarea.value you must dispatch an
'input' event on the element (e.g. create an Event('input', { bubbles: true,
cancelable: true }) and call textarea.dispatchEvent(event)) so React's change
handlers run and the controlled value stays in sync; add this dispatch
immediately after each textarea.value assignment.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.
Wrap the declaration in a block to restrict its access to the switch clause.

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.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)

🤖 Prompt for AI Agents
In markdoc/editor/hotkeys/hotkeys.markdoc.ts around lines 89-94 (and also apply
the same change to 95-112 and 113-135), case-level const/let declarations leak
between switch cases; wrap the body of each case in its own block (add { ... }
around the existing statements) so variables like lines, quotedLines, and
newText are scoped to that case only, keeping the existing logic and break
statements intact.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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;
});
// Find matching hotkey
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;
});
🤖 Prompt for AI Agents
In markdoc/editor/hotkeys/hotkeys.markdoc.ts around lines 162 to 167, the
current match uses e.key which is case- and layout-dependent and fails for Shift
combos and punctuation; change the matching to normalize the event key (e.g.,
const key = e.key?.toLowerCase() || '') and compare against lower-cased
hotkey.key, and for keys that are layout-sensitive (digits/punctuation) fall
back to using e.code (e.code normalized) when e.key doesn't match; preserve the
existing useShift check (hotkey.useShift ? e.shiftKey : !e.shiftKey) and ensure
both normalized key and fallback code are checked when deciding a match.


if (matchingHotkey) {
handleHotkey(matchingHotkey)(e);
}
};

textarea.addEventListener('keydown', keydownHandler);

return () => {
textarea.removeEventListener('keydown', keydownHandler);
};
}, [textareaRef, handleHotkey]);
};
Loading