diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fdb8da8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +* text=auto +*.ts text eol=crlf +*.tsx text eol=crlf +*.json text eol=crlf +*.css text eol=crlf +*.scss text eol=crlf +*.html text eol=crlf +*.yaml text eol=crlf +*.yml text eol=crlf diff --git a/.yarnrc.yml b/.yarnrc.yml index 0acb263..3186f3f 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1 +1 @@ -nodeLinker: node-modules +nodeLinker: node-modules diff --git a/package.json b/package.json index 34aeb32..0d57961 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "goober": "^2.1.18", "preact": "^10.27.2", "quill": "^2.0.3", - "sn-extension-api": "0.4.0" + "sn-extension-api": "0.4.0", + "highlight.js": "^11.11.1" }, "devDependencies": { "@babel/core": "^7.28.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56c350b..89686f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: goober: specifier: ^2.1.18 version: 2.1.18(csstype@3.1.3) + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 preact: specifier: ^10.27.2 version: 10.27.2 @@ -1703,6 +1706,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hpack.js@2.1.6: resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} @@ -1741,6 +1748,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} @@ -2047,6 +2058,10 @@ packages: resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -4813,6 +4828,8 @@ snapshots: he@1.2.0: {} + highlight.js@11.11.1: {} + hpack.js@2.1.6: dependencies: inherits: 2.0.4 @@ -4871,6 +4888,14 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-parser-js@0.5.10: {} http-proxy-middleware@2.0.9(@types/express@4.17.24): @@ -5155,6 +5180,10 @@ snapshots: dependencies: mime-db: 1.54.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} minimalistic-assert@1.0.1: {} @@ -5597,8 +5626,8 @@ snapshots: escape-html: 1.0.3 etag: 1.8.1 fresh: 2.0.0 - http-errors: 2.0.0 - mime-types: 3.0.1 + http-errors: 2.0.1 + mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 diff --git a/public/ext.json b/public/ext.json index 626a575..1d2083f 100644 --- a/public/ext.json +++ b/public/ext.json @@ -3,6 +3,8 @@ "name": "Quill", "content_type": "SN|Component", "area": "editor-editor", + "note_type": "rich-text", + "file_type": "json", "version": "$VERSION$", "description": "A rich text editor using the quill library", "url": "https://nienow.github.io/sn-quill/", diff --git a/public/local.json b/public/local.json index 734fc82..5ec3e07 100644 --- a/public/local.json +++ b/public/local.json @@ -3,6 +3,8 @@ "name": "Quill (local)", "content_type": "SN|Component", "area": "editor-editor", + "note_type": "rich-text", + "file_type": "json", "version": "0.0.1", "url": "http://localhost:8080/" } diff --git a/src/QuillEditor.tsx b/src/QuillEditor.tsx index 29b36bb..18de6f0 100644 --- a/src/QuillEditor.tsx +++ b/src/QuillEditor.tsx @@ -3,10 +3,66 @@ import {styled} from "goober"; import Quill from 'quill'; import './quill.css'; +import hljs from 'highlight.js'; +import './hljs.css'; + +const CODE_LANGUAGES = [ + { key: 'plain', label: 'Plain' }, + { key: 'asciidoc', label: 'Asciidoc' }, + { key: 'bash', label: 'Bash' }, + { key: 'c', label: 'C' }, + { key: 'cpp', label: 'C++' }, + { key: 'csharp', label: 'C#' }, + { key: 'css', label: 'CSS' }, + { key: 'csv', label: 'CSV' }, + { key: 'diff', label: 'Diff' }, + { key: 'elixir', label: 'Elixir' }, + { key: 'go', label: 'Go' }, + { key: 'html', label: 'HTML' }, + { key: 'java', label: 'Java' }, + { key: 'javascript', label: 'JavaScript' }, + { key: 'json', label: 'JSON' }, + { key: 'jsx', label: 'JSX' }, + { key: 'kotlin', label: 'Kotlin' }, + { key: 'lua', label: 'Lua' }, + { key: 'markdown', label: 'Markdown' }, + { key: 'nix', label: 'Nix' }, + { key: 'perl', label: 'Perl' }, + { key: 'php', label: 'PHP' }, + { key: 'python', label: 'Python' }, + { key: 'ruby', label: 'Ruby' }, + { key: 'rust', label: 'Rust' }, + { key: 'scss', label: 'SCSS' }, + { key: 'shell', label: 'Shell' }, + { key: 'solidity', label: 'Solidity' }, + { key: 'sql', label: 'SQL' }, + { key: 'swift', label: 'Swift' }, + { key: 'toml', label: 'TOML' }, + { key: 'tsx', label: 'TSX' }, + { key: 'typescript', label: 'TypeScript' }, + { key: 'xml', label: 'XML' }, + { key: 'yaml', label: 'YAML' }, + { key: 'zig', label: 'Zig' }, +]; import {MarkdownShortcuts} from "./quill-markdown"; import snApi from "sn-extension-api"; import {getPreviewText} from "./utils"; +// Register Quill modules/formats once at module load time +const Font = Quill.import('attributors/class/font') as { whitelist: (string | boolean)[] }; +Font.whitelist = [false, 'serif', 'sans-serif', 'monospace', 'arial', 'comic-sans']; +Quill.register(Font as any, true); +Quill.register('modules/markdown', MarkdownShortcuts, true); + +const BlockEmbed = Quill.import('blots/block/embed') as { new(): any }; +class DividerBlot extends BlockEmbed { + static blotName = 'divider'; + static tagName = 'hr'; +} +Quill.register(DividerBlot as any, true); + +(Quill.import('ui/icons') as Record).divider = ''; + const Container = styled('div')` position: absolute; top: 0; @@ -17,37 +73,27 @@ const Container = styled('div')` flex-direction: column; `; +const HLJS_MAX_LENGTH = 5000; + const QuillEditor = () => { let quill; useEffect(() => { - const Font = Quill.import('attributors/class/font'); - Font.whitelist = [false, 'serif', 'sans-serif', 'monospace', 'arial', 'comic-sans']; - Quill.register(Font, true); - Quill.register('modules/markdown', MarkdownShortcuts); - const BlockEmbed = Quill.import('blots/block/embed'); - - class DividerBlot extends BlockEmbed { - } - - DividerBlot.blotName = 'divider'; - DividerBlot.tagName = 'hr'; - Quill.register(DividerBlot); - - Quill.import('ui/icons').divider = ''; + const initialText = snApi.text; + const enableSyntax = !initialText || initialText.length <= HLJS_MAX_LENGTH; quill = new Quill(`#quill`, { readOnly: snApi.locked, modules: { toolbar: [ - [{'font': Font.whitelist}, {'header': '1'}, {'header': '2'}, 'bold', 'italic', 'underline', 'strike', 'blockquote', 'code', 'link', 'image', 'divider', {'list': 'ordered'}, {'list': 'bullet'}, {'align': []}, {'color': []}, {'background': []}, 'clean'], + [{'font': Font.whitelist}, {'header': '1'}, {'header': '2'}, 'bold', 'italic', 'underline', 'strike', 'blockquote', 'code-block', 'link', 'image', 'divider', {'list': 'ordered'}, {'list': 'bullet'}, {'align': []}, {'color': []}, {'background': []}, 'clean'], ], keyboard: { bindings: { 'keep-font': { key: 'Enter', - handler: function (range, context) { + handler: function (_range, context) { setTimeout(() => { - // keep font style + // keep font style on new lines this.quill.format('font', context.format.font, Quill.sources.USER); }); return true; @@ -56,10 +102,10 @@ const QuillEditor = () => { } }, markdown: {}, + syntax: enableSyntax ? { hljs, languages: CODE_LANGUAGES } : false, }, theme: 'snow', }); - const initialText = snApi.text; if (initialText) { try { const data = JSON.parse(initialText); @@ -84,7 +130,7 @@ const QuillEditor = () => { snApi.text = JSON.stringify(quill.getContents()); snApi.preview = getPreviewText(quill.getText()); }); - }); + }, []); return ( diff --git a/src/demo/test-data.ts b/src/demo/test-data.ts index 66dbe43..dafb748 100644 --- a/src/demo/test-data.ts +++ b/src/demo/test-data.ts @@ -147,6 +147,29 @@ export const RICH = { }, 'insert': 'Red Background' }, + { + 'insert': '\n\n' + }, + { + 'attributes': { + }, + 'insert': 'const language = "JavaScript";' + }, + { + 'attributes': { + 'code-block': 'javascript' + }, + 'insert': '\n' + }, + { + 'insert': 'console.log("I love " + language + "!");' + }, + { + 'attributes': { + 'code-block': true + }, + 'insert': '\n' + }, { 'insert': '\n' } diff --git a/src/hljs.css b/src/hljs.css new file mode 100644 index 0000000..9cee71e --- /dev/null +++ b/src/hljs.css @@ -0,0 +1,84 @@ +pre code.hljs { + display: block; + overflow-x: auto; + padding: 1em +} + +code.hljs { + padding: 3px 5px +} + +.hljs { + color: var(--sn-stylekit-foreground-color); + background: var(--sn-stylekit-background-color) +} + +.hljs-comment, +.hljs-quote { + color: var(--sn-stylekit-passive-color-1); + font-style: italic +} + +.hljs-doctag, +.hljs-formula, +.hljs-keyword { + color: var(--sn-stylekit-accessory-tint-color-4) +} + +.hljs-deletion, +.hljs-name, +.hljs-section, +.hljs-selector-tag, +.hljs-subst { + color: var(--sn-stylekit-accessory-tint-color-2) +} + +.hljs-literal { + color: var(--sn-stylekit-accessory-tint-color-1) +} + +.hljs-addition, +.hljs-attribute, +.hljs-meta .hljs-string, +.hljs-regexp, +.hljs-string { + color: var(--sn-stylekit-accessory-tint-color-5) +} + +.hljs-attr, +.hljs-number, +.hljs-selector-attr, +.hljs-selector-class, +.hljs-selector-pseudo, +.hljs-template-variable, +.hljs-type, +.hljs-variable { + color: var(--sn-stylekit-accessory-tint-color-6) +} + +.hljs-bullet, +.hljs-link, +.hljs-meta, +.hljs-selector-id, +.hljs-symbol, +.hljs-title { + color: var(--sn-stylekit-accessory-tint-color-1) +} + +.hljs-built_in, +.hljs-class .hljs-title, +.hljs-title.class_ { + color: var(--sn-stylekit-accessory-tint-color-3) +} + +.hljs-emphasis { + font-style: italic +} + +.hljs-strong { + font-weight: 700 +} + +.hljs-link { + text-decoration: underline +} diff --git a/src/quill-markdown.ts b/src/quill-markdown.ts index c95107c..8812b01 100644 --- a/src/quill-markdown.ts +++ b/src/quill-markdown.ts @@ -19,7 +19,7 @@ export class MarkdownShortcuts { { name: 'blockquote', pattern: /^(>)\s/g, - action: (text, selection) => { + action: (_text, selection) => { // Need to defer this action https://github.com/quilljs/quill/issues/1134 setTimeout(() => { this.quill.formatLine(selection.index, 1, 'blockquote', true); @@ -27,21 +27,10 @@ export class MarkdownShortcuts { }, 0); } }, - { - name: 'code-block', - pattern: /^`{3}(?:\s|\n)/g, - action: (text, selection) => { - // Need to defer this action https://github.com/quilljs/quill/issues/1134 - setTimeout(() => { - this.quill.formatLine(selection.index, 1, 'code-block', true); - this.quill.deleteText(selection.index - 4, 4); - }, 0); - } - }, { name: 'bolditalic', pattern: /(?:\*|_){3}(.+?)(?:\*|_){3}/g, - action: (text, selection, pattern, lineStart) => { + action: (text, _selection, pattern, lineStart) => { let match = pattern.exec(text); const annotatedText = match[0]; @@ -62,7 +51,7 @@ export class MarkdownShortcuts { { name: 'bold', pattern: /(?:\*|_){2}(.+?)(?:\*|_){2}/g, - action: (text, selection, pattern, lineStart) => { + action: (text, _selection, pattern, lineStart) => { let match = pattern.exec(text); const annotatedText = match[0]; @@ -83,7 +72,7 @@ export class MarkdownShortcuts { { name: 'italic', pattern: /(?:\*|_){1}(.+?)(?:\*|_){1}/g, - action: (text, selection, pattern, lineStart) => { + action: (text, _selection, pattern, lineStart) => { let match = pattern.exec(text); const annotatedText = match[0]; @@ -104,7 +93,7 @@ export class MarkdownShortcuts { { name: 'strikethrough', pattern: /(?:~~)(.+?)(?:~~)/g, - action: (text, selection, pattern, lineStart) => { + action: (text, _selection, pattern, lineStart) => { let match = pattern.exec(text); const annotatedText = match[0]; @@ -122,10 +111,24 @@ export class MarkdownShortcuts { }, 0); } }, + { + name: 'code-block', + pattern: /^`{3}(\S*)\s$/, + action: (text, selection, pattern) => { + const match = pattern.exec(text); + if (!match) return; + const language = match[1]?.trim() || true; + // Need to defer this action https://github.com/quilljs/quill/issues/1134 + setTimeout(() => { + this.quill.formatLine(selection.index, 1, 'code-block', language); + this.quill.deleteText(selection.index - match[0].length, match[0].length); + }, 0); + } + }, { name: 'code', pattern: /(?:`)(.+?)(?:`)/g, - action: (text, selection, pattern, lineStart) => { + action: (text, _selection, pattern, lineStart) => { let match = pattern.exec(text); const annotatedText = match[0]; @@ -143,30 +146,53 @@ export class MarkdownShortcuts { this.quill.insertText(this.quill.getSelection(), ' '); }, 0); } - },]; + }]; constructor(private quill) { // Handler that looks for insert deltas that match specific characters this.quill.on('text-change', (delta) => { - for (let i = 0; i < delta.ops.length; i++) { - if (delta.ops[i].hasOwnProperty('insert')) { - if (delta.ops[i].insert === ' ') { - this.onSpace(); - } + for (const op of delta.ops) { + if (typeof op.insert !== 'string') continue; + if (op.insert === ' ') { + this.onSpace(); + } else if (op.insert.includes('```')) { + setTimeout(() => this.onPasteCodeBlock(), 0); } } }); } - isValid(text, tagName) { - return ( - typeof text !== 'undefined' && - text && - this.ignoreTags.indexOf(tagName) === -1 - ); + private onPasteCodeBlock() { + const selection = this.quill.getSelection(); + if (!selection) return; + + const searchStart = Math.max(0, selection.index - 5000); + const searchEnd = Math.min(this.quill.getLength(), selection.index + 100); + const text = this.quill.getText(searchStart, searchEnd - searchStart); + + const match = /`{3}(\S*)\n([\s\S]*?)\n`{3}/.exec(text); + if (!match) return; + + const language = match[1]?.trim() || true; + const codeContent = match[2] ?? ""; + const startIndex = searchStart + match.index; + + this.quill.deleteText(startIndex, match[0].length); + this.quill.insertText(startIndex, codeContent + "\n"); + + // Format each line as code-block + let curIndex = startIndex; + for (const line of codeContent.split("\n")) { + this.quill.formatLine(curIndex, 1, "code-block", language); + curIndex += line.length + 1; + } + } + + private isValid(text, tagName) { + return text && !this.ignoreTags.includes(tagName); } - onSpace() { + private onSpace() { const selection = this.quill.getSelection(); if (!selection) { return; diff --git a/src/quill.css b/src/quill.css index 115b91e..131cc0e 100644 --- a/src/quill.css +++ b/src/quill.css @@ -866,13 +866,12 @@ hr { } .ql-snow .ql-editor code, -.ql-snow .ql-editor pre { +.ql-snow .ql-editor .ql-code-block-container { background-color: var(--sn-stylekit-contrast-background-color); border-radius: 3px; } -.ql-snow .ql-editor pre { - white-space: pre-wrap; +.ql-snow .ql-editor .ql-code-block-container { margin-bottom: 5px; margin-top: 5px; padding: 5px 10px; @@ -883,9 +882,9 @@ hr { padding: 2px 4px; } -.ql-snow .ql-editor pre.ql-syntax { - background-color: #23241f; - color: #f8f8f2; +.ql-snow .ql-editor .ql-code-block-container { + background-color: var(--sn-stylekit-contrast-background-color); + color: var(--sn-stylekit-contrast-foreground-color); overflow: visible; } @@ -1158,6 +1157,15 @@ hr { background-color: var(--sn-stylekit-foreground-color); } +.ql-code-block-container { + position: relative +} + +.ql-code-block-container .ql-ui { + right: 5px; + top: 5px +} + .ql-toolbar.ql-snow { flex: 0 0 auto; border-bottom: 1px solid var(--sn-stylekit-border-color); diff --git a/tsconfig.json b/tsconfig.json index 959f45e..9e00f5b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,6 @@ "baseUrl": "src", "forceConsistentCasingInFileNames": true, "esModuleInterop": true, - "suppressImplicitAnyIndexErrors": true, "strictNullChecks": false, "allowSyntheticDefaultImports": true, "experimentalDecorators": true