diff --git a/.changeset/khaki-owls-worry.md b/.changeset/khaki-owls-worry.md new file mode 100644 index 0000000..1e7469c --- /dev/null +++ b/.changeset/khaki-owls-worry.md @@ -0,0 +1,5 @@ +--- +'@nextlint/svelte': minor +--- + +refactor: codeBlock diff --git a/README.md b/README.md index 4da1526..533ded1 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ type PluginOptions = { image?: ImagePluginOptions; gpt?: AskOptions; dropCursor?: DropcursorOptions; + codeBlock?: NextlintCodeBlockOptions; }; ``` @@ -319,6 +320,45 @@ Config dropCursor color/width/class. /> ``` +### plugins.codeBlock + +Type: `NextlintCodeBlockOptions|undefined` + +Default: + +```ts +{ + themes: { + dark: 'github-dark', + light: 'github-light' + }, + langs: [] +} +``` + +The `codeBlock` theme will sync with the `theme` props. + + +https://github.com/lynhan318/nextlint/assets/32099104/d5d5c72d-787d-4b16-882f-2cba0dbfaa35 + + +```svelte + +``` ## Contributing Please follow the [contribute guideline](https://github.com/sveltor/nextlint/blob/main/CONTRIBUTING.md) diff --git a/packages/svelte/package.json b/packages/svelte/package.json index cf8c787..50c3cc3 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -34,9 +34,10 @@ "svelte": "*" }, "dependencies": { + "@nextlint/core": "^1.4.0", "@floating-ui/dom": "^1.5.3", "@melt-ui/svelte": "^0.61.1", - "@nextlint/core": "^1.4.0", + "@prosemirror-adapter/svelte": "^0.2.6", "@svelte-put/clickoutside": "^3.0.0", "@svelte-put/lockscroll": "^1.0.1", "@tiptap/core": "^2.1.13", @@ -52,6 +53,7 @@ "hast-util-to-html": "^9.0.0", "highlight.js": "^11.9.0", "lucide-svelte": "^0.292.0", + "prosemirror-highlight": "^0.5.0", "radash": "^11.0.0", "radix-icons-svelte": "^1.2.1", "shiki": "^1.2.0", diff --git a/packages/svelte/src/lib/Editor.svelte b/packages/svelte/src/lib/Editor.svelte index e5d527a..cbf7c5e 100644 --- a/packages/svelte/src/lib/Editor.svelte +++ b/packages/svelte/src/lib/Editor.svelte @@ -3,6 +3,7 @@ image?: ImagePluginOptions; ask?: AskOptions; dropCursor?: DropcursorOptions; + codeBlock?: NextlintCodeBlockOptions; }; @@ -11,7 +12,10 @@ import type {Content, Editor, Extensions} from '@tiptap/core'; import {LinkExtension} from '$lib/plugins/link'; - import {NextlintCodeBlock} from '$lib/plugins/codeBlock'; + import { + NextlintCodeBlock, + type NextlintCodeBlockOptions + } from '$lib/plugins/codeBlock'; import {PluginAsk, type AskOptions} from '$lib/plugins/ask'; import {FigureExtension} from '$lib/plugins/figure'; import { @@ -25,10 +29,12 @@ Dropcursor, type DropcursorOptions } from '@tiptap/extension-dropcursor'; + import {useProsemirrorAdapterProvider} from '@prosemirror-adapter/svelte'; import BubbleMenu from './components/BubbleMenu/BubbleMenu.svelte'; import {BubbleMenuExtension} from './plugins/bubbleMenu/bubbleMenu'; + useProsemirrorAdapterProvider(); export let content: Content; export let placeholder = "Press 'space' GPT support, type '/' for help"; export let onChange: (editor: Editor) => void; @@ -55,7 +61,15 @@ FigureExtension, SelectImageExtension.configure(plugins.image), Dropcursor.configure(plugins.dropCursor), - NextlintCodeBlock.configure(), + NextlintCodeBlock.configure( + plugins.codeBlock || { + themes: { + dark: 'github-dark', + light: 'github-light' + }, + langs: [] + } + ), BubbleMenuExtension.configure({ component: BubbleMenu }), diff --git a/packages/svelte/src/lib/EditorTheme.scss b/packages/svelte/src/lib/EditorTheme.scss index a896150..98b91c1 100644 --- a/packages/svelte/src/lib/EditorTheme.scss +++ b/packages/svelte/src/lib/EditorTheme.scss @@ -78,7 +78,7 @@ @apply outline-none text-foreground; h1 { - @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl; + @apply mt-10 scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl; } h2 { @@ -114,22 +114,9 @@ @apply list-decimal; } - code-block { - width: 100%; - min-height: 40px; - display: block; - margin-top: theme('margin.6'); - height: 100%; - display: inline-flex; - position: relative; - font-family: 'Fira Code', monospace; - font-size: 14px; - border-radius: 4px; - border: 1px solid theme('colors.border'); - box-sizing: border-box; - } - .shiki { + display: inline-flex; + width: 100%; position: relative; z-index: 1; overflow-x: auto; @@ -142,6 +129,8 @@ padding: 16px; min-height: 52px; height: 100%; + border: 1px solid theme('colors.border'); + margin-top: 1.5rem; } p > code { diff --git a/packages/svelte/src/lib/EditorTheme.svelte b/packages/svelte/src/lib/EditorTheme.svelte index 45141fb..32c20cd 100644 --- a/packages/svelte/src/lib/EditorTheme.svelte +++ b/packages/svelte/src/lib/EditorTheme.svelte @@ -1,5 +1,16 @@ + + diff --git a/packages/svelte/src/lib/adapter/Counter.svelte b/packages/svelte/src/lib/adapter/Counter.svelte new file mode 100644 index 0000000..c8f2456 --- /dev/null +++ b/packages/svelte/src/lib/adapter/Counter.svelte @@ -0,0 +1,26 @@ + + + + +
+ + diff --git a/packages/svelte/src/lib/helpers/index.ts b/packages/svelte/src/lib/helpers/index.ts index 6ce079f..4aede20 100644 --- a/packages/svelte/src/lib/helpers/index.ts +++ b/packages/svelte/src/lib/helpers/index.ts @@ -3,7 +3,6 @@ import {clsx} from 'clsx'; import {renderHTML} from '@nextlint/core'; import {twMerge} from 'tailwind-merge'; -import {getHighlighter} from '$lib/plugins/codeBlock'; import type {Editor} from '$lib'; export function cn(...inputs: ClassValue[]) { @@ -13,21 +12,6 @@ export function cn(...inputs: ClassValue[]) { export const svelteEditorToHtml = async (editor: Editor) => { try { const html = await renderHTML(editor, async element => { - if (element.nodeName === 'PRE') { - const highlighter = await getHighlighter(); - const lang = element._attributes['code-block-lang'] || 'javascript'; - const code = highlighter.codeToHtml( - element.querySelector('code')?.textContent || '', - { - lang, - themes: { - dark: 'github-dark', - light: 'github-light' - } - } - ); - return code; - } return element.render(); }); return html; diff --git a/packages/svelte/src/lib/node-view/SvelteNodeViewRenderer.ts b/packages/svelte/src/lib/node-view/SvelteNodeViewRenderer.ts index f42973d..af50427 100644 --- a/packages/svelte/src/lib/node-view/SvelteNodeViewRenderer.ts +++ b/packages/svelte/src/lib/node-view/SvelteNodeViewRenderer.ts @@ -1,7 +1,11 @@ -import type {NodeView as ProseMirrorNodeView} from '@tiptap/pm/view'; -import type {Node as PMNode} from '@tiptap/pm/model'; +//Inspire by Prosemirror Adapter by Mirone. +import type { + Decoration, + NodeView as ProseMirrorNodeView +} from '@tiptap/pm/view'; +import type {Attrs, Node as PMNode} from '@tiptap/pm/model'; -import type {ComponentType} from 'svelte'; +import {getContext, type ComponentType, type SvelteComponent} from 'svelte'; import { NodeView, type Editor, @@ -10,16 +14,38 @@ import { type NodeViewRendererOptions as TNodeViewRendererOptions } from '@tiptap/core'; -import {get, writable, type Writable} from 'svelte/store'; +import {writable, type Writable} from 'svelte/store'; -import {SvelteRenderer} from './SvelteRenderer'; +export type Theme = 'dark' | 'light'; +export interface NodeViewContext { + // immutable + editor: Editor; + contentDOM: (node: HTMLElement) => void; + getPos: () => number | undefined; + updateAttributes: (attrs: Attrs) => void; + deleteNode: () => void; + selectNode: () => void; + deSelectNode: () => void; + options?: unknown; + + // changes between updates + node: Writable; + selected: Writable; + decorations: Writable; +} + +//@ts-expect-error skip export class SvelteNodeView extends NodeView implements ProseMirrorNodeView { - renderer!: SvelteRenderer; store!: Writable; + props: NodeViewRendererProps; + context: NodeViewContext; + renderer: SvelteComponent; + contentElement: HTMLElement | null = null; + element: HTMLElement; constructor( readonly options: NodeViewRendererOptions, @@ -29,34 +55,55 @@ export class SvelteNodeView stopEvent: options.stopEvent || null, ignoreMutation: options.ignoreMutation || null }); - this.store = writable({ + this.props = props; + this.context = { + //immutable editor: this.editor, - node: this.node, - decorations: this.decorations, - selected: false, - extension: this.extension, - getPos: () => this.getPos(), - updateAttributes: (attributes = {}) => this.updateAttributes(attributes), - deleteNode: () => this.deleteNode() + getPos: this.getPos, + options: options.options || {}, + updateAttributes: (attrs = {}) => this.updateAttributes(attrs), + deleteNode: () => this.deleteNode(), + selectNode: () => this.selectNode(), + deSelectNode: () => this.deselectNode(), + contentDOM: (contentElement: HTMLElement) => { + this.contentElement = this.#createElement(options.contentAs); + this.contentElement.setAttribute('data-node-view-content', 'true'); + this.contentElement.style.whiteSpace = 'inherit'; + contentElement.appendChild(this.contentElement); + }, + + //change between updates + node: writable(this.node), + selected: writable(false), + decorations: writable(this.decorations) + }; + + this.element = this.#createElement(options.domAs); + this.element.setAttribute('data-node-view-root', 'true'); + this.renderer = new options.component({ + target: this.element, + context: new Map(Object.entries(this.context)) }); + } - this.renderer = new SvelteRenderer( - { - component: options.component, - contentAs: options.contentAs, - domAs: options.domAs - }, - this.store - ); + #createElement(as?: string | HTMLElement | ((node: PMNode) => HTMLElement)) { + const {node} = this; + return as == null + ? document.createElement(node.isInline ? 'span' : 'div') + : as instanceof HTMLElement + ? as + : as instanceof Function + ? as(this.node) + : document.createElement(as); } override get dom() { - return this.renderer.element; + return this.element; } override get contentDOM() { if (this.node.isLeaf) return null; - return this.renderer.contentElement || null; + return this.contentElement || null; } deleteNode = () => { @@ -65,46 +112,61 @@ export class SvelteNodeView }; update(node: PMNode) { - this.store.update(store => { - store.node = node; - return store; - }); - return true; + if (this.shouldUpdate(node)) { + this.context.node.set(node); + return true; + } + return false; + } + selectNode() { + this.context.selected.set(true); } - selectNode = () => { - this.store.update(store => { - store.selected = true; - return store; - }); - }; + deselectNode() { + this.context.selected.set(false); + } - deselectNode = () => { - this.store.update(store => { - store.selected = false; - return store; - }); + shouldUpdate = (node: PMNode) => { + if (node.type !== this.node.type) return false; + if (node.sameMarkup(this.node) && node.content.eq(this.node.content)) + return false; + return true; }; + destroy() { + this.contentElement?.remove(); + this.element?.remove(); + this.renderer?.$destroy(); + } + ignoreMutation(mutation: MutationRecord) { + if (!this.dom || !this.contentDOM) return true; - toggleNodeSelection = () => { - if (get(this.store).selected) { - this.deselectNode(); - return; - } - return this.selectNode(); - }; + if (this.node.isLeaf || this.node.isAtom) return true; - destroy() { - this.renderer.destroy(); + if ((mutation.type as unknown) === 'selection') return false; + + if (this.contentDOM === mutation.target && mutation.type === 'attributes') + return true; + + if (this.contentDOM.contains(mutation.target)) return false; + + return true; } } export interface NodeViewRendererOptions extends Partial { component: ComponentType; + options?: unknown; + domAs?: string | ((node: PMNode) => HTMLElement); contentAs?: string; - domAs?: string; } + export const SvelteNodeViewRenderer = (options: NodeViewRendererOptions) => { - return (props: NodeViewRendererProps) => new SvelteNodeView(options, props); + return (props: NodeViewRendererProps) => { + return new SvelteNodeView(options, props); + }; }; + +export const useNodeViewContext = ( + ctxKey: K +) => getContext(ctxKey); diff --git a/packages/svelte/src/lib/node-view/SvelteRenderer.ts b/packages/svelte/src/lib/node-view/SvelteRenderer.ts deleted file mode 100644 index c983ea8..0000000 --- a/packages/svelte/src/lib/node-view/SvelteRenderer.ts +++ /dev/null @@ -1,60 +0,0 @@ -import {getContext, type ComponentType, type SvelteComponent} from 'svelte'; -import type {NodeViewProps} from '@tiptap/core'; - -import {get, type Writable} from 'svelte/store'; - -export interface SvelteNodeViewContext { - props?: Writable; - contentRef?: (element: HTMLElement) => void; -} - -export interface SvelteRenderOptions { - component: ComponentType; - domAs?: string; - contentAs?: string; -} - -export class SvelteRenderer { - element!: HTMLElement; - contentElement?: HTMLElement; - - component: SvelteComponent; - context: SvelteNodeViewContext = {}; - - constructor(opts: SvelteRenderOptions, props: Writable) { - const {component: Component, domAs, contentAs} = opts; - this.context = { - props, - contentRef: (element: HTMLElement) => { - element.setAttribute('data-node-view-content', 'true'); - element.style.whiteSpace = 'inherit'; - this.contentElement = element; - } - }; - - // Create dom node - this.element = document.createElement(domAs || 'div'); - this.element.setAttribute('data-node-view-root', 'true'); - - this.component = new Component({ - target: this.element, - props: { - as: domAs, - contentAs - }, - context: new Map(Object.entries(this.context)) - }); - } - - destroy() { - this.component.$destroy(); - this.contentElement?.remove(); - this.element?.remove(); - } -} - -export const useNodeViewProps = () => - getContext>('props'); - -export const useContentRef = () => - getContext<(element: HTMLElement) => void>('contentRef'); diff --git a/packages/svelte/src/lib/node-view/index.ts b/packages/svelte/src/lib/node-view/index.ts index 22876fa..117ca87 100644 --- a/packages/svelte/src/lib/node-view/index.ts +++ b/packages/svelte/src/lib/node-view/index.ts @@ -1,3 +1,2 @@ export * from './SvelteNodeViewRenderer'; -export * from './SvelteRenderer'; export * from './FloatingRenderer'; diff --git a/packages/svelte/src/lib/plugins/codeBlock/CodeBlock.svelte b/packages/svelte/src/lib/plugins/codeBlock/CodeBlock.svelte index 8e66f3f..7f12134 100644 --- a/packages/svelte/src/lib/plugins/codeBlock/CodeBlock.svelte +++ b/packages/svelte/src/lib/plugins/codeBlock/CodeBlock.svelte @@ -1,43 +1,11 @@ -
- {@html highlightCode} +
+
- -
-  
-
+ + diff --git a/packages/svelte/src/lib/plugins/codeBlock/Languages.svelte b/packages/svelte/src/lib/plugins/codeBlock/Languages.svelte index e0fd5a8..b07f465 100644 --- a/packages/svelte/src/lib/plugins/codeBlock/Languages.svelte +++ b/packages/svelte/src/lib/plugins/codeBlock/Languages.svelte @@ -1,19 +1,18 @@
@@ -79,6 +76,7 @@ value: lang, label: lang })} + on:mousedown|preventDefault={() => setAttrs({lang})} class="flex flex-row items-center justify-between cursor-pointer rounded-md py-1 pl-4 pr-4 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50" diff --git a/packages/svelte/src/lib/plugins/codeBlock/cache.ts b/packages/svelte/src/lib/plugins/codeBlock/cache.ts new file mode 100644 index 0000000..65431cc --- /dev/null +++ b/packages/svelte/src/lib/plugins/codeBlock/cache.ts @@ -0,0 +1,105 @@ +import {Node as ProseMirrorNode} from '@tiptap/pm/model'; +import {Transaction} from '@tiptap/pm/state'; +import {Decoration} from '@tiptap/pm/view'; + +/** + * Represents a cache of doc positions to the node and decorations at that position + */ +export class DecorationCache { + private cache: Map< + number, + [node: ProseMirrorNode, decorations: Decoration[]] + >; + + constructor( + cache?: Map + ) { + this.cache = new Map(cache); + } + + /** + * Gets the cache entry at the given doc position, or null if it doesn't exist + * @param pos The doc position of the node you want the cache for + */ + get(pos: number) { + return this.cache.get(pos); + } + + /** + * Sets the cache entry at the given position with the give node/decoration + * values + * @param pos The doc position of the node to set the cache for + * @param node The node to place in cache + * @param decorations The decorations to place in cache + */ + set(pos: number, node: ProseMirrorNode, decorations: Decoration[]): void { + if (pos < 0) { + return; + } + + this.cache.set(pos, [node, decorations]); + } + + /** + * Removes the value at the oldPos (if it exists) and sets the new position to + * the given values + * @param oldPos The old node position to overwrite + * @param newPos The new node position to set the cache for + * @param node The new node to place in cache + * @param decorations The new decorations to place in cache + */ + private replace( + oldPos: number, + newPos: number, + node: ProseMirrorNode, + decorations: Decoration[] + ): void { + this.remove(oldPos); + this.set(newPos, node, decorations); + } + + /** + * Removes the cache entry at the given position + * @param pos The doc position to remove from cache + */ + remove(pos: number): void { + this.cache.delete(pos); + } + + /** + * Invalidates the cache by removing all decoration entries on nodes that have + * changed, updating the positions of the nodes that haven't and removing all + * the entries that have been deleted; NOTE: this does not affect the current + * cache, but returns an entirely new one + * @param tr A transaction to map the current cache to + */ + invalidate(tr: Transaction): DecorationCache { + const returnCache = new DecorationCache(this.cache); + const mapping = tr.mapping; + + this.cache.forEach(([node, decorations], pos) => { + if (pos < 0) { + return; + } + + const result = mapping.mapResult(pos); + const mappedNode = tr.doc.nodeAt(result.pos); + + if (result.deleted || !mappedNode?.eq(node)) { + returnCache.remove(pos); + } else if (pos !== result.pos) { + // update the decorations' from/to values to match the new node position + const updatedDecorations = decorations + .map((d): Decoration | null => { + // @ts-expect-error: internal api + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + return d.map(mapping, 0, 0) as Decoration | null; + }) + .filter((d): d is Decoration => d != null); + returnCache.replace(pos, result.pos, mappedNode, updatedDecorations); + } + }); + + return returnCache; + } +} diff --git a/packages/svelte/src/lib/plugins/codeBlock/codeBlock.ts b/packages/svelte/src/lib/plugins/codeBlock/codeBlock.ts index 9914f14..1ed0e12 100644 --- a/packages/svelte/src/lib/plugins/codeBlock/codeBlock.ts +++ b/packages/svelte/src/lib/plugins/codeBlock/codeBlock.ts @@ -1,19 +1,16 @@ import {CodeBlock} from '@tiptap/extension-code-block'; -import type {BundledLanguage, BundledTheme} from 'shiki'; - -import {SvelteNodeViewRenderer} from '$lib/node-view'; +import type {BundledLanguage} from 'shiki'; import SvelteCodeBlock from './CodeBlock.svelte'; import {PluginKey, Plugin} from '@tiptap/pm/state'; - -export type NextlintCodeBlockAttrs = { - lang: BundledLanguage; - theme: 'github-light' | 'github-dark'; -}; +import {SvelteNodeViewRenderer} from '$lib/node-view'; +import {createHighlightPlugin, highlighter, lazyParser} from './plugin'; +import type {ShikiTheme} from './shiki'; +import {mergeAttributes} from '@tiptap/core'; export type NextlintCodeBlockOptions = { langs: BundledLanguage[]; - themes: BundledTheme[]; + themes: ShikiTheme; }; export const NextlintCodeBlock = CodeBlock.extend({ @@ -24,87 +21,90 @@ export const NextlintCodeBlock = CodeBlock.extend({ lang: { default: this.options.langs[0], parseHTML: html => { - return html.getAttribute('code-block-lang'); + return html.getAttribute('data-lang'); }, renderHTML: attrs => { return { - 'code-block-lang': attrs.lang - }; - } - }, - theme: { - default: this.options.themes[0], - parseHTML: html => { - return html.getAttribute('code-block-theme'); - }, - renderHTML: attrs => { - return { - 'code-block-theme': attrs.theme + 'data-lang': attrs.lang }; } } }; }, - - addOptions() { - return { - themes: ['github-light', 'github-dark'], - langs: [ - 'javascript', - 'rust', - 'typescript', - 'css', - 'html', - 'tsx', - 'svelte', - 'json', - 'shell', - 'yaml', - 'vue', - 'lua', - 'python', - 'c', - 'c++', - 'java', - 'zig', - 'swift', - 'kotlin', - 'go', - 'angular-ts', - 'angular-html' + renderHTML({HTMLAttributes, node}) { + const textContent = node.textContent; + const withSyntax = highlighter?.codeToHtml(textContent, { + lang: node.attrs.lang, + themes: this.options.themes + }); + const parsed = new DOMParser().parseFromString( + withSyntax || '', + 'text/html' + ); + const pre = parsed.querySelector('pre.shiki'); + if (pre) { + pre.setAttribute('data-lang', node.attrs.lang); + return pre; + } + return [ + 'pre', + mergeAttributes(HTMLAttributes, { + 'data-node-view-root': true + }), + [ + 'pre', + { + 'data-node-view-content': true + }, + 0 ] - }; + ]; }, - addNodeView() { - return SvelteNodeViewRenderer({ - component: SvelteCodeBlock, - domAs: 'code-block', - contentAs: 'pre' - }); + onCreate() { + const {dark, light} = this.options.themes; + if (dark) { + highlighter?.loadTheme(dark); + } + if (light) { + highlighter?.loadTheme(light); + } }, - addProseMirrorPlugins() { - const codeBlockPlugin = new Plugin({ - key: new PluginKey('codeBlock'), - props: { - handleKeyDown: (view, event) => { - if (event.key === 'Tab') { - const resolver = view.state.selection.$from; - const pNode = resolver.node(1); - if (pNode.type.name === this.name) { - event.preventDefault(); - event.stopPropagation(); - this.editor - .chain() - .insertContentAt(resolver.pos, '\t') - .setTextSelection(resolver.pos + 1) - .run(); + return [ + createHighlightPlugin({parser: lazyParser, themes: this.options.themes}), + new Plugin({ + key: new PluginKey('codeBlock'), + props: { + handleKeyDown: (view, event) => { + if (event.key === 'Tab') { + const resolver = view.state.selection.$from; + const pNode = resolver.node(1); + if (pNode.type.name === this.name) { + event.preventDefault(); + event.stopPropagation(); + this.editor + .chain() + .insertContentAt(resolver.pos, '\t') + .setTextSelection(resolver.pos + 1) + .run(); + } } } } + }) + ]; + }, + + addNodeView() { + return SvelteNodeViewRenderer({ + component: SvelteCodeBlock, + options: this.options, + domAs: () => { + const pre = document.createElement('pre'); + pre.classList.add('shiki'); + return pre; } }); - return [codeBlockPlugin]; } }); diff --git a/packages/svelte/src/lib/plugins/codeBlock/index.ts b/packages/svelte/src/lib/plugins/codeBlock/index.ts index 9e74d82..db19320 100644 --- a/packages/svelte/src/lib/plugins/codeBlock/index.ts +++ b/packages/svelte/src/lib/plugins/codeBlock/index.ts @@ -1,20 +1 @@ -import { - getHighlighter as shikijiHighlighter, - type HighlighterGeneric, - type BundledLanguage, - type BundledTheme -} from 'shiki/bundle-full.mjs'; - -import {NextlintCodeBlock} from './codeBlock'; - -let highlighter: HighlighterGeneric; - -export async function getHighlighter() { - highlighter ||= await shikijiHighlighter({ - langs: NextlintCodeBlock.options.langs, - themes: NextlintCodeBlock.options.themes - }); - return highlighter; -} - export * from './codeBlock'; diff --git a/packages/svelte/src/lib/plugins/codeBlock/plugin.ts b/packages/svelte/src/lib/plugins/codeBlock/plugin.ts new file mode 100644 index 0000000..bb7b0fe --- /dev/null +++ b/packages/svelte/src/lib/plugins/codeBlock/plugin.ts @@ -0,0 +1,193 @@ +import {Node as ProseMirrorNode} from '@tiptap/pm/model'; +import {Plugin, PluginKey} from '@tiptap/pm/state'; +import {Decoration, DecorationSet} from '@tiptap/pm/view'; + +import {DecorationCache} from './cache'; +import type {LanguageExtractor, Parser, TiptapShikiTheme} from './types'; +import {getHighlighter} from 'shiki'; +import type {BuiltinLanguage, BundledTheme, Highlighter} from 'shiki'; + +import {createParser, type ShikiTheme} from './shiki'; + +export interface HighlightPluginState { + cache: DecorationCache; + decorations: DecorationSet; + promises: Promise[]; +} + +export {highlighter}; + +let highlighterPromise: Promise | undefined; +let highlighter: Highlighter | undefined; +let parser: Parser | undefined; +const loadedLanguages = new Set(); + +export const lazyParser: Parser = options => { + if (!highlighterPromise) { + highlighterPromise = getHighlighter({ + themes: Object.values(options.themes) as BundledTheme[], + langs: [] + }).then(h => { + highlighter = h; + }); + return highlighterPromise; + } + + if (!highlighter) { + return highlighterPromise; + } + + const language = options.language; + if (language && !loadedLanguages.has(language)) { + return highlighter.loadLanguage(language as BuiltinLanguage).then(() => { + loadedLanguages.add(language); + }); + } + + if (!parser) { + parser = createParser(highlighter); + } + + return parser(options); +}; + +export function createHighlightPlugin({ + parser, + nodeTypes = ['NextlintCodeBlock'], + languageExtractor = (node: ProseMirrorNode) => + node?.attrs?.lang || 'javascript', + themes +}: { + parser: Parser; + nodeTypes?: string[]; + languageExtractor?: LanguageExtractor; + themes: ShikiTheme; +}): Plugin { + const key = new PluginKey(); + + return new Plugin({ + key, + state: { + init(_, instance) { + const cache = new DecorationCache(); + const [decorations, promises] = calculateDecoration( + instance.doc, + parser, + nodeTypes, + languageExtractor, + cache, + themes + ); + + return {cache, decorations, promises}; + }, + apply: (tr, data) => { + const cache = data.cache.invalidate(tr); + const refresh = !!tr.getMeta('prosemirror-highlight-refresh'); + + if (!tr.docChanged && !refresh) { + const decorations = data.decorations.map(tr.mapping, tr.doc); + const promises = data.promises; + return {cache, decorations, promises}; + } + + const [decorations, promises] = calculateDecoration( + tr.doc, + parser, + nodeTypes, + languageExtractor, + cache, + themes + ); + return {cache, decorations, promises}; + } + }, + view: view => { + const promises = new Set>(); + + // Refresh the decorations when all promises resolve + const refresh = () => { + if (promises.size > 0) { + return; + } + const tr = view.state.tr.setMeta('prosemirror-highlight-refresh', true); + view.dispatch(tr); + }; + + const check = () => { + const state = key.getState(view.state); + + for (const promise of state?.promises ?? []) { + promises.add(promise); + promise + .then(() => { + promises.delete(promise); + refresh(); + }) + .catch(() => { + promises.delete(promise); + }); + } + }; + + check(); + + return { + update: () => { + check(); + } + }; + }, + props: { + decorations(this, state) { + return this.getState(state)?.decorations; + } + } + }); +} + +function calculateDecoration( + doc: ProseMirrorNode, + parser: Parser, + nodeTypes: string[], + languageExtractor: LanguageExtractor, + cache: DecorationCache, + themes: TiptapShikiTheme +) { + const result: Decoration[] = []; + const promises: Promise[] = []; + + doc.descendants((node: ProseMirrorNode, pos: number) => { + if (!node.type.isTextblock) { + return true; + } + + if (nodeTypes.includes(node.type.name)) { + const language = languageExtractor(node); + const cached = cache.get(pos); + + if (cached) { + const [, decorations] = cached; + result.push(...decorations); + } else { + const decorations = parser({ + content: node.textContent, + language: language || undefined, + pos, + themes + }); + + if (decorations && Array.isArray(decorations)) { + cache.set(pos, node, decorations); + result.push(...decorations); + } else if (decorations instanceof Promise) { + cache.remove(pos); + promises.push(decorations); + } + } + } + return false; + }); + + return [DecorationSet.create(doc, result), promises] as const; +} diff --git a/packages/svelte/src/lib/plugins/codeBlock/shiki.ts b/packages/svelte/src/lib/plugins/codeBlock/shiki.ts new file mode 100644 index 0000000..fef5515 --- /dev/null +++ b/packages/svelte/src/lib/plugins/codeBlock/shiki.ts @@ -0,0 +1,49 @@ +import {Decoration} from '@tiptap/pm/view'; + +import { + type BundledLanguage, + type BundledTheme, + type CodeToTokensWithThemesOptions, + type Highlighter +} from 'shiki'; + +export type ShikiTheme = CodeToTokensWithThemesOptions< + BundledLanguage, + BundledTheme +>['themes']; + +export type Parser = (options: { + content: string; + pos: number; + language?: string; + themes: ShikiTheme; +}) => Decoration[] | Promise; + +export function createParser(highlighter: Highlighter): Parser { + return function parser({content, language, pos, themes}) { + const decorations: Decoration[] = []; + const tokens = highlighter.codeToTokensWithThemes(content, { + lang: language as BundledLanguage, + themes + }); + + let from = pos + 1; + + for (const line of tokens) { + for (const token of line) { + const to = from + token.content.length; + const decoration = Decoration.inline(from, to, { + style: `color: ${token.variants.light.color};--shiki-dark:${token.variants.dark.color}` + }); + + decorations.push(decoration); + + from = to; + } + + from += 1; + } + + return decorations; + }; +} diff --git a/packages/svelte/src/lib/plugins/figure/Figure.svelte b/packages/svelte/src/lib/plugins/figure/Figure.svelte index 27d5104..3f301d1 100644 --- a/packages/svelte/src/lib/plugins/figure/Figure.svelte +++ b/packages/svelte/src/lib/plugins/figure/Figure.svelte @@ -1,31 +1,30 @@
- {#if selected} + {#if visible}
updateAttributes({fit: 'contain'})} class={cn( - attrs.fit === 'contain' ? 'bg-accent' : 'bg-background', + attrs?.fit === 'contain' ? 'bg-accent' : 'bg-background', 'p-[6px] rounded-md hover:bg-secondary' )} > @@ -44,7 +43,7 @@ aria-label="Fit View" on:mousedown={() => updateAttributes({fit: 'cover'})} class={cn( - attrs.fit === 'cover' ? 'bg-accent' : 'bg-background', + attrs?.fit === 'cover' ? 'bg-accent' : 'bg-background', 'p-[6px] rounded-md hover:bg-secondary' )} > @@ -54,7 +53,7 @@ @@ -62,18 +61,21 @@
{/if} {attrs.alt} -
- {node.textContent || 'description...'} -
+