diff --git a/.gitignore b/.gitignore index 4152d107a..0cab290c4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ /docs/.jekyll-cache/ /docs/.jekyll-metadata /docs/Gemfile.lock +.yarn/install-state.gz +.yarn/install-state.gz diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 000000000..3186f3f07 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css index b075e30fb..793f69be5 100644 --- a/app/assets/stylesheets/lexxy-editor.css +++ b/app/assets/stylesheets/lexxy-editor.css @@ -399,6 +399,53 @@ min-block-size: var(--lexxy-editor-rows); outline: 0; padding: var(--lexxy-editor-padding); + + /* Code blocks in editor */ + code, pre { + background-color: var(--lexxy-color-code-bg, var(--lexxy-color-ink-lightest)); + border-radius: var(--lexxy-radius); + color: var(--lexxy-color-code-text, var(--lexxy-color-ink)); + font-family: var(--lexxy-font-mono); + font-size: 0.9em; + padding: 0.25ch 0.5ch; + + &:is(pre), + &[data-language] { + display: block; + hyphens: none; + margin-block: 0 var(--lexxy-content-margin, 1rem); + overflow-x: auto; + padding: 1ch; + padding-block-start: 2.5em; + tab-size: 2; + text-wrap: nowrap; + white-space: pre; + word-break: break-word; + } + } + + /* Syntax highlighting token colors */ + .code-token__attr { color: var(--lexxy-color-code-token-att); } + .code-token__property { color: var(--lexxy-color-code-token-property); } + .code-token__selector { color: var(--lexxy-color-code-token-selector); } + .code-token__comment { color: var(--lexxy-color-code-token-comment); font-style: italic; } + .code-token__operator { color: var(--lexxy-color-code-token-operator); } + .code-token__function { color: var(--lexxy-color-code-token-function); } + .code-token__variable { color: var(--lexxy-color-code-token-variable); } + .code-token__punctuation { color: var(--lexxy-color-code-token-punctuation); } + + /* Blockquotes in editor */ + blockquote { + border-inline-start: 3px solid var(--lexxy-color-ink-lighter); + color: var(--lexxy-color-ink-medium); + margin-block: 0 var(--lexxy-content-margin, 1rem); + margin-inline: 0; + padding: 0.25lh 2ch; + + p:last-child { + margin-block-end: 0; + } + } } :where(.lexxy-editor--drag-over) { diff --git a/rollup.config.mjs b/rollup.config.mjs index b5cf8348e..434625c36 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -17,11 +17,13 @@ export default [ { file: "./app/assets/javascript/lexxy.js", format: "esm", - sourcemap: true + sourcemap: true, + inlineDynamicImports: true }, { file: "./app/assets/javascript/lexxy.min.js", format: "esm", + inlineDynamicImports: true, plugins: [ terser() ] } ], diff --git a/src/config/prism.js b/src/config/prism.js index 882efc279..e1b20424f 100644 --- a/src/config/prism.js +++ b/src/config/prism.js @@ -10,10 +10,38 @@ import "prismjs/components/prism-clike" import "prismjs/components/prism-markup" import "prismjs/components/prism-markup-templating" -// Import languages +// Languages also bundled by @lexical/code for in-editor highlighting +import "prismjs/components/prism-c" +import "prismjs/components/prism-cpp" +import "prismjs/components/prism-css" +import "prismjs/components/prism-java" +import "prismjs/components/prism-javascript" +import "prismjs/components/prism-markdown" +import "prismjs/components/prism-objectivec" +import "prismjs/components/prism-powershell" +import "prismjs/components/prism-python" +import "prismjs/components/prism-rust" +import "prismjs/components/prism-sql" +import "prismjs/components/prism-swift" +import "prismjs/components/prism-typescript" + +// Additional languages for common use cases import "prismjs/components/prism-ruby" import "prismjs/components/prism-php" import "prismjs/components/prism-go" import "prismjs/components/prism-bash" import "prismjs/components/prism-json" import "prismjs/components/prism-diff" +import "prismjs/components/prism-yaml" +import "prismjs/components/prism-kotlin" +import "prismjs/components/prism-docker" +import "prismjs/components/prism-graphql" +import "prismjs/components/prism-jsx" +import "prismjs/components/prism-tsx" +import "prismjs/components/prism-scss" +import "prismjs/components/prism-regex" +import "prismjs/components/prism-toml" +import "prismjs/components/prism-lua" +import "prismjs/components/prism-elixir" +import "prismjs/components/prism-erlang" +import "prismjs/components/prism-hcl" diff --git a/src/elements/code_language_picker.js b/src/elements/code_language_picker.js index de9d3a977..b5b25bfa3 100644 --- a/src/elements/code_language_picker.js +++ b/src/elements/code_language_picker.js @@ -46,12 +46,25 @@ export class CodeLanguagePicker extends HTMLElement { get #languages() { const languages = { ...CODE_LANGUAGE_FRIENDLY_NAME_MAP } + // Add languages supported by Lexxy's Prism config but not in Lexical's default map if (!languages.ruby) languages.ruby = "Ruby" if (!languages.php) languages.php = "PHP" if (!languages.go) languages.go = "Go" if (!languages.bash) languages.bash = "Bash" if (!languages.json) languages.json = "JSON" if (!languages.diff) languages.diff = "Diff" + if (!languages.yaml) languages.yaml = "YAML" + if (!languages.kotlin) languages.kotlin = "Kotlin" + if (!languages.docker) languages.docker = "Docker" + if (!languages.graphql) languages.graphql = "GraphQL" + if (!languages.jsx) languages.jsx = "JSX" + if (!languages.tsx) languages.tsx = "TSX" + if (!languages.scss) languages.scss = "SCSS" + if (!languages.toml) languages.toml = "TOML" + if (!languages.lua) languages.lua = "Lua" + if (!languages.elixir) languages.elixir = "Elixir" + if (!languages.erlang) languages.erlang = "Erlang" + if (!languages.hcl) languages.hcl = "HCL" const sortedEntries = Object.entries(languages) .sort(([ , a ], [ , b ]) => a.localeCompare(b)) diff --git a/src/elements/editor.js b/src/elements/editor.js index 7fa0df19f..676d4cb50 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -1,11 +1,11 @@ -import { $addUpdateTag, $createParagraphNode, $getRoot, $isElementNode, $isLineBreakNode, $isTextNode, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical" +import { $addUpdateTag, $createParagraphNode, $getRoot, $isElementNode, $isLineBreakNode, $isParagraphNode, $isTextNode, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical" import { buildEditorFromExtensions } from "@lexical/extension" import { ListItemNode, ListNode, registerList } from "@lexical/list" import { AutoLinkNode, LinkNode } from "@lexical/link" import { registerPlainText } from "@lexical/plain-text" import { HeadingNode, QuoteNode, registerRichText } from "@lexical/rich-text" import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html" -import { CodeHighlightNode, CodeNode, registerCodeHighlighting } from "@lexical/code" +import { $createCodeNode, CodeHighlightNode, CodeNode, registerCodeHighlighting } from "@lexical/code" import { TRANSFORMERS, registerMarkdownShortcuts } from "@lexical/markdown" import { registerMarkdownLeadingTagHandler } from "../editor/markdown/leading_tag_handler" import { createEmptyHistoryState, registerHistory } from "@lexical/history" @@ -397,6 +397,7 @@ export class LexicalEditorElement extends HTMLElement { #registerCodeHiglightingComponents() { registerCodeHighlighting(this.editor) + registerCodeFenceShortcut(this.editor) this.codeLanguagePicker = createElement("lexxy-code-language-picker") this.append(this.codeLanguagePicker) } @@ -554,6 +555,24 @@ export class LexicalEditorElement extends HTMLElement { export default LexicalEditorElement +const CODE_FENCE_REGEX = /^`{3,}([\w-]*)$/ + +function registerCodeFenceShortcut(editor) { + return editor.registerNodeTransform(TextNode, (textNode) => { + const parent = textNode.getParent() + if (!$isParagraphNode(parent)) return + if (parent.getChildrenSize() !== 1) return + + const text = textNode.getTextContent() + if (!text.match(CODE_FENCE_REGEX)) return + + const language = text.replace(/^`+/, "") || undefined + const codeNode = $createCodeNode(language) + parent.replace(codeNode) + codeNode.select() + }) +} + // Like $getRoot().getTextContent() but uses readable text for custom attachment nodes // (e.g., mentions) instead of their single-character cursor placeholder. function $getReadableTextContent(node) { diff --git a/src/extensions/trix_content_extension.js b/src/extensions/trix_content_extension.js index 67909d1d2..38c9a5ba5 100644 --- a/src/extensions/trix_content_extension.js +++ b/src/extensions/trix_content_extension.js @@ -54,10 +54,26 @@ function $applyStrikethrough(textNode) { } function onlyPreLanguageElements(element, conversion) { - return element.hasAttribute(TRIX_LANGUAGE_ATTR) ? conversion : null + return detectLanguage(element) ? conversion : null +} + +function detectLanguage(element) { + // Trix format:
+  if (element.hasAttribute(TRIX_LANGUAGE_ATTR)) {
+    return element.getAttribute(TRIX_LANGUAGE_ATTR)
+  }
+
+  // CommonMark / marked format: 

+  const codeChild = element.querySelector("code[class*='language-']")
+  if (codeChild) {
+    const match = codeChild.className.match(/language-(\S+)/)
+    if (match) return match[1]
+  }
+
+  return null
 }
 
 function $applyLanguage(conversionOutput, element) {
-  const language = normalizeCodeLang(element.getAttribute(TRIX_LANGUAGE_ATTR))
+  const language = normalizeCodeLang(detectLanguage(element))
   conversionOutput.node.setLanguage(language)
 }
diff --git a/test/browser/tests/paste/markdown.test.js b/test/browser/tests/paste/markdown.test.js
index 5e015249c..a3429a930 100644
--- a/test/browser/tests/paste/markdown.test.js
+++ b/test/browser/tests/paste/markdown.test.js
@@ -11,6 +11,21 @@ test.describe("Paste — Markdown", () => {
     await assertEditorHtml(editor, "

Hello there

") }) + test("preserve language when pasting markdown code fences", async ({ + page, + editor, + }) => { + await page.goto("/") + await editor.waitForConnected() + + await editor.paste("```ruby\ndef hello\n puts 'world'\nend\n```") + + await assertEditorContent(editor, async (content) => { + await expect(content.locator("code[data-highlight-language='ruby']")).toBeVisible() + await expect(content).toContainText("def hello") + }) + }) + test("don't convert markdown when pasting into code block", async ({ page, editor,