From 925aa3894a04d18272700b58f3ad47acf0dc9009 Mon Sep 17 00:00:00 2001 From: Daniel Callaghan Date: Sat, 18 Mar 2023 10:41:56 +0000 Subject: [PATCH 1/2] feat(shortcuts): add hotkeys and shortcuts for the blog editor --- EDITOR_SHORTCUTS.MD | 52 ++++++++ README.md | 6 + .../CustomTextareaAutosize.tsx | 35 +++++ markdoc/editor/hotkeys/hotkeys.markdoc.ts | 125 ++++++++++++++++++ markdoc/editor/shortcuts/shortcuts.markdoc.ts | 103 +++++++++++++++ package-lock.json | 18 ++- package.json | 1 + pages/create/[[...postIdArr]].tsx | 16 ++- 8 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 EDITOR_SHORTCUTS.MD create mode 100644 components/CustomTextareAutosize/CustomTextareaAutosize.tsx create mode 100644 markdoc/editor/hotkeys/hotkeys.markdoc.ts create mode 100644 markdoc/editor/shortcuts/shortcuts.markdoc.ts diff --git a/EDITOR_SHORTCUTS.MD b/EDITOR_SHORTCUTS.MD new file mode 100644 index 00000000..1a15ae3f --- /dev/null +++ b/EDITOR_SHORTCUTS.MD @@ -0,0 +1,52 @@ +# Hotkeys and Shortcuts Guide + +This guide provides an overview of the available hotkeys and shortcuts for the custom text editor. These hotkeys and shortcuts can help you speed up your content creation and editing process. + +## Hotkeys + +| Hotkey | Description | +|-----------------|------------------------------| +| `Tab` | Trigger shortcuts (see below)| +| `Backspace` | Select Previous word | +| `Meta+(1-6)` | Heading 1 - 6 | +| `Meta+b` | Bold | +| `Meta+i` | Italic | +| `Meta+shift+b` | Bold & Italic | +| `Meta+s` | Code Snippet | +| `Meta+shift+c` | Code Block | +| `Meta+Shift+.` | Block Quote | +| `Meta+l` | Link | +| `Meta+Shift+i` | Image | + +## How to Use Hotkeys + +1. Press the meta key (windows key or mac cmd key) with the desired hotkey eg, cmd+1 to render # +2. You can also highlight the word and then use the hotkey combination and for this you can double click the word or phrase or press meta+backspace a few times to highlight the required selection of text. +3. For Links and images, select the text and then use the hotkey combination. You will be prompted for the url. + + +## Markdown Shortcuts + +| Shortcut | Description | Example | +|----------|----------------------------------------|-------------------------------| +| `/link` | Create a link with text and URL | `[text](url)` | +| `/image` | Insert an image with alt text and URL | `![text](url)` | + +## Custom Tag Shortcuts + +| Shortcut | Description | Example | +|---------------|------------------------------------|-------------------------------| +| `/media` | Embed a media file with src | `{% media src="url" /%}` | +| `/youtube` | Embed a YouTube video with src | `{% youtube src="url" /%}` | +| `/codepen` | Embed a CodePen project with src | `{% codepen src="url" /%}` | +| `/codesandbox`| Embed a CodeSandbox project with src | `{% codesandbox src="url" /%}`| + +## How to Use Shortcuts + +1. Place the cursor where you want to insert the content. +2. Type the shortcut (e.g., `/link`). +3. Press the `Tab` key. +4. For Markdown shortcuts `/link` and `/image`, you'll be prompted to enter the text and URL. +5. For custom tag shortcuts, you'll be prompted to enter the URL for the `src` attribute. + +The editor will automatically replace the shortcut with the corresponding content. diff --git a/README.md b/README.md index c3342694..74be370e 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,12 @@ To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +### Editor Doc + +To learn about the editor shortcuts and hotkeys you can check out this document + +- [Markdoc Editor Hotkeys and Shortcus](/EDITOR_SHORTCUTS.MD) + ## 💥 Issues You are welcome to [open issues](https://github.com/codu-code/codu/issues/new/choose) to discuss ideas about improving our Codú. Enhancements are encouraged and appreciated. diff --git a/components/CustomTextareAutosize/CustomTextareaAutosize.tsx b/components/CustomTextareAutosize/CustomTextareaAutosize.tsx new file mode 100644 index 00000000..968d1c7d --- /dev/null +++ b/components/CustomTextareAutosize/CustomTextareaAutosize.tsx @@ -0,0 +1,35 @@ +import React, { forwardRef, Ref, ForwardRefRenderFunction } from 'react'; +import TextareaAutosize, { TextareaAutosizeProps } from 'react-textarea-autosize'; + +interface TextareaAutosizeWrapperProps extends TextareaAutosizeProps { + inputRef?: Ref; +} + +const TextareaAutosizeWrapper: ForwardRefRenderFunction = ( + props, + ref +) => { + const { inputRef, ...rest } = props; + + const combinedRef = (node: HTMLTextAreaElement | null) => { + if (ref) { + if (typeof ref === 'function') { + ref(node); + } else { + (ref as React.MutableRefObject).current = node; + } + } + + if (inputRef) { + if (typeof inputRef === 'function') { + inputRef(node); + } else { + (inputRef as React.MutableRefObject).current = node; + } + } + }; + + return ; +}; + +export default forwardRef(TextareaAutosizeWrapper); diff --git a/markdoc/editor/hotkeys/hotkeys.markdoc.ts b/markdoc/editor/hotkeys/hotkeys.markdoc.ts new file mode 100644 index 00000000..2dd295e5 --- /dev/null +++ b/markdoc/editor/hotkeys/hotkeys.markdoc.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; + +export interface Hotkey { + key: string; + useShift: boolean; + markup: string; + type: string; +} + +type Hotkeys = { + [name: string]: Hotkey; +}; + +// Define hotkeys, markup, and it is +metaKey or +export const hotkeys: Record = { + 'selectPrevious': {key: 'backspace', useShift: false, markup: '', type: "select"}, + 'heading 1': { key: '1', useShift: false, markup: '# ', type: "pre" }, + 'heading 2': { key: '2', useShift: false, markup: '## ', type: "pre" }, + 'heading 3': { key: '3', useShift: false, markup: '### ', type: "pre" }, + 'heading 4': { key: '4', useShift: false, markup: '#### ', type: "pre" }, + 'heading 5': { key: '5', useShift: false, markup: '##### ', type: "pre" }, + 'heading 6': { key: '6', useShift: false, markup: '###### ', type: "pre" }, + 'bold': { key: 'b', useShift: false, markup: '**', type: "wrap" }, + 'italic': { key: 'i', useShift: false, markup: '_', type: "wrap" }, + 'boldItalic': { key: 'b', useShift: true, markup: '***', type: "wrap" }, + 'codeSnippet': {key: 's', useShift: false, markup: '`', type: "wrap"}, + 'codeBlock': {key: 'c', useShift: true, markup: '```', type: "wrap"}, + 'blockQuote': {key: '.', useShift: true, markup: '>', type: "blockQuote"}, + 'link': { key: 'l', useShift: false, markup: '[text](url)', type: "linkOrImage" }, + 'image': { key: 'i', useShift: true, markup: '![text](url)', type: "linkOrImage" }, + 'url': { key: 'u', useShift: false, markup: '<>', type: "wrap" }, + }; + + + +export const useMarkdownHotkeys = (textareaRef: React.RefObject) => { + +// Create a single callback for all hotkeys +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, 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); + } + }, []); + +// Map each hotkey to its corresponding callback +Object.values(hotkeys).forEach((hotkey) => { + useHotkeys(`${hotkey.key}${hotkey.useShift ? '+meta+shift' : '+meta'}`, handleHotkey(hotkey), { enableOnFormTags: true }); + }); +}; diff --git a/markdoc/editor/shortcuts/shortcuts.markdoc.ts b/markdoc/editor/shortcuts/shortcuts.markdoc.ts new file mode 100644 index 00000000..438c725d --- /dev/null +++ b/markdoc/editor/shortcuts/shortcuts.markdoc.ts @@ -0,0 +1,103 @@ +// useCustomShortcuts.tsx +import { useCallback } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { RefObject } from 'react'; + +type Shortcuts = { + [name: string]: string; +}; + +export const markdownShortcuts: Shortcuts = { + '/link': '[text](url)', + '/image': '![text](url)', +}; + +export const customTagsShortcuts: Shortcuts = { + '/media': '{% media src="" /%}', + '/youtube': '{% youtube src="" /%}', + '/codepen': '{% codepen src="" /%}', + '/codesandbox': '{% codesandbox src="" /%}', +}; + +const insertCustomTag = ( + shortcut: string, + textarea: HTMLTextAreaElement +) => { + const tag = customTagsShortcuts[shortcut]; + const currentValue = textarea.value; + const startPos = textarea.selectionStart; + + textarea.value = `${currentValue.slice( + 0, + startPos - shortcut.length + )}${tag}${currentValue.slice(startPos)}`; + + const cursorPos = startPos + tag.length - shortcut.length; + textarea.setSelectionRange(cursorPos, cursorPos); +}; + +export const useMarkdownShortcuts = ( + textareaRef: RefObject + ) => { + const handleShortcut = useCallback( + (e: KeyboardEvent) => { + const textarea = textareaRef.current; + const value = textarea.value; + const startPos = textarea.selectionStart; + + for (const shortcut in customTagsShortcuts) { + if (value.slice(startPos - shortcut.length, startPos) === shortcut) { + e.preventDefault(); + + // Prompt for URL + const url = prompt('Enter the URL:'); + if (!url) return; + + const tag = customTagsShortcuts[shortcut].replace('src=""', `src="${url}"`); + const currentValue = textarea.value; + + textarea.value = `${currentValue.slice( + 0, + startPos - shortcut.length + )}${tag}${currentValue.slice(startPos)}`; + const cursorPos = startPos + tag.length - shortcut.length; + textarea.setSelectionRange(cursorPos, cursorPos); + break; + } + } + + for (const shortcut in markdownShortcuts) { + if (value.slice(startPos - shortcut.length, startPos) === shortcut) { + e.preventDefault(); + const tagTemplate = markdownShortcuts[shortcut]; + + if (shortcut === '/link' || shortcut === '/image') { + // Prompt for text + const text = prompt('Enter the text:'); + if (!text) return; + + // Prompt for URL + const url = prompt('Enter the URL:'); + if (!url) return; + + const tag = tagTemplate.replace('text', text).replace('url', url); + const currentValue = textarea.value; + + textarea.value = `${currentValue.slice( + 0, + startPos - shortcut.length + )}${tag}${currentValue.slice(startPos)}`; + const cursorPos = startPos + tag.length - shortcut.length; + textarea.setSelectionRange(cursorPos, cursorPos); + } + break; + } + } + }, + [textareaRef] + ); + + useHotkeys('tab', handleShortcut, { enableOnFormTags: true }); + }; + + diff --git a/package-lock.json b/package-lock.json index d9d35f5b..5dc76a4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.34.0", "react-hot-toast": "^2.3.0", + "react-hotkeys-hook": "^4.3.8", "react-intersection-observer": "^9.4.1", "react-query": "^3.39.2", "react-textarea-autosize": "^8.3.4", @@ -8304,6 +8305,15 @@ "react-dom": ">=16" } }, + "node_modules/react-hotkeys-hook": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.3.8.tgz", + "integrity": "sha512-RmrIQ3M259c84MnYVEAQsmHkD6s7XUgLG0rW6S7qjt1Lh7q+SPIz5b6obVU8OJw1Utsj1mUCj6twtBPaK/ytww==", + "peerDependencies": { + "react": ">=16.8.1", + "react-dom": ">=16.8.1" + } + }, "node_modules/react-intersection-observer": { "version": "9.4.1", "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.1.tgz", @@ -10791,7 +10801,7 @@ "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", - "json5": "^2.2.1", + "json5": "2.2.2", "semver": "^6.3.0" }, "dependencies": { @@ -15450,6 +15460,12 @@ "goober": "^2.1.10" } }, + "react-hotkeys-hook": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.3.8.tgz", + "integrity": "sha512-RmrIQ3M259c84MnYVEAQsmHkD6s7XUgLG0rW6S7qjt1Lh7q+SPIz5b6obVU8OJw1Utsj1mUCj6twtBPaK/ytww==", + "requires": {} + }, "react-intersection-observer": { "version": "9.4.1", "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.1.tgz", diff --git a/package.json b/package.json index 11e13885..9a0b1ec7 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.34.0", "react-hot-toast": "^2.3.0", + "react-hotkeys-hook": "^4.3.8", "react-intersection-observer": "^9.4.1", "react-query": "^3.39.2", "react-textarea-autosize": "^8.3.4", diff --git a/pages/create/[[...postIdArr]].tsx b/pages/create/[[...postIdArr]].tsx index f9bbd62c..e34ba1d6 100644 --- a/pages/create/[[...postIdArr]].tsx +++ b/pages/create/[[...postIdArr]].tsx @@ -1,11 +1,11 @@ import type { NextPage, GetServerSideProps } from "next"; import { ZodError } from "zod"; import { useRouter } from "next/router"; -import React, { useState, useEffect, Fragment } from "react"; +import React, { useState, useEffect, Fragment, useRef } from "react"; import { unstable_getServerSession } from "next-auth/next"; import { authOptions } from "../api/auth/[...nextauth]"; import { useForm } from "react-hook-form"; -import TextareaAutosize from "react-textarea-autosize"; +import CustomTextareaAutosize from "../../components/CustomTextareAutosize/CustomTextareaAutosize"; import toast, { Toaster } from "react-hot-toast"; import { Disclosure, Transition } from "@headlessui/react"; import { ChevronUpIcon } from "@heroicons/react/solid"; @@ -18,6 +18,8 @@ import { PromptDialog } from "../../components/PromptService/PromptService"; import { trpc } from "../../utils/trpc"; import { useDebounce } from "../../hooks/useDebounce"; import Markdoc from "@markdoc/markdoc"; +import { useMarkdownHotkeys } from "../../markdoc/editor/hotkeys/hotkeys.markdoc"; +import { useMarkdownShortcuts } from "../../markdoc/editor/shortcuts/shortcuts.markdoc"; import { markdocComponents } from "../../markdoc/components"; import { config } from "../../markdoc/config"; @@ -36,7 +38,11 @@ const Create: NextPage = () => { const [unsavedChanges, setUnsavedChanges] = useState(false); const [delayDebounce, setDelayDebounce] = useState(false); const allowUpdate = unsavedChanges && !delayDebounce - + const textareaRef = useRef(null); + + useMarkdownHotkeys(textareaRef); + useMarkdownShortcuts(textareaRef); + const { handleSubmit, register, watch, reset, getValues, formState: {isDirty} } = useForm({ mode: "onSubmit", @@ -488,12 +494,14 @@ const Create: NextPage = () => { {...register("title")} /> - +
<> {saveStatus === "loading" && ( From 5f38db196da26a1766a798063191f7b594bacfd3 Mon Sep 17 00:00:00 2001 From: Daniel Callaghan Date: Sat, 18 Mar 2023 10:52:45 +0000 Subject: [PATCH 2/2] add url to hotkeys --- EDITOR_SHORTCUTS.MD | 1 + 1 file changed, 1 insertion(+) diff --git a/EDITOR_SHORTCUTS.MD b/EDITOR_SHORTCUTS.MD index 1a15ae3f..9b761ead 100644 --- a/EDITOR_SHORTCUTS.MD +++ b/EDITOR_SHORTCUTS.MD @@ -15,6 +15,7 @@ This guide provides an overview of the available hotkeys and shortcuts for the c | `Meta+s` | Code Snippet | | `Meta+shift+c` | Code Block | | `Meta+Shift+.` | Block Quote | +| `Meta+u` | URL | | `Meta+l` | Link | | `Meta+Shift+i` | Image |