From 87761e9bf23608940047ce8ff343a780bc982ea7 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 24 Oct 2022 09:44:46 -0400 Subject: [PATCH] feat: Code blocks can now optionally display line numbers (#4324) * feat: Code blocks can now optionally display line numbers as a user preference * Touch more breathing room --- app/components/Editor.tsx | 5 ++- app/editor/index.tsx | 8 ++-- app/scenes/Settings/Preferences.tsx | 12 ++++++ package.json | 2 +- shared/editor/components/Styles.ts | 15 +++++++ shared/editor/nodes/CodeFence.ts | 14 ++++++- shared/editor/plugins/Prism.ts | 46 ++++++++++++++++++++-- shared/i18n/locales/en_US/translation.json | 2 + shared/types.ts | 1 + yarn.lock | 18 ++++----- 10 files changed, 103 insertions(+), 20 deletions(-) diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 0397fc7c7..264f1eedf 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -57,10 +57,12 @@ export type Props = Optional< function Editor(props: Props, ref: React.RefObject | null) { const { id, shareId, onChange, onHeadingsChange } = props; - const { documents } = useStores(); + const { documents, auth } = useStores(); const { showToast } = useToasts(); const dictionary = useDictionary(); const embeds = useEmbeds(!shareId); + const preferences = auth.user?.preferences; + const [ activeLinkEvent, setActiveLinkEvent, @@ -286,6 +288,7 @@ function Editor(props: Props, ref: React.RefObject | null) { uploadFile={onUploadFile} onShowToast={showToast} embeds={embeds} + userPreferences={preferences} dictionary={dictionary} {...props} onHoverLink={handleLinkActive} diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 327f9c28f..e07f2615e 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -28,9 +28,8 @@ import Node from "@shared/editor/nodes/Node"; import ReactNode from "@shared/editor/nodes/ReactNode"; import fullExtensionsPackage from "@shared/editor/packages/full"; import { EventType } from "@shared/editor/types"; -import { IntegrationType } from "@shared/types"; +import { UserPreferences } from "@shared/types"; import EventEmitter from "@shared/utils/events"; -import Integration from "~/models/Integration"; import Flex from "~/components/Flex"; import { Dictionary } from "~/hooks/useDictionary"; import Logger from "~/utils/Logger"; @@ -105,14 +104,15 @@ export type Props = { onKeyDown?: (event: React.KeyboardEvent) => void; /** Collection of embed types to render in the document */ embeds: EmbedDescriptor[]; + /** Display preferences for the logged in user, if any. */ + userPreferences?: UserPreferences | null; /** Whether embeds should be rendered without an iframe */ embedsDisabled?: boolean; /** Callback when a toast message is triggered (eg "link copied") */ onShowToast: (message: string) => void; className?: string; + /** Optional style overrides */ style?: React.CSSProperties; - - embedIntegrations?: Integration[]; }; type State = { diff --git a/app/scenes/Settings/Preferences.tsx b/app/scenes/Settings/Preferences.tsx index 8e8282814..38bdc9451 100644 --- a/app/scenes/Settings/Preferences.tsx +++ b/app/scenes/Settings/Preferences.tsx @@ -98,6 +98,18 @@ function Preferences() { onChange={handlePreferenceChange} /> + + + void; }) { + console.log({ options }); super(options); } @@ -174,7 +177,11 @@ export default class CodeFence extends Node { return [ "div", { - class: "code-block", + class: `code-block ${ + this.options.userPreferences?.codeBlockLineNumbers + ? "with-line-numbers" + : "" + }`, "data-language": node.attrs.language, }, ...(actions ? [["div", { contentEditable: "false" }, actions]] : []), @@ -301,7 +308,10 @@ export default class CodeFence extends Node { get plugins() { return [ - Prism({ name: this.name }), + Prism({ + name: this.name, + lineNumbers: this.options.userPreferences?.codeBlockLineNumbers, + }), Mermaid({ name: this.name }), new Plugin({ key: new PluginKey("triple-click"), diff --git a/shared/editor/plugins/Prism.ts b/shared/editor/plugins/Prism.ts index faad51a48..4eb0a2f63 100644 --- a/shared/editor/plugins/Prism.ts +++ b/shared/editor/plugins/Prism.ts @@ -40,7 +40,18 @@ type ParsedNode = { const cache: Record = {}; -function getDecorations({ doc, name }: { doc: Node; name: string }) { +function getDecorations({ + doc, + name, + lineNumbers, +}: { + /** The prosemirror document to operate on. */ + doc: Node; + /** The node name. */ + name: string; + /** Whether to include decorations representing line numbers */ + lineNumbers?: boolean; +}) { const decorations: Decoration[] = []; const blocks: { node: Node; pos: number }[] = findBlockNodes(doc).filter( (item) => item.node.type.name === name @@ -71,6 +82,27 @@ function getDecorations({ doc, name }: { doc: Node; name: string }) { } if (!cache[block.pos] || !cache[block.pos].node.eq(block.node)) { + if (lineNumbers) { + const lineCount = + (block.node.textContent.match(/\n/g) || []).length + 1; + decorations.push( + Decoration.widget(block.pos + 1, () => { + const el = document.createElement("div"); + el.innerText = new Array(lineCount) + .fill(0) + .map((_, i) => i + 1) + .join("\n"); + el.className = "line-numbers"; + return el; + }) + ); + decorations.push( + Decoration.node(block.pos, block.pos + block.node.nodeSize, { + style: `--line-number-gutter-width: ${String(lineCount).length}`, + }) + ); + } + const nodes = refractor.highlight(block.node.textContent, language); const _decorations = flattenDeep(parseNodes(nodes)) .map((node: ParsedNode) => { @@ -111,7 +143,15 @@ function getDecorations({ doc, name }: { doc: Node; name: string }) { return DecorationSet.create(doc, decorations); } -export default function Prism({ name }: { name: string }) { +export default function Prism({ + name, + lineNumbers, +}: { + /** The node name. */ + name: string; + /** Whether to include decorations representing line numbers */ + lineNumbers?: boolean; +}) { let highlighted = false; return new Plugin({ @@ -129,7 +169,7 @@ export default function Prism({ name }: { name: string }) { if (!highlighted || codeBlockChanged || ySyncEdit) { highlighted = true; - return getDecorations({ doc: transaction.doc, name }); + return getDecorations({ doc: transaction.doc, name, lineNumbers }); } return decorationSet.map(transaction.mapping, transaction.doc); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index fca803646..ccdb14005 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -696,6 +696,8 @@ "Choose the interface language. Community translations are accepted though our <2>translation portal.": "Choose the interface language. Community translations are accepted though our <2>translation portal.", "Use pointer cursor": "Use pointer cursor", "Show a hand cursor when hovering over interactive elements.": "Show a hand cursor when hovering over interactive elements.", + "Show line numbers": "Show line numbers", + "Show line numbers on code blocks in documents.": "Show line numbers on code blocks in documents.", "Remember previous location": "Remember previous location", "Automatically return to the document you were last viewing when the app is re-opened.": "Automatically return to the document you were last viewing when the app is re-opened.", "You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable", diff --git a/shared/types.ts b/shared/types.ts index da60f5a5a..9cc093aff 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -49,6 +49,7 @@ export enum UserPreference { RememberLastPath = "rememberLastPath", /** If web-style hand pointer should be used on interactive elements. */ UseCursorPointer = "useCursorPointer", + CodeBlockLineNumers = "codeBlockLineNumbers", } export type UserPreferences = { [key in UserPreference]?: boolean }; diff --git a/yarn.lock b/yarn.lock index 05f402a04..06677f393 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12455,10 +12455,10 @@ pretty@^2.0.0: extend-shallow "^2.0.1" js-beautify "^1.6.12" -prismjs@~1.25.0: - version "1.25.0" - resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.25.0.tgz#6f822df1bdad965734b310b315a23315cf999756" - integrity sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg== +prismjs@~1.27.0: + version "1.27.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.27.0.tgz#bb6ee3138a0b438a3653dd4d6ce0cc6510a45057" + integrity sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA== process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: version "2.0.1" @@ -13272,14 +13272,14 @@ reflect.ownkeys@^0.2.0: resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" integrity sha1-dJrO7H8/34tj+SegSAnpDFwLNGA= -refractor@^3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/refractor/-/refractor-3.5.0.tgz#334586f352dda4beaf354099b48c2d18e0819aec" - integrity sha512-QwPJd3ferTZ4cSPPjdP5bsYHMytwWYnAN5EEnLtGvkqp/FCCnGsBgxrm9EuIDnjUC3Uc/kETtvVi7fSIVC74Dg== +refractor@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/refractor/-/refractor-3.6.0.tgz#ac318f5a0715ead790fcfb0c71f4dd83d977935a" + integrity sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA== dependencies: hastscript "^6.0.0" parse-entities "^2.0.0" - prismjs "~1.25.0" + prismjs "~1.27.0" regenerate-unicode-properties@^8.2.0: version "8.2.0"