From edbdc1f91a45ee9632934f5f953e62675382789e Mon Sep 17 00:00:00 2001 From: Deepak Jose Date: Wed, 25 Sep 2024 13:14:25 +0530 Subject: [PATCH 01/13] Updated the CodeBlock component to render the highlight button when there's a text selection. --- .../CodeBlock/CodeBlockComponent.jsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx b/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx index a9f74ca8..2d1bb8fd 100644 --- a/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx +++ b/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx @@ -33,6 +33,15 @@ const CodeBlockComponent = ({ node, editor, updateAttributes }) => { } }; + useEffect(() => { + editor.on("selectionUpdate", handleSelectionChange); + + return () => { + editor.off("selectionUpdate", handleSelectionChange); + }; + }, [editor, handleSelectionChange]); + + return (
@@ -78,6 +87,13 @@ const CodeBlockComponent = ({ node, editor, updateAttributes }) => { style="secondary" value={node?.content?.content[0]?.text} /> + {showHighlightButton && ( +
From 73bec9481d1c1215eaaff47c3d1fb24ac134beac Mon Sep 17 00:00:00 2001 From: Deepak Jose Date: Wed, 25 Sep 2024 14:39:51 +0530 Subject: [PATCH 02/13] Implemented the handle highlight function to set the selected line numbers as data attributes. --- .../CodeBlock/CodeBlockComponent.jsx | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx b/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx index 2d1bb8fd..91f0e3b9 100644 --- a/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx +++ b/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx @@ -1,9 +1,10 @@ -import { useRef, useState } from "react"; +import React, { useRef, useState, useCallback, useEffect } from "react"; import { NodeViewWrapper, NodeViewContent } from "@tiptap/react"; import { Down } from "neetoicons"; import CopyToClipboardButton from "neetomolecules/CopyToClipboardButton"; -import { Dropdown, Input } from "neetoui"; +import { Dropdown, Input, Button } from "neetoui"; +import { union } from "ramda"; import { useTranslation } from "react-i18next"; import { SORTED_LANGUAGE_LIST } from "./constants"; @@ -12,7 +13,7 @@ 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,6 +34,12 @@ 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); @@ -41,6 +48,33 @@ const CodeBlockComponent = ({ node, editor, updateAttributes }) => { }; }, [editor, handleSelectionChange]); + 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 newHighlightedLines = selectedLines.map( + (_, index) => startLine + index + ); + const currentHighlightedLines = codeBlock.attrs.highlightedLines || []; + + const highlightedLines = union( + currentHighlightedLines, + newHighlightedLines + ); + + editor.commands.updateAttributes(codeBlock.type, { highlightedLines }); + }; return ( @@ -92,6 +126,7 @@ const CodeBlockComponent = ({ node, editor, updateAttributes }) => { label="Highlight" size="small" style="secondary" + onClick={handleHighlight} /> )} From 06cdaddc01f61e687cdfbf580d3d632ca7aed5a5 Mon Sep 17 00:00:00 2001 From: Deepak Jose Date: Wed, 25 Sep 2024 14:47:16 +0530 Subject: [PATCH 03/13] Created a plugin that can add highlight style to the selected lines of code. --- .../CustomExtensions/CodeBlock/plugins.js | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/components/Editor/CustomExtensions/CodeBlock/plugins.js 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 }; From e3bb44f78025250da7138d57fd33a42394b15d48 Mon Sep 17 00:00:00 2001 From: Deepak Jose Date: Wed, 25 Sep 2024 14:48:52 +0530 Subject: [PATCH 04/13] Added the hightlight plugin and addAttributes to set the attributes based on the line selection. --- .../CodeBlock/CodeBlockComponent.jsx | 3 +++ .../CodeBlock/ExtensionConfig.js | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx b/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx index 91f0e3b9..c7aef827 100644 --- a/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx +++ b/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx @@ -8,6 +8,7 @@ import { union } from "ramda"; import { useTranslation } from "react-i18next"; import { SORTED_LANGUAGE_LIST } from "./constants"; +import { codeBlockHighlightKey } from "./plugins"; const { Menu, MenuItem } = Dropdown; @@ -74,6 +75,8 @@ const CodeBlockComponent = ({ node, editor, updateAttributes }) => { ); editor.commands.updateAttributes(codeBlock.type, { highlightedLines }); + // Trigger the plugin to update decorations + editor.view.dispatch(editor.state.tr.setMeta(codeBlockHighlightKey, true)); }; return ( 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); }, From 4c2e5e84710380d80e2ef52886b8a6c2ea5274a5 Mon Sep 17 00:00:00 2001 From: Deepak Jose Date: Thu, 26 Sep 2024 11:24:28 +0530 Subject: [PATCH 05/13] Fixed editor content ignoring existing highlight information. --- .../Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx b/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx index c7aef827..1bfd4f32 100644 --- a/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx +++ b/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx @@ -49,6 +49,10 @@ const CodeBlockComponent = ({ node, editor, updateAttributes }) => { }; }, [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); From 201bfcbcbc29ef7c62f00d5cf0f7cf9aa78b5ad3 Mon Sep 17 00:00:00 2001 From: Deepak Jose Date: Thu, 26 Sep 2024 11:42:24 +0530 Subject: [PATCH 06/13] Implemented the code highlight preview feature in EditorContent. --- src/components/EditorContent/constants.js | 2 +- src/components/EditorContent/index.jsx | 7 ++- src/components/EditorContent/utils.js | 67 ++++++++++++++++++++++- 3 files changed, 73 insertions(+), 3 deletions(-) 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..f1b95ba0 100644
--- a/src/components/EditorContent/utils.js
+++ b/src/components/EditorContent/utils.js
@@ -27,11 +27,53 @@ const buildReactElementFromAST = element => {
   return element.value;
 };
 
