Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@
/docs/.jekyll-cache/
/docs/.jekyll-metadata
/docs/Gemfile.lock
.yarn/install-state.gz
.yarn/install-state.gz
1 change: 1 addition & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodeLinker: node-modules
47 changes: 47 additions & 0 deletions app/assets/stylesheets/lexxy-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() ]
}
],
Expand Down
30 changes: 29 additions & 1 deletion src/config/prism.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
13 changes: 13 additions & 0 deletions src/elements/code_language_picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
23 changes: 21 additions & 2 deletions src/elements/editor.js
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 18 additions & 2 deletions src/extensions/trix_content_extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: <pre language="ruby">
if (element.hasAttribute(TRIX_LANGUAGE_ATTR)) {
return element.getAttribute(TRIX_LANGUAGE_ATTR)
}

// CommonMark / marked format: <pre><code class="language-ruby">
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)
}
15 changes: 15 additions & 0 deletions test/browser/tests/paste/markdown.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ test.describe("Paste — Markdown", () => {
await assertEditorHtml(editor, "<p>Hello <strong>there</strong></p>")
})

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,
Expand Down
Loading