Skip to content

Commit

Permalink
Adds highlight lines feature to the code block. (#1233)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
deepakjosp authored Oct 6, 2024
1 parent 7c03a33 commit 7e5300f
Show file tree
Hide file tree
Showing 10 changed files with 295 additions and 11 deletions.
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 (
<NodeViewWrapper data-cy="neeto-editor-code-block">
<NodeViewWrapper
className="ne-codeblock-nodeview-wrapper"
data-cy="neeto-editor-code-block"
>
<div {...{ ref }}>
<pre ref={handleContentMount}>
<div
Expand Down Expand Up @@ -78,6 +141,15 @@ const CodeBlockComponent = ({ node, editor, updateAttributes }) => {
style="secondary"
value={node?.content?.content[0]?.text}
/>
{showHighlightButton && (
<Button
icon={Highlight}
size="small"
style="secondary"
tooltipProps={{ content: t("neetoEditor.menu.highlight") }}
onClick={handleHighlight}
/>
)}
</div>
<NodeViewContent as="code" />
</pre>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down
73 changes: 73 additions & 0 deletions src/components/Editor/CustomExtensions/CodeBlock/plugins.js
Original file line number Diff line number Diff line change
@@ -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 };
32 changes: 29 additions & 3 deletions src/components/EditorContent/codeBlockHighlight.js
Original file line number Diff line number Diff line change
@@ -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/[email protected]/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);
});
});
2 changes: 1 addition & 1 deletion src/components/EditorContent/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const SANITIZE_OPTIONS = {
};

export const CODE_BLOCK_REGEX =
/<pre><code(?:\s+class="language-([^"]*)")?>([\S\s]*?)<\/code><\/pre>/gim;
/<pre(?:\s+[^>]+)?><code(?:\s+class="language-([^"]*)")?>([\S\s]*?)<\/code><\/pre>/gim;

export const VARIABLE_SPAN_REGEX =
/<span data-variable="" [^>]*data-label="([^"]+)">{{([^}]+)}}<\/span>/g;
Expand Down
7 changes: 6 additions & 1 deletion src/components/EditorContent/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand Down Expand Up @@ -65,6 +69,7 @@ const EditorContent = ({
useEffect(() => {
injectCopyButtonToCodeBlocks();
bindImageClickListener();
applyLineHighlighting(editorContentRef.current);
}, [content]);

return (
Expand Down
63 changes: 62 additions & 1 deletion src/components/EditorContent/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,49 @@ const buildReactElementFromAST = element => {
return element.value;
};

const highlightLinesCode = (code, options) => {
code.innerHTML = code.innerHTML.replace(
/([ \S]*\n|[ \S]*$)/gm,
match => `<div class="highlight-line">${match}</div>`
);

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 {
Expand All @@ -45,7 +83,30 @@ export const highlightCode = content => {
buildReactElementFromAST
);

return `<pre><code>${renderToString(highlightedNode)}</code></pre>`;
return `<pre data-highlighted-lines=${
match?.[1] ?? ""
}><code>${renderToString(highlightedNode)}</code></pre>`;
});
};

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);
}
});
};

Expand Down
10 changes: 10 additions & 0 deletions src/styles/editor/_codeblock.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
18 changes: 18 additions & 0 deletions src/styles/editor/editor-content.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit 7e5300f

Please sign in to comment.