Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds highlight lines feature to the code block. #1233

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
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
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
Loading