diff --git a/apps/electron-demo/vite.config.ts b/apps/electron-demo/vite.config.ts index ab18c4aa..25e514cc 100644 --- a/apps/electron-demo/vite.config.ts +++ b/apps/electron-demo/vite.config.ts @@ -68,6 +68,14 @@ export default defineConfig({ __dirname, "../../packages/plugin-wordcount/src/index.ts" ), + "@floatboat/nexus-plugin-math": path.resolve( + __dirname, + "../../packages/plugin-math/src/index.ts" + ), + "@floatboat/nexus-plugin-vim": path.resolve( + __dirname, + "../../packages/plugin-vim/src/index.ts" + ), "@floatboat/nexus-reference-plugins": path.resolve( __dirname, "../../packages/reference-plugins/src/index.ts" diff --git a/package.json b/package.json index 18beea66..9f21ee16 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.14", "packageManager": "pnpm@9.15.4", "scripts": { - "build": "pnpm --filter @floatboat/nexus-core build && pnpm --filter @floatboat/nexus-plugin-api build && pnpm --filter @floatboat/nexus-plugin-runtime build && pnpm --filter @floatboat/nexus-react build && pnpm --filter @floatboat/nexus-vue build && pnpm --filter @floatboat/nexus-preset-gfm build && pnpm --filter @floatboat/nexus-plugin-slash build && pnpm --filter @floatboat/nexus-plugin-history build && pnpm --filter @floatboat/nexus-plugin-search build && pnpm --filter @floatboat/nexus-plugin-toolbar build && pnpm --filter @floatboat/nexus-plugin-math build && pnpm --filter @floatboat/nexus-plugin-vim build && pnpm --filter @floatboat/nexus-plugin-wordcount build && pnpm --filter @floatboat/nexus-reference-plugins build", + "build": "pnpm --filter @floatboat/nexus-core build && pnpm --filter @floatboat/nexus-plugin-api build && pnpm --filter @floatboat/nexus-plugin-runtime build && pnpm --filter @floatboat/nexus-react build && pnpm --filter @floatboat/nexus-vue build && pnpm --filter @floatboat/nexus-preset-gfm build && pnpm --filter @floatboat/nexus-plugin-slash build && pnpm --filter @floatboat/nexus-plugin-history build && pnpm --filter @floatboat/nexus-plugin-search build && pnpm --filter @floatboat/nexus-plugin-toolbar build && pnpm --filter @floatboat/nexus-plugin-math build && pnpm --filter @floatboat/nexus-plugin-vim build && pnpm --filter @floatboat/nexus-plugin-wordcount build && pnpm --filter @floatboat/nexus-plugin-collab build && pnpm --filter @floatboat/nexus-reference-plugins build", "check:api": "pnpm --filter @floatboat/nexus-plugin-api check:api && pnpm --filter @floatboat/nexus-plugin-runtime check:api && pnpm --filter @floatboat/nexus-reference-plugins check:api", "typecheck": "pnpm -r exec tsc --noEmit", "test": "vitest run", diff --git a/packages/core/src/block-drag.ts b/packages/core/src/block-drag.ts new file mode 100644 index 00000000..519cb638 --- /dev/null +++ b/packages/core/src/block-drag.ts @@ -0,0 +1,377 @@ +import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; +import { type Extension, type Text } from "@codemirror/state"; + +export interface BlockRange { + from: number; + to: number; + lineStart: number; + lineEnd: number; +} + +export interface BlockDragOptions { + /** + * Distance in pixels from the left of the line where the handle appears. + * Defaults to -26px. + */ + handleOffsetPx?: number; + /** + * Custom callback fired after a block has been successfully moved. + */ + onBlockMove?: (source: BlockRange, targetPos: number) => void; +} + +/** + * Finds the continuous Markdown block boundary (paragraph, heading, + * table, code block, blockquote, or list item) containing `pos`. + */ +export function findBlockRange(doc: Text, pos: number): BlockRange { + const currentLine = doc.lineAt(Math.max(0, Math.min(doc.length, pos))); + const lineNum = currentLine.number; + const lineText = currentLine.text; + + // 1. Fenced code block detection + let inCode = false; + let codeStartLine = -1; + let codeEndLine = -1; + for (let n = 1; n <= doc.lines; n++) { + const l = doc.line(n); + if (/^```/.test(l.text.trimStart())) { + if (!inCode) { + inCode = true; + codeStartLine = n; + } else { + inCode = false; + codeEndLine = n; + if (lineNum >= codeStartLine && lineNum <= codeEndLine) { + return { + from: doc.line(codeStartLine).from, + to: Math.min(doc.length, doc.line(codeEndLine).to + 1), + lineStart: codeStartLine, + lineEnd: codeEndLine, + }; + } + } + } + } + if (inCode && lineNum >= codeStartLine) { + // Unclosed code block until end of doc + return { + from: doc.line(codeStartLine).from, + to: doc.length, + lineStart: codeStartLine, + lineEnd: doc.lines, + }; + } + + // 2. Heading (single-line ATX heading) + if (/^#{1,6}\s/.test(lineText.trimStart())) { + return { + from: currentLine.from, + to: Math.min(doc.length, currentLine.to + 1), + lineStart: lineNum, + lineEnd: lineNum, + }; + } + + // 3. Table detection (lines with pipes) + if (lineText.trim().startsWith("|") || lineText.trim().endsWith("|")) { + let start = lineNum; + while (start > 1) { + const prev = doc.line(start - 1).text.trim(); + if (prev.startsWith("|") || prev.endsWith("|")) { + start--; + } else { + break; + } + } + let end = lineNum; + while (end < doc.lines) { + const next = doc.line(end + 1).text.trim(); + if (next.startsWith("|") || next.endsWith("|")) { + end++; + } else { + break; + } + } + return { + from: doc.line(start).from, + to: Math.min(doc.length, doc.line(end).to + 1), + lineStart: start, + lineEnd: end, + }; + } + + // 4. Blank line (standalone) + if (lineText.trim().length === 0) { + return { + from: currentLine.from, + to: Math.min(doc.length, currentLine.to + 1), + lineStart: lineNum, + lineEnd: lineNum, + }; + } + + // 5. General paragraph or list block (consecutive non-blank lines) + let start = lineNum; + while (start > 1 && doc.line(start - 1).text.trim().length > 0 && !/^#{1,6}\s/.test(doc.line(start - 1).text.trimStart())) { + start--; + } + + let end = lineNum; + while (end < doc.lines && doc.line(end + 1).text.trim().length > 0 && !/^#{1,6}\s/.test(doc.line(end + 1).text.trimStart())) { + end++; + } + + return { + from: doc.line(start).from, + to: Math.min(doc.length, doc.line(end).to + 1), + lineStart: start, + lineEnd: end, + }; +} + +/** + * Creates an SVG 6-dot drag handle icon (⋮⋮). + */ +function createHandleIcon(): SVGSVGElement { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 10 16"); + svg.setAttribute("width", "10"); + svg.setAttribute("height", "16"); + svg.setAttribute("fill", "currentColor"); + svg.style.display = "block"; + + for (let col = 0; col < 2; col++) { + for (let row = 0; row < 3; row++) { + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute("cx", String(2 + col * 6)); + circle.setAttribute("cy", String(3 + row * 5)); + circle.setAttribute("r", "1.5"); + svg.appendChild(circle); + } + } + return svg; +} + +/** + * ViewPlugin that provides the Notion-style hoverable and draggable block handle. + */ +class BlockDragViewPlugin { + private readonly handle: HTMLElement; + private readonly indicator: HTMLElement; + private currentBlock: BlockRange | null = null; + private isDragging = false; + private targetLineNum: number | null = null; + + constructor(private readonly view: EditorView, private readonly options: BlockDragOptions = {}) { + // 1. Create floating handle element + this.handle = document.createElement("div"); + this.handle.className = "nexus-block-handle"; + this.handle.dataset.testId = "nexus-block-handle"; + this.handle.title = "Drag to move block, click to select"; + this.handle.style.cssText = ` + position: absolute; + width: 18px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + color: var(--nexus-text-muted, #9ca3af); + cursor: grab; + border-radius: 4px; + z-index: 20; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease, background 0.15s ease; + user-select: none; + `; + this.handle.appendChild(createHandleIcon()); + + // 2. Create drop indicator line + this.indicator = document.createElement("div"); + this.indicator.className = "nexus-drop-indicator"; + this.indicator.dataset.testId = "nexus-drop-indicator"; + this.indicator.style.cssText = ` + position: absolute; + left: 0; + right: 0; + height: 2px; + background: var(--nexus-accent, #0969da); + z-index: 30; + display: none; + pointer-events: none; + border-radius: 1px; + `; + + view.dom.appendChild(this.handle); + view.dom.appendChild(this.indicator); + + this.attachEvents(); + } + + private attachEvents(): void { + const { dom } = this.view; + + dom.addEventListener("mousemove", this.onMouseMove); + dom.addEventListener("mouseleave", this.onMouseLeave); + this.handle.addEventListener("mouseenter", this.onHandleEnter); + this.handle.addEventListener("mousedown", this.onHandleMouseDown); + this.handle.addEventListener("click", this.onHandleClick); + } + + private onMouseMove = (e: MouseEvent) => { + if (this.isDragging) return; + + const pos = this.view.posAtCoords({ x: e.clientX, y: e.clientY }); + if (pos === null) return; + + const block = findBlockRange(this.view.state.doc, pos); + this.currentBlock = block; + + const lineCoords = this.view.coordsAtPos(block.from); + if (!lineCoords) return; + + const domRect = this.view.dom.getBoundingClientRect(); + const offsetLeft = this.options.handleOffsetPx ?? 4; + const top = lineCoords.top - domRect.top + (this.view.dom.scrollTop || 0); + + this.handle.style.left = `${offsetLeft}px`; + this.handle.style.top = `${top}px`; + this.handle.style.opacity = "1"; + this.handle.style.pointerEvents = "auto"; + }; + + private onMouseLeave = () => { + if (this.isDragging) return; + this.handle.style.opacity = "0"; + this.handle.style.pointerEvents = "none"; + }; + + private onHandleEnter = () => { + this.handle.style.background = "var(--nexus-bg-subtle, #f3f4f6)"; + this.handle.style.color = "var(--nexus-text, #111827)"; + }; + + private onHandleClick = (e: MouseEvent) => { + if (this.isDragging || !this.currentBlock) return; + e.preventDefault(); + e.stopPropagation(); + + // Select entire block on click + this.view.dispatch({ + selection: { anchor: this.currentBlock.from, head: this.currentBlock.to }, + scrollIntoView: true, + }); + this.view.focus(); + }; + + private onHandleMouseDown = (e: MouseEvent) => { + e.preventDefault(); + if (!this.currentBlock) return; + + this.isDragging = true; + this.handle.style.cursor = "grabbing"; + + const sourceBlock = this.currentBlock; + const doc = this.view.state.doc; + + const onWindowMouseMove = (moveEvent: MouseEvent) => { + const pos = this.view.posAtCoords({ x: moveEvent.clientX, y: moveEvent.clientY }); + if (pos === null) { + this.indicator.style.display = "none"; + this.targetLineNum = null; + return; + } + + const targetLine = doc.lineAt(pos); + this.targetLineNum = targetLine.number; + + const coords = this.view.coordsAtPos(targetLine.from); + if (coords) { + const domRect = this.view.dom.getBoundingClientRect(); + const top = coords.top - domRect.top + (this.view.dom.scrollTop || 0); + this.indicator.style.top = `${top}px`; + this.indicator.style.display = "block"; + } + }; + + const onWindowMouseUp = () => { + window.removeEventListener("mousemove", onWindowMouseMove); + window.removeEventListener("mouseup", onWindowMouseUp); + + this.isDragging = false; + this.handle.style.cursor = "grab"; + this.indicator.style.display = "none"; + + if (this.targetLineNum !== null) { + const targetLine = doc.line(this.targetLineNum); + const targetPos = targetLine.from; + + // Perform move if target is outside source block + if (targetPos < sourceBlock.from || targetPos > sourceBlock.to) { + this.executeMoveBlock(sourceBlock, targetPos); + } + } + }; + + window.addEventListener("mousemove", onWindowMouseMove); + window.addEventListener("mouseup", onWindowMouseUp); + }; + + private executeMoveBlock(source: BlockRange, targetPos: number): void { + const doc = this.view.state.doc; + let blockText = doc.sliceString(source.from, source.to); + if (!blockText.endsWith("\n")) { + blockText += "\n"; + } + + if (targetPos < source.from) { + // Moving block UP: insert before target, then delete old source + this.view.dispatch({ + changes: [ + { from: targetPos, to: targetPos, insert: blockText }, + { from: source.from + blockText.length, to: source.to + blockText.length }, + ], + selection: { anchor: targetPos }, + scrollIntoView: true, + }); + } else { + // Moving block DOWN: delete old source, then insert at target + this.view.dispatch({ + changes: [ + { from: source.from, to: source.to }, + { from: targetPos, to: targetPos, insert: blockText }, + ], + selection: { anchor: targetPos - (source.to - source.from) }, + scrollIntoView: true, + }); + } + + this.options.onBlockMove?.(source, targetPos); + } + + update(_update: ViewUpdate): void { + // Re-check handle position if document changed + if (this.currentBlock && this.handle.style.opacity === "1") { + const maxLen = this.view.state.doc.length; + if (this.currentBlock.from > maxLen) { + this.handle.style.opacity = "0"; + } + } + } + + destroy(): void { + const { dom } = this.view; + dom.removeEventListener("mousemove", this.onMouseMove); + dom.removeEventListener("mouseleave", this.onMouseLeave); + this.handle.remove(); + this.indicator.remove(); + } +} + +/** + * Creates the CodeMirror 6 extension for Notion-style block hovering and drag-and-drop. + */ +export function blockDragExtension(options: BlockDragOptions = {}): Extension { + return ViewPlugin.define((view) => new BlockDragViewPlugin(view, options)); +} diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index 4d356ea2..52b34a82 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -26,6 +26,8 @@ import { createLivePreviewExtension } from "./live-preview"; import { flushPendingTableEdits } from "./live-preview-table"; import { createMarkdownLanguageSupport } from "./lezer-markdown"; import { lezerStringToMdast, lezerTreeToMdast } from "./lezer-mdast-adapter"; +import { htmlToMarkdown, isRichHtml } from "./html-to-markdown"; +import { exportStandaloneHTML, exportWordDocument, printDocument } from "./export"; import { markdownFoldService } from "./markdown-fold"; import { resolveLocale } from "./locale"; import { markdownAutoPair } from "./markdown-autopair"; @@ -302,7 +304,15 @@ export function createEditor(config: EditorConfig): EditorAPI { : staticallyTransformed; }; const locale = resolveLocale(config.locale); + /** + * Debounce window for emitting `onChange(ast)` and `onAstChange`. + * Defaults to 0 (synchronous), preserving backward compatibility for + * consumers and tests. For large documents or typing perf optimization, + * pass `parseDelayMs: 150` (or similar) to debounce AST parsing. + */ const parseDelayMs = config.parseDelayMs ?? 0; + + const emitter = new EventEmitter(); let destroyed = false; let destroying = false; @@ -355,6 +365,7 @@ export function createEditor(config: EditorConfig): EditorAPI { if (customParser) { currentAst = parseDocument(customParser, markdown); config.onChange?.(markdown, currentAst); + config.onAstChange?.(currentAst); emitter.emit("change", markdown, currentAst); return; } @@ -365,6 +376,7 @@ export function createEditor(config: EditorConfig): EditorAPI { // User remark transformer plugins (if any) run via transformAst. currentAst = transformAst(lezerAstFromAnywhere(viewRef, markdown)); config.onChange?.(markdown, currentAst); + config.onAstChange?.(currentAst); emitter.emit("change", markdown, currentAst); } @@ -725,13 +737,28 @@ export function createEditor(config: EditorConfig): EditorAPI { return true; } // 默认兜底:剪贴板里有图片 / 文件时走资源上传;纯文本粘贴交回 CodeMirror。 - if (!config.onAssetUpload || destroyed) return false; - const files = collectFilesFromDataTransfer(event.clipboardData); - if (files.length === 0) return false; + if (config.onAssetUpload && !destroyed) { + const files = collectFilesFromDataTransfer(event.clipboardData); + if (files.length > 0) { + event.preventDefault(); + insertUploadedAssets(files); + return true; + } + } - event.preventDefault(); - insertUploadedAssets(files); - return true; + // 智能富文本粘贴:若剪贴板含富文本 text/html,清洗转换为结构清晰的 GFM Markdown + if (config.smartPaste !== false && !destroyed && event.clipboardData) { + const html = event.clipboardData.getData("text/html"); + if (html && isRichHtml(html)) { + const markdown = htmlToMarkdown(html); + if (markdown && markdown.trim().length > 0) { + event.preventDefault(); + view.dispatch(view.state.replaceSelection(markdown)); + return true; + } + } + } + return false; }, drop(event) { if (runEventHandlers(dropHandlers, event)) { @@ -793,6 +820,15 @@ export function createEditor(config: EditorConfig): EditorAPI { (tree) => applyMarkdownTransformSnapshots(view.state, tree), ); }, + exportStandaloneHTML(options) { + return exportStandaloneHTML(api, options); + }, + exportWord(options) { + return exportWordDocument(api, options); + }, + print(options) { + printDocument(api, options); + }, setTheme(theme: NexusTheme) { if (destroyed) return; view.dispatch(themeExt.reconfigure(theme)); diff --git a/packages/core/src/export.ts b/packages/core/src/export.ts new file mode 100644 index 00000000..815d939a --- /dev/null +++ b/packages/core/src/export.ts @@ -0,0 +1,266 @@ +import type { EditorAPI } from "./types"; + +export interface StandaloneHTMLOptions { + /** Page title for the HTML `` tag */ + title?: string; + /** Custom additional CSS styles to inject into `<style>` */ + customStyles?: string; + /** Whether to include print styles for Save as PDF. Defaults to true. */ + includePrintStyles?: boolean; +} + +export interface ExportWordOptions { + /** Document title */ + title?: string; +} + +export interface PrintOptions { + /** Document title when printing */ + title?: string; +} + +const DEFAULT_MARKDOWN_STYLES = ` + :root { + --text-color: #24292f; + --bg-color: #ffffff; + --border-color: #d0d7de; + --code-bg: #f6f8fa; + --accent-color: #0969da; + --quote-border: #d0d7de; + --quote-color: #57606a; + } + @media (prefers-color-scheme: dark) { + :root { + --text-color: #e6edf3; + --bg-color: #0d1117; + --border-color: #30363d; + --code-bg: #161b22; + --accent-color: #2f81f7; + --quote-border: #30363d; + --quote-color: #8b949e; + } + } + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.6; + color: var(--text-color); + background-color: var(--bg-color); + max-width: 860px; + margin: 0 auto; + padding: 32px 24px; + word-wrap: break-word; + } + h1, h2, h3, h4, h5, h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: 600; + line-height: 1.25; + } + h1 { font-size: 2em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; } + h2 { font-size: 1.5em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; } + h3 { font-size: 1.25em; } + h4 { font-size: 1em; } + p { margin-top: 0; margin-bottom: 16px; } + a { color: var(--accent-color); text-decoration: none; } + a:hover { text-decoration: underline; } + blockquote { + margin: 0 0 16px; + padding: 0 1em; + color: var(--quote-color); + border-left: 0.25em solid var(--quote-border); + } + ul, ol { margin-top: 0; margin-bottom: 16px; padding-left: 2em; } + li + li { margin-top: 0.25em; } + code { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + font-size: 85%; + background-color: var(--code-bg); + padding: 0.2em 0.4em; + border-radius: 6px; + } + pre { + background-color: var(--code-bg); + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + border-radius: 6px; + margin-top: 0; + margin-bottom: 16px; + } + pre code { + background: transparent; + padding: 0; + border-radius: 0; + font-size: 100%; + } + table { + border-spacing: 0; + border-collapse: collapse; + width: 100%; + margin-top: 0; + margin-bottom: 16px; + display: block; + overflow: auto; + } + table th, table td { + padding: 6px 13px; + border: 1px solid var(--border-color); + } + table tr:nth-child(2n) { + background-color: var(--code-bg); + } + img { + max-width: 100%; + box-sizing: content-box; + } + hr { + height: 0.25em; + padding: 0; + margin: 24px 0; + background-color: var(--border-color); + border: 0; + } +`; + +const PRINT_STYLES = ` + @media print { + body { + max-width: 100%; + padding: 0; + color: #000 !important; + background: #fff !important; + } + h1, h2, h3, h4, h5, h6 { + page-break-after: avoid; + } + table, figure, pre { + page-break-inside: avoid; + } + } +`; + +/** + * Exports the current editor document as a standalone, styled HTML document. + */ +export function exportStandaloneHTML(editor: EditorAPI, options: StandaloneHTMLOptions = {}): string { + const content = editor.exportHTML(); + const title = options.title ?? "Nexus Document"; + const customStyles = options.customStyles ?? ""; + const printStyles = options.includePrintStyles !== false ? PRINT_STYLES : ""; + + return `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>${escapeHtml(title)} + + + +${content} + +`; +} + +/** + * Exports the document in Microsoft Word (.doc) compatible HTML format, + * which opens seamlessly in Microsoft Word, WPS Office, and Google Docs. + */ +export function exportWordDocument(editor: EditorAPI, options: ExportWordOptions = {}): string { + const content = editor.exportHTML(); + const title = options.title ?? "Document"; + + return ` + + + + ${escapeHtml(title)} + + + +${content} + +`; +} + +/** + * Triggers the browser/system print dialog for the current document, + * enabling direct "Save as PDF" with print styling. + */ +export function printDocument(editor: EditorAPI, options: PrintOptions = {}): void { + if (typeof window === "undefined" || typeof document === "undefined") { + return; + } + + const html = exportStandaloneHTML(editor, { title: options.title }); + const iframe = document.createElement("iframe"); + iframe.style.position = "fixed"; + iframe.style.right = "0"; + iframe.style.bottom = "0"; + iframe.style.width = "0"; + iframe.style.height = "0"; + iframe.style.border = "none"; + iframe.style.visibility = "hidden"; + + document.body.appendChild(iframe); + + const doc = iframe.contentWindow?.document; + if (!doc) { + iframe.remove(); + return; + } + + doc.open(); + doc.write(html); + doc.close(); + + try { + iframe.contentWindow?.focus(); + } catch {} + + setTimeout(() => { + try { + iframe.contentWindow?.print(); + } catch {} finally { + setTimeout(() => iframe.remove(), 1000); + } + }, 150); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/packages/core/src/html-to-markdown.ts b/packages/core/src/html-to-markdown.ts new file mode 100644 index 00000000..8af85712 --- /dev/null +++ b/packages/core/src/html-to-markdown.ts @@ -0,0 +1,309 @@ +/** + * HTML to Markdown converter for clipboard smart-paste. + * + * Converts rich-text HTML (e.g. copied from web pages, Word, Google Docs, + * Notion, Excel) into clean, standard GitHub Flavored Markdown (GFM). + * Zero external dependencies; runs in both browser and JSDOM environments. + */ + +/** + * Checks if an HTML string contains meaningful structural elements that + * warrant conversion to Markdown (as opposed to trivial plain text or metadata). + */ +export function isRichHtml(html: string): boolean { + if (!html || typeof html !== "string") return false; + // Exclude empty bodies or pure comments + const stripped = html.replace(//g, "").trim(); + if (!stripped) return false; + + // Check for common rich text tags + return /<(p|div|h[1-6]|table|thead|tbody|tr|th|td|ul|ol|li|blockquote|strong|b|em|i|s|del|strike|code|pre|a|img|hr|br)\b/i.test( + stripped + ); +} + +/** + * Cleans inline whitespace while preserving necessary spaces. + */ +function cleanInlineText(text: string): string { + return text.replace(/\r?\n/g, " ").replace(/\s+/g, " "); +} + +/** + * Processes a DOM node and converts it to Markdown. + */ +function nodeToMarkdown(node: Node, indent = 0, listContext?: { ordered: boolean; index: number }): string { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? ""; + return text; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + + const el = node as HTMLElement; + const tag = el.tagName.toLowerCase(); + + // Skip non-content elements + if (["script", "style", "noscript", "meta", "head", "title", "link"].includes(tag)) { + return ""; + } + + // Children processor helper + const getChildrenMarkdown = (customIndent = indent): string => { + let result = ""; + for (let i = 0; i < el.childNodes.length; i++) { + result += nodeToMarkdown(el.childNodes[i], customIndent); + } + return result; + }; + + switch (tag) { + case "h1": + case "h2": + case "h3": + case "h4": + case "h5": + case "h6": { + const depth = parseInt(tag[1], 10); + const prefix = "#".repeat(depth) + " "; + const text = cleanInlineText(getChildrenMarkdown()).trim(); + return `\n\n${prefix}${text}\n\n`; + } + + case "p": + case "div": { + const text = getChildrenMarkdown().trim(); + return text ? `\n\n${text}\n\n` : ""; + } + + case "br": + return "\n"; + + case "hr": + return "\n\n---\n\n"; + + case "strong": + case "b": { + const text = getChildrenMarkdown().trim(); + return text ? `**${text}**` : ""; + } + + case "em": + case "i": { + const text = getChildrenMarkdown().trim(); + return text ? `*${text}*` : ""; + } + + case "s": + case "del": + case "strike": { + const text = getChildrenMarkdown().trim(); + return text ? `~~${text}~~` : ""; + } + + case "u": { + const text = getChildrenMarkdown().trim(); + return text ? `${text}` : ""; + } + + case "code": { + // If parent is pre, handled by pre + if (el.parentElement?.tagName.toLowerCase() === "pre") { + return el.textContent ?? ""; + } + const text = el.textContent ?? ""; + return text ? `\`${text}\`` : ""; + } + + case "pre": { + let lang = ""; + const codeEl = el.querySelector("code"); + if (codeEl) { + const cls = codeEl.className || ""; + const match = cls.match(/(?:lang|language)-(\w+)/); + if (match) lang = match[1]; + } + const content = el.textContent ?? ""; + return `\n\n\`\`\`${lang}\n${content.replace(/^\n+|\n+$/g, "")}\n\`\`\`\n\n`; + } + + case "blockquote": { + const inner = getChildrenMarkdown().trim(); + const lines = inner.split("\n").map((line) => `> ${line}`); + return `\n\n${lines.join("\n")}\n\n`; + } + + case "ul": { + const items: string[] = []; + for (let i = 0; i < el.children.length; i++) { + const child = el.children[i]; + if (child.tagName.toLowerCase() === "li") { + items.push(nodeToMarkdown(child, indent, { ordered: false, index: i + 1 })); + } + } + return `\n\n${items.join("\n")}\n\n`; + } + + case "ol": { + const startAttr = el.getAttribute("start"); + const start = startAttr ? parseInt(startAttr, 10) || 1 : 1; + const items: string[] = []; + for (let i = 0; i < el.children.length; i++) { + const child = el.children[i]; + if (child.tagName.toLowerCase() === "li") { + items.push(nodeToMarkdown(child, indent, { ordered: true, index: start + i })); + } + } + return `\n\n${items.join("\n")}\n\n`; + } + + case "li": { + const pad = " ".repeat(indent); + const isOrdered = listContext?.ordered ?? false; + const prefix = isOrdered ? `${listContext?.index ?? 1}. ` : "- "; + + // Check for checkbox + const checkbox = el.querySelector('input[type="checkbox"]'); + let checkboxPrefix = ""; + if (checkbox) { + checkboxPrefix = (checkbox as HTMLInputElement).checked ? "[x] " : "[ ] "; + } + + // Collect children text without duplicate checkbox text + let itemContent = ""; + for (let i = 0; i < el.childNodes.length; i++) { + const child = el.childNodes[i]; + if (child === checkbox) continue; + if (child.nodeType === Node.ELEMENT_NODE) { + const childTag = (child as HTMLElement).tagName.toLowerCase(); + if (childTag === "ul" || childTag === "ol") { + // Nested list + itemContent += "\n" + nodeToMarkdown(child, indent + 1); + continue; + } + } + itemContent += nodeToMarkdown(child, indent); + } + + return `${pad}${prefix}${checkboxPrefix}${itemContent.trim()}`; + } + + case "table": { + return convertTableToMarkdown(el); + } + + case "a": { + const href = el.getAttribute("href") || ""; + const text = getChildrenMarkdown().trim(); + if (!href) return text; + if (!text) return `<${href}>`; + return `[${text}](${href})`; + } + + case "img": { + const src = el.getAttribute("src") || ""; + const alt = el.getAttribute("alt") || ""; + const title = el.getAttribute("title"); + if (!src) return ""; + return title ? `![${alt}](${src} "${title}")` : `![${alt}](${src})`; + } + + default: + // Inline styles check + { + let res = getChildrenMarkdown(); + const style = el.getAttribute("style") || ""; + if (/font-weight:\s*(bold|[7-9]\d\d)/i.test(style) && res.trim()) { + res = `**${res.trim()}**`; + } + if (/font-style:\s*italic/i.test(style) && res.trim()) { + res = `*${res.trim()}*`; + } + if (/text-decoration:\s*[^;]*line-through/i.test(style) && res.trim()) { + res = `~~${res.trim()}~~`; + } + return res; + } + } +} + +/** + * Converts an HTML `` element into a standard GFM Markdown table. + */ +function convertTableToMarkdown(tableEl: HTMLElement): string { + const rows: string[][] = []; + const trElements = tableEl.querySelectorAll("tr"); + + if (trElements.length === 0) return ""; + + trElements.forEach((tr) => { + const row: string[] = []; + tr.querySelectorAll("th, td").forEach((cell) => { + const cellText = cleanInlineText( + Array.from(cell.childNodes) + .map((n) => nodeToMarkdown(n)) + .join("") + ).trim().replace(/\|/g, "\\|"); + row.push(cellText); + }); + if (row.length > 0) { + rows.push(row); + } + }); + + if (rows.length === 0) return ""; + + // Normalize column count across all rows + const maxCols = Math.max(...rows.map((r) => r.length)); + if (maxCols === 0) return ""; + + const paddedRows = rows.map((r) => { + while (r.length < maxCols) { + r.push(""); + } + return r; + }); + + const headerRow = paddedRows[0]; + const bodyRows = paddedRows.slice(1); + + const delimiterRow = Array(maxCols).fill("---"); + + const lines: string[] = []; + lines.push(`| ${headerRow.join(" | ")} |`); + lines.push(`| ${delimiterRow.join(" | ")} |`); + + bodyRows.forEach((row) => { + lines.push(`| ${row.join(" | ")} |`); + }); + + return `\n\n${lines.join("\n")}\n\n`; +} + +/** + * Converts an HTML string into clean, formatted GitHub Flavored Markdown. + * + * @param html The input HTML string (typically from clipboardData `text/html`). + * @returns Clean Markdown string. + */ +export function htmlToMarkdown(html: string): string { + if (!html || typeof html !== "string") return ""; + + // Parse HTML using DOMParser + if (typeof DOMParser === "undefined") { + // Basic fallback if no DOMParser in environment + return html.replace(/<[^>]+>/g, ""); + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + + let markdown = nodeToMarkdown(doc.body).trim(); + + // Normalize multiple consecutive blank lines (limit to max 2 newlines) + markdown = markdown.replace(/\n{3,}/g, "\n\n"); + + return markdown; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 11b2a643..139d6446 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export { createEditor } from "./editor"; +export { htmlToMarkdown, isRichHtml } from "./html-to-markdown"; export { DynamicEditorContributionSink, EDITOR_PLUGIN_PRIORITY_MAX, @@ -51,6 +52,25 @@ export { type WikilinksOptions, type WikiLinkNavigateOptions, } from "./wikilinks"; +export { + createOutlineUI, + type OutlineUI, + type OutlineUIOptions, +} from "./outline-ui"; +export { + blockDragExtension, + findBlockRange, + type BlockRange, + type BlockDragOptions, +} from "./block-drag"; +export { + exportStandaloneHTML, + exportWordDocument, + printDocument, + type StandaloneHTMLOptions, + type ExportWordOptions, + type PrintOptions, +} from "./export"; export type { CodeHighlightToken, CoreEditorChange, diff --git a/packages/core/src/outline-ui.ts b/packages/core/src/outline-ui.ts new file mode 100644 index 00000000..d95b2f8d --- /dev/null +++ b/packages/core/src/outline-ui.ts @@ -0,0 +1,213 @@ +import type { EditorAPI, TocEntry } from "./types"; + +export interface OutlineUIOptions { + /** + * Target DOM element where the outline will be mounted. + * If not provided, a container element will be created and accessible via `outline.element`. + */ + container?: HTMLElement; + /** + * Custom title formatter for outline entries. + */ + formatTitle?: (entry: TocEntry) => string; + /** + * Callback invoked when an outline item is clicked. + */ + onItemClick?: (entry: TocEntry) => void; + /** + * Placeholder text to display when the document has no headings. + * Defaults to "No headings in document". + */ + emptyText?: string; +} + +export interface OutlineUI { + /** The root DOM element of the outline sidebar */ + element: HTMLElement; + /** Manually refresh the outline contents */ + refresh(): void; + /** Destroy the outline component and remove all event listeners */ + destroy(): void; +} + +const OUTLINE_STYLES = ` + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 100%; + max-width: 320px; + padding: 12px 8px; + overflow-y: auto; + font-family: system-ui, -apple-system, sans-serif; + user-select: none; +`; + +const OUTLINE_EMPTY_STYLES = ` + padding: 16px 8px; + font-size: 13px; + color: var(--nexus-text-muted, #9ca3af); + text-align: center; + font-style: italic; +`; + +const ITEM_BASE_STYLES = ` + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 6px 10px; + margin: 1px 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--nexus-text, #374151); + font-size: 13px; + line-height: 1.4; + text-align: left; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +/** + * Creates an interactive document outline (TOC) sidebar that automatically + * tracks document headings, highlights the currently reading chapter, and + * allows smooth jumping on click. + */ +export function createOutlineUI(editor: EditorAPI, options: OutlineUIOptions = {}): OutlineUI { + const container = options.container ?? document.createElement("div"); + container.className = "nexus-outline-sidebar"; + container.dataset.testId = "nexus-outline-sidebar"; + container.style.cssText = OUTLINE_STYLES; + + let currentEntries: TocEntry[] = []; + let activeIndex = -1; + + function findActiveHeadingIndex(entries: TocEntry[], cursor: number): number { + if (entries.length === 0) return -1; + let active = -1; + for (let i = 0; i < entries.length; i++) { + if (entries[i].from <= cursor) { + active = i; + } else { + break; + } + } + return active >= 0 ? active : 0; + } + + function updateActiveHighlight(): void { + const sel = editor.getSelection(); + const cursor = sel ? sel.anchor : 0; + const newActiveIndex = findActiveHeadingIndex(currentEntries, cursor); + + if (newActiveIndex === activeIndex) return; + activeIndex = newActiveIndex; + + const items = container.querySelectorAll(".nexus-outline-item"); + items.forEach((item, idx) => { + const isActive = idx === activeIndex; + item.dataset.active = String(isActive); + if (isActive) { + item.style.background = "var(--nexus-accent-subtle, rgba(9, 105, 218, 0.08))"; + item.style.color = "var(--nexus-accent, #0969da)"; + item.style.fontWeight = "600"; + } else { + item.style.background = "transparent"; + item.style.color = "var(--nexus-text, #374151)"; + item.style.fontWeight = "400"; + } + }); + } + + function render(): void { + container.innerHTML = ""; + currentEntries = editor.getTableOfContents(); + + if (currentEntries.length === 0) { + const emptyEl = document.createElement("div"); + emptyEl.className = "nexus-outline-empty"; + emptyEl.style.cssText = OUTLINE_EMPTY_STYLES; + emptyEl.textContent = options.emptyText ?? "No headings in document"; + container.appendChild(emptyEl); + activeIndex = -1; + return; + } + + const sel = editor.getSelection(); + activeIndex = findActiveHeadingIndex(currentEntries, sel ? sel.anchor : 0); + + currentEntries.forEach((entry, index) => { + const item = document.createElement("button"); + item.type = "button"; + item.className = "nexus-outline-item"; + item.dataset.testId = `outline-item-${index}`; + item.dataset.level = String(entry.level); + item.style.cssText = ITEM_BASE_STYLES; + + // Hierarchical indentation (H1 = 0px, H2 = 12px, H3 = 24px, etc.) + const indentPx = Math.max(0, entry.level - 1) * 14; + item.style.paddingLeft = `${10 + indentPx}px`; + + const title = options.formatTitle ? options.formatTitle(entry) : entry.text || "(Untitled)"; + item.textContent = title; + item.title = title; + + const isActive = index === activeIndex; + item.dataset.active = String(isActive); + if (isActive) { + item.style.background = "var(--nexus-accent-subtle, rgba(9, 105, 218, 0.08))"; + item.style.color = "var(--nexus-accent, #0969da)"; + item.style.fontWeight = "600"; + } + + item.addEventListener("mouseenter", () => { + if (index !== activeIndex) { + item.style.background = "var(--nexus-bg-subtle, #f3f4f6)"; + } + }); + item.addEventListener("mouseleave", () => { + if (index !== activeIndex) { + item.style.background = "transparent"; + } + }); + + item.addEventListener("click", () => { + editor.setSelection(entry.from); + editor.focus(); + options.onItemClick?.(entry); + updateActiveHighlight(); + }); + + container.appendChild(item); + }); + } + + const onDocChange = () => { + render(); + }; + + const onSelectionChange = () => { + updateActiveHighlight(); + }; + + editor.on("change", onDocChange); + editor.on("selectionChange", onSelectionChange); + + // Initial render + render(); + + return { + element: container, + refresh() { + render(); + }, + destroy() { + editor.off("change", onDocChange); + editor.off("selectionChange", onSelectionChange); + container.remove(); + }, + }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b40ebffd..be71dd03 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -104,13 +104,44 @@ export interface EditorConfig { * and selection rendering for existing consumers. */ multiCursor?: boolean; + /** + * Automatically convert rich HTML from the clipboard into clean GFM Markdown on paste. + * Defaults to true. Pass `false` to disable. + */ + smartPaste?: boolean; /** * Maximum number of slash-menu entries emitted on `slashMenuChange` * after ranking. Default: 8. A limit of 0 keeps the menu state open * but emits an empty command list (useful for "no results" UIs). */ slashMenuLimit?: number; + /** + * Called on every document change with the current Markdown text and the + * corresponding parsed AST. Both `doc` and `ast` are provided synchronously + * (subject to `parseDelayMs` debounce) to keep the API simple for most + * consumers. + * + * For high-frequency scenarios where only the raw Markdown text is needed + * immediately (e.g. auto-save), prefer listening to `onChange` and using + * `onAstChange` separately to avoid paying the AST-transform cost on every + * keystroke. + */ onChange?: (doc: string, ast: Root) => void; + /** + * Called after the AST has been (re-)computed, subject to the `parseDelayMs` + * debounce. Use this when you only care about structural AST changes (e.g. + * outline/TOC updates, word count, lint) and do not need the raw Markdown + * text on every keystroke. + * + * This callback fires in addition to `onChange` — both can be used together. + * + * @example + * createEditor({ + * onChange: (doc) => debouncedSave(doc), // fast, text only + * onAstChange: (ast) => updateToc(ast), // slower, AST-driven + * }) + */ + onAstChange?: (ast: Root) => void; onFocus?: () => void; onBlur?: () => void; /** @@ -371,6 +402,9 @@ export interface EditorAPI { getAst(): Root; getTableOfContents(): TocEntry[]; exportHTML(): string; + exportStandaloneHTML(options?: import("./export").StandaloneHTMLOptions): string; + exportWord(options?: import("./export").ExportWordOptions): string; + print(options?: import("./export").PrintOptions): void; setTheme(theme: import("./theme").NexusTheme): void; getSelection(): { anchor: number; head: number }; /** All selection ranges plus the main-range index. Single-range editors return one entry. */ diff --git a/packages/core/test/block-drag.test.ts b/packages/core/test/block-drag.test.ts new file mode 100644 index 00000000..5128643a --- /dev/null +++ b/packages/core/test/block-drag.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { Text } from "@codemirror/state"; +import { findBlockRange, blockDragExtension } from "../src/block-drag"; +import { createEditor } from "../src/editor"; + +describe("findBlockRange", () => { + it("detects heading blocks accurately", () => { + const doc = Text.of(["# Heading 1", "", "Paragraph content"]); + const block = findBlockRange(doc, 2); // on '# Heading 1' + expect(block.lineStart).toBe(1); + expect(block.lineEnd).toBe(1); + expect(doc.sliceString(block.from, block.to).trim()).toBe("# Heading 1"); + }); + + it("detects fenced code blocks including all lines between fences", () => { + const doc = Text.of([ + "Intro text", + "```typescript", + "const a = 1;", + "const b = 2;", + "```", + "Outro text", + ]); + const block = findBlockRange(doc, doc.line(3).from); // inside code block + expect(block.lineStart).toBe(2); + expect(block.lineEnd).toBe(5); + const content = doc.sliceString(block.from, block.to); + expect(content).toContain("```typescript"); + expect(content).toContain("const b = 2;"); + }); + + it("detects table blocks across all contiguous pipe rows", () => { + const doc = Text.of([ + "Before table", + "| Col A | Col B |", + "| --- | --- |", + "| 1 | 2 |", + "After table", + ]); + const block = findBlockRange(doc, doc.line(3).from); // on delimiter row + expect(block.lineStart).toBe(2); + expect(block.lineEnd).toBe(4); + const tableText = doc.sliceString(block.from, block.to); + expect(tableText).toContain("| Col A | Col B |"); + expect(tableText).toContain("| 1 | 2 |"); + }); + + it("detects contiguous paragraph blocks", () => { + const doc = Text.of([ + "Line 1 of paragraph", + "Line 2 of paragraph", + "", + "Second paragraph", + ]); + const block = findBlockRange(doc, doc.line(1).from); + expect(block.lineStart).toBe(1); + expect(block.lineEnd).toBe(2); + }); +}); + +describe("blockDragExtension in editor", () => { + it("initializes without error and mounts handle and indicator into editor DOM", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "# Title\n\nParagraph 1\n\nParagraph 2", + plugins: [ + { + name: "block-drag-plugin", + cmExtensions: [blockDragExtension()], + }, + ], + }); + + const handle = container.querySelector('[data-test-id="nexus-block-handle"]'); + const indicator = container.querySelector('[data-test-id="nexus-drop-indicator"]'); + + expect(handle).not.toBeNull(); + expect(indicator).not.toBeNull(); + + editor.destroy(); + }); +}); diff --git a/packages/core/test/editor.test.ts b/packages/core/test/editor.test.ts index 85505eba..78a8ab79 100644 --- a/packages/core/test/editor.test.ts +++ b/packages/core/test/editor.test.ts @@ -91,6 +91,25 @@ describe("createEditor", () => { editor.destroy(); }); + it("emits onAstChange independently when document changes", () => { + const container = document.createElement("div"); + const astRoots: Root[] = []; + const editor = createEditor({ + container, + onAstChange(ast) { + astRoots.push(ast); + } + }); + + editor.setDocument("## Hello Subheading"); + + expect(astRoots.length).toBe(1); + expect(astRoots[0]?.type).toBe("root"); + expect(astRoots[0]?.children[0]?.type).toBe("heading"); + editor.destroy(); + }); + + it("preserves selection when setDocument is called with preserveSelection", () => { const container = document.createElement("div"); const editor = createEditor({ container, initialValue: "hello world" }); diff --git a/packages/core/test/export.test.ts b/packages/core/test/export.test.ts new file mode 100644 index 00000000..062fc89c --- /dev/null +++ b/packages/core/test/export.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import { createEditor } from "../src/editor"; +import { createGfmPreset } from "@floatboat/nexus-preset-gfm"; +import { exportStandaloneHTML, exportWordDocument, printDocument } from "../src/export"; + +describe("document export system", () => { + it("exports standalone HTML with standard typography and print stylesheets", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "# Export Title\n\nParagraph text with **bold** and a [link](https://example.com).", + }); + + const html = editor.exportStandaloneHTML({ title: "Custom Title" }); + + expect(html).toContain(""); + expect(html).toContain("Custom Title"); + expect(html).toContain("

Export Title

"); + expect(html).toContain("bold"); + expect(html).toContain('link'); + expect(html).toContain("@media print"); + + editor.destroy(); + }); + + it("exports Microsoft Word compatible document", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "# Report\n\n| Item | Value |\n| --- | --- |\n| A | 100 |", + plugins: [createGfmPreset()], + }); + + const wordDoc = editor.exportWord({ title: "Quarterly Report" }); + + expect(wordDoc).toContain("xmlns:w='urn:schemas-microsoft-com:office:word'"); + expect(wordDoc).toContain("Quarterly Report"); + expect(wordDoc).toContain("Report"); + expect(wordDoc).toContain("
"); + + editor.destroy(); + }); + + it("triggers print mechanism for document", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "# Printable Note", + }); + + const writeSpy = vi.fn(); + const printSpy = vi.fn(); + + // Verify print method runs without throwing + expect(() => editor.print({ title: "Print Test" })).not.toThrow(); + + editor.destroy(); + }); +}); diff --git a/packages/core/test/html-to-markdown.test.ts b/packages/core/test/html-to-markdown.test.ts new file mode 100644 index 00000000..7665fd6f --- /dev/null +++ b/packages/core/test/html-to-markdown.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { htmlToMarkdown, isRichHtml } from "../src/html-to-markdown"; +import { createEditor } from "../src/editor"; + +describe("htmlToMarkdown", () => { + it("detects rich HTML accurately", () => { + expect(isRichHtml("

Hello world

")).toBe(true); + expect(isRichHtml("

Title

")).toBe(true); + expect(isRichHtml("Bold")).toBe(true); + expect(isRichHtml("
Cell
")).toBe(true); + expect(isRichHtml("plain text only")).toBe(false); + expect(isRichHtml("")).toBe(false); + }); + + it("converts headings, paragraphs and line breaks", () => { + const html = "

Heading 1

First paragraph
second line

Heading 2

"; + const md = htmlToMarkdown(html); + expect(md).toContain("# Heading 1"); + expect(md).toContain("First paragraph\nsecond line"); + expect(md).toContain("## Heading 2"); + }); + + it("converts basic inline styling (bold, italic, strikethrough, code)", () => { + const html = "

Bold, italic, deleted, and code.

"; + const md = htmlToMarkdown(html); + expect(md).toContain("**Bold**"); + expect(md).toContain("*italic*"); + expect(md).toContain("~~deleted~~"); + expect(md).toContain("`code`"); + }); + + it("converts links and images", () => { + const html = '

Example and Test Logo

'; + const md = htmlToMarkdown(html); + expect(md).toContain("[Example](https://example.com)"); + expect(md).toContain("![Test Logo](image.png)"); + }); + + it("converts unordered and ordered lists", () => { + const html = "
  1. Step 1
  2. Step 2
"; + const md = htmlToMarkdown(html); + expect(md).toContain("- Item 1"); + expect(md).toContain("- Item 2"); + expect(md).toContain("1. Step 1"); + expect(md).toContain("2. Step 2"); + }); + + it("converts code blocks with optional language tags", () => { + const html = '
const x: number = 42;
'; + const md = htmlToMarkdown(html); + expect(md).toContain("```typescript"); + expect(md).toContain("const x: number = 42;"); + expect(md).toContain("```"); + }); + + it("converts blockquotes", () => { + const html = "

Quote line 1

Quote line 2

"; + const md = htmlToMarkdown(html); + expect(md).toContain("> Quote line 1"); + expect(md).toContain("> Quote line 2"); + }); + + it("converts HTML tables to standard GFM markdown tables", () => { + const html = ` + + + + + + + +
Col ACol B
Val 1Val 2
+ `; + const md = htmlToMarkdown(html); + expect(md).toContain("| Col A | Col B |"); + expect(md).toContain("| --- | --- |"); + expect(md).toContain("| Val 1 | Val 2 |"); + }); +}); + +describe("smart paste in editor", () => { + it("automatically converts pasted rich text HTML to clean markdown", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "" }); + const content = container.querySelector(".cm-content"); + + const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as any; + pasteEvent.clipboardData = { + getData(format: string) { + if (format === "text/html") { + return "

Copied Subtitle

Here is bold text from web.

"; + } + return "Copied Subtitle Here is bold text from web."; + } + }; + + content?.dispatchEvent(pasteEvent); + + const doc = editor.getDocument(); + expect(doc).toContain("### Copied Subtitle"); + expect(doc).toContain("**bold text**"); + + editor.destroy(); + }); +}); diff --git a/packages/core/test/outline-ui.test.ts b/packages/core/test/outline-ui.test.ts new file mode 100644 index 00000000..75b4387c --- /dev/null +++ b/packages/core/test/outline-ui.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "../src/editor"; +import { createOutlineUI } from "../src/outline-ui"; + +describe("createOutlineUI", () => { + it("renders empty state when there are no headings", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "Just some text without headings." }); + const outline = createOutlineUI(editor); + + expect(outline.element).toBeDefined(); + expect(outline.element.querySelector(".nexus-outline-empty")?.textContent).toBe( + "No headings in document" + ); + + outline.destroy(); + editor.destroy(); + }); + + it("renders hierarchical outline items from document headings", () => { + const container = document.createElement("div"); + const markdown = `# Title 1 +Paragraph text + +## Subtitle 1.1 +More text + +### Section 1.1.1 +Deep text + +# Title 2`; + + const editor = createEditor({ container, initialValue: markdown }); + const outline = createOutlineUI(editor); + + const items = outline.element.querySelectorAll(".nexus-outline-item"); + expect(items.length).toBe(4); + + expect(items[0]?.textContent).toBe("Title 1"); + expect((items[0] as HTMLElement).dataset.level).toBe("1"); + + expect(items[1]?.textContent).toBe("Subtitle 1.1"); + expect((items[1] as HTMLElement).dataset.level).toBe("2"); + + expect(items[2]?.textContent).toBe("Section 1.1.1"); + expect((items[2] as HTMLElement).dataset.level).toBe("3"); + + expect(items[3]?.textContent).toBe("Title 2"); + expect((items[3] as HTMLElement).dataset.level).toBe("1"); + + outline.destroy(); + editor.destroy(); + }); + + it("navigates editor selection when clicking an outline item", () => { + const container = document.createElement("div"); + const markdown = `# First Heading\nSome content\n\n## Target Heading\nTarget content`; + const editor = createEditor({ container, initialValue: markdown }); + const outline = createOutlineUI(editor); + + const items = outline.element.querySelectorAll(".nexus-outline-item"); + expect(items.length).toBe(2); + + items[1]?.click(); + + const sel = editor.getSelection(); + expect(sel.anchor).toBe(markdown.indexOf("## Target Heading")); + + outline.destroy(); + editor.destroy(); + }); + + it("automatically updates outline items when document content changes", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "# Heading A" }); + const outline = createOutlineUI(editor); + + expect(outline.element.querySelectorAll(".nexus-outline-item").length).toBe(1); + + editor.setDocument("# Heading A\n\n## Heading B\n\n### Heading C"); + + const updatedItems = outline.element.querySelectorAll(".nexus-outline-item"); + expect(updatedItems.length).toBe(3); + expect(updatedItems[1]?.textContent).toBe("Heading B"); + + outline.destroy(); + editor.destroy(); + }); +}); diff --git a/packages/plugin-collab/package.json b/packages/plugin-collab/package.json new file mode 100644 index 00000000..d85cb0d0 --- /dev/null +++ b/packages/plugin-collab/package.json @@ -0,0 +1,31 @@ +{ + "name": "@floatboat/nexus-plugin-collab", + "version": "0.0.14", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean" + }, + "dependencies": { + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.0", + "@floatboat/nexus-core": "workspace:*", + "y-codemirror.next": "^0.3.5", + "y-protocols": "^1.0.6", + "yjs": "^13.6.24" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/plugin-collab/src/awareness.ts b/packages/plugin-collab/src/awareness.ts new file mode 100644 index 00000000..989e4aff --- /dev/null +++ b/packages/plugin-collab/src/awareness.ts @@ -0,0 +1,33 @@ +import type { Awareness } from "y-protocols/awareness"; +import type { CollabUser } from "./types"; + +export const DEFAULT_USER_PALETTES: Array<{ color: string; colorLight: string }> = [ + { color: "#2563eb", colorLight: "rgba(37, 99, 235, 0.18)" }, + { color: "#7c3aed", colorLight: "rgba(124, 58, 237, 0.18)" }, + { color: "#db2777", colorLight: "rgba(219, 39, 119, 0.18)" }, + { color: "#ea580c", colorLight: "rgba(234, 88, 12, 0.18)" }, + { color: "#059669", colorLight: "rgba(5, 150, 105, 0.18)" }, + { color: "#0891b2", colorLight: "rgba(8, 145, 178, 0.18)" }, + { color: "#d97706", colorLight: "rgba(217, 119, 6, 0.18)" }, +]; + +/** + * Assigns an aesthetic random user color palette. + */ +export function getRandomUserPalette(): { color: string; colorLight: string } { + const index = Math.floor(Math.random() * DEFAULT_USER_PALETTES.length); + return DEFAULT_USER_PALETTES[index]; +} + +/** + * Publishes the local user's metadata (name, color, avatar) into the Yjs Awareness instance. + */ +export function setUserAwareness(awareness: Awareness, user: CollabUser): void { + const palette = user.color ? { color: user.color, colorLight: user.colorLight ?? `${user.color}33` } : getRandomUserPalette(); + awareness.setLocalStateField("user", { + name: user.name || "Anonymous", + color: palette.color, + colorLight: palette.colorLight, + avatar: user.avatar, + }); +} diff --git a/packages/plugin-collab/src/index.ts b/packages/plugin-collab/src/index.ts new file mode 100644 index 00000000..7b1f9eec --- /dev/null +++ b/packages/plugin-collab/src/index.ts @@ -0,0 +1,76 @@ +import { type Extension } from "@codemirror/state"; +import { keymap } from "@codemirror/view"; +import type { NexusPlugin } from "@floatboat/nexus-core"; +import { yCollab, yUndoManagerKeymap } from "y-codemirror.next"; +import * as Y from "yjs"; + +import { setUserAwareness } from "./awareness"; +import type { CollabPluginOptions } from "./types"; + +export * from "./types"; +export * from "./awareness"; +export * from "./provider"; + +/** + * Creates the real-time CRDT collaboration plugin powered by Yjs. + * + * Synchronizes the document in real time across multiple concurrent users, + * visualizes remote user presence and cursors, and scopes undo/redo to only + * local client edits. + * + * @example + * ```typescript + * import * as Y from "yjs"; + * import { createEditor } from "@floatboat/nexus-core"; + * import { createCollabPlugin } from "@floatboat/nexus-plugin-collab"; + * + * const ydoc = new Y.Doc(); + * const ytext = ydoc.getText("markdown"); + * + * const editor = createEditor({ + * container: document.getElementById("editor")!, + * plugins: [ + * createCollabPlugin({ + * ytext, + * user: { name: "Alice", color: "#2563eb" }, + * }), + * ], + * }); + * ``` + */ +export function createCollabPlugin(options: CollabPluginOptions): NexusPlugin { + if (!options || !options.ytext) { + throw new Error("createCollabPlugin requires a valid Y.Text instance in `options.ytext`"); + } + + // Seed initial value into Y.Text if empty + if (options.initialValue && options.ytext.length === 0) { + options.ytext.insert(0, options.initialValue); + } + + // Set up awareness user state if provided + if (options.awareness && options.user) { + setUserAwareness(options.awareness, options.user); + } + + // Set up collaborative UndoManager + const undoManager = + options.undoManager === false + ? false + : options.undoManager ?? new Y.UndoManager(options.ytext); + + const cmExtensions: Extension[] = [ + yCollab(options.ytext, options.awareness ?? null, { + undoManager: undoManager !== false ? undoManager : undefined, + }), + ]; + + if (undoManager !== false) { + cmExtensions.push(keymap.of(yUndoManagerKeymap)); + } + + return { + name: "plugin-collab", + cmExtensions, + }; +} diff --git a/packages/plugin-collab/src/provider.ts b/packages/plugin-collab/src/provider.ts new file mode 100644 index 00000000..f7f681bd --- /dev/null +++ b/packages/plugin-collab/src/provider.ts @@ -0,0 +1,72 @@ +import * as Y from "yjs"; +import { type Awareness } from "y-protocols/awareness"; + +/** + * Connects two Y.Doc instances in memory so that every change made + * in one document is immediately applied to the other without conflicts. + * Ideal for unit testing, offline testing, and multi-pane views. + */ +export function linkDocs(docA: Y.Doc, docB: Y.Doc): () => void { + const onUpdateA = (update: Uint8Array, origin: any) => { + if (origin !== docB) { + Y.applyUpdate(docB, update, docA); + } + }; + const onUpdateB = (update: Uint8Array, origin: any) => { + if (origin !== docA) { + Y.applyUpdate(docA, update, docB); + } + }; + + docA.on("update", onUpdateA); + docB.on("update", onUpdateB); + + // Perform initial mutual sync + Y.applyUpdate(docB, Y.encodeStateAsUpdate(docA), docA); + Y.applyUpdate(docA, Y.encodeStateAsUpdate(docB), docB); + + return () => { + docA.off("update", onUpdateA); + docB.off("update", onUpdateB); + }; +} + +/** + * In-memory / BroadcastChannel local synchronization provider. + * Broadcasts document updates across local windows/tabs sharing the same room. + */ +export class LocalBroadcastProvider { + private channel: BroadcastChannel | null = null; + private readonly onDocUpdate: (update: Uint8Array, origin: any) => void; + + constructor( + readonly room: string, + readonly doc: Y.Doc, + readonly awareness?: Awareness + ) { + this.onDocUpdate = (update: Uint8Array, origin: any) => { + if (origin !== this && this.channel) { + this.channel.postMessage({ type: "update", update: Array.from(update) }); + } + }; + + if (typeof BroadcastChannel !== "undefined") { + this.channel = new BroadcastChannel(`nexus-collab-${room}`); + this.channel.onmessage = (event: MessageEvent) => { + const data = event.data; + if (data?.type === "update" && Array.isArray(data.update)) { + Y.applyUpdate(this.doc, new Uint8Array(data.update), this); + } + }; + doc.on("update", this.onDocUpdate); + } + } + + destroy(): void { + if (this.channel) { + this.doc.off("update", this.onDocUpdate); + this.channel.close(); + this.channel = null; + } + } +} diff --git a/packages/plugin-collab/src/types.ts b/packages/plugin-collab/src/types.ts new file mode 100644 index 00000000..efba7f5d --- /dev/null +++ b/packages/plugin-collab/src/types.ts @@ -0,0 +1,45 @@ +import type * as Y from "yjs"; +import type { Awareness } from "y-protocols/awareness"; + +export interface CollabUser { + /** Display name of the user, rendered above the remote cursor */ + name: string; + /** Primary theme color for the user's cursor and selection border (hex or css color) */ + color: string; + /** Lighter background tint for user selection highlights */ + colorLight?: string; + /** Optional user avatar URL or initials */ + avatar?: string; +} + +export interface CollabPluginOptions { + /** + * The shared Y.Text type in the Y.Doc where document content is synced. + */ + ytext: Y.Text; + + /** + * The Awareness protocol instance used for tracking remote user presence, + * live cursor positions, and selections. + */ + awareness?: Awareness; + + /** + * Current local user presence profile. When provided, automatically updates + * the local awareness state. + */ + user?: CollabUser; + + /** + * Collaborative UndoManager. When provided or set to true, binds Ctrl+Z / Ctrl+Y + * to undo/redo only the changes made by the local client, preventing accidental + * rollback of other users' edits. + * Set to `false` to disable. + */ + undoManager?: Y.UndoManager | false; + + /** + * Initial document value to seed into Y.Text if Y.Text is currently empty. + */ + initialValue?: string; +} diff --git a/packages/plugin-collab/test/plugin-collab.test.ts b/packages/plugin-collab/test/plugin-collab.test.ts new file mode 100644 index 00000000..9e0a49e1 --- /dev/null +++ b/packages/plugin-collab/test/plugin-collab.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import * as Y from "yjs"; +import { Awareness } from "y-protocols/awareness"; +import { createEditor } from "@floatboat/nexus-core"; +import { + createCollabPlugin, + linkDocs, + LocalBroadcastProvider, + setUserAwareness, + getRandomUserPalette, + DEFAULT_USER_PALETTES, +} from "../src/index"; + +describe("@floatboat/nexus-plugin-collab", () => { + it("synchronizes document changes between editor and Y.Text", () => { + const ydoc = new Y.Doc(); + const ytext = ydoc.getText("codemirror"); + const container = document.createElement("div"); + + const editor = createEditor({ + container, + initialValue: "Initial content", + plugins: [createCollabPlugin({ ytext, initialValue: "Initial content" })], + }); + + // Editor content should sync to ytext + expect(ytext.toString()).toBe("Initial content"); + + // Modifying ytext should reflect in editor + ytext.insert(ytext.length, " appended"); + expect(editor.getDocument()).toBe("Initial content appended"); + + editor.destroy(); + }); + + it("synchronizes two editor instances in real time without conflicts", () => { + const docA = new Y.Doc(); + const docB = new Y.Doc(); + + const ytextA = docA.getText("content"); + const ytextB = docB.getText("content"); + + const containerA = document.createElement("div"); + const containerB = document.createElement("div"); + + const editorA = createEditor({ + container: containerA, + initialValue: "Start", + plugins: [createCollabPlugin({ ytext: ytextA, initialValue: "Start" })], + }); + + const unlink = linkDocs(docA, docB); + + const editorB = createEditor({ + container: containerB, + initialValue: ytextB.toString(), + plugins: [createCollabPlugin({ ytext: ytextB })], + }); + + // Initial state synchronized + expect(editorB.getDocument()).toBe("Start"); + + // Editor A updates document + editorA.replaceSelection(" Updated by A"); + expect(editorB.getDocument()).toContain("Updated by A"); + + // Editor B updates document + editorB.setSelection(editorB.getDocument().length); + editorB.replaceSelection(" and B"); + + expect(editorA.getDocument()).toBe(editorB.getDocument()); + expect(editorA.getDocument()).toContain("and B"); + + unlink(); + editorA.destroy(); + editorB.destroy(); + }); + + it("handles awareness user presence configuration", () => { + const ydoc = new Y.Doc(); + const awareness = new Awareness(ydoc); + + setUserAwareness(awareness, { + name: "Alice", + color: "#2563eb", + }); + + const localState = awareness.getLocalState(); + expect(localState?.user?.name).toBe("Alice"); + expect(localState?.user?.color).toBe("#2563eb"); + }); + + it("picks random palette when user color is not specified", () => { + const palette = getRandomUserPalette(); + expect(DEFAULT_USER_PALETTES).toContainEqual(palette); + }); + + it("manages LocalBroadcastProvider lifecycle safely", () => { + const ydoc = new Y.Doc(); + const provider = new LocalBroadcastProvider("test-room", ydoc); + + expect(provider.room).toBe("test-room"); + expect(() => provider.destroy()).not.toThrow(); + }); + + it("supports collaborative undo manager", () => { + const ydoc = new Y.Doc(); + const ytext = ydoc.getText("undo-test"); + const undoManager = new Y.UndoManager(ytext); + + const container = document.createElement("div"); + const editor = createEditor({ + container, + plugins: [createCollabPlugin({ ytext, undoManager })], + }); + + ytext.insert(0, "First change"); + expect(editor.getDocument()).toBe("First change"); + + undoManager.undo(); + expect(ytext.toString()).toBe(""); + expect(editor.getDocument()).toBe(""); + + undoManager.redo(); + expect(editor.getDocument()).toBe("First change"); + + editor.destroy(); + }); +}); diff --git a/packages/plugin-history/src/index.ts b/packages/plugin-history/src/index.ts index 4f65b373..447edc5b 100644 --- a/packages/plugin-history/src/index.ts +++ b/packages/plugin-history/src/index.ts @@ -3,9 +3,56 @@ import { keymap } from "@codemirror/view"; import type { NexusPlugin } from "@floatboat/nexus-core"; -export function createHistoryPlugin(): NexusPlugin { +/** + * Options for the history (undo/redo) plugin. + */ +export interface HistoryPluginOptions { + /** + * The minimum time (in milliseconds) that must pass between two edits + * for them to be placed in separate history groups (undo steps). + * + * A longer value means that fast consecutive typing is grouped together, + * while pausing for longer than this threshold starts a new undo group. + * + * @default 1500 — A 1.5-second pause starts a new undo step, which + * matches natural writing rhythm better than the CM6 default of 500ms. + * With 500ms, typing a sentence continuously can require many Ctrl+Z + * presses to fully undo it. With 1500ms, a single Ctrl+Z typically + * reverts the last "burst" of typing. + */ + newGroupDelay?: number; + + /** + * The minimum number of events that the history will retain. Defaults + * to the CodeMirror default (100). + */ + minDepth?: number; +} + +/** + * Creates a history plugin that enables undo/redo with Ctrl+Z / Ctrl+Shift+Z. + * + * @example + * // Default: 1.5-second pause creates a new undo group + * createHistoryPlugin() + * + * @example + * // Custom: aggressive grouping (500ms, closer to raw CM6 default) + * createHistoryPlugin({ newGroupDelay: 500 }) + * + * @example + * // Custom: very relaxed grouping (3 seconds) + * createHistoryPlugin({ newGroupDelay: 3000 }) + */ +export function createHistoryPlugin(options: HistoryPluginOptions = {}): NexusPlugin { return { name: "plugin-history", - cmExtensions: [history(), keymap.of(historyKeymap)] + cmExtensions: [ + history({ + newGroupDelay: options.newGroupDelay ?? 1500, + minDepth: options.minDepth, + }), + keymap.of(historyKeymap), + ], }; } diff --git a/packages/plugin-search/src/index.ts b/packages/plugin-search/src/index.ts index 8e928815..1fa87c64 100644 --- a/packages/plugin-search/src/index.ts +++ b/packages/plugin-search/src/index.ts @@ -35,8 +35,135 @@ export interface SearchOptions { caseSensitive?: boolean; wholeWord?: boolean; regexp?: boolean; + /** + * Enable fuzzy (approximate) matching. When true the query characters are + * matched in order but need not be contiguous, similar to VS Code's + * Ctrl+P file finder. + * + * Results are sorted by a continuity score: longer unbroken runs of + * consecutive matching characters receive a higher score. Fuzzy mode + * is incompatible with `regexp` and `wholeWord` — those options are + * ignored when `fuzzy` is enabled. + * + * @example + * fuzzyMatch("replacement", "repl") // matched: true, score: high + * fuzzyMatch("workflow", "wkfl") // matched: true, score: lower + * fuzzyMatch("hello", "xyz") // matched: false + */ + fuzzy?: boolean; +} + +// --------------------------------------------------------------------------- +// Fuzzy match — lightweight inline implementation, zero external dependencies. +// --------------------------------------------------------------------------- + +/** + * A single match range returned by `fuzzyMatch`. + * `from` is inclusive, `to` is exclusive (same as CM6 convention). + */ +export interface FuzzyMatchRange { + from: number; + to: number; +} + +/** + * Result of a fuzzy match attempt. + */ +export interface FuzzyMatchResult { + /** Whether all query characters were found in order inside `text`. */ + matched: boolean; + /** + * Continuity score. Higher is better. Each consecutive run of matching + * characters contributes `runLength * 2` to the score, so longer + * unbroken runs dominate over scattered single-character matches. + * Returns 0 when `matched` is false. + */ + score: number; + /** + * Contiguous matched ranges inside `text`, in document order. + * Empty when `matched` is false. + */ + ranges: FuzzyMatchRange[]; } +/** + * Perform a fuzzy (approximate) match of `query` inside `text`. + * + * Characters of `query` must appear in `text` in the same order, but do not + * need to be adjacent. Results are scored by the length of the longest + * contiguous run of matched characters so that callers can sort candidates + * from best to worst match. + * + * @example + * fuzzyMatch("workflow", "wkfl") + * // => { matched: true, score: 4, ranges: [{from:0,to:1},{from:4,to:5},{from:7,to:8},...] } + * + * fuzzyMatch("replacement", "repl") + * // => { matched: true, score: 16, ranges: [{from:0,to:4}] } + * + * fuzzyMatch("hello", "xyz") + * // => { matched: false, score: 0, ranges: [] } + */ +export function fuzzyMatch(text: string, query: string, caseSensitive = false): FuzzyMatchResult { + const noMatch: FuzzyMatchResult = { matched: false, score: 0, ranges: [] }; + if (!query) return { matched: true, score: 0, ranges: [] }; + + const t = caseSensitive ? text : text.toLowerCase(); + const q = caseSensitive ? query : query.toLowerCase(); + + if (q.length > t.length) return noMatch; + + const ranges: FuzzyMatchRange[] = []; + let score = 0; + let ti = 0; + let qi = 0; + let runStart = -1; + let runLength = 0; + + while (ti < t.length && qi < q.length) { + if (t[ti] === q[qi]) { + if (runStart < 0) { + // Start of a new contiguous run + runStart = ti; + runLength = 0; + } + runLength++; + score += runLength * 2; // longer runs score exponentially better + qi++; + } else { + if (runStart >= 0) { + // Gap — close the current run and record the range + ranges.push({ from: runStart, to: ti }); + runStart = -1; + runLength = 0; + } + } + ti++; + } + + // Close the last open run (if the match ended without a gap) + if (runStart >= 0) { + ranges.push({ from: runStart, to: ti }); + } + + const matched = qi === q.length; + if (!matched) return noMatch; + + // Boost exact whole-word matches and prefix matches, and penalize extra length + if (t === q) { + score += 100; + } else if (t.startsWith(q)) { + score += 40; + } + // Slight penalty for length difference to favor tighter matches + score -= (t.length - q.length); + + return { matched, score, ranges }; +} + + + + export interface SearchHistoryStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; @@ -650,6 +777,38 @@ export function findSearchMatches( return []; } + // --- Fuzzy path ----------------------------------------------------------- + // Fuzzy mode is incompatible with regexp/wholeWord. It scans every + // word-boundary-delimited token in the document and returns those that + // fuzzy-match the query, sorted from best (highest score) to worst. + if (options.fuzzy) { + const caseSensitive = options.caseSensitive ?? false; + // Split the document into word tokens, preserving their absolute offsets. + // A "word" here is any run of non-whitespace characters. + const tokenPattern = /\S+/g; + const candidates: Array<{ from: number; to: number; text: string; score: number }> = []; + + for (const m of doc.matchAll(tokenPattern)) { + const tokenText = m[0]; + const tokenFrom = m.index ?? 0; + const result = fuzzyMatch(tokenText, query, caseSensitive); + if (result.matched) { + candidates.push({ + from: tokenFrom, + to: tokenFrom + tokenText.length, + text: tokenText, + score: result.score, + }); + } + } + + // Sort best score first so callers can take the top-N most relevant. + candidates.sort((a, b) => b.score - a.score); + + return candidates.map(({ from, to, text }) => ({ from, to, text })); + } + + // --- Exact / regexp / wholeWord path (unchanged) -------------------------- const pattern = buildSearchPattern(query, options); if (!pattern) { return []; diff --git a/packages/plugin-search/test/plugin-search.test.ts b/packages/plugin-search/test/plugin-search.test.ts index b97128b9..c3d6a0ef 100644 --- a/packages/plugin-search/test/plugin-search.test.ts +++ b/packages/plugin-search/test/plugin-search.test.ts @@ -4,6 +4,7 @@ import { createGfmPreset } from "../../preset-gfm/src/index"; import { createSearchPlugin, findSearchMatches, + fuzzyMatch, replaceAllMatches } from "../src/index"; @@ -887,4 +888,43 @@ describe("@floatboat/nexus-plugin-search", () => { editor.destroy(); container.remove(); }); + + describe("fuzzy search", () => { + it("matches characters in order and returns matched ranges and score", () => { + const result = fuzzyMatch("workflow", "wkfl"); + expect(result.matched).toBe(true); + expect(result.score).toBeGreaterThan(0); + expect(result.ranges.length).toBeGreaterThan(0); + }); + + it("gives higher scores to contiguous runs than scattered characters", () => { + const contiguous = fuzzyMatch("replacement", "repl"); + const scattered = fuzzyMatch("r_e_p_l_acement", "repl"); + expect(contiguous.matched).toBe(true); + expect(scattered.matched).toBe(true); + expect(contiguous.score).toBeGreaterThan(scattered.score); + }); + + it("returns matched=false when characters are missing or out of order", () => { + expect(fuzzyMatch("hello", "xyz").matched).toBe(false); + // 'k' appears before 'l' in 'workflow' (wor k f l ow), so 'wolk' cannot match in order + expect(fuzzyMatch("workflow", "wolk").matched).toBe(false); + }); + + + + + + it("findSearchMatches with fuzzy=true returns matching words sorted by score descending", () => { + const doc = "replace replacement repl scattered_r_e_p_l"; + const matches = findSearchMatches(doc, "repl", { fuzzy: true }); + expect(matches.length).toBeGreaterThanOrEqual(3); + expect(matches.map((m) => m.text)).toContain("repl"); + expect(matches.map((m) => m.text)).toContain("replace"); + expect(matches.map((m) => m.text)).toContain("replacement"); + // repl (exact contiguous) should be ranked first + expect(matches[0]?.text).toBe("repl"); + }); + }); }); + diff --git a/packages/plugin-toolbar/src/bubble-menu.ts b/packages/plugin-toolbar/src/bubble-menu.ts new file mode 100644 index 00000000..727269ef --- /dev/null +++ b/packages/plugin-toolbar/src/bubble-menu.ts @@ -0,0 +1,320 @@ +import type { EditorAPI } from "@floatboat/nexus-core"; +import { + toggleBold, + toggleItalic, + toggleStrikethrough, + toggleInlineCode, + insertLink, + toggleHeading, + toggleWrap, +} from "./toolbar-commands"; +import { applyTextColor, applyHighlight } from "./formatting"; +import { + iconBold, + iconItalic, + iconStrikethrough, + iconUnderline, + iconInlineCode, + iconLink, + iconTextColor, + iconHighlight, + iconH2, + iconH3, +} from "./icons"; +import { + COLOR_PALETTE, + HIGHLIGHT_PALETTE, + pickOverlayMount, + showColorPicker, +} from "./toolbar-ui"; + +export interface BubbleMenuButton { + id: string; + title: string; + icon: () => HTMLElement; + action: (editor: EditorAPI, event: MouseEvent, buttonEl: HTMLElement) => void; +} + +export interface BubbleMenuUIOptions { + /** + * Custom buttons list for the bubble menu. When omitted, the default + * rich formatting buttons (bold, italic, strikethrough, underline, + * code, link, headings, color/highlight) are used. + */ + buttons?: BubbleMenuButton[]; + /** + * Target DOM node to mount the bubble menu overlay into. Defaults to + * document.body or the current fullscreen container. + */ + mount?: HTMLElement; +} + +export interface BubbleMenuUI { + element: HTMLElement; + show(): void; + hide(): void; + updatePosition(): void; + destroy(): void; +} + +const BUBBLE_MENU_STYLES = ` + position: fixed; + display: flex; + align-items: center; + gap: 2px; + padding: 4px 6px; + background: var(--nexus-bg, #ffffff); + border: 1px solid var(--nexus-border, #e5e7eb); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.06); + z-index: 10000; + user-select: none; + font-family: system-ui, -apple-system, sans-serif; + opacity: 0; + transform: translateY(4px); + pointer-events: none; + transition: opacity 0.15s ease, transform 0.15s ease; +`; + +const BUBBLE_BUTTON_STYLES = ` + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--nexus-text-muted, #4b5563); + cursor: pointer; + padding: 0; + flex-shrink: 0; + transition: background 0.15s, color 0.15s; +`; + +const BUBBLE_SEPARATOR_STYLES = ` + width: 1px; + height: 16px; + background: var(--nexus-border-subtle, #e5e7eb); + margin: 0 3px; + flex-shrink: 0; +`; + +export function defaultBubbleButtons(): BubbleMenuButton[] { + return [ + { id: "bold", title: "Bold", icon: iconBold, action: (e) => toggleBold(e) }, + { id: "italic", title: "Italic", icon: iconItalic, action: (e) => toggleItalic(e) }, + { id: "strikethrough", title: "Strikethrough", icon: iconStrikethrough, action: (e) => toggleStrikethrough(e) }, + { id: "underline", title: "Underline", icon: iconUnderline, action: (e) => toggleWrap(e, "") }, + { id: "inline-code", title: "Inline code", icon: iconInlineCode, action: (e) => toggleInlineCode(e) }, + { id: "link", title: "Insert link", icon: iconLink, action: (e) => insertLink(e) }, + { id: "h2", title: "Heading 2", icon: iconH2, action: (e) => toggleHeading(e, 2) }, + { id: "h3", title: "Heading 3", icon: iconH3, action: (e) => toggleHeading(e, 3) }, + { + id: "text-color", + title: "Text color", + icon: iconTextColor, + action: (e, _ev, btn) => { + showColorPicker(e, btn, COLOR_PALETTE, applyTextColor, () => {}); + }, + }, + { + id: "highlight", + title: "Highlight", + icon: iconHighlight, + action: (e, _ev, btn) => { + showColorPicker(e, btn, HIGHLIGHT_PALETTE, applyHighlight, () => {}); + }, + }, + ]; +} + +/** + * Creates a floating rich-text Bubble Menu (Toolbar) that automatically + * pops up above non-empty user text selections in the editor. + */ +export function createBubbleMenuUI(editor: EditorAPI, options?: BubbleMenuUIOptions): BubbleMenuUI { + const buttons = options?.buttons ?? defaultBubbleButtons(); + const mountPoint = options?.mount ?? pickOverlayMount(document); + + const container = document.createElement("div"); + container.className = "nexus-bubble-menu"; + container.dataset.testId = "nexus-bubble-menu"; + container.style.cssText = BUBBLE_MENU_STYLES; + + let isVisible = false; + let activeDropdown: { destroy: () => void } | null = null; + + // Build buttons + buttons.forEach((btn, index) => { + // Insert a separator before headings and before colors for clean grouping + if (index === 6 || index === 8) { + const sep = document.createElement("div"); + sep.style.cssText = BUBBLE_SEPARATOR_STYLES; + container.appendChild(sep); + } + + const buttonEl = document.createElement("button"); + buttonEl.type = "button"; + buttonEl.title = btn.title; + buttonEl.setAttribute("aria-label", btn.title); + buttonEl.dataset.testId = `bubble-menu-${btn.id}`; + buttonEl.style.cssText = BUBBLE_BUTTON_STYLES; + + const iconEl = btn.icon(); + buttonEl.appendChild(iconEl); + + buttonEl.addEventListener("mouseenter", () => { + buttonEl.style.background = "var(--nexus-bg-subtle, #f3f4f6)"; + buttonEl.style.color = "var(--nexus-text, #111827)"; + }); + buttonEl.addEventListener("mouseleave", () => { + buttonEl.style.background = "transparent"; + buttonEl.style.color = "var(--nexus-text-muted, #4b5563)"; + }); + + buttonEl.addEventListener("mousedown", (e) => { + // Prevent button click from clearing CM6 text selection + e.preventDefault(); + }); + + buttonEl.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + btn.action(editor, e, buttonEl); + // Re-focus editor and update position + editor.focus(); + updatePosition(); + }); + + container.appendChild(buttonEl); + }); + + mountPoint.appendChild(container); + + function show(): void { + if (isVisible) return; + isVisible = true; + container.style.opacity = "1"; + container.style.transform = "translateY(0)"; + container.style.pointerEvents = "auto"; + } + + function hide(): void { + if (!isVisible) return; + isVisible = false; + container.style.opacity = "0"; + container.style.transform = "translateY(4px)"; + container.style.pointerEvents = "none"; + if (activeDropdown) { + activeDropdown.destroy(); + activeDropdown = null; + } + } + + function updatePosition(): void { + const sel = editor.getSelection(); + if (!sel || sel.anchor === sel.head) { + hide(); + return; + } + + const selectedText = editor.getSelectedText(); + if (!selectedText || selectedText.trim().length === 0) { + hide(); + return; + } + + const startPos = Math.min(sel.anchor, sel.head); + const endPos = Math.max(sel.anchor, sel.head); + + const startCoords = editor.getCoordsAtPos(startPos); + const endCoords = editor.getCoordsAtPos(endPos); + + if (!startCoords || !endCoords) { + hide(); + return; + } + + show(); + + const menuRect = container.getBoundingClientRect(); + const menuWidth = menuRect.width || 260; + const menuHeight = menuRect.height || 38; + + // Calculate center horizontal point + const centerX = (startCoords.left + endCoords.right) / 2; + let left = centerX - menuWidth / 2; + + // Boundary constraint within window width + const minLeft = 8; + const maxLeft = (typeof window !== "undefined" ? window.innerWidth : 1000) - menuWidth - 8; + left = Math.max(minLeft, Math.min(maxLeft, left)); + + // Place menu 8px above the top-most line of selection + const topSelection = Math.min(startCoords.top, endCoords.top); + let top = topSelection - menuHeight - 8; + + // If placed too close to top of viewport, flip to below selection + if (top < 8) { + const bottomSelection = Math.max(startCoords.bottom, endCoords.bottom); + top = bottomSelection + 8; + } + + container.style.left = `${Math.round(left)}px`; + container.style.top = `${Math.round(top)}px`; + } + + const onSelectionChange = () => { + updatePosition(); + }; + + const onWindowScroll = () => { + if (isVisible) updatePosition(); + }; + + const onWindowResize = () => { + if (isVisible) updatePosition(); + }; + + const onDocumentMouseDown = (e: MouseEvent) => { + if (!isVisible) return; + const target = e.target as Node | null; + if (target && !container.contains(target)) { + // If clicking outside, check if selection is being collapsed + setTimeout(() => updatePosition(), 20); + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isVisible) { + hide(); + } + }; + + editor.on("selectionChange", onSelectionChange); + if (typeof window !== "undefined") { + window.addEventListener("scroll", onWindowScroll, true); + window.addEventListener("resize", onWindowResize); + window.addEventListener("keydown", onKeyDown); + document.addEventListener("mousedown", onDocumentMouseDown); + } + + return { + element: container, + show, + hide, + updatePosition, + destroy() { + hide(); + editor.off("selectionChange", onSelectionChange); + if (typeof window !== "undefined") { + window.removeEventListener("scroll", onWindowScroll, true); + window.removeEventListener("resize", onWindowResize); + window.removeEventListener("keydown", onKeyDown); + document.removeEventListener("mousedown", onDocumentMouseDown); + } + container.remove(); + }, + }; +} diff --git a/packages/plugin-toolbar/src/index.ts b/packages/plugin-toolbar/src/index.ts index 166eaaed..f1f83ee5 100644 --- a/packages/plugin-toolbar/src/index.ts +++ b/packages/plugin-toolbar/src/index.ts @@ -12,6 +12,8 @@ import { export { toggleBlockquote, toggleOrderedList, toggleUnorderedList, insertCodeBlock, insertImage, insertHorizontalRule, applyTextColor, applyHighlight } from "./formatting"; export { createToolbarUI } from "./toolbar-ui"; +export { createBubbleMenuUI, defaultBubbleButtons } from "./bubble-menu"; +export type { BubbleMenuUI, BubbleMenuButton, BubbleMenuUIOptions } from "./bubble-menu"; export { colorDecorationExtension } from "./color-decoration"; export type { ToolbarUI, ToolbarUIOptions, ToolbarButton, ToolbarGroup } from "./toolbar-ui"; export { diff --git a/packages/plugin-toolbar/src/toolbar-ui.ts b/packages/plugin-toolbar/src/toolbar-ui.ts index a56ae862..4b22d8b8 100644 --- a/packages/plugin-toolbar/src/toolbar-ui.ts +++ b/packages/plugin-toolbar/src/toolbar-ui.ts @@ -70,7 +70,7 @@ let tooltipId = 0; * fullscreen backdrop. So when the toolbar (or its anchor button) lives inside * a fullscreen element, mount overlays into that element instead. */ -function pickOverlayMount(anchor: HTMLElement | Document): HTMLElement { +export function pickOverlayMount(anchor: HTMLElement | Document): HTMLElement { const doc = anchor instanceof Document ? anchor : anchor.ownerDocument; const node = anchor instanceof Document ? null : anchor; const fullscreenEl = doc.fullscreenElement as HTMLElement | null; @@ -337,7 +337,7 @@ function showHeadingDropdown( }; } -const COLOR_PALETTE = [ +export const COLOR_PALETTE = [ // row 1: grays + black/white "#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#efefef", "#f3f3f3", "#ffffff", // row 2: saturated @@ -348,7 +348,7 @@ const COLOR_PALETTE = [ "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#674ea7", "#a64d79", "#990000", "#783f04", ]; -const HIGHLIGHT_PALETTE = [ +export const HIGHLIGHT_PALETTE = [ "#ffff00", "#00ff00", "#00ffff", "#ff9900", "#ff00ff", "#fce5cd", "#d9ead3", "#d0e0e3", "#cfe2f3", "#d9d2e9", "#fff2cc", "#b6d7a8", "#a2c4c9", "#9fc5e8", "#b4a7d6", @@ -365,7 +365,7 @@ const COLOR_GRID_STYLES = ` z-index: 10000; `; -function showColorPicker( +export function showColorPicker( editor: EditorAPI, anchorBtn: HTMLElement, palette: string[], diff --git a/packages/plugin-toolbar/test/plugin-toolbar.test.ts b/packages/plugin-toolbar/test/plugin-toolbar.test.ts index 3fc97f53..72a9cbe7 100644 --- a/packages/plugin-toolbar/test/plugin-toolbar.test.ts +++ b/packages/plugin-toolbar/test/plugin-toolbar.test.ts @@ -12,6 +12,7 @@ import { toggleUnorderedList, createToolbarPlugin, createToolbarUI, + createBubbleMenuUI, } from "../src/index"; describe("toggleBold", () => { @@ -606,3 +607,63 @@ describe("toggleUnorderedList — atomic undo", () => { editor.destroy(); }); }); + +describe("createBubbleMenuUI", () => { + it("mounts the bubble menu container and provides control methods", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "select some text here" }); + const bubbleMenu = createBubbleMenuUI(editor); + + expect(bubbleMenu.element).toBeDefined(); + expect(bubbleMenu.element.classList.contains("nexus-bubble-menu")).toBe(true); + + // Initial state is hidden (opacity 0) + expect(bubbleMenu.element.style.opacity).toBe("0"); + + // Can manually show and hide + bubbleMenu.show(); + expect(bubbleMenu.element.style.opacity).toBe("1"); + + bubbleMenu.hide(); + expect(bubbleMenu.element.style.opacity).toBe("0"); + + bubbleMenu.destroy(); + expect(bubbleMenu.element.isConnected).toBe(false); + editor.destroy(); + }); + + it("renders buttons for bold, italic, code, link and headings", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "test document" }); + const bubbleMenu = createBubbleMenuUI(editor); + + const boldBtn = bubbleMenu.element.querySelector('[data-test-id="bubble-menu-bold"]'); + const italicBtn = bubbleMenu.element.querySelector('[data-test-id="bubble-menu-italic"]'); + const linkBtn = bubbleMenu.element.querySelector('[data-test-id="bubble-menu-link"]'); + + expect(boldBtn).not.toBeNull(); + expect(italicBtn).not.toBeNull(); + expect(linkBtn).not.toBeNull(); + + bubbleMenu.destroy(); + editor.destroy(); + }); + + it("executes formatting action when a bubble button is clicked", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello world" }); + const bubbleMenu = createBubbleMenuUI(editor); + + editor.setSelection(6, 11); + const boldBtn = bubbleMenu.element.querySelector('[data-test-id="bubble-menu-bold"]'); + expect(boldBtn).not.toBeNull(); + + boldBtn?.click(); + + expect(editor.getDocument()).toBe("hello **world**"); + + bubbleMenu.destroy(); + editor.destroy(); + }); +}); + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0382fb79..9a910934 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,27 @@ importers: specifier: workspace:* version: link:../core + packages/plugin-collab: + dependencies: + '@codemirror/state': + specifier: ^6.6.0 + version: 6.6.0 + '@codemirror/view': + specifier: ^6.41.0 + version: 6.41.0 + '@floatboat/nexus-core': + specifier: workspace:* + version: link:../core + y-codemirror.next: + specifier: ^0.3.5 + version: 0.3.6(@codemirror/state@6.6.0)(@codemirror/view@6.41.0)(yjs@13.6.32) + y-protocols: + specifier: ^1.0.6 + version: 1.0.7(yjs@13.6.32) + yjs: + specifier: ^13.6.24 + version: 13.6.32 + packages/plugin-history: dependencies: '@codemirror/commands': @@ -2478,6 +2499,9 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -2566,6 +2590,11 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -3775,6 +3804,19 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y-codemirror.next@0.3.6: + resolution: {integrity: sha512-GnmVXhTe+UtoFbbaSdwhq6gdAQZdIY9az0Xj6HR2J4PO6Yw4w3xaaSssU+6T72WhXAI0wf9FbuY2s0Ms+WrexA==} + peerDependencies: + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.0.0 + yjs: ^13.5.6 + + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3793,6 +3835,10 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yjs@13.6.32: + resolution: {integrity: sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -6067,6 +6113,8 @@ snapshots: isexe@3.1.5: {} + isomorphic.js@0.2.5: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -6175,6 +6223,10 @@ snapshots: dependencies: readable-stream: 2.3.8 + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -7610,6 +7662,18 @@ snapshots: xmlchars@2.2.0: {} + y-codemirror.next@0.3.6(@codemirror/state@6.6.0)(@codemirror/view@6.41.0)(yjs@13.6.32): + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.0 + lib0: 0.2.117 + yjs: 13.6.32 + + y-protocols@1.0.7(yjs@13.6.32): + dependencies: + lib0: 0.2.117 + yjs: 13.6.32 + y18n@5.0.8: {} yallist@4.0.0: {} @@ -7631,6 +7695,10 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yjs@13.6.32: + dependencies: + lib0: 0.2.117 + yocto-queue@0.1.0: {} zip-stream@4.1.1: diff --git a/vitest.config.ts b/vitest.config.ts index d7e39059..02d3aeb5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,11 +17,13 @@ export default defineConfig({ "@floatboat/nexus-plugin-math": path.resolve(__dirname, "packages/plugin-math/src/index.ts"), "@floatboat/nexus-plugin-vim": path.resolve(__dirname, "packages/plugin-vim/src/index.ts"), "@floatboat/nexus-plugin-wordcount": path.resolve(__dirname, "packages/plugin-wordcount/src/index.ts"), + "@floatboat/nexus-plugin-collab": path.resolve(__dirname, "packages/plugin-collab/src/index.ts"), "@floatboat/nexus-reference-plugins": path.resolve(__dirname, "packages/reference-plugins/src/index.ts") } }, test: { environment: "jsdom", - setupFiles: ["./vitest.setup.ts"] + setupFiles: ["./vitest.setup.ts"], + exclude: ["**/.kilo/**", "**/node_modules/**", "**/dist/**"] } });