diff --git a/electron/main/filesystem/filesystem.ts b/electron/main/filesystem/filesystem.ts index 4070c8272..38d30d5e9 100644 --- a/electron/main/filesystem/filesystem.ts +++ b/electron/main/filesystem/filesystem.ts @@ -112,7 +112,7 @@ export function GetFilesInfoListForListOfPaths(paths: string[]): FileInfo[] { return fileInfoListWithoutDuplicates } -export function createFileRecursive(filePath: string, content: string, charset?: BufferEncoding): void { +export function createFileRecursive(filePath: string, content: string, charset?: BufferEncoding): FileInfo | null { const dirname = path.dirname(filePath) if (!fs.existsSync(dirname)) { @@ -120,11 +120,18 @@ export function createFileRecursive(filePath: string, content: string, charset?: } if (fs.existsSync(filePath)) { - return + return null } const filePathWithExtension = addExtensionToFilenameIfNoExtensionPresent(filePath, markdownExtensions, '.md') fs.writeFileSync(filePathWithExtension, content, charset) + return { + name: path.basename(filePathWithExtension), + path: filePathWithExtension, + relativePath: path.relative(dirname, filePathWithExtension), + dateModified: new Date(), + dateCreated: new Date(), + } } export function updateFileListForRenderer(win: BrowserWindow, directory: string): void { @@ -194,3 +201,26 @@ export function splitDirectoryPathIntoBaseAndRepo(fullPath: string) { return { localModelPath, repoName } } + +export function findFileRecursive(dir: string, filename: string): string | null { + const files = fs.readdirSync(dir) + const basename = path.parse(filename).name + + for (const file of files) { + const fullPath = path.resolve(dir, file) + const stat = fs.statSync(fullPath) + + if (stat.isDirectory()) { + const result = findFileRecursive(fullPath, filename) + if (result) return result + } else { + // Check if files match + const fileName = path.parse(file).name + if (fileName === basename) { + return fullPath + } + } + } + + return null +} diff --git a/electron/main/filesystem/ipcHandlers.ts b/electron/main/filesystem/ipcHandlers.ts index e46653d25..c24232e07 100644 --- a/electron/main/filesystem/ipcHandlers.ts +++ b/electron/main/filesystem/ipcHandlers.ts @@ -9,7 +9,7 @@ import { StoreSchema } from '../electron-store/storeConfig' import { handleFileRename, updateFileInTable } from '../vector-database/tableHelperFunctions' import { GetFilesInfoTree, createFileRecursive, isHidden, GetFilesInfoListForListOfPaths } from './filesystem' -import { FileInfoTree, WriteFileProps, RenameFileProps, FileInfoWithContent } from './types' +import { FileInfoTree, WriteFileProps, RenameFileProps, FileInfoWithContent, FileInfo } from './types' import ImageStorage from './storage/ImageStore' import VideoStorage from './storage/VideoStore' @@ -145,8 +145,10 @@ const registerFileHandlers = (store: Store, _windowsManager: Window await updateFileInTable(windowInfo.dbTableClient, filePath) }) - ipcMain.handle('create-file', async (event, filePath: string, content: string): Promise => { - createFileRecursive(filePath, content, 'utf-8') + ipcMain.handle('create-file', async (event, filePath: string, content: string): Promise => { + const fileObject = createFileRecursive(filePath, content, 'utf-8') + if (fileObject) return fileObject + return undefined }) ipcMain.handle('create-directory', async (event, dirPath: string): Promise => { @@ -217,6 +219,17 @@ const registerFileHandlers = (store: Store, _windowsManager: Window }) return result.filePaths }) + + ipcMain.handle('get-file-info', (event, absolutePath: string, parentRelativePath: string): FileInfo => { + const fileInfo = fs.statSync(absolutePath) + return { + name: path.basename(absolutePath), + path: absolutePath, + relativePath: parentRelativePath, + dateModified: fileInfo.mtime, + dateCreated: fileInfo.birthtime, // Add the birthtime property here + } + }) } export default registerFileHandlers diff --git a/electron/main/path/ipcHandlers.ts b/electron/main/path/ipcHandlers.ts index 9cb196eb5..74d89305e 100644 --- a/electron/main/path/ipcHandlers.ts +++ b/electron/main/path/ipcHandlers.ts @@ -24,6 +24,11 @@ const registerPathHandlers = () => { ) ipcMain.handle('path-ext-name', (event, pathString: string) => path.extname(pathString)) + + ipcMain.handle('find-absolute-path', (event, filePath: string) => { + const absolutePath = path.resolve(filePath) + return absolutePath + }) } export default registerPathHandlers diff --git a/electron/main/vector-database/lanceTableWrapper.ts b/electron/main/vector-database/lanceTableWrapper.ts index b57d17e2b..4aff3a803 100644 --- a/electron/main/vector-database/lanceTableWrapper.ts +++ b/electron/main/vector-database/lanceTableWrapper.ts @@ -1,5 +1,4 @@ import { Connection, Table as LanceDBTable, MetricType, makeArrowTable } from 'vectordb' - import { EmbeddingModelConfig } from '../electron-store/storeConfig' import { EnhancedEmbeddingFunction, createEmbeddingFunction } from './embeddings' @@ -102,7 +101,7 @@ class LanceDBTableWrapper { } async search(query: string, limit: number, filter?: string): Promise { - const lanceQuery = await this.lanceTable.search(query).metricType(MetricType.Cosine).limit(limit) + const lanceQuery = this.lanceTable.search(query).metricType(MetricType.Cosine).limit(limit) if (filter) { lanceQuery.prefilter(true) diff --git a/electron/main/vector-database/schema.ts b/electron/main/vector-database/schema.ts index c1d5e5f86..6fd2b77ca 100644 --- a/electron/main/vector-database/schema.ts +++ b/electron/main/vector-database/schema.ts @@ -1,6 +1,7 @@ import { Schema, Field, Utf8, FixedSizeList, Float32, Float64, DateUnit, Date_ as ArrowDate } from 'apache-arrow' export interface DBEntry { + name: string notepath: string content: string subnoteindex: number @@ -15,6 +16,7 @@ export interface DBQueryResult extends DBEntry { export const chunksize = 500 export enum DatabaseFields { + NAME = 'name', NOTE_PATH = 'notepath', VECTOR = 'vector', CONTENT = 'content', @@ -27,6 +29,7 @@ export enum DatabaseFields { const CreateDatabaseSchema = (vectorDim: number): Schema => { const schemaFields = [ + new Field(DatabaseFields.NAME, new Utf8(), false), new Field(DatabaseFields.NOTE_PATH, new Utf8(), false), new Field(DatabaseFields.VECTOR, new FixedSizeList(vectorDim, new Field('item', new Float32())), false), new Field(DatabaseFields.CONTENT, new Utf8(), false), diff --git a/electron/main/vector-database/tableHelperFunctions.ts b/electron/main/vector-database/tableHelperFunctions.ts index 2b92f3d6e..69cf6c9cd 100644 --- a/electron/main/vector-database/tableHelperFunctions.ts +++ b/electron/main/vector-database/tableHelperFunctions.ts @@ -1,5 +1,6 @@ import * as fs from 'fs' import * as fsPromises from 'fs/promises' +import path from 'path' import { BrowserWindow } from 'electron' import { chunkMarkdownByHeadingsAndByCharsIfBig } from '../common/chunking' @@ -20,6 +21,7 @@ const convertFileTypeToDBType = async (file: FileInfo): Promise => { const fileContent = readFile(file.path) const chunks = await chunkMarkdownByHeadingsAndByCharsIfBig(fileContent) const entries = chunks.map((content, index) => ({ + name: file.name, notepath: file.path, content, subnoteindex: index, @@ -169,8 +171,10 @@ export const updateFileInTable = async (dbTable: LanceDBTableWrapper, filePath: await dbTable.deleteDBItemsByFilePaths([filePath]) const content = readFile(filePath) const chunkedContentList = await chunkMarkdownByHeadingsAndByCharsIfBig(content) + const fileName = path.basename(filePath).split('.')[0] const stats = fs.statSync(filePath) const dbEntries = chunkedContentList.map((_content, index) => ({ + name: fileName, notepath: filePath, content: _content, subnoteindex: index, diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 14fcaf21a..f4f3ef817 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -9,7 +9,13 @@ import { TamaguiThemeTypes, } from 'electron/main/electron-store/storeConfig' import { SearchProps } from 'electron/main/electron-store/types' -import { FileInfoTree, FileInfoWithContent, RenameFileProps, WriteFileProps } from 'electron/main/filesystem/types' +import { + FileInfo, + FileInfoTree, + FileInfoWithContent, + RenameFileProps, + WriteFileProps, +} from 'electron/main/filesystem/types' import { DBQueryResult } from 'electron/main/vector-database/schema' import { AgentConfig, ChatMetadata, Chat } from '@/lib/llm/types' @@ -102,12 +108,14 @@ const fileSystem = { getVideo: createIPCHandler<(fileName: string) => Promise>('get-video'), isDirectory: createIPCHandler<(filePath: string) => Promise>('is-directory'), renameFile: createIPCHandler<(renameFileProps: RenameFileProps) => Promise>('rename-file'), - createFile: createIPCHandler<(filePath: string, content: string) => Promise>('create-file'), + createFile: createIPCHandler<(filePath: string, content: string) => Promise>('create-file'), createDirectory: createIPCHandler<(dirPath: string) => Promise>('create-directory'), checkFileExists: createIPCHandler<(filePath: string) => Promise>('check-file-exists'), deleteFile: createIPCHandler<(filePath: string) => Promise>('delete-file'), getAllFilenamesInDirectory: createIPCHandler<(dirName: string) => Promise>('get-files-in-directory'), getFiles: createIPCHandler<(filePaths: string[]) => Promise>('get-files'), + getFileInfo: + createIPCHandler<(absolutePath: string, parentRelativePath: string) => Promise>('get-file-info'), } const path = { diff --git a/package-lock.json b/package-lock.json index 4a368db29..85efea2fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -148,7 +148,8 @@ "vaul": "^1.0.0", "vectordb": "0.4.10", "yjs": "^13.6.23", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zustand": "^5.0.3" }, "devDependencies": { "@electron/notarize": "2.3.2", @@ -3160,6 +3161,22 @@ } } }, + "node_modules/@jest/core/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/create-cache-key-function": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", @@ -11747,20 +11764,6 @@ "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", "dev": true }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "engines": { - "node": ">=8" - } - }, "node_modules/cjs-module-lexer": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", @@ -16622,6 +16625,22 @@ "is-ci": "bin.js" } }, + "node_modules/is-ci/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -17384,6 +17403,22 @@ "concat-map": "0.0.1" } }, + "node_modules/jest-config/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jest-config/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -17800,6 +17835,21 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-util/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", @@ -33074,6 +33124,35 @@ "zod": "^3.24.1" } }, + "node_modules/zustand": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", + "integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index f8a46f41d..292b11d67 100644 --- a/package.json +++ b/package.json @@ -168,7 +168,8 @@ "vaul": "^1.0.0", "vectordb": "0.4.10", "yjs": "^13.6.23", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zustand": "^5.0.3" }, "devDependencies": { "@electron/notarize": "2.3.2", diff --git a/src/App.tsx b/src/App.tsx index a2e7ffa95..263273a63 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,10 +5,13 @@ import posthog from 'posthog-js' import { ToastContainer, toast } from 'react-toastify' import 'react-toastify/dist/ReactToastify.css' +import { FileInfo } from 'electron/main/filesystem/types' import IndexingProgress from './components/Common/IndexingProgress' import MainPageComponent from './components/MainPage' import InitialSetupSinglePage from './components/Settings/InitialSettingsSinglePage' import { ThemeProvider } from './contexts/ThemeContext' +import useFileSearchIndex from './lib/utils/cache/fileSearchIndex' +import { flattenFileInfoTree } from './lib/file' interface AppProps {} @@ -16,7 +19,6 @@ const App: React.FC = () => { const [userHasConfiguredSettingsForIndexing, setUserHasConfiguredSettingsForIndexing] = useState( undefined, ) - const [indexingProgress, setIndexingProgress] = useState(0) useEffect(() => { @@ -75,6 +77,18 @@ const App: React.FC = () => { fetchSettings() }, []) + // Cache all of the files to build a quick search index + useEffect(() => { + const hydrateIndex = async () => { + const files = await window.fileSystem.getFilesTreeForWindow() + const flat = flattenFileInfoTree(files).map((f: FileInfo) => ({ + ...f, + })) + useFileSearchIndex.getState().hydrate(flat) + } + hydrateIndex() + }, []) + const handleAllInitialSettingsAreReady = () => { // setUserHasConfiguredSettingsForIndexing(true) window.database.indexFilesInDirectory() diff --git a/src/components/Editor/EditorManager.tsx b/src/components/Editor/EditorManager.tsx index b398c11e8..a92116f73 100644 --- a/src/components/Editor/EditorManager.tsx +++ b/src/components/Editor/EditorManager.tsx @@ -2,7 +2,13 @@ import React, { useEffect, useState } from 'react' import { YStack } from 'tamagui' import InEditorBacklinkSuggestionsDisplay from './BacklinkSuggestionsDisplay' import { useFileContext } from '@/contexts/FileContext' -import { BlockNoteView, FormattingToolbarPositioner, SlashMenuPositioner, SideMenuPositioner } from '@/lib/blocknote' +import { + BlockNoteView, + FormattingToolbarPositioner, + SlashMenuPositioner, + SideMenuPositioner, + LinkToolbarPositioner, +} from '@/lib/blocknote' import SearchBar from './Search/SearchBar' const EditorManager: React.FC = () => { @@ -53,6 +59,7 @@ const EditorManager: React.FC = () => { + )} diff --git a/src/components/Editor/RichTextLink.tsx b/src/components/Editor/RichTextLink.tsx index bd21c6c52..417d0ce27 100644 --- a/src/components/Editor/RichTextLink.tsx +++ b/src/components/Editor/RichTextLink.tsx @@ -1,113 +1,238 @@ -import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core' -import { Link } from '@tiptap/extension-link' +import { InputRule, markInputRule, markPasteRule, PasteRule, mergeAttributes } from '@tiptap/core' +import { isAllowedUri, Link } from '@tiptap/extension-link' +import { Plugin } from '@tiptap/pm/state' import type { LinkOptions } from '@tiptap/extension-link' +import autolink from '@/lib/tiptap-extension-link/helpers/autolink' +import clickHandler from '@/lib/tiptap-extension-link/helpers/clickHandler' +import useFileSearchIndex from '@/lib/utils/cache/fileSearchIndex' +import { isHypermediaScheme } from '@/lib/utils' /** - * The input regex for Markdown links with title support, and multiple quotation marks (required - * in case the `Typography` extension is being included). + * Handles conventially markdown link syntax. + * [title](url) */ const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)$/i /** - * The paste regex for Markdown links with title support, and multiple quotation marks (required - * in case the `Typography` extension is being included). + * Handles markdown link syntax for pasting. + * [title](url) */ const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)/gi /** - * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in - * parentheses (e.g., `(https://doist.dev)`). - * - * @see https://github.com/ueberdosis/tiptap/discussions/1865 + * Handles inline linking between files. For instance, triggers when a user types [[filename]]. */ -function linkInputRule(config: Parameters[0]) { - const defaultMarkInputRule = markInputRule(config) +const fileRegex = /\[\[(.*?)\]\]/ + +/** + * Returns true if this is an internal link that represents a file. + * @param href the absolute path to the file + * @returns true if this is an internal link + */ +function isInternalLink(href?: string) { + return href?.startsWith('reor://') +} + +/** + * Retruns true if this is a valid URI or a hypermedia scheme. For instance, http, https, ftp, reor, etc.. + * @param url the url to check + * @returns + */ +function isValidURI(url: string): boolean { + return (isAllowedUri(url) as boolean) || isHypermediaScheme(url) +} + +/** + * Generates a random 32 digit string. This is used to generate a random file name when the user types [[filename]] + * towards a file that does not exist. + * @returns a 32 digit string + */ +function generateRandom32DigitString(): string { + return Array.from({ length: 32 }, () => Math.floor(Math.random() * 10)).join('') +} +/** + * Handles convential markdown link syntax + * @param config + * @returns a configured tag + */ +function linkInputRule(config: Parameters[0]) { + const baseRule = markInputRule(config) return new InputRule({ find: config.find, handler(props) { - const { tr } = props.state - - defaultMarkInputRule.handler(props) - tr.setMeta('preventAutolink', true) + baseRule.handler(props) + props.state.tr.setMeta('preventAutolink', true) }, }) } /** - * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in - * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple - * implementations found in a Tiptap discussion at GitHub. - * - * @see https://github.com/ueberdosis/tiptap/discussions/1865 + * Handles convential markdown pasting syntax + * @param config */ function linkPasteRule(config: Parameters[0]) { - const defaultMarkPasteRule = markPasteRule(config) - + const baseRule = markPasteRule(config) return new PasteRule({ find: config.find, handler(props) { - const { tr } = props.state - - defaultMarkPasteRule.handler(props) - tr.setMeta('preventAutolink', true) + baseRule.handler(props) + props.state.tr.setMeta('preventAutolink', true) }, }) } /** - * Custom extension that extends the built-in `Link` extension to add additional input/paste rules - * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also - * adds support for the `title` attribute. + * Deals with the [[filename]] syntax. This is used to link files in the editor. + * It will create a link to the file if it exists, otherwise it will create a link to a new file. + * @param config */ -const RichTextLink = Link.extend({ - inclusive: false, - addAttributes() { - return { - ...this.parent?.(), - title: { - default: null, - }, - } - }, - addInputRules() { - return [ - linkInputRule({ - find: inputRegex, - type: this.type, - - // We need to use `pop()` to remove the last capture groups from the match to - // satisfy Tiptap's `markPasteRule` expectation of having the content as the last - // capture group in the match (this makes the attribute order important) - getAttributes(match) { - return { - title: match.pop()?.trim(), - href: match.pop()?.trim(), - } +function linkFileInputRule(config: Parameters[0]) { + return new InputRule({ + find: fileRegex, + handler(props) { + const { tr } = props.state + const { range, match } = props + const { from, to } = range + + const markedText = match[1]?.trim() || generateRandom32DigitString() + const fileName = `${markedText}.md` + const cacheResult = useFileSearchIndex.getState().getPath(fileName) + const filePath = cacheResult ? `reor://${cacheResult}` : `reor://${fileName}` + + const mark = config.type.create({ href: filePath, title: markedText }) + + tr.deleteRange(from, to) + tr.insertText(markedText, from) + tr.addMark(from, from + markedText.length, mark) + + props.commands.focus() + }, + }) +} + +const createLinkExtension = (linkExtensionOpts: Partial = {}) => { + return Link.extend({ + inclusive: false, + + addOptions() { + return { + ...this.parent?.(), + autolink: true, + openOnClick: true, + linkOnPaste: true, + defaultProtocol: 'http', + protocols: [], + HTMLAttributes: { + target: '_blank', + rel: 'noopener noreferrer nofollow', + class: null, }, - }), - ] - }, - addPasteRules() { - return [ - linkPasteRule({ - find: pasteRegex, - type: this.type, - - // We need to use `pop()` to remove the last capture groups from the match to - // satisfy Tiptap's `markInputRule` expectation of having the content as the last - // capture group in the match (this makes the attribute order important) - getAttributes(match) { - return { - title: match.pop()?.trim(), - href: match.pop()?.trim(), - } + onLinkClick: () => {}, + ...linkExtensionOpts, + } + }, + + addAttributes() { + return { + ...this.parent?.(), + title: { default: null }, + } + }, + + renderHTML({ HTMLAttributes }) { + return isInternalLink(HTMLAttributes.href) + ? [ + 'span', + { + 'data-path': HTMLAttributes.href, + class: 'link internal-link', + }, + 0, + ] + : ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + + parseHTML() { + return [ + { + tag: 'a[href]', + getAttrs: (dom) => { + const href = (dom as HTMLElement).getAttribute('href') + if (!href || !isValidURI(href)) return false + return null + }, }, - }), - ] - }, -}) + ] + }, + + addInputRules() { + return [ + linkInputRule({ + find: inputRegex, + type: this.type, + getAttributes(match) { + return { + title: match.pop()?.trim(), + href: match.pop()?.trim(), + } + }, + }), + linkFileInputRule({ + find: fileRegex, + type: this.type, + getAttributes(match) { + const title = match.pop()?.trim() + const fileName = `${title}.md` + const cacheResult = useFileSearchIndex.getState().getPath(fileName) + return { + title, + href: cacheResult ? `reor://${cacheResult}` : `reor://${fileName}`, + } + }, + }), + ] + }, + + addPasteRules() { + return [ + linkPasteRule({ + find: pasteRegex, + type: this.type, + getAttributes(match) { + return { + title: match.pop()?.trim(), + href: match.pop()?.trim(), + } + }, + }), + ] + }, + + addProseMirrorPlugins() { + const plugins: Plugin[] = [] + + if (this.options.autolink) { + plugins.push( + autolink({ + type: this.type, + validate: isValidURI, + }), + ) + } -export { RichTextLink } + if (this.options.openOnClick) { + plugins.push( + clickHandler({ + openFile: (this.options as any).openFile, + type: this.type, + }), + ) + } + + return plugins + }, + }).configure(linkExtensionOpts) +} -export type { LinkOptions as RichTextLinkOptions } +export default createLinkExtension diff --git a/src/components/Editor/editor.css b/src/components/Editor/editor.css index 97ed2775f..0e97ebfea 100644 --- a/src/components/Editor/editor.css +++ b/src/components/Editor/editor.css @@ -29,34 +29,25 @@ } /* recommendation from Haz: https://twitter.com/diegohaz/status/1591829631811846147 */ -.link, -.hm-link { +.link { text-decoration-line: underline; text-underline-offset: 0.2em; text-decoration-skip-ink: none; } -.link:hover, -.hm-link:hover { +.link:hover { text-decoration-thickness: 3px; + cursor: pointer; } /* manually synced with tamagui colors: blue11 and blueDark11 */ -/* .ProseMirror .link { */ -/* color: var(--blue11); */ -/* } */ +.ProseMirror .link { + color: var(--blue11); +} /* .seed-app-dark .ProseMirror .link { color: hsl(210, 100%, 66.1%); } */ -/* manually sync these values with colors.ts brand5 and brandDark5 */ -.ProseMirror .hm-link { - color: var(--brand5); -} -/* .seed-app-dark .ProseMirror .hm-link { - color: hsl(184, 80%, 71%); -} */ - .ProseMirror .link-placeholder { width: 16px; height: 16px; @@ -100,11 +91,6 @@ display: list-item; } -[data-list-type='ul'], -[data-list-type='ol'] { - display: list-item; -} - .editor code { background-color: var(--color5); font-family: 'ui-monospace', 'SFMono-Regular', 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; diff --git a/src/components/Editor/schema.ts b/src/components/Editor/schema.ts index c59ccdc48..eacae93a2 100644 --- a/src/components/Editor/schema.ts +++ b/src/components/Editor/schema.ts @@ -7,8 +7,8 @@ import { VideoBlock } from './types/Video/video' export const hmBlockSchema: BlockSchema = { paragraph: defaultBlockSchema.paragraph, heading: defaultBlockSchema.heading, - // bulletListItem: defaultBlockSchema.bulletListItem, - // numberedListItem: defaultBlockSchema.numberedListItem, + bulletListItem: defaultBlockSchema.bulletListItem, + numberedListItem: defaultBlockSchema.numberedListItem, image: ImageBlock, // @ts-ignore 'code-block': { diff --git a/src/components/Sidebars/SimilarFilesSidebar.tsx b/src/components/Sidebars/SimilarFilesSidebar.tsx index 59e5ae3af..d21652d79 100644 --- a/src/components/Sidebars/SimilarFilesSidebar.tsx +++ b/src/components/Sidebars/SimilarFilesSidebar.tsx @@ -2,12 +2,11 @@ import React, { useEffect, useState } from 'react' import { DBQueryResult } from 'electron/main/vector-database/schema' import { toast } from 'react-toastify' -import removeMd from 'remove-markdown' - import '../../styles/global.css' - import posthog from 'posthog-js' import { Stack } from 'tamagui' + +import { getSimilarFiles } from '@/lib/semanticService' import errorToStringRendererProcess from '@/lib/error' import SimilarEntriesComponent from './SemanticSidebar/SimilarEntriesComponent' import { useFileContext } from '@/contexts/FileContext' @@ -20,31 +19,13 @@ const SimilarFilesSidebarComponent: React.FC = () => { const { currentlyOpenFilePath } = useFileContext() const { openContent: openTabContent } = useContentContext() - const getChunkForInitialSearchFromFile = async (filePathForChunk: string | null) => { - // TODO: proper semantic chunking - current quick win is just to take top 500 characters - if (!filePathForChunk) { - return undefined - } - const fileContent: string = await window.fileSystem.readFile(filePathForChunk, 'utf-8') - if (!fileContent) { - return undefined - } - const sanitizedText = removeMd(fileContent.slice(0, 500)) - return sanitizedText - } - const performSearchOnChunk = async ( - sanitizedText: string, - fileToBeExcluded: string | null, - ): Promise => { + const fetchSimilarEntries = async (path: string) => { try { - const databaseFields = await window.database.getDatabaseFields() - const filterString = `${databaseFields.NOTE_PATH} != '${fileToBeExcluded}'` - setIsLoadingSimilarEntries(true) - const searchResults: DBQueryResult[] = await window.database.search(sanitizedText, 20, filterString) - setIsLoadingSimilarEntries(false) - return searchResults + const searchResults = await getSimilarFiles(path) + + setSimilarEntries(searchResults ?? []) } catch (error) { toast.error(errorToStringRendererProcess(error), { className: 'mt-5', @@ -52,53 +33,28 @@ const SimilarFilesSidebarComponent: React.FC = () => { closeOnClick: false, draggable: false, }) - return [] + } finally { + setIsLoadingSimilarEntries(false) } } useEffect(() => { - const handleNewFileOpen = async (path: string) => { - const sanitizedText = await getChunkForInitialSearchFromFile(path) - if (!sanitizedText) { - return - } - const searchResults = await performSearchOnChunk(sanitizedText, path) - - if (searchResults.length > 0) { - setSimilarEntries(searchResults) - } else { - setSimilarEntries([]) - } - } if (currentlyOpenFilePath) { - handleNewFileOpen(currentlyOpenFilePath) + fetchSimilarEntries(currentlyOpenFilePath) } }, [currentlyOpenFilePath]) const updateSimilarEntries = async () => { - const sanitizedText = await getChunkForInitialSearchFromFile(currentlyOpenFilePath) - - if (!sanitizedText) { - toast.error(`Error: Could not get chunk for search ${currentlyOpenFilePath}`) + if (!currentlyOpenFilePath) { + toast.error('No file currently open.') return } - const searchResults = await performSearchOnChunk(sanitizedText, currentlyOpenFilePath) - setSimilarEntries(searchResults) + await fetchSimilarEntries(currentlyOpenFilePath) } return ( - {/* { - setSimilarEntries([]) - const databaseFields = await window.database.getDatabaseFields() - const filterString = `${databaseFields.NOTE_PATH} != '${currentlyOpenFilePath}'` - const searchResults: DBQueryResult[] = await window.database.search(highlightData.text, 20, filterString) - setSimilarEntries(searchResults) - }} - />{' '} */} = ({ childr ) } +export const ThemedDropdown: React.FC = ({ children, ...restProps }) => { + const theme = useTheme() + + return ( + + {children} + + ) +} + export default ThemedMenu diff --git a/src/contexts/FileContext.tsx b/src/contexts/FileContext.tsx index 2d49e91bc..6d9f0aec0 100644 --- a/src/contexts/FileContext.tsx +++ b/src/contexts/FileContext.tsx @@ -20,8 +20,10 @@ import useOrderedSet from '../lib/hooks/use-ordered-set' import welcomeNote from '@/lib/welcome-note' import { useBlockNote, BlockNoteEditor } from '@/lib/blocknote' import { hmBlockSchema } from '@/components/Editor/schema' -import { setGroupTypes } from '@/lib/utils' +import { setGroupTypes, useEditorState, useSemanticCache } from '@/lib/utils' +import useFileSearchIndex from '@/lib/utils/cache/fileSearchIndex' import slashMenuItems from '../components/Editor/slash-menu-items' +import { getSimilarFiles } from '@/lib/semanticService' type FileContextType = { vaultFilesTree: FileInfoTree @@ -94,7 +96,7 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => fetchSpellCheckMode() }, [spellCheckEnabled]) - const createFileIfNotExists = async (filePath: string, optionalContent?: string): Promise => { + const createFileIfNotExists = async (filePath: string, optionalContent?: string): Promise => { const invalidChars = await getInvalidCharacterInFilePath(filePath) if (invalidChars) { const errorMessage = `Could not create note ${filePath}. Character ${invalidChars} cannot be included in note name.` @@ -108,12 +110,16 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => : await window.path.join(await window.electronStore.getVaultDirectoryForWindow(), filePathWithExtension) const fileExists = await window.fileSystem.checkFileExists(absolutePath) + let fileObject = null if (!fileExists) { - await window.fileSystem.createFile(absolutePath, optionalContent || ``) + fileObject = await window.fileSystem.createFile(absolutePath, optionalContent || ``) + if (!fileObject) throw new Error(`Could not create file ${filePathWithExtension}`) setNeedToIndexEditorContent(true) + } else { + fileObject = await window.fileSystem.getFileInfo(absolutePath, filePathWithExtension) } - return absolutePath + return fileObject } const loadFileIntoEditor = async (filePath: string) => { @@ -124,7 +130,7 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => setNeedToIndexEditorContent(false) } const fileContent = (await window.fileSystem.readFile(filePath, 'utf-8')) ?? '' - // editor?.commands.setContent(fileContent) + useSemanticCache.getState().setSemanticData(filePath, await getSimilarFiles(filePath)) const blocks = await editor.markdownToBlocks(fileContent) // @ts-expect-error editor.replaceBlocks(editor.topLevelBlocks, blocks) @@ -134,11 +140,15 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => setCurrentlyChangingFilePath(false) const parentDirectory = await window.path.dirname(filePath) setSelectedDirectory(parentDirectory) + editor.setCurrentFilePath(filePath) } const openOrCreateFile = async (filePath: string, optionalContentToWriteOnCreate?: string): Promise => { - const absolutePath = await createFileIfNotExists(filePath, optionalContentToWriteOnCreate) - await loadFileIntoEditor(absolutePath) + const fileObject = await createFileIfNotExists(filePath, optionalContentToWriteOnCreate) + await loadFileIntoEditor(fileObject.path) + if (!useFileSearchIndex.getState().getPath(fileObject.name)) { + useFileSearchIndex.getState().add(fileObject) + } } const editor = useBlockNote({ @@ -148,6 +158,11 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => }, blockSchema: hmBlockSchema, slashMenuItems, + linkExtensionOptions: { + openFile: (path: string) => { + openOrCreateFile(path) + }, + }, }) const [debouncedEditor] = useDebounce(editor?.topLevelBlocks, 3000) @@ -161,6 +176,12 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => } }, [debouncedEditor, currentlyOpenFilePath, editor, currentlyChangingFilePath]) + useEffect(() => { + if (editor) { + useEditorState.getState().setCurrentFilePath(currentlyOpenFilePath) + } + }, [editor, currentlyOpenFilePath]) + const saveCurrentlyOpenedFile = async () => { await writeEditorContentToDisk(editor, currentlyOpenFilePath) } diff --git a/src/lib/blocknote/core/BlockNoteEditor.ts b/src/lib/blocknote/core/BlockNoteEditor.ts index 9475f4c98..89e380565 100644 --- a/src/lib/blocknote/core/BlockNoteEditor.ts +++ b/src/lib/blocknote/core/BlockNoteEditor.ts @@ -26,7 +26,7 @@ import { ColorStyle, Styles, ToggledStyle } from './extensions/Blocks/api/inline import { Selection } from './extensions/Blocks/api/selectionTypes' import { getBlockInfoFromPos } from './extensions/Blocks/helpers/getBlockInfoFromPos' -import { FormattingToolbarProsemirrorPlugin } from './extensions/FormattingToolbar/FormattingToolbarPlugin' +import FormattingToolbarProsemirrorPlugin from './extensions/FormattingToolbar/FormattingToolbarPlugin' import { HyperlinkToolbarProsemirrorPlugin } from './extensions/HyperlinkToolbar/HyperlinkToolbarPlugin' import { SideMenuProsemirrorPlugin } from './extensions/SideMenu/SideMenuPlugin' import { BaseSlashMenuItem } from './extensions/SlashMenu/BaseSlashMenuItem' @@ -36,6 +36,8 @@ import UniqueID from './extensions/UniqueID/UniqueID' import { mergeCSSClasses } from './shared/utils' import { HMBlockSchema, hmBlockSchema } from '@/components/Editor/schema' import '@/components/Editor/editor.css' +import LinkToolbarProsemirrorPlugin from './extensions/LinkToolbar/LinkToolbarPlugin' +import { removeFileExtension } from '@/lib/file' export type BlockNoteEditorOptions = { // TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them. @@ -92,6 +94,11 @@ export type BlockNoteEditorOptions = { */ blockSchema: BSchema + /** + * The absolute path to the current file the editor is displaying. + */ + currentFilePath: string + /** * When enabled, allows for collaboration between multiple users. */ @@ -142,10 +149,14 @@ export class BlockNoteEditor { public inlineEmbedOptions = [] + public currentFilePath: string | null = null + public readonly sideMenu: SideMenuProsemirrorPlugin public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin + public readonly similarFilesToolbar: LinkToolbarProsemirrorPlugin + public readonly slashMenu: SlashMenuProsemirrorPlugin public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin @@ -166,6 +177,7 @@ export class BlockNoteEditor { this.sideMenu = new SideMenuProsemirrorPlugin(this) this.formattingToolbar = new FormattingToolbarProsemirrorPlugin(this) + this.similarFilesToolbar = new LinkToolbarProsemirrorPlugin(this) this.slashMenu = new SlashMenuProsemirrorPlugin( this, newOptions.slashMenuItems || getDefaultSlashMenuItems(newOptions.blockSchema), @@ -188,6 +200,7 @@ export class BlockNoteEditor { return [ this.sideMenu.plugin, this.formattingToolbar.plugin, + this.similarFilesToolbar.plugin, this.slashMenu.plugin, this.hyperlinkToolbar.plugin, ] @@ -659,6 +672,52 @@ export class BlockNoteEditor { ) } + /** + * Adds a new link at the current location + * @param url + */ + public addLink(url: string, text: string) { + if (!url || !text) return + + const { state, view } = this._tiptapEditor + const { tr, schema, selection, doc } = state + const { from } = selection + const textWithoutExt = removeFileExtension(text) + const shouldAddSym = !url.startsWith('reor://') + const urlWithSym = shouldAddSym ? `reor://${url}` : url + + const maxSearchLength = 100 + const searchStart = Math.max(0, from - maxSearchLength) + + const textBefore = doc.textBetween(searchStart, from, undefined, '\0') + + // Find the last `[` before the cursor + const lastBracketIndex = textBefore.lastIndexOf('[[') + if (lastBracketIndex === -1) { + // fallback: insert at cursor + const mark = schema.mark('link', { href: urlWithSym, title: textWithoutExt }) + const insertTr = tr.insertText(textWithoutExt, from).addMark(from, from + textWithoutExt.length, mark) + view.dispatch(insertTr) + view.focus() + return + } + + const matchStart = from - (textBefore.length - lastBracketIndex) + tr.delete(matchStart, from) + tr.insertText(textWithoutExt, matchStart) + tr.addMark( + matchStart, + matchStart + textWithoutExt.length, + schema.mark('link', { + href: urlWithSym, + title: textWithoutExt, + }), + ) + + view.dispatch(tr) + view.focus() + } + /** * Checks if the block containing the text cursor can be nested. */ @@ -736,4 +795,20 @@ export class BlockNoteEditor { public async markdownToBlocks(markdown: string): Promise[]> { return markdownToBlocks(markdown, this.schema, this._tiptapEditor.schema) } + + /** + * Sets the current file path that the editor is displaying. + * + * @param filePath The absolute path to the current file the editor is displaying. + */ + public setCurrentFilePath(filePath: string | null) { + this.currentFilePath = filePath + } + + /** + * Gets the current file path that the editor is displaying. + */ + public getCurrentFilePath() { + return this.currentFilePath + } } diff --git a/src/lib/blocknote/core/BlockNoteExtensions.ts b/src/lib/blocknote/core/BlockNoteExtensions.ts index 26487dde0..941238c25 100644 --- a/src/lib/blocknote/core/BlockNoteExtensions.ts +++ b/src/lib/blocknote/core/BlockNoteExtensions.ts @@ -11,8 +11,6 @@ import { Italic } from '@tiptap/extension-italic' import { Strike } from '@tiptap/extension-strike' import { Text } from '@tiptap/extension-text' import { Underline } from '@tiptap/extension-underline' -// import {createInlineEmbedNode} from '../../mentions-plugin' -import { Link } from '../../tiptap-extension-link' import BlockManipulationExtension from './extensions/BlockManipulation/BlockManipulationExtension' import { BlockContainer, BlockGroup, Doc } from './extensions/Blocks' import { BlockNoteDOMAttributes } from './extensions/Blocks/api/blockTypes' @@ -27,6 +25,8 @@ import SearchAndReplace from '@/components/Editor/Search/SearchAndReplaceExtensi import TextAlignmentExtension from './extensions/TextAlignment/TextAlignmentExtension' import { BlockNoteEditor } from './BlockNoteEditor' import LocalMediaPastePlugin from './extensions/Pasting/local-media-paste-plugin' +// import { RichTextLink } from '@/components/Editor/RichTextLink' +import createLinkExtension from '@/components/Editor/RichTextLink' /** * Get all the Tiptap extensions BlockNote is configured with by default @@ -68,7 +68,7 @@ const getBlockNoteExtensions = (opts: { // copy paste: // @ts-ignore - createMarkdownExtension(), + createMarkdownExtension(opts.editor), // block manupulations: BlockManipulationExtension, @@ -79,7 +79,8 @@ const getBlockNoteExtensions = (opts: { Italic, Strike, Underline, - Link.configure(opts.linkExtensionOptions), + // RichTextLink.configure(opts.linkExtensionOptions), + createLinkExtension(opts.linkExtensionOptions), // TextColorMark, // TextColorExtension, TextAlignmentExtension, diff --git a/src/lib/blocknote/core/api/formatConversions/formatConversions.ts b/src/lib/blocknote/core/api/formatConversions/formatConversions.ts index ba8f4da30..6b03db73e 100644 --- a/src/lib/blocknote/core/api/formatConversions/formatConversions.ts +++ b/src/lib/blocknote/core/api/formatConversions/formatConversions.ts @@ -196,6 +196,7 @@ export async function blocksToMarkdown(blocks: Bloc // @ts-expect-error .use(remarkStringify) .process(convertBlocksToHtml(blocks)) + return tmpMarkdownString.value as string } diff --git a/src/lib/blocknote/core/extensions/Blocks/nodes/BlockContainer.ts b/src/lib/blocknote/core/extensions/Blocks/nodes/BlockContainer.ts index aa253cceb..cc335c45a 100644 --- a/src/lib/blocknote/core/extensions/Blocks/nodes/BlockContainer.ts +++ b/src/lib/blocknote/core/extensions/Blocks/nodes/BlockContainer.ts @@ -157,7 +157,7 @@ class HeadingLinePlugin { this.line.style.left = `${rect.left - editorRect.left + groupPadding}px` this.line.style.width = `2.5px` this.line.style.height = `${rect.height - groupPadding * 2}px` - this.line.style.backgroundColor = 'var(--brand5)' + // this.line.style.backgroundColor = 'var(--brand5)' this.line.style.opacity = '0.4' } else { this.line.style.opacity = '0' @@ -545,7 +545,7 @@ export const BlockContainer = Node.create<{ .chain() .deleteSelection() .BNSplitBlock(state.selection.from, false) - .UpdateGroup(-1, blockInfo.node.attrs.listType, true) + // .UpdateGroup(-1, blockInfo.node.attrs.listType, true) .run() }) } else { diff --git a/src/lib/blocknote/core/extensions/EditorToolbar/EditorToolbar.ts b/src/lib/blocknote/core/extensions/EditorToolbar/EditorToolbar.ts new file mode 100644 index 000000000..007931bc9 --- /dev/null +++ b/src/lib/blocknote/core/extensions/EditorToolbar/EditorToolbar.ts @@ -0,0 +1,188 @@ +import { EditorState } from 'prosemirror-state' +import { EditorView } from 'prosemirror-view' +import { isNodeSelection, posToDOMRect } from '@tiptap/core' +import { BaseUiElementCallbacks, BaseUiElementState, BlockNoteEditor, BlockSchema } from '../..' + +export type EditorToolbarCallbacks = BaseUiElementCallbacks +export type EditorToolbarState = BaseUiElementState + +export class EditorToolbarView { + private editorToolbarState?: EditorToolbarState + + public updateEditorToolbar: () => void + + public preventHide = false + + public preventShow = false + + public prevWasEditable: boolean | null = null + + public shouldShow: (props: { view: EditorView; state: EditorState; from: number; to: number }) => boolean + + constructor( + private readonly editor: BlockNoteEditor, + private readonly pmView: EditorView, + updateEditorToolbar: (editorToolbarState: EditorToolbarState) => void, + shouldShow: (props: { view: EditorView; state: EditorState; from: number; to: number }) => boolean, + ) { + this.updateEditorToolbar = () => { + if (!this.editorToolbarState) { + throw new Error('Attempting to update uninitialized editor toolbar') + } + + updateEditorToolbar(this.editorToolbarState) + } + + this.shouldShow = shouldShow + + pmView.dom.addEventListener('mousedown', this.viewMousedownHandler) + pmView.dom.addEventListener('mouseup', this.viewMouseupHandler) + pmView.dom.addEventListener('dragstart', this.updateEditorToolbar) + + pmView.dom.addEventListener('focus', this.focusHandler) + pmView.dom.addEventListener('blur', this.blurHandler) + + document.addEventListener('scroll', this.scrollHandler) + } + + viewMousedownHandler = () => { + this.preventShow = true + } + + viewMouseupHandler = () => { + this.preventShow = false + setTimeout(() => this.update(this.pmView)) + } + + dragStartHandler = () => { + if (this.editorToolbarState?.show) { + this.editorToolbarState.show = false + this.updateEditorToolbar() + } + } + + focusHandler = () => { + setTimeout(() => this.update(this.pmView)) + } + + blurHandler = (event: FocusEvent) => { + if (this.preventHide) { + this.preventHide = false + return + } + + const editorWrapper = this.pmView.dom.parentElement! + + // Check if we are moving the focus to an element outside the editor + if ( + event && + event.relatedTarget && + (editorWrapper === (event.relatedTarget as Node) || editorWrapper?.contains(event.relatedTarget as Node)) + ) { + return + } + + if (this.editorToolbarState?.show) { + this.editorToolbarState.show = false + this.updateEditorToolbar() + } + } + + scrollHandler = () => { + if (this.editorToolbarState?.show) { + this.editorToolbarState.referencePos = this.getSelectionBoundingBox() + this.updateEditorToolbar() + } + } + + update = (view: EditorView, oldState?: EditorState) => { + const { state, composing } = view + const { doc, selection } = state + const isSame = oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection) + + if ((this.prevWasEditable === null || this.prevWasEditable === this.editor.isEditable) && (composing || isSame)) { + return + } + + this.prevWasEditable = this.editor.isEditable + + // support for CellSelections + const { ranges } = selection + const from = Math.min(...ranges.map((range) => range.$from.pos)) + const to = Math.max(...ranges.map((range) => range.$to.pos)) + + // Call child component of shouldShow + const shouldShow = this.shouldShow?.({ + view, + state, + from, + to, + }) + + // Checks if menu should be shown/updated. + if (this.editor.isEditable && !this.preventShow && (shouldShow || this.preventHide)) { + this.editorToolbarState = { + show: true, + referencePos: this.getSelectionBoundingBox(), + } + + this.updateEditorToolbar() + + return + } + + // Checks if menu should be hidden. + if ( + this.editorToolbarState?.show && + !this.preventHide && + (!shouldShow || this.preventShow || !this.editor.isEditable) + ) { + this.editorToolbarState.show = false + this.updateEditorToolbar() + } + } + + getSelectionBoundingBox = () => { + const { state } = this.pmView + const { selection } = state + + const { ranges } = selection + const from = Math.min(...ranges.map((range) => range.$from.pos)) + const to = Math.max(...ranges.map((range) => range.$to.pos)) + + if (isNodeSelection(selection)) { + const node = this.pmView.nodeDOM(from) as HTMLElement + + if (node) { + return node.getBoundingClientRect() + } + } + + return posToDOMRect(this.pmView, from, to) + } +} + +// export const editorToolbarPluginKey = new PluginKey('EditorToolbarPlugin') + +// export class EditorToolbarProsemirrorPlugin extends EventEmitter { +// private view: EditorToolbarView | undefined + +// public readonly plugin: Plugin + +// constructor(editor: BlockNoteEditor) { +// super() +// this.plugin = new Plugin({ +// key: editorToolbarPluginKey, +// view: (editorView) => { +// this.view = new EditorToolbarView(editor, editorView, (state) => { +// this.emit("update", state) +// }) +// return this.view +// } +// }) +// } + +// public onUpdate(callback: (state: EditorToolbarState) => void) { +// this.on("update", callback) +// } +// } diff --git a/src/lib/blocknote/core/extensions/FormattingToolbar/FormattingToolbarPlugin.ts b/src/lib/blocknote/core/extensions/FormattingToolbar/FormattingToolbarPlugin.ts index 5834be5ff..5c35ffdd9 100644 --- a/src/lib/blocknote/core/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +++ b/src/lib/blocknote/core/extensions/FormattingToolbar/FormattingToolbarPlugin.ts @@ -1,209 +1,14 @@ -/* eslint-disable max-classes-per-file */ -import { isNodeSelection, isTextSelection, posToDOMRect } from '@tiptap/core' -import { EditorState, Plugin, PluginKey } from 'prosemirror-state' -import { EditorView } from 'prosemirror-view' -import { BaseUiElementCallbacks, BaseUiElementState, BlockNoteEditor, BlockSchema } from '../..' -import EventEmitter from '../../shared/EventEmitter' - -export type FormattingToolbarCallbacks = BaseUiElementCallbacks - -export type FormattingToolbarState = BaseUiElementState - -export class FormattingToolbarView { - private formattingToolbarState?: FormattingToolbarState - - public updateFormattingToolbar: () => void - - public preventHide = false - - public preventShow = false - - public prevWasEditable: boolean | null = null - - public static shouldShow: (props: { view: EditorView; state: EditorState; from: number; to: number }) => boolean = ({ - view, - state, - from, - to, - }) => { - const { doc, selection } = state - const { empty } = selection - - // Sometime check for `empty` is not enough. - // Doubleclick an empty paragraph returns a node size of 2. - // So we check also for an empty text size. - const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(state.selection) - - return !( - !view.hasFocus() || - empty || - isEmptyTextBlock || - // Don't show menu if node selection (image, video, file, embed) - isNodeSelection(state.selection) - ) - } - - constructor( - private readonly editor: BlockNoteEditor, - private readonly pmView: EditorView, - updateFormattingToolbar: (formattingToolbarState: FormattingToolbarState) => void, - ) { - this.updateFormattingToolbar = () => { - if (!this.formattingToolbarState) { - throw new Error('Attempting to update uninitialized formatting toolbar') - } - - updateFormattingToolbar(this.formattingToolbarState) - } - - pmView.dom.addEventListener('mousedown', this.viewMousedownHandler) - pmView.dom.addEventListener('mouseup', this.viewMouseupHandler) - pmView.dom.addEventListener('dragstart', this.dragstartHandler) - - pmView.dom.addEventListener('focus', this.focusHandler) - pmView.dom.addEventListener('blur', this.blurHandler) - - document.addEventListener('scroll', this.scrollHandler) - } - - viewMousedownHandler = () => { - this.preventShow = true - } - - viewMouseupHandler = () => { - this.preventShow = false - setTimeout(() => this.update(this.pmView)) - } - - // For dragging the whole editor. - dragstartHandler = () => { - if (this.formattingToolbarState?.show) { - this.formattingToolbarState.show = false - this.updateFormattingToolbar() - } - } - - focusHandler = () => { - // we use `setTimeout` to make sure `selection` is already updated - setTimeout(() => this.update(this.pmView)) - } - - blurHandler = (event: FocusEvent) => { - if (this.preventHide) { - this.preventHide = false - - return - } - - const editorWrapper = this.pmView.dom.parentElement! +import { Plugin, PluginKey } from 'prosemirror-state' +import { BlockNoteEditor, BlockSchema } from '../..' +import { EditorToolbarState, EditorToolbarView } from '../EditorToolbar/EditorToolbar' - // Checks if the focus is moving to an element outside the editor. If it is, - // the toolbar is hidden. - if ( - // An element is clicked. - event && - event.relatedTarget && - // Element is inside the editor. - (editorWrapper === (event.relatedTarget as Node) || editorWrapper?.contains(event.relatedTarget as Node)) - ) { - return - } - - if (this.formattingToolbarState?.show) { - this.formattingToolbarState.show = false - this.updateFormattingToolbar() - } - } - - scrollHandler = () => { - if (this.formattingToolbarState?.show) { - this.formattingToolbarState.referencePos = this.getSelectionBoundingBox() - this.updateFormattingToolbar() - } - } - - update(view: EditorView, oldState?: EditorState) { - const { state, composing } = view - const { doc, selection } = state - const isSame = oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection) - - if ((this.prevWasEditable === null || this.prevWasEditable === this.editor.isEditable) && (composing || isSame)) { - return - } - - this.prevWasEditable = this.editor.isEditable - - // support for CellSelections - const { ranges } = selection - const from = Math.min(...ranges.map((range) => range.$from.pos)) - const to = Math.max(...ranges.map((range) => range.$to.pos)) - - const shouldShow = FormattingToolbarView.shouldShow?.({ - view, - state, - from, - to, - }) - - // Checks if menu should be shown/updated. - if (this.editor.isEditable && !this.preventShow && (shouldShow || this.preventHide)) { - this.formattingToolbarState = { - show: true, - referencePos: this.getSelectionBoundingBox(), - } - - this.updateFormattingToolbar() - - return - } - - // Checks if menu should be hidden. - if ( - this.formattingToolbarState?.show && - !this.preventHide && - (!shouldShow || this.preventShow || !this.editor.isEditable) - ) { - this.formattingToolbarState.show = false - this.updateFormattingToolbar() - } - } - - destroy() { - this.pmView.dom.removeEventListener('mousedown', this.viewMousedownHandler) - this.pmView.dom.removeEventListener('mouseup', this.viewMouseupHandler) - this.pmView.dom.removeEventListener('dragstart', this.dragstartHandler) - - this.pmView.dom.removeEventListener('focus', this.focusHandler) - this.pmView.dom.removeEventListener('blur', this.blurHandler) - - document.removeEventListener('scroll', this.scrollHandler) - } - - getSelectionBoundingBox() { - const { state } = this.pmView - const { selection } = state - - // support for CellSelections - const { ranges } = selection - const from = Math.min(...ranges.map((range) => range.$from.pos)) - const to = Math.max(...ranges.map((range) => range.$to.pos)) - - if (isNodeSelection(selection)) { - const node = this.pmView.nodeDOM(from) as HTMLElement - - if (node) { - return node.getBoundingClientRect() - } - } - - return posToDOMRect(this.pmView, from, to) - } -} +import FormattingToolbarView from './FormattingToolbarView' +import EventEmitter from '../../shared/EventEmitter' export const formattingToolbarPluginKey = new PluginKey('FormattingToolbarPlugin') -export class FormattingToolbarProsemirrorPlugin extends EventEmitter { - private view: FormattingToolbarView | undefined +class FormattingToolbarProsemirrorPlugin extends EventEmitter { + private view: EditorToolbarView | undefined public readonly plugin: Plugin @@ -220,7 +25,9 @@ export class FormattingToolbarProsemirrorPlugin ext }) } - public onUpdate(callback: (state: FormattingToolbarState) => void) { + public onUpdate(callback: (state: EditorToolbarState) => void) { return this.on('update', callback) } } + +export default FormattingToolbarProsemirrorPlugin diff --git a/src/lib/blocknote/core/extensions/FormattingToolbar/FormattingToolbarView.ts b/src/lib/blocknote/core/extensions/FormattingToolbar/FormattingToolbarView.ts new file mode 100644 index 000000000..c33653271 --- /dev/null +++ b/src/lib/blocknote/core/extensions/FormattingToolbar/FormattingToolbarView.ts @@ -0,0 +1,23 @@ +import { isTextSelection, isNodeSelection } from '@tiptap/core' +import { EditorView } from 'prosemirror-view' +import { BlockNoteEditor, BlockSchema } from '../..' +import { EditorToolbarState, EditorToolbarView } from '../EditorToolbar/EditorToolbar' + +class FormattingToolbarView extends EditorToolbarView { + constructor( + editor: BlockNoteEditor, + pmView: EditorView, + updateEditorToolbar: (state: EditorToolbarState) => void, + ) { + super(editor, pmView, updateEditorToolbar, ({ view, state, from, to }) => { + const { doc, selection } = state + const { empty } = selection + + const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(state.selection) + + return !(!view.hasFocus() || empty || isEmptyTextBlock || isNodeSelection(state.selection)) + }) + } +} + +export default FormattingToolbarView diff --git a/src/lib/blocknote/core/extensions/LinkToolbar/LinkToolbarPlugin.ts b/src/lib/blocknote/core/extensions/LinkToolbar/LinkToolbarPlugin.ts new file mode 100644 index 000000000..c1d25ff95 --- /dev/null +++ b/src/lib/blocknote/core/extensions/LinkToolbar/LinkToolbarPlugin.ts @@ -0,0 +1,32 @@ +import { Plugin, PluginKey } from 'prosemirror-state' +import { BlockNoteEditor, BlockSchema } from '../..' +import { EditorToolbarState, EditorToolbarView } from '../EditorToolbar/EditorToolbar' +import EventEmitter from '../../shared/EventEmitter' +import LinkToolbarView from './LinkToolbarView' + +export const linkToolbarPluginKey = new PluginKey('LinkToolbarPlugin') + +class LinkToolbarProsemirrorPlugin extends EventEmitter { + private view: EditorToolbarView | undefined + + public readonly plugin: Plugin + + constructor(editor: BlockNoteEditor) { + super() + this.plugin = new Plugin({ + key: linkToolbarPluginKey, + view: (editorView) => { + this.view = new LinkToolbarView(editor, editorView, (state) => { + this.emit('update', state) + }) + return this.view + }, + }) + } + + public onUpdate(callback: (state: EditorToolbarState) => void) { + return this.on('update', callback) + } +} + +export default LinkToolbarProsemirrorPlugin diff --git a/src/lib/blocknote/core/extensions/LinkToolbar/LinkToolbarView.ts b/src/lib/blocknote/core/extensions/LinkToolbar/LinkToolbarView.ts new file mode 100644 index 000000000..9a2e39008 --- /dev/null +++ b/src/lib/blocknote/core/extensions/LinkToolbar/LinkToolbarView.ts @@ -0,0 +1,33 @@ +import { EditorView } from 'prosemirror-view' +import { isNodeSelection } from '@tiptap/core' +import { BlockNoteEditor, BlockSchema } from '../..' +import { EditorToolbarState, EditorToolbarView } from '../EditorToolbar/EditorToolbar' + +class LinkToolbarView extends EditorToolbarView { + constructor( + editor: BlockNoteEditor, + pmView: EditorView, + updateEditorToolbar: (state: EditorToolbarState) => void, + ) { + super(editor, pmView, updateEditorToolbar, ({ view, state, from }) => { + const { selection } = state + + if (!view.hasFocus() || !selection.empty || isNodeSelection(selection)) { + return false + } + + const $from = state.doc.resolve(from) + const start = $from.start() + + const maxSearchLength = 100 + const searchStart = Math.max(0, from - maxSearchLength) + const cleanedStart = searchStart < start ? start : searchStart + + const textBefore = state.doc.textBetween(cleanedStart, from, undefined, '\0') + const lastBracketIndex = textBefore.lastIndexOf('[[') + return lastBracketIndex !== -1 + }) + } +} + +export default LinkToolbarView diff --git a/src/lib/blocknote/core/extensions/Markdown/MarkdownExtension.ts b/src/lib/blocknote/core/extensions/Markdown/MarkdownExtension.ts index d7a62b278..fe32e3321 100644 --- a/src/lib/blocknote/core/extensions/Markdown/MarkdownExtension.ts +++ b/src/lib/blocknote/core/extensions/Markdown/MarkdownExtension.ts @@ -2,6 +2,9 @@ import { Editor, Extension } from '@tiptap/core' import { Fragment, Node } from '@tiptap/pm/model' import { Plugin } from 'prosemirror-state' import { youtubeParser } from '@/components/Editor/types/utils' +import { BlockNoteEditor } from '../../BlockNoteEditor' +import { getBlockInfoFromPos } from '@/lib/utils' +import * as BlockUtils from '@/lib/utils/block-utils' function containsMarkdownSymbols(pastedText: string) { // Regex to detect unique Markdown symbols at the start of a line @@ -53,7 +56,7 @@ function getPastedNodes(parent: Node | Fragment, editor: Editor) { return nodes } -const createMarkdownExtension = () => { +const createMarkdownExtension = (bnEditor: BlockNoteEditor) => { const MarkdownExtension = Extension.create({ name: 'MarkdownPasteHandler', priority: 900, @@ -76,6 +79,7 @@ const createMarkdownExtension = () => { const { selection } = state const isMarkdown = pastedHtml ? containsMarkdownSymbols(pastedText) : true + if (!isMarkdown) { if (hasList) { const firstBlockGroup = slice.content.firstChild?.type.name === 'blockGroup' @@ -96,7 +100,6 @@ const createMarkdownExtension = () => { } return false } - if (hasVideo) { let embedUrl = 'https://www.youtube.com/embed/' if (pastedText.includes('youtu.be') || pastedText.includes('youtube')) { @@ -110,19 +113,18 @@ const createMarkdownExtension = () => { view.dispatch(view.state.tr.replaceSelectionWith(node)) } } + } else { + // This is not a media file, just plaintext + bnEditor.markdownToBlocks(pastedText).then((organizedBlocks: any) => { + const blockInfo = getBlockInfoFromPos(state.doc, selection.from) + bnEditor.replaceBlocks( + [blockInfo.node.attrs.id], + // @ts-ignore + organizedBlocks, + ) + BlockUtils.setGroupTypes(bnEditor._tiptapEditor, organizedBlocks) + }) } - - // bnEditor.markdownToBlocks(pastedText).then((organizedBlocks) => { - // const blockInfo = getBlockInfoFromPos(state.doc, selection.from) - // console.log(`BLockINfo type: `, blockInfo.node.type.name) - // bnEditor.replaceBlocks( - // [blockInfo.node.attrs.id], - // // @ts-ignore - // organizedBlocks, - // ) - // BlockUtils.setGroupTypes(bnEditor._tiptapEditor, organizedBlocks) - // }) - return true }, }, diff --git a/src/lib/blocknote/react/LinkToolbar/LinkToolbarPositioner.tsx b/src/lib/blocknote/react/LinkToolbar/LinkToolbarPositioner.tsx new file mode 100644 index 000000000..a864afa86 --- /dev/null +++ b/src/lib/blocknote/react/LinkToolbar/LinkToolbarPositioner.tsx @@ -0,0 +1,74 @@ +import Tippy from '@tippyjs/react' +import React, { FC, useEffect, useMemo, useRef, useState } from 'react' +import { BlockSchema, DefaultBlockSchema, BlockNoteEditor } from '@/lib/blocknote/core' + +// import DefaultFormattingToolbar from '../FormattingToolbar/components/DefaultFormattingToolbar' +import LinkToolbarContent from './components/LinkToolbarContent' + +export type LinkToolbarPositionerProps = { + editor: BlockNoteEditor +} + +// We want Tippy to call `getReferenceClientRect` whenever the reference +// DOMRect's position changes. This happens automatically on scroll, but we need +// the `sticky` plugin to make it happen in all cases. This is most evident +// when changing the text alignment using the formatting toolbar. +// const tippyPlugins = [sticky] + +export const LinkToolbarPositioner = (props: { + editor: BlockNoteEditor + linkToolbarPositioner?: FC> +}) => { + const [show, setShow] = useState(false) + const referencePos = useRef() + + useEffect(() => { + return props.editor.similarFilesToolbar.onUpdate((state) => { + setShow(state.show) + + referencePos.current = state.referencePos + }) + }, [props.editor]) + + const getReferenceClientRect = useMemo( + () => { + if (!referencePos.current) { + return undefined + } + + const boundingRect = referencePos.current! + const newRect = { + top: boundingRect.top, + right: boundingRect.right, + bottom: boundingRect.bottom, + left: boundingRect.left, + width: boundingRect.width, + height: boundingRect.height, + } + + if (boundingRect.bottom + boundingRect.y > window.innerHeight) { + newRect.top = window.innerHeight / 2.15 + } + + return () => newRect as DOMRect + }, + [referencePos.current], // eslint-disable-line + ) + + const linkToolbarElement = useMemo(() => { + const LinkContentToolbar = props.linkToolbarPositioner || LinkToolbarContent + return + }, [props.editor, props.linkToolbarPositioner]) + + return ( + + ) +} diff --git a/src/lib/blocknote/react/LinkToolbar/components/LinkToolbarContent.tsx b/src/lib/blocknote/react/LinkToolbar/components/LinkToolbarContent.tsx new file mode 100644 index 000000000..d46129a49 --- /dev/null +++ b/src/lib/blocknote/react/LinkToolbar/components/LinkToolbarContent.tsx @@ -0,0 +1,82 @@ +import React, { useState, useEffect } from 'react' +import { Spinner, SizableText, YStack } from 'tamagui' +import { DBQueryResult } from 'electron/main/vector-database/schema' +import { Stack, Text } from '@mantine/core' +import { BlockNoteEditor, BlockSchema } from '@/lib/blocknote/core' +import { LinkToolbarPositionerProps } from '../LinkToolbarPositioner' +import { getUniqueSimilarFiles } from '@/lib/semanticService' +import ThemedMenu, { ThemedDropdown, ThemedMenuItem } from '@/components/ui/ThemedMenu' + +const LinkToolbarContent = ( + props: LinkToolbarPositionerProps & { + editor: BlockNoteEditor + }, +) => { + const [similarFiles, setSimilarFiles] = useState([]) + const [loading, setLoading] = useState(true) + const [triggerRender, setTriggerRender] = useState(0) + + useEffect(() => { + const timeout = setTimeout(() => { + setTriggerRender((prev) => prev + 1) + }, 100) + + return () => clearTimeout(timeout) + }, []) + + useEffect(() => { + const fetchSimilarFiles = async () => { + if (!props.editor.currentFilePath) return + try { + const files = await getUniqueSimilarFiles(props.editor.currentFilePath, 5) + setSimilarFiles(files) + } finally { + setLoading(false) + } + } + + fetchSimilarFiles() + }, [props.editor.currentFilePath, triggerRender]) + + if (loading) { + return ( + + + + Loading similar files... + + + ) + } + + /** + * Cannot use Menu or Popover because we have some async behavior. + * When I tried to use an external library it would introduce many bugs + */ + return ( + + e.preventDefault()}> + {similarFiles.slice(0, 5).map((file) => ( + props.editor.addLink(file.notepath, file.name)}> + + {file.name} + + {file.notepath} + + + + ))} + + + ) +} + +export default LinkToolbarContent diff --git a/src/lib/blocknote/react/SharedComponents/Toolbar/components/ToolbarButton.tsx b/src/lib/blocknote/react/SharedComponents/Toolbar/components/ToolbarButton.tsx index 2ff311d7d..e4bd90c16 100644 --- a/src/lib/blocknote/react/SharedComponents/Toolbar/components/ToolbarButton.tsx +++ b/src/lib/blocknote/react/SharedComponents/Toolbar/components/ToolbarButton.tsx @@ -12,6 +12,7 @@ export type ToolbarButtonProps = { isSelected?: boolean children?: any isDisabled?: boolean + hint?: string } /** @@ -20,13 +21,13 @@ export type ToolbarButtonProps = { // eslint-disable-next-line react/display-name export const ToolbarButton = forwardRef((props: ToolbarButtonProps, ref: ForwardedRef) => { const ButtonIcon = props.icon + const hasChildrenOrHint = props.children || props.hint return ( } trigger="mouseenter" > - {/* Creates an ActionIcon instead of a Button if only an icon is provided as content. */} - {props.children ? ( + {hasChildrenOrHint ? ( ) : ( { + if (!filePath) { + return undefined + } + + const fileContent: string = await window.fileSystem.readFile(filePath, 'utf-8') + if (!fileContent) { + return undefined + } + const sanitizedText = removeMd(fileContent.slice(0, 500)) + return sanitizedText +} + +export async function getSimilarFiles(filePath: string | null, limit: number = 20): Promise { + if (!filePath) { + return [] + } + const store = useSemanticCache.getState() + const { getSemanticData, setSemanticData, shouldRefetch } = store + if (!shouldRefetch(filePath)) { + return getSemanticData(filePath).data // We've already fetched this recently + } + + store.setFetching(filePath, true) + const sanitizedText = await getSanitizedChunk(filePath) + if (!sanitizedText) { + store.setFetching(filePath, false) + return [] + } + + const databaseFields = await window.database.getDatabaseFields() + const filterString = `${databaseFields.NOTE_PATH} != '${filePath}'` + + const searchResults: DBQueryResult[] = await window.database.search(sanitizedText, limit, filterString) + + setSemanticData(filePath, searchResults) + return searchResults +} + +/** + * Gets all of the unique files for a specific file path. + * + * @param filePath Filepath we want to fetch similar files for + * @param limit How many files we want to search for + * @returns + */ +export async function getUniqueSimilarFiles(filePath: string | null, limit: number = 20): Promise { + const results = await getSimilarFiles(filePath, limit) + const seen = new Set() + const deduped = results.filter((row) => { + if (seen.has(row.notepath)) return false + seen.add(row.notepath) + return true + }) + return deduped +} + +// useSemanticCache.getState().setSemanticData(filePath, await getSimilarFiles(filePath)) +export async function setSimilarFiles(filePath: string | null): Promise { + if (!filePath) { + return + } + const { setSemanticData } = useSemanticCache.getState() + setSemanticData(filePath, await getSimilarFiles(filePath)) +} diff --git a/src/lib/tiptap-extension-link/helpers/clickHandler.ts b/src/lib/tiptap-extension-link/helpers/clickHandler.ts index 410f91728..6d0e39b99 100644 --- a/src/lib/tiptap-extension-link/helpers/clickHandler.ts +++ b/src/lib/tiptap-extension-link/helpers/clickHandler.ts @@ -5,25 +5,30 @@ import { Plugin, PluginKey } from '@tiptap/pm/state' type ClickHandlerOptions = { type: MarkType openUrl?: any + openFile?: any } function clickHandler(options: ClickHandlerOptions): Plugin { return new Plugin({ key: new PluginKey('handleClickLink'), props: { - handleClick: (view, pos, event) => { + handleClick: (view, _, event) => { if (event.button !== 0) { return false } - const attrs = getAttributes(view.state, options.type.name) const link = event.target as HTMLLinkElement const href = link?.href ?? attrs.href + if (!href) return false - if (link && href) { - const newWindow = false // todo, check for meta key - options.openUrl(href, newWindow) + if (href.startsWith('reor://')) { + const path = href.replace('reor://', '') + options.openFile(path) + return true + } + if (/^https?:\/\//.test(href)) { + window.open(href, '_blank', 'noopener,noreferrer') return true } diff --git a/src/lib/tiptap-extension-link/link.ts b/src/lib/tiptap-extension-link/link.ts index e2a67c2c2..d4c31c7eb 100644 --- a/src/lib/tiptap-extension-link/link.ts +++ b/src/lib/tiptap-extension-link/link.ts @@ -47,11 +47,21 @@ declare module '@tiptap/core' { /** * Set a link mark */ - setLink: (attributes: { href: string; target?: string | null }) => ReturnType + setLink: (attributes: { + href: string + target?: string | null + rel?: string | null + class?: string | null + }) => ReturnType /** * Toggle a link mark */ - toggleLink: (attributes: { href: string; target?: string | null }) => ReturnType + toggleLink: (attributes: { + href: string + target?: string | null + rel?: string | null + class?: string | null + }) => ReturnType /** * Unset a link mark */ @@ -81,10 +91,6 @@ export const Link = Mark.create({ reset() }, - // inclusive() { - // return this.options.autolink - // }, - inclusive: false, addOptions() { diff --git a/src/lib/utils/cache/fileSearchIndex.ts b/src/lib/utils/cache/fileSearchIndex.ts new file mode 100644 index 000000000..941362911 --- /dev/null +++ b/src/lib/utils/cache/fileSearchIndex.ts @@ -0,0 +1,75 @@ +import { create } from 'zustand' +// import { FileMetadata } from '@/lib/llm/types' +import { FileInfo } from 'electron/main/filesystem/types' + +type FileSearchIndexState = { + index: Map + + hydrate: (entries: FileInfo[]) => void + + // Asynchronous methods + add: (metadata: FileInfo) => Promise + rename: (oldName: string, newName: string) => Promise + move: (fileName: string, newPath: string) => Promise + remove: (fileName: string) => Promise + + getPath: (fileName: string) => string | undefined + getMetadata: (fileName: string) => FileInfo | undefined +} + +const useFileSearchIndex = create((set, get) => ({ + index: new Map(), + + hydrate: (entries) => { + const map = new Map(entries.map((e) => [e.name, e])) + set({ index: map }) + }, + + add: async (metadata) => { + set((s) => { + const newMap = new Map(s.index) + newMap.set(metadata.name, metadata) + return { index: newMap } + }) + }, + + rename: async (oldName, newName) => { + const file = get().index.get(oldName) + if (!file) return + const newPath = file.path.replace(file.name, newName) + await window.fileSystem.renameFile({ oldFilePath: file.path, newFilePath: newPath }) + set((s) => { + const newMap = new Map(s.index) + newMap.delete(oldName) + newMap.set(newName, { ...file, name: newName, path: newPath }) + return { index: newMap } + }) + }, + + move: async (fileName, newPath) => { + const file = get().index.get(fileName) + if (!file) return + await window.fileSystem.renameFile({ oldFilePath: file.path, newFilePath: newPath }) + set((s) => { + const newMap = new Map(s.index) + newMap.set(fileName, { ...file, path: newPath }) + return { index: newMap } + }) + }, + + remove: async (fileName) => { + const file = get().index.get(fileName) + if (!file) return + await window.fileSystem.deleteFile(file.path) + set((s) => { + const newMap = new Map(s.index) + newMap.delete(fileName) + return { index: newMap } + }) + }, + + getPath: (name) => get().index.get(name)?.path, + getMetadata: (name) => get().index.get(name), +})) + +export default useFileSearchIndex diff --git a/src/lib/utils/editor-state.ts b/src/lib/utils/editor-state.ts new file mode 100644 index 000000000..95926cc13 --- /dev/null +++ b/src/lib/utils/editor-state.ts @@ -0,0 +1,90 @@ +import { create } from 'zustand' +import { DBQueryResult } from 'electron/main/vector-database/schema' + +type EditorStateStore = { + currentFilePath: string | null + setCurrentFilePath: (path: string | null) => void +} + +type SemanticEntry = { + data: DBQueryResult[] + lastFetched: number + isStale: boolean + isFetching: boolean +} + +type SemanticCacheState = { + semanticCache: Record + getSemanticData: (filePath: string) => SemanticEntry + setSemanticData: (filePath: string, data: DBQueryResult[]) => void + markStale: (filePath: string) => void + shouldRefetch: (filePath: string, thresholdMs?: number) => boolean + setFetching: (filePath: string, isFetching: boolean) => void +} + +export const useEditorState = create((set) => ({ + currentFilePath: null, + setCurrentFilePath: (path) => set({ currentFilePath: path }), +})) + +export const useSemanticCache = create((set, get) => ({ + semanticCache: {}, + + setSemanticData: (filePath: string, data: DBQueryResult[]) => { + set((state) => ({ + semanticCache: { + ...state.semanticCache, + [filePath]: { + data, + lastFetched: Date.now(), + isStale: false, + isFetching: false, + }, + }, + })) + }, + + getSemanticData: (filePath: string) => { + return get().semanticCache[filePath] ?? { data: [], lastFetched: 0, isStale: true, isFetching: false } + }, + + markStale: (filePath: string) => { + set((state) => { + const entry = state.semanticCache[filePath] + if (!entry) return {} + return { + semanticCache: { + ...state.semanticCache, + [filePath]: { + ...entry, + isStale: true, + }, + }, + } + }) + }, + + setFetching: (filePath: string, isFetching: boolean) => { + set((state) => { + const entry = state.semanticCache[filePath] + if (!entry) return {} + return { + semanticCache: { + ...state.semanticCache, + [filePath]: { + ...entry, + isFetching, + }, + }, + } + }) + }, + + shouldRefetch: (filePath: string, thresholdMs = 30000) => { + const entry = get().semanticCache[filePath] + if (!entry) return true + if (entry.isStale) return true + const age = Date.now() - entry.lastFetched + return age > thresholdMs + }, +})) diff --git a/src/lib/utils/entity-id-url.ts b/src/lib/utils/entity-id-url.ts index 643764053..f06898b72 100644 --- a/src/lib/utils/entity-id-url.ts +++ b/src/lib/utils/entity-id-url.ts @@ -1,5 +1,5 @@ export const HYPERMEDIA_SCHEME = 'hm' export function isHypermediaScheme(url?: string) { - return !!url?.startsWith(`${HYPERMEDIA_SCHEME}://`) + return !!url?.startsWith(`${HYPERMEDIA_SCHEME}://`) || !!url?.startsWith(`reor://`) } diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 30645bf53..8632becb3 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,3 +1,4 @@ export * from './block-utils' export { default as getNodeById } from './node-utils' export * from './entity-id-url' +export * from './editor-state' diff --git a/src/styles/tiptap.scss b/src/styles/tiptap.scss index 222bd5fe2..812d1c665 100644 --- a/src/styles/tiptap.scss +++ b/src/styles/tiptap.scss @@ -20,6 +20,7 @@ h5, h6 { line-height: 1.1; + margin: 0.4rem 0; } code {