diff --git a/README.md b/README.md index c4df29e..34e1df0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,6 @@ import { Editor } from 'codice' title="My Code Editor" value="const hello = 'world';" onChange={(code) => console.log(code)} - highlight={(code) => code} /> ``` @@ -33,7 +32,6 @@ The following props are supported by the `Editor` component: - `title` (optional): A string representing the title of the editor. - `controls` (optional): A boolean value indicating whether to display the controls for the editor. - `lineNumbers` (optional): A boolean value indicating whether to display line numbers in the editor. -- `highlight` (optional): A function used to provide syntax highlighting for the code. It should accept the code as an argument and return the highlighted code as an HTML string. You can use any syntax highlighting library (e.g., [Prism](https://prismjs.com/)) to implement this functionality. Additionally, you can pass any other props to the `Editor` component, which will be applied to the root `div` element. diff --git a/package.json b/package.json index e09fd55..15194c9 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,13 @@ "devDependencies": { "@types/react": "^19.0.7", "bunchee": "^6.3.2", - "next": "15.1.5", + "next": "15.1.6", "react-dom": "^19.0.0", "typescript": "^5.7.3", "vitest": "^3.0.2" }, - "packageManager": "pnpm@9.15.4" + "packageManager": "pnpm@9.15.4", + "dependencies": { + "sugar-high": "^0.8.2" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index feb4d26..b84c912 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: react: specifier: ^17.0.0 || ^18.0.0 || ^19.0.0 version: 19.0.0 + sugar-high: + specifier: ^0.8.2 + version: 0.8.2 devDependencies: '@types/react': specifier: ^19.0.7 @@ -19,8 +22,8 @@ importers: specifier: ^6.3.2 version: 6.3.2(typescript@5.7.3) next: - specifier: 15.1.5 - version: 15.1.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + specifier: 15.1.6 + version: 15.1.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) @@ -333,54 +336,105 @@ packages: '@next/env@15.1.5': resolution: {integrity: sha512-jg8ygVq99W3/XXb9Y6UQsritwhjc+qeiO7QrGZRYOfviyr/HcdnhdBQu4gbp2rBIh2ZyBYTBMWbPw3JSCb0GHw==} + '@next/env@15.1.6': + resolution: {integrity: sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==} + '@next/swc-darwin-arm64@15.1.5': resolution: {integrity: sha512-5ttHGE75Nw9/l5S8zR2xEwR8OHEqcpPym3idIMAZ2yo+Edk0W/Vf46jGqPOZDk+m/SJ+vYZDSuztzhVha8rcdA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@next/swc-darwin-arm64@15.1.6': + resolution: {integrity: sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-x64@15.1.5': resolution: {integrity: sha512-8YnZn7vDURUUTInfOcU5l0UWplZGBqUlzvqKKUFceM11SzfNEz7E28E1Arn4/FsOf90b1Nopboy7i7ufc4jXag==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@next/swc-darwin-x64@15.1.6': + resolution: {integrity: sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-linux-arm64-gnu@15.1.5': resolution: {integrity: sha512-rDJC4ctlYbK27tCyFUhgIv8o7miHNlpCjb2XXfTLQszwAUOSbcMN9q2y3urSrrRCyGVOd9ZR9a4S45dRh6JF3A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@next/swc-linux-arm64-gnu@15.1.6': + resolution: {integrity: sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@next/swc-linux-arm64-musl@15.1.5': resolution: {integrity: sha512-FG5RApf4Gu+J+pHUQxXPM81oORZrKBYKUaBTylEIQ6Lz17hKVDsLbSXInfXM0giclvXbyiLXjTv42sQMATmZ0A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@next/swc-linux-arm64-musl@15.1.6': + resolution: {integrity: sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@next/swc-linux-x64-gnu@15.1.5': resolution: {integrity: sha512-NX2Ar3BCquAOYpnoYNcKz14eH03XuF7SmSlPzTSSU4PJe7+gelAjxo3Y7F2m8+hLT8ZkkqElawBp7SWBdzwqQw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@next/swc-linux-x64-gnu@15.1.6': + resolution: {integrity: sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@next/swc-linux-x64-musl@15.1.5': resolution: {integrity: sha512-EQgqMiNu3mrV5eQHOIgeuh6GB5UU57tu17iFnLfBEhYfiOfyK+vleYKh2dkRVkV6ayx3eSqbIYgE7J7na4hhcA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@next/swc-linux-x64-musl@15.1.6': + resolution: {integrity: sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@next/swc-win32-arm64-msvc@15.1.5': resolution: {integrity: sha512-HPULzqR/VqryQZbZME8HJE3jNFmTGcp+uRMHabFbQl63TtDPm+oCXAz3q8XyGv2AoihwNApVlur9Up7rXWRcjg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@next/swc-win32-arm64-msvc@15.1.6': + resolution: {integrity: sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-x64-msvc@15.1.5': resolution: {integrity: sha512-n74fUb/Ka1dZSVYfjwQ+nSJ+ifUff7jGurFcTuJNKZmI62FFOxQXUYit/uZXPTj2cirm1rvGWHG2GhbSol5Ikw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@next/swc-win32-x64-msvc@15.1.6': + resolution: {integrity: sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -943,6 +997,27 @@ packages: sass: optional: true + next@15.1.6: + resolution: {integrity: sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -1420,30 +1495,56 @@ snapshots: '@next/env@15.1.5': {} + '@next/env@15.1.6': {} + '@next/swc-darwin-arm64@15.1.5': optional: true + '@next/swc-darwin-arm64@15.1.6': + optional: true + '@next/swc-darwin-x64@15.1.5': optional: true + '@next/swc-darwin-x64@15.1.6': + optional: true + '@next/swc-linux-arm64-gnu@15.1.5': optional: true + '@next/swc-linux-arm64-gnu@15.1.6': + optional: true + '@next/swc-linux-arm64-musl@15.1.5': optional: true + '@next/swc-linux-arm64-musl@15.1.6': + optional: true + '@next/swc-linux-x64-gnu@15.1.5': optional: true + '@next/swc-linux-x64-gnu@15.1.6': + optional: true + '@next/swc-linux-x64-musl@15.1.5': optional: true + '@next/swc-linux-x64-musl@15.1.6': + optional: true + '@next/swc-win32-arm64-msvc@15.1.5': optional: true + '@next/swc-win32-arm64-msvc@15.1.6': + optional: true + '@next/swc-win32-x64-msvc@15.1.5': optional: true + '@next/swc-win32-x64-msvc@15.1.6': + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1947,6 +2048,31 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@15.1.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@next/env': 15.1.6 + '@swc/counter': 0.1.3 + '@swc/helpers': 0.5.15 + busboy: 1.6.0 + caniuse-lite: 1.0.30001692 + postcss: 8.4.31 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + styled-jsx: 5.1.6(react@19.0.0) + optionalDependencies: + '@next/swc-darwin-arm64': 15.1.6 + '@next/swc-darwin-x64': 15.1.6 + '@next/swc-linux-arm64-gnu': 15.1.6 + '@next/swc-linux-arm64-musl': 15.1.6 + '@next/swc-linux-x64-gnu': 15.1.6 + '@next/swc-linux-x64-musl': 15.1.6 + '@next/swc-win32-arm64-msvc': 15.1.6 + '@next/swc-win32-x64-msvc': 15.1.6 + sharp: 0.33.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + onetime@7.0.0: dependencies: mimic-function: 5.0.1 diff --git a/site/app/code-example.tsx b/site/app/code-example.tsx index 4fd46ba..c5327a0 100644 --- a/site/app/code-example.tsx +++ b/site/app/code-example.tsx @@ -1,9 +1,8 @@ import { Code } from 'codice' -import { highlight } from 'sugar-high' -const CODE_SIMPLE_SNIPPET_HTML = highlight(`console.log("hello world")`) +const CODE_SIMPLE_SNIPPET_HTML = (`console.log("hello world")`) -const CODE_ULTIMATE_SNIPPET_HTML = highlight(`\ +const CODE_ULTIMATE_SNIPPET_HTML = (`\ import { Code } from 'codice' @@ -29,18 +28,30 @@ function CodeExampleItem({ export function CodeExamples() { return (
- + {CODE_ULTIMATE_SNIPPET_HTML} - - - {highlight(`\ + + + {`\ +import { highlight } from 'sugar-high' + function marker() { - return "long live sugar-high" -}`)} + const code = "return 'long live sugar-high'" + return highlight(code) +} + +const html = marker() + +render(html) +`} diff --git a/site/app/editor-example.tsx b/site/app/editor-example.tsx index 92dea26..64229b6 100644 --- a/site/app/editor-example.tsx +++ b/site/app/editor-example.tsx @@ -16,7 +16,6 @@ export default function Page() { value={code} className='editor' title='index.js' - highlight={text => highlight(text)} onChange={(text) => setCode(text)} />
diff --git a/site/app/live-editor.tsx b/site/app/live-editor.tsx index b64e99c..d142428 100644 --- a/site/app/live-editor.tsx +++ b/site/app/live-editor.tsx @@ -2,7 +2,6 @@ import { Editor } from 'codice' import { useState } from 'react' -import { highlight } from 'sugar-high' const CODE_QUERY_KEY = 'c' @@ -71,7 +70,6 @@ export function LiveEditor({ title={title} controls={controls} lineNumbers={lineNumbers} - highlight={(text) => highlight(text)} onChange={(text) => setCode(text)} /> diff --git a/site/app/styles.css b/site/app/styles.css index e001558..eba53a4 100644 --- a/site/app/styles.css +++ b/site/app/styles.css @@ -153,16 +153,10 @@ input[type=radio] { .editor[data-codice-editor] textarea:focus { border: 1px solid hsla(137, 100.00%, 94.30%, 0.30); } -.editor[data-codice-editor-line-numbers="false"] [data-codice-editor-content] { - padding-left: 0; -} -.editor[data-codice-editor-line-numbers="true"] [data-codice-editor-content] { - padding-left: 16px; -} [data-codice-editor-title] { color: hsla(0, 0%, 87%, 0.34); } -:nth + [data-codice-editor-control] { transition: background-color 0.2s ease-in-out; } diff --git a/site/next.config.js b/site/next.config.js new file mode 100644 index 0000000..b275330 --- /dev/null +++ b/site/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + experimental: { + reactOwnerStack: true, + }, +} diff --git a/src/code/code.test.tsx b/src/code/code.test.tsx index f613cb1..9dcacc6 100644 --- a/src/code/code.test.tsx +++ b/src/code/code.test.tsx @@ -6,7 +6,8 @@ describe('Code', () => { it('default props', () => { expect(renderToString(test)).toMatchInlineSnapshot(` "
test
" + [data-codice-code] .sh__line { + display: inline-block; + width: 100%; + } + [data-codice-code] .sh__line[data-highlight] { + background-color: var(--codice-code-highlight-color); + } +
test
" `) }) it('with title', () => { expect(renderToString(test)).toMatchInlineSnapshot(` "
file.js
test
" +
file.js
test
" `) }) it('with controls', () => { expect(renderToString(test)).toMatchInlineSnapshot(` "
test
" +
test
" `) }) }) \ No newline at end of file diff --git a/src/code/code.tsx b/src/code/code.tsx index 539ea50..8610fcb 100644 --- a/src/code/code.tsx +++ b/src/code/code.tsx @@ -1,4 +1,66 @@ +import { tokenize, generate } from 'sugar-high' import { baseCss, headerCss, lineNumbersCss } from './css' +import { useMemo } from 'react' + +function generateHighlightedLines( + codeText: string, + highlightLines: ([number, number] | number)[], + lineNumbers: boolean +) { + const childrenLines = generate(tokenize(codeText)) + + // each line will contain class name 'sh__line', + // if it's highlighted, it will contain [data-highlight] + const highlightedLines = new Set() + if (highlightLines) { + for (const line of highlightLines) { + if (Array.isArray(line)) { + // Add range of lines + for (let i = line[0]; i <= line[1]; i++) { + highlightedLines.add(i) + } + } else { + // Add single line + highlightedLines.add(line) + } + } + } + + const lines = ( + childrenLines.map((line, index) => { + const isHighlighted = highlightedLines.has(index + 1) + const { tagName: Line, properties: lineProperties } = line + const tokens = line.children + .map((child, childIndex) => { + const { tagName: Token, children, properties } = child + return ( + + {(children[0].value)} + + ) + }) + + + return ( + + {lineNumbers ? {index + 1} : null} + {tokens} + + ) + }) + ) + return lines +} export function CodeHeader({ title, controls = false }: { title?: string; controls: boolean }) { if (!title && !controls) return null @@ -25,6 +87,7 @@ export function Code({ controls, preformatted = true, lineNumbers = false, + highlightLines, ...props }: { children: string @@ -33,8 +96,13 @@ export function Code({ title?: string controls?: boolean lineNumbers?: boolean + highlightLines?: ([number, number] | number)[] } & React.HTMLAttributes) { const css = baseCss + (lineNumbers ? lineNumbersCss : '') + const lineElements = useMemo(() => + generateHighlightedLines(code, highlightLines, lineNumbers), + [code, highlightLines, lineNumbers] + ) return (
@@ -44,10 +112,14 @@ export function Code({ {preformatted ? (
-          
+          
+            <>
+              {lineElements}
+            
+          
         
) : ( -
{code}
+
{lineElements}
)}
) diff --git a/src/code/css.ts b/src/code/css.ts index 81be509..f2ecf1d 100644 --- a/src/code/css.ts +++ b/src/code/css.ts @@ -3,7 +3,8 @@ const H = `[data-codice-editor-header]` export const baseCss = `\ ${C} { - --codice-editor-line-number-color: #a4a4a4; + --codice-code-line-number-color: #a4a4a4; + --codice-code-highlight-color: #555555; } ${C} pre { white-space: pre-wrap; @@ -12,6 +13,13 @@ ${C} pre { ${C} code { border: none; } +${C} .sh__line { + display: inline-block; + width: 100%; +} +${C} .sh__line[data-highlight] { + background-color: var(--codice-code-highlight-color); +} ` export const headerCss = `\ @@ -50,16 +58,19 @@ ${H} [data-codice-editor-control] { export const lineNumbersCss = `\ @scope { - code { counter-reset: codice-code-line-number; } - .sh__line::before { + code { + counter-reset: codice-code-line-number; + padding-left + } + [data-codice-code-line-number] { counter-increment: codice-code-line-number 1; content: counter(codice-code-line-number); display: inline-block; min-width: 24px; - margin-right: 18px; - margin-left: -42px; + margin-right: 16px; text-align: right; - color: var(--codice-editor-line-number-color); + user-select: none; + color: var(--codice-code-line-number-color); } } ` \ No newline at end of file diff --git a/src/editor/css.ts b/src/editor/css.ts index 5bf76e9..42502c3 100644 --- a/src/editor/css.ts +++ b/src/editor/css.ts @@ -14,7 +14,7 @@ ${R} textarea { line-break: anywhere; overflow-wrap: break-word; scrollbar-width: none; - padding: 24px 36px; + padding: 24px 16px; font-size: 16px; line-height: 20px; caret-color: var(--codice-editor-caret-color); @@ -48,6 +48,7 @@ ${R} textarea { overflow: hidden; } ${R}[data-codice-editor-line-numbers="true"] textarea { - padding-left: 51px; + padding-left: 55px; } -` \ No newline at end of file +` +// line number padding-left is [[width 24px] margin-right 16px] + 15px \ No newline at end of file diff --git a/src/editor/editor.test.tsx b/src/editor/editor.test.tsx index ae3f407..36159fb 100644 --- a/src/editor/editor.test.tsx +++ b/src/editor/editor.test.tsx @@ -23,7 +23,7 @@ describe('Code', () => { line-break: anywhere; overflow-wrap: break-word; scrollbar-width: none; - padding: 24px 36px; + padding: 24px 16px; font-size: 16px; line-height: 20px; caret-color: var(--codice-editor-caret-color); @@ -57,7 +57,7 @@ describe('Code', () => { overflow: hidden; } [data-codice-editor][data-codice-editor-line-numbers="true"] textarea { - padding-left: 51px; + padding-left: 55px; }
" @@ -137,7 +148,7 @@ describe('Code', () => { line-break: anywhere; overflow-wrap: break-word; scrollbar-width: none; - padding: 24px 36px; + padding: 24px 16px; font-size: 16px; line-height: 20px; caret-color: var(--codice-editor-caret-color); @@ -171,7 +182,7 @@ describe('Code', () => { overflow: hidden; } [data-codice-editor][data-codice-editor-line-numbers="true"] textarea { - padding-left: 51px; + padding-left: 55px; }
file.js
" @@ -251,7 +273,7 @@ describe('Code', () => { line-break: anywhere; overflow-wrap: break-word; scrollbar-width: none; - padding: 24px 36px; + padding: 24px 16px; font-size: 16px; line-height: 20px; caret-color: var(--codice-editor-caret-color); @@ -285,10 +307,11 @@ describe('Code', () => { overflow: hidden; } [data-codice-editor][data-codice-editor-line-numbers="true"] textarea { - padding-left: 51px; + padding-left: 55px; }
" diff --git a/src/editor/editor.tsx b/src/editor/editor.tsx index 1761d54..d4c05b8 100644 --- a/src/editor/editor.tsx +++ b/src/editor/editor.tsx @@ -4,13 +4,17 @@ import { useEffect, useState, useRef, forwardRef } from 'react' import { Code } from '../code' import { CodeHeader } from '../code/code' -function composeRefs(...refs) { - return (node) => { +function composeRefs(...refs: React.Ref[]) { + return (node: HTMLElement | null) => { refs.forEach((ref) => { if (typeof ref === 'function') { - ref(node) + if (node) { + ref(node) + } } else if (ref) { - ref.current = node + if (node) { + ref.current = node + } } }) } @@ -23,35 +27,32 @@ const Editor = forwardRef(function EditorComponent( controls, lineNumbers, onChange = () => {}, - highlight = () => '', }: { title?: string value?: string controls?: boolean lineNumbers?: boolean onChange?: (code: string) => void - highlight?: (code: string) => string } & React.HTMLAttributes, ref: React.Ref ) { - const [text, setText] = useState(value) - const [output, setOutput] = useState(() => highlight(text)) + const [code, setCode] = useState(value) const textareaRef = useRef(null) - function update(code: string) { - const highlighted = highlight(code) - setText(code) - setOutput(highlighted) - onChange(code) + function update(textContent: string) { + setCode(textContent) + onChange(textContent) } useEffect(() => { - update(value) - }, [value]) + if (value !== code) { + update(value) + } + }, [value, code]) - function onInput(event) { - const code = event.target.value || '' - update(code) + function onInput(event: React.ChangeEvent) { + const textContent = event.target.value || '' + update(textContent) } return ( @@ -65,9 +66,9 @@ const Editor = forwardRef(function EditorComponent( controls={false} lineNumbers={lineNumbers ?? true} > - {output} + {code}
-