+function highlightLinesCode(code, options) {
+  function highlightLinesCodeWithoutNumbers() {
+    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`; + } + } + } + + highlightLinesCodeWithoutNumbers(); +} + +export const highlightLinesElement = (code, options, has_numbers) => { + // eslint-disable-next-line @bigbinary/neeto/use-array-methods + for (const option of options) { + --option.start; + --option.end; + } + highlightLinesCode(code, options, has_numbers); +}; + 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 +87,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); + } }); }; From f047b69d1f2d83530fa10d0cb84a9946e7109300 Mon Sep 17 00:00:00 2001 From: Deepak Jose Date: Thu, 26 Sep 2024 15:32:45 +0530 Subject: [PATCH 07/13] Modify the handle selection callback to properly toggle the highlight based on the selection. --- .../CodeBlock/CodeBlockComponent.jsx | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx b/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx index 1bfd4f32..42f1348c 100644 --- a/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx +++ b/src/components/Editor/CustomExtensions/CodeBlock/CodeBlockComponent.jsx @@ -1,10 +1,10 @@ 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, Button } from "neetoui"; -import { union } from "ramda"; +import { difference, intersection, union } from "ramda"; import { useTranslation } from "react-i18next"; import { SORTED_LANGUAGE_LIST } from "./constants"; @@ -68,16 +68,26 @@ const CodeBlockComponent = ({ node, editor, updateAttributes }) => { ); const selectedLines = selectedText.split("\n"); - const newHighlightedLines = selectedLines.map( - (_, index) => startLine + index - ); + const newSelectedLines = selectedLines.map((_, index) => startLine + index); const currentHighlightedLines = codeBlock.attrs.highlightedLines || []; - const highlightedLines = union( + // Check the overlap between new selection and current highlights + const overlapLines = intersection( currentHighlightedLines, - newHighlightedLines + 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)); @@ -130,7 +140,7 @@ const CodeBlockComponent = ({ node, editor, updateAttributes }) => { /> {showHighlightButton && (