From 7e5300f6e8c261107ac069fd2edaa7013b2d258f Mon Sep 17 00:00:00 2001 From: Deepak Jose Date: Sun, 6 Oct 2024 21:59:24 +0530 Subject: [PATCH] Adds highlight lines feature to the code block. (#1233) * Updated the CodeBlock component to render the highlight button when there's a text selection. * Implemented the handle highlight function to set the selected line numbers as data attributes. * Created a plugin that can add highlight style to the selected lines of code. * Added the hightlight plugin and addAttributes to set the attributes based on the line selection. * Fixed editor content ignoring existing highlight information. * Implemented the code highlight preview feature in EditorContent. * Modify the handle selection callback to properly toggle the highlight based on the selection. * Added tooltip text to the highlight button. * Added highlightLine feature to the codeBlockhight.js * Added a filter to prevent setting line number zeros to highlight js. * Added css style to style the highlighted code in the code block component. * Minor improvements to the code while reviewing. * Added before and after to the higlighted lines to extend it beyond the container and hide padding. --- .../CodeBlock/CodeBlockComponent.jsx | 82 +++++++++++++++++-- .../CodeBlock/ExtensionConfig.js | 18 ++++ .../CustomExtensions/CodeBlock/plugins.js | 73 +++++++++++++++++ .../EditorContent/codeBlockHighlight.js | 32 +++++++- src/components/EditorContent/constants.js | 2 +- src/components/EditorContent/index.jsx | 7 +- src/components/EditorContent/utils.js | 63 +++++++++++++- src/styles/editor/_codeblock.scss | 10 +++ src/styles/editor/editor-content.scss | 18 ++++ src/styles/editor/index.scss | 1 + 10 files changed, 295 insertions(+), 11 deletions(-) create mode 100644 src/components/Editor/CustomExtensions/CodeBlock/plugins.js create mode 100644 src/styles/editor/_codeblock.scss diff --git a/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx b/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx index a9f74ca8..76ef7c78 100644 --- a/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx +++ b/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx @@ -1,18 +1,20 @@ -import { useRef, useState } from "react"; +import React, { useRef, useState, useCallback, useEffect } from "react"; import { NodeViewWrapper, NodeViewContent } from "@tiptap/react"; -import { Down } from "neetoicons"; +import { Down, Highlight } from "neetoicons"; import CopyToClipboardButton from "neetomolecules/CopyToClipboardButton"; -import { Dropdown, Input } from "neetoui"; +import { Dropdown, Input, Button } from "neetoui"; +import { difference, intersection, union } from "ramda"; import { useTranslation } from "react-i18next"; import { SORTED_LANGUAGE_LIST } from "./constants"; +import { codeBlockHighlightKey } from "./plugins"; const { Menu, MenuItem } = Dropdown; const CodeBlockComponent = ({ node, editor, updateAttributes }) => { const [keyword, setKeyword] = useState(""); - + const [showHighlightButton, setShowHighlightButton] = useState(false); const ref = useRef(); const { t } = useTranslation(); @@ -33,8 +35,69 @@ const CodeBlockComponent = ({ node, editor, updateAttributes }) => { } }; + const handleSelectionChange = useCallback(() => { + const { from, to } = editor.state.selection; + const isCodeBlockSelected = editor.isActive("codeBlock"); + setShowHighlightButton(isCodeBlockSelected && from !== to); + }, [editor]); + + useEffect(() => { + editor.on("selectionUpdate", handleSelectionChange); + + return () => { + editor.off("selectionUpdate", handleSelectionChange); + }; + }, [editor, handleSelectionChange]); + + useEffect(() => { + editor.view.dispatch(editor.state.tr.setMeta(codeBlockHighlightKey, true)); + }, []); + + const handleHighlight = () => { + const { from, to } = editor.state.selection; + const $from = editor.state.doc.resolve(from); + + const codeBlock = $from.node($from.depth); + const codeBlockStart = $from.start($from.depth); + + const textBeforeSelection = codeBlock.textBetween(0, from - codeBlockStart); + const startLine = textBeforeSelection.split("\n").length; + const selectedText = codeBlock.textBetween( + from - codeBlockStart, + to - codeBlockStart + ); + const selectedLines = selectedText.split("\n"); + + const newSelectedLines = selectedLines.map((_, index) => startLine + index); + const currentHighlightedLines = codeBlock.attrs.highlightedLines || []; + + // Check the overlap between new selection and current highlights + const overlapLines = intersection( + currentHighlightedLines, + newSelectedLines + ); + + let highlightedLines; + + if (overlapLines.length === newSelectedLines.length) { + // If the new selection is entirely within currently highlighted lines, + // remove the highlight from the selected lines + highlightedLines = difference(currentHighlightedLines, newSelectedLines); + } else { + // Add unhighlighted lines in the new selection to the highlight list + highlightedLines = union(currentHighlightedLines, newSelectedLines); + } + + editor.commands.updateAttributes(codeBlock.type, { highlightedLines }); + // Trigger the plugin to update decorations + editor.view.dispatch(editor.state.tr.setMeta(codeBlockHighlightKey, true)); + }; + return ( - +
           
{ style="secondary" value={node?.content?.content[0]?.text} /> + {showHighlightButton && ( +
diff --git a/src/components/Editor/CustomExtensions/CodeBlock/ExtensionConfig.js b/src/components/Editor/CustomExtensions/CodeBlock/ExtensionConfig.js index 496c99b6..e2ea2279 100644 --- a/src/components/Editor/CustomExtensions/CodeBlock/ExtensionConfig.js +++ b/src/components/Editor/CustomExtensions/CodeBlock/ExtensionConfig.js @@ -3,8 +3,26 @@ import { ReactNodeViewRenderer } from "@tiptap/react"; import { lowlight } from "lowlight"; import CodeBlockComponent from "./CodeBlockComponent"; +import { codeBlockHighlightPlugin } from "./plugins"; export default CodeBlockLowlight.extend({ + addAttributes() { + return { + ...this.parent?.(), + highlightedLines: { + default: [], + parseHTML: element => + element.dataset.highlightedLines?.split(",").map(Number) || [], + renderHTML: attributes => ({ + "data-highlighted-lines": + attributes.highlightedLines?.join(",") ?? "", + }), + }, + }; + }, + addProseMirrorPlugins() { + return [...(this.parent?.() || []), codeBlockHighlightPlugin]; + }, addNodeView() { return ReactNodeViewRenderer(CodeBlockComponent); }, diff --git a/src/components/Editor/CustomExtensions/CodeBlock/plugins.js b/src/components/Editor/CustomExtensions/CodeBlock/plugins.js new file mode 100644 index 00000000..acad7b7a --- /dev/null +++ b/src/components/Editor/CustomExtensions/CodeBlock/plugins.js @@ -0,0 +1,73 @@ +/* eslint-disable @bigbinary/neeto/file-name-and-export-name-standards */ +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; + +const codeBlockHighlightKey = new PluginKey("codeBlockHighlight"); + +function createLineDecoration(from, lineNumber) { + return Decoration.widget(from, view => { + const line = document.createElement("div"); + line.className = "highlighted-line"; + line.setAttribute("data-line-number", lineNumber.toString()); + + // Set the height of the highlight to match the line height + const lineElement = view.domAtPos(from).node; + if (lineElement) { + const lineHeight = window.getComputedStyle(lineElement).lineHeight; + line.style.height = lineHeight; + } + + return line; + }); +} + +function getLineRanges(node, pos) { + const lines = node.textContent.split("\n"); + let currentPos = pos + 1; // +1 to skip the opening tag of the code block + + return lines.map((line, index) => { + const from = currentPos; + const to = from + line.length; // +1 for newline, except last line + currentPos = to + (index < lines.length - 1 ? 1 : 0); + + return { from, to, lineNumber: index + 1 }; + }); +} + +const codeBlockHighlightPlugin = new Plugin({ + key: codeBlockHighlightKey, + state: { + init() { + return DecorationSet.empty; + }, + apply(tr, set) { + set = set.map(tr.mapping, tr.doc); + + if (tr.getMeta(codeBlockHighlightKey)) { + const decorations = []; + tr.doc.descendants((node, pos) => { + if (!(node.type.name === "codeBlock")) return; + const highlightedLines = node.attrs.highlightedLines || []; + const lineRanges = getLineRanges(node, pos); + + lineRanges.forEach(({ from, lineNumber }) => { + if (highlightedLines.includes(lineNumber)) { + decorations.push(createLineDecoration(from, lineNumber)); + } + }); + }); + + return DecorationSet.create(tr.doc, decorations); + } + + return set; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, +}); + +export { codeBlockHighlightPlugin, codeBlockHighlightKey }; diff --git a/src/components/EditorContent/codeBlockHighlight.js b/src/components/EditorContent/codeBlockHighlight.js index fe33ff5a..7495d018 100644 --- a/src/components/EditorContent/codeBlockHighlight.js +++ b/src/components/EditorContent/codeBlockHighlight.js @@ -1,7 +1,33 @@ +/* eslint-disable @bigbinary/neeto/file-name-and-export-name-standards */ import hljs from "highlight.js/lib/common"; +const highlightLinesScriptCDNPath = + "//cdn.jsdelivr.net/gh/TRSasasusu/highlightjs-highlight-lines.js@1.2.0/highlightjs-highlight-lines.min.js"; + +function applyLineHighlighting(codeElement) { + hljs.highlightElement(codeElement); + const preElement = codeElement.closest("pre"); + + const highlightedLines = preElement.getAttribute("data-highlighted-lines"); + if (highlightedLines) { + const linesToHighlight = highlightedLines.split(",").map(Number); + const highlightLinesOptions = linesToHighlight + .filter(line => line > 0) + .map(line => ({ + start: line, + end: line, + color: "rgba(255, 255, 0, 0.2)", + })); + hljs.highlightLinesElement(codeElement, highlightLinesOptions); + } +} + document.addEventListener("DOMContentLoaded", () => { - document - .querySelectorAll("pre code") - .forEach(element => hljs.highlightElement(element)); + const script = document.createElement("script"); + script.src = highlightLinesScriptCDNPath; + script.async = true; + document.head.appendChild(script); + script.addEventListener("load", () => { + document.querySelectorAll("pre code").forEach(applyLineHighlighting); + }); }); diff --git a/src/components/EditorContent/constants.js b/src/components/EditorContent/constants.js index 3eef285e..1808808c 100644 --- a/src/components/EditorContent/constants.js +++ b/src/components/EditorContent/constants.js @@ -8,7 +8,7 @@ export const SANITIZE_OPTIONS = { }; export const CODE_BLOCK_REGEX = - /
([\S\s]*?)<\/code><\/pre>/gim;
+  /]+)?>([\S\s]*?)<\/code><\/pre>/gim;
 
 export const VARIABLE_SPAN_REGEX =
   /]*data-label="([^"]+)">{{([^}]+)}}<\/span>/g;
diff --git a/src/components/EditorContent/index.jsx b/src/components/EditorContent/index.jsx
index 6d6a201e..6786536e 100644
--- a/src/components/EditorContent/index.jsx
+++ b/src/components/EditorContent/index.jsx
@@ -10,7 +10,11 @@ import "src/styles/editor/editor-content.scss";
 
 import { EDITOR_CONTENT_CLASSNAME, SANITIZE_OPTIONS } from "./constants";
 import ImagePreview from "./ImagePreview";
-import { highlightCode, substituteVariables } from "./utils";
+import {
+  highlightCode,
+  substituteVariables,
+  applyLineHighlighting,
+} from "./utils";
 
 const EditorContent = ({
   content = "",
@@ -65,6 +69,7 @@ const EditorContent = ({
   useEffect(() => {
     injectCopyButtonToCodeBlocks();
     bindImageClickListener();
+    applyLineHighlighting(editorContentRef.current);
   }, [content]);
 
   return (
diff --git a/src/components/EditorContent/utils.js b/src/components/EditorContent/utils.js
index 8e5e7b8d..61bb65f1 100644
--- a/src/components/EditorContent/utils.js
+++ b/src/components/EditorContent/utils.js
@@ -27,11 +27,49 @@ const buildReactElementFromAST = element => {
   return element.value;
 };
 
+const highlightLinesCode = (code, options) => {
+  code.innerHTML = code.innerHTML.replace(
+    /([ \S]*\n|[ \S]*$)/gm,
+    match => `
${match}
` + ); + + if (options === undefined) { + return; + } + + const paddingLeft = parseInt(window.getComputedStyle(code).paddingLeft); + const paddingRight = parseInt(window.getComputedStyle(code).paddingRight); + + const lines = code.getElementsByClassName("highlight-line"); + const scroll_width = code.scrollWidth; + // eslint-disable-next-line @bigbinary/neeto/use-array-methods + for (const option of options) { + for (let j = option.start; j <= option.end; ++j) { + lines[j].style.backgroundColor = option.color; + lines[j].style.minWidth = `${ + scroll_width - paddingLeft - paddingRight + }px`; + } + } +}; + +export const highlightLinesElement = (code, options) => { + // eslint-disable-next-line @bigbinary/neeto/use-array-methods + for (const option of options) { + --option.start; + --option.end; + } + highlightLinesCode(code, options); +}; + export const highlightCode = content => { lowlight.highlightAuto(""); let highlightedAST = {}; return content.replace(CODE_BLOCK_REGEX, (_, language, code) => { + const regex = /data-highlighted-lines="([^"]*)"/; + const match = content.match(regex); + if (language && LANGUAGE_LIST.includes(language)) { highlightedAST = lowlight.highlight(language, transformCode(code)); } else { @@ -45,7 +83,30 @@ export const highlightCode = content => { buildReactElementFromAST ); - return `
${renderToString(highlightedNode)}
`; + return `
${renderToString(highlightedNode)}
`; + }); +}; + +export const applyLineHighlighting = editorContent => { + const codeTags = editorContent?.querySelectorAll("pre code"); + + codeTags.forEach(codeTag => { + const highlightedLines = codeTag + .closest("pre") + .getAttribute("data-highlighted-lines"); + if (highlightedLines) { + const linesToHighlight = highlightedLines.split(",")?.map(Number); + + const highlightLinesOptions = linesToHighlight.map(line => ({ + start: line, + end: line, + color: "rgba(255, 255, 0, 0.2)", + })); + + highlightLinesElement(codeTag, highlightLinesOptions); + } }); }; diff --git a/src/styles/editor/_codeblock.scss b/src/styles/editor/_codeblock.scss new file mode 100644 index 00000000..86360caa --- /dev/null +++ b/src/styles/editor/_codeblock.scss @@ -0,0 +1,10 @@ +.ne-codeblock-nodeview-wrapper { + .highlighted-line { + background-color: rgba(255, 255, 0, 0.2); + position: absolute; + left: 0; + right: 0; + z-index: 0; + pointer-events: none; + } +} diff --git a/src/styles/editor/editor-content.scss b/src/styles/editor/editor-content.scss index a3125603..96de3164 100644 --- a/src/styles/editor/editor-content.scss +++ b/src/styles/editor/editor-content.scss @@ -152,6 +152,24 @@ margin: 4px 4px; } } + + .highlight-line { + position: relative; + &:before { + left: -12px; + } + &:after { + right: -12px; + } + &:before, &:after { + content: ""; + position: absolute; + top: 0px; + width: 12px; + height: 25px; + background-color: inherit; + } + } } pre > code { diff --git a/src/styles/editor/index.scss b/src/styles/editor/index.scss index 34e401f9..5f2f792f 100644 --- a/src/styles/editor/index.scss +++ b/src/styles/editor/index.scss @@ -7,6 +7,7 @@ @import "./table"; @import "./link-popover"; @import "./collaboration-cursor"; +@import "./codeblock"; .ProseMirror { overflow-y: auto;