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}

-
- {node.textContent || 'description...'}
-
+