| import type { Editor } from "@tiptap/core"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const SAFE_URL_PROTOCOLS = /^(https?|mailto|tel):/i; |
| const SAFE_URL_RELATIVE = /^[/#?]/; |
|
|
| function isSafeUrl(url: string): boolean { |
| const trimmed = url.trim(); |
| return SAFE_URL_PROTOCOLS.test(trimmed) || SAFE_URL_RELATIVE.test(trimmed); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| function getDocTextWithPositions(editor: Editor): { text: string; map: number[] } { |
| const chunks: string[] = []; |
| const map: number[] = []; |
|
|
| editor.state.doc.descendants((node, pos) => { |
| if (node.isText && node.text) { |
| for (let i = 0; i < node.text.length; i++) { |
| map.push(pos + i); |
| chunks.push(node.text[i]); |
| } |
| } else if (node.isBlock && chunks.length > 0) { |
| map.push(pos); |
| chunks.push("\n"); |
| } |
| }); |
|
|
| return { text: chunks.join(""), map }; |
| } |
|
|
| interface SelectTextResult { |
| found: boolean; |
| from?: number; |
| to?: number; |
| } |
|
|
| function handleSelectText( |
| editor: Editor, |
| args: { |
| text: string; |
| occurrence?: number; |
| contextBefore?: string; |
| contextAfter?: string; |
| }, |
| ): SelectTextResult { |
| const { text, occurrence = 1, contextBefore, contextAfter } = args; |
| if (!text) return { found: false }; |
|
|
| const { text: docText, map } = getDocTextWithPositions(editor); |
|
|
| |
| const candidates: number[] = []; |
| let startIdx = 0; |
| while (true) { |
| const idx = docText.indexOf(text, startIdx); |
| if (idx === -1) break; |
| candidates.push(idx); |
| startIdx = idx + 1; |
| } |
| if (candidates.length === 0) return { found: false }; |
|
|
| let chosenIdx: number; |
| if (contextBefore || contextAfter) { |
| let bestIdx = candidates[0]; |
| let bestScore = -1; |
| for (const idx of candidates) { |
| let score = 0; |
| if (contextBefore) { |
| const before = docText.slice(Math.max(0, idx - contextBefore.length - 10), idx); |
| if (before.includes(contextBefore)) score += 2; |
| else if (before.toLowerCase().includes(contextBefore.toLowerCase())) score += 1; |
| } |
| if (contextAfter) { |
| const after = docText.slice(idx + text.length, idx + text.length + contextAfter.length + 10); |
| if (after.includes(contextAfter)) score += 2; |
| else if (after.toLowerCase().includes(contextAfter.toLowerCase())) score += 1; |
| } |
| if (score > bestScore) { |
| bestScore = score; |
| bestIdx = idx; |
| } |
| } |
| chosenIdx = bestIdx; |
| } else { |
| const occIdx = Math.max(0, Math.min(occurrence - 1, candidates.length - 1)); |
| chosenIdx = candidates[occIdx]; |
| } |
|
|
| const from = map[chosenIdx]; |
| const to = map[chosenIdx + text.length - 1] + 1; |
| editor.chain().focus().setTextSelection({ from, to }).run(); |
| return { found: true, from, to }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| const EMPTY_ARG_COMMANDS = new Set([ |
| "toggleBold", |
| "toggleItalic", |
| "toggleStrike", |
| "toggleCode", |
| "unsetLink", |
| "setParagraph", |
| "toggleBulletList", |
| "toggleOrderedList", |
| "toggleBlockquote", |
| ]); |
|
|
| |
| |
| |
| |
| |
| |
| export function executeTiptapCommand( |
| editor: Editor, |
| toolName: string, |
| args: Record<string, unknown>, |
| ): string | null { |
| |
| if (toolName === "selectText") { |
| const result = handleSelectText(editor, args as Parameters<typeof handleSelectText>[1]); |
| if (!result.found) { |
| return `selectText: text not found in the document`; |
| } |
| return `selectText: selected range ${result.from}-${result.to}`; |
| } |
|
|
| |
| if (EMPTY_ARG_COMMANDS.has(toolName)) { |
| const chain = editor.chain().focus(); |
| const fn = (chain as unknown as Record<string, () => typeof chain>)[toolName]; |
| if (typeof fn !== "function") { |
| return `Unknown editor command: ${toolName}`; |
| } |
| const success = fn.call(chain).run(); |
| return success ? `${toolName} applied` : `${toolName} failed (selection state?)`; |
| } |
|
|
| |
| switch (toolName) { |
| case "toggleHeading": { |
| const level = (args as { level?: number }).level; |
| if (!level || level < 1 || level > 3) return "toggleHeading: invalid level"; |
| const ok = editor |
| .chain() |
| .focus() |
| .toggleHeading({ level: level as 1 | 2 | 3 }) |
| .run(); |
| return ok ? `toggleHeading (level ${level}) applied` : "toggleHeading failed"; |
| } |
| case "toggleCodeBlock": { |
| const language = (args as { language?: string }).language; |
| const chain = editor.chain().focus(); |
| const ok = language |
| ? chain.toggleCodeBlock({ language }).run() |
| : chain.toggleCodeBlock().run(); |
| return ok ? "toggleCodeBlock applied" : "toggleCodeBlock failed"; |
| } |
| case "setLink": { |
| const href = (args as { href?: string }).href; |
| if (!href || !isSafeUrl(href)) return `setLink: unsafe or missing URL`; |
| const ok = editor.chain().focus().setLink({ href }).run(); |
| return ok ? `setLink applied (${href})` : "setLink failed (is text selected?)"; |
| } |
| default: |
| return null; |
| } |
| } |
|
|
| |
| export const TIPTAP_TOOL_NAMES = new Set<string>([ |
| "selectText", |
| "toggleBold", |
| "toggleItalic", |
| "toggleStrike", |
| "toggleCode", |
| "setLink", |
| "unsetLink", |
| "toggleHeading", |
| "setParagraph", |
| "toggleBulletList", |
| "toggleOrderedList", |
| "toggleBlockquote", |
| "toggleCodeBlock", |
| ]); |
|
|