From 231c6f8a1cd4e75d3c5b924c8a30cd9f2abf0ca4 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:25:53 +0200 Subject: [PATCH 01/41] chore: removed unused toast in app --- app/src/routes/scan/+page.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/routes/scan/+page.svelte b/app/src/routes/scan/+page.svelte index 9db7f5d9..b9e80704 100644 --- a/app/src/routes/scan/+page.svelte +++ b/app/src/routes/scan/+page.svelte @@ -22,7 +22,6 @@ const result = await scan({ formats: [Format.QRCode], windowed: true }); const url = new URL(result.content); const code = url.searchParams.get('code'); - toast.success(`Scanned code: ${code}`); goto(`/login?code=${code}`); } catch { toast.error('Failed to scan QR code'); From 986fc179c46e90d3d4e664f12a7eb92382532310 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:35:26 +0200 Subject: [PATCH 02/41] feat: added tiptap toolbar components --- frontend/package.json | 11 + frontend/src/lib/components/nav.svelte.ts | 6 + .../src/lib/components/tiptap/TipTab.svelte | 72 ++ frontend/src/lib/components/tiptap/config.ts | 61 ++ .../tiptap/extensions/search-and-replace.ts | 437 ++++++++++ frontend/src/lib/components/tiptap/tiptap.css | 441 ++++++++++ .../tiptap/toolbar/EditorToolbar.svelte | 67 ++ .../tiptap/toolbar/MobileToolbarItem.svelte | 32 + .../tiptap/toolbar/alignment.svelte | 118 +++ .../tiptap/toolbar/blockquote.svelte | 54 ++ .../lib/components/tiptap/toolbar/bold.svelte | 53 ++ .../tiptap/toolbar/bullet-list.svelte | 48 ++ .../tiptap/toolbar/code-block.svelte | 48 ++ .../tiptap/toolbar/color-and-highlight.svelte | 202 +++++ .../tiptap/toolbar/hard-break.svelte | 38 + .../components/tiptap/toolbar/headings.svelte | 109 +++ .../components/tiptap/toolbar/italic.svelte | 38 + .../lib/components/tiptap/toolbar/link.svelte | 137 ++++ .../toolbar/mobile-toolbar-group.svelte | 63 ++ .../tiptap/toolbar/ordered-list.svelte | 37 + .../lib/components/tiptap/toolbar/redo.svelte | 36 + .../toolbar/search-and-replace-toolbar.svelte | 233 ++++++ .../tiptap/toolbar/strikethrough.svelte | 38 + .../tiptap/toolbar/underline.svelte | 38 + .../lib/components/tiptap/toolbar/undo.svelte | 36 + frontend/src/routes/notes/+page.svelte | 5 + package-lock.json | 760 ++++++++++++++++-- package.json | 3 + 28 files changed, 3171 insertions(+), 50 deletions(-) create mode 100644 frontend/src/lib/components/tiptap/TipTab.svelte create mode 100644 frontend/src/lib/components/tiptap/config.ts create mode 100644 frontend/src/lib/components/tiptap/extensions/search-and-replace.ts create mode 100644 frontend/src/lib/components/tiptap/tiptap.css create mode 100644 frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/MobileToolbarItem.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/alignment.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/blockquote.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/bold.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/code-block.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/hard-break.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/headings.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/italic.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/link.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/mobile-toolbar-group.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/redo.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/underline.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/undo.svelte create mode 100644 frontend/src/routes/notes/+page.svelte diff --git a/frontend/package.json b/frontend/package.json index 226ebebf..1d2d563d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,17 @@ "@profidev/pleiades": "^1.9.6", "@simplewebauthn/browser": "^13.3.0", "@sveltejs/enhanced-img": "0.10.4", + "@tiptap/core": "^3.26.0", + "@tiptap/extension-bubble-menu": "^3.26.0", + "@tiptap/extension-emoji": "^3.26.0", + "@tiptap/extension-highlight": "^3.26.0", + "@tiptap/extension-list": "^3.26.0", + "@tiptap/extension-text-align": "^3.26.0", + "@tiptap/extension-text-style": "^3.26.0", + "@tiptap/extension-typography": "^3.26.0", + "@tiptap/extensions": "^3.26.0", + "@tiptap/pm": "^3.26.0", + "@tiptap/starter-kit": "^3.26.0", "jsencrypt": "3.5.4", "qrcode": "^1.5.4", "valibot": "^1.2.0" diff --git a/frontend/src/lib/components/nav.svelte.ts b/frontend/src/lib/components/nav.svelte.ts index 9f65bf66..c48550f0 100644 --- a/frontend/src/lib/components/nav.svelte.ts +++ b/frontend/src/lib/components/nav.svelte.ts @@ -7,6 +7,7 @@ import KeyRound from '@lucide/svelte/icons/key-round'; import Goal from '@lucide/svelte/icons/goal'; import UserKey from '@lucide/svelte/icons/user-key'; import Telescope from '@lucide/svelte/icons/telescope'; +import NotepadText from '@lucide/svelte/icons/notepad-text'; import type { NavGroup } from '@profidev/pleiades/components/nav/sidebar/types'; export const items: NavGroup[] = [ @@ -16,6 +17,11 @@ export const items: NavGroup[] = [ }, { items: [ + { + href: '/notes', + icon: NotepadText, + label: 'Notes' + }, { href: '/apod', icon: Telescope, diff --git a/frontend/src/lib/components/tiptap/TipTab.svelte b/frontend/src/lib/components/tiptap/TipTab.svelte new file mode 100644 index 00000000..f83cea44 --- /dev/null +++ b/frontend/src/lib/components/tiptap/TipTab.svelte @@ -0,0 +1,72 @@ + + +
+ {#if editorState.editor} +
+ + + +
+ {/if} + +
+
+ + diff --git a/frontend/src/lib/components/tiptap/config.ts b/frontend/src/lib/components/tiptap/config.ts new file mode 100644 index 00000000..c7c1c564 --- /dev/null +++ b/frontend/src/lib/components/tiptap/config.ts @@ -0,0 +1,61 @@ +import BubbleMenu from '@tiptap/extension-bubble-menu'; +import Emoji from '@tiptap/extension-emoji'; +import { TaskItem, TaskList } from '@tiptap/extension-list'; +import TextAlign from '@tiptap/extension-text-align'; +import { Color, TextStyle } from '@tiptap/extension-text-style'; +import { Placeholder } from '@tiptap/extensions'; +import { Highlight } from '@tiptap/extension-highlight'; +import Typography from '@tiptap/extension-typography'; +import StarterKit from '@tiptap/starter-kit'; + +import type { Extension } from '@tiptap/core'; +import SearchAndReplace from './extensions/search-and-replace'; + +export const extensions: Extension[] = [ + StarterKit.configure({ + orderedList: { + HTMLAttributes: { + class: 'list-decimal' + } + }, + bulletList: { + HTMLAttributes: { + class: 'list-disc' + } + }, + heading: { + levels: [1, 2, 3, 4] + } + }), + Placeholder.configure({ + emptyNodeClass: 'is-editor-empty', + placeholder: ({ node }) => { + switch (node.type.name) { + case 'heading': + return `Heading ${node.attrs.level}`; + case 'detailsSummary': + return 'Section title'; + case 'codeBlock': + // never show the placeholder when editing code + return ''; + default: + return 'Write something...'; + } + }, + includeChildren: false + }), + TextAlign.configure({ + types: ['heading', 'paragraph'] + }), + TextStyle, + Color, + Highlight.configure({ + multicolor: true + }), + SearchAndReplace, + Typography, + Emoji, + TaskItem, + TaskList, + BubbleMenu +] as Extension[]; diff --git a/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts b/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts new file mode 100644 index 00000000..7748c488 --- /dev/null +++ b/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts @@ -0,0 +1,437 @@ +// @ts-nocheck +import { type Editor as CoreEditor, Extension, type Range } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'; + +declare module '@tiptap/core' { + interface Commands { + search: { + /** + * @description Set search term in extension. + */ + setSearchTerm: (searchTerm: string) => ReturnType; + /** + * @description Set replace term in extension. + */ + setReplaceTerm: (replaceTerm: string) => ReturnType; + /** + * @description Replace first instance of search result with given replace term. + */ + replace: () => ReturnType; + /** + * @description Replace all instances of search result with given replace term. + */ + replaceAll: () => ReturnType; + /** + * @description Select the next search result. + */ + selectNextResult: () => ReturnType; + /** + * @description Select the previous search result. + */ + selectPreviousResult: () => ReturnType; + /** + * @description Set case sensitivity in extension. + */ + setCaseSensitive: (caseSensitive: boolean) => ReturnType; + }; + } +} + +interface TextNodeWithPosition { + text: string; + pos: number; +} + +const getRegex = ( + searchString: string, + disableRegex: boolean, + caseSensitive: boolean +): RegExp => { + const escapedString = disableRegex + ? searchString.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + : searchString; + return new RegExp(escapedString, caseSensitive ? 'gu' : 'gui'); +}; + +interface ProcessedSearches { + decorationsToReturn: DecorationSet; + results: Range[]; +} + +function processSearches( + doc: PMNode, + searchTerm: RegExp, + selectedResultIndex: number, + searchResultClass: string, + selectedResultClass: string +): ProcessedSearches { + const decorations: Decoration[] = []; + const results: Range[] = []; + const textNodesWithPosition: TextNodeWithPosition[] = []; + + if (!searchTerm) { + return { decorationsToReturn: DecorationSet.empty, results: [] }; + } + + doc.descendants((node, pos) => { + if (node.isText) { + textNodesWithPosition.push({ text: node.text || '', pos }); + } + }); + + for (const { text, pos } of textNodesWithPosition) { + const matches = Array.from(text.matchAll(searchTerm)).filter( + ([matchText]) => matchText.trim() + ); + + for (const match of matches) { + if (match.index !== undefined) { + results.push({ + from: pos + match.index, + to: pos + match.index + match[0].length + }); + } + } + } + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (!result) continue; + const { from, to } = result; + decorations.push( + Decoration.inline(from, to, { + class: + selectedResultIndex === i ? selectedResultClass : searchResultClass + }) + ); + } + + return { + decorationsToReturn: DecorationSet.create(doc, decorations), + results + }; +} + +const replace = ( + replaceTerm: string, + results: Range[], + { state, dispatch }: any +) => { + const firstResult = results[0]; + + if (!firstResult) { + return; + } + + const { from, to } = firstResult; + + if (dispatch) { + dispatch(state.tr.insertText(replaceTerm, from, to)); + } +}; + +const rebaseNextResult = ( + replaceTerm: string, + index: number, + lastOffset: number, + results: Range[] +): [number, Range[]] | null => { + const nextIndex = index + 1; + + if (!results[nextIndex]) { + return null; + } + + const currentResult = results[index]; + if (!currentResult) { + return null; + } + + const { from: currentFrom, to: currentTo } = currentResult; + + const offset = currentTo - currentFrom - replaceTerm.length + lastOffset; + + const { from, to } = results[nextIndex]; + + results[nextIndex] = { + to: to - offset, + from: from - offset + }; + + return [offset, results]; +}; + +const replaceAll = ( + replaceTerm: string, + results: Range[], + { tr, dispatch }: { tr: any; dispatch: any } +) => { + if (!results.length) { + return; + } + + let offset = 0; + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (!result) continue; + const { from, to } = result; + tr.insertText(replaceTerm, from, to); + const rebaseResponse = rebaseNextResult(replaceTerm, i, offset, results); + + if (rebaseResponse) { + offset = rebaseResponse[0]; + } + } + + dispatch(tr); +}; + +const selectNext = (editor: CoreEditor) => { + const { results } = editor.storage + .searchAndReplace as SearchAndReplaceStorage; + + if (!results.length) { + return; + } + + const { selectedResult } = editor.storage.searchAndReplace; + + if (selectedResult >= results.length - 1) { + editor.storage.searchAndReplace.selectedResult = 0; + } else { + editor.storage.searchAndReplace.selectedResult += 1; + } + + const result = results[editor.storage.searchAndReplace.selectedResult]; + if (!result) return; + + const { from } = result; + + const view: EditorView | undefined = editor.view; + + if (view) { + view + .domAtPos(from) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .node.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +}; + +const selectPrevious = (editor: CoreEditor) => { + const { results } = editor.storage.searchAndReplace; + + if (!results.length) { + return; + } + + const { selectedResult } = editor.storage.searchAndReplace; + + if (selectedResult <= 0) { + editor.storage.searchAndReplace.selectedResult = results.length - 1; + } else { + editor.storage.searchAndReplace.selectedResult -= 1; + } + + const { from } = results[editor.storage.searchAndReplace.selectedResult]; + + const view: EditorView | undefined = editor.view; + + if (view) { + view + .domAtPos(from) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .node.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +}; + +export const searchAndReplacePluginKey = new PluginKey( + 'searchAndReplacePlugin' +); + +export interface SearchAndReplaceOptions { + searchResultClass: string; + selectedResultClass: string; + disableRegex: boolean; +} + +export interface SearchAndReplaceStorage { + searchTerm: string; + replaceTerm: string; + results: Range[]; + lastSearchTerm: string; + selectedResult: number; + lastSelectedResult: number; + caseSensitive: boolean; + lastCaseSensitiveState: boolean; +} + +export const SearchAndReplace = Extension.create< + SearchAndReplaceOptions, + SearchAndReplaceStorage +>({ + name: 'searchAndReplace', + + addOptions() { + return { + searchResultClass: ' bg-yellow-200', + selectedResultClass: 'bg-yellow-500', + disableRegex: true + }; + }, + + addStorage() { + return { + searchTerm: '', + replaceTerm: '', + results: [], + lastSearchTerm: '', + selectedResult: 0, + lastSelectedResult: 0, + caseSensitive: false, + lastCaseSensitiveState: false + }; + }, + + addCommands() { + return { + setSearchTerm: + (searchTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.searchTerm = searchTerm; + + return false; + }, + setReplaceTerm: + (replaceTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.replaceTerm = replaceTerm; + + return false; + }, + replace: + () => + ({ editor, state, dispatch }) => { + const { replaceTerm, results } = editor.storage.searchAndReplace; + + replace(replaceTerm, results, { state, dispatch }); + + return false; + }, + replaceAll: + () => + ({ editor, tr, dispatch }) => { + const { replaceTerm, results } = editor.storage.searchAndReplace; + + replaceAll(replaceTerm, results, { tr, dispatch }); + + return false; + }, + selectNextResult: + () => + ({ editor }) => { + selectNext(editor); + + return false; + }, + selectPreviousResult: + () => + ({ editor }) => { + selectPrevious(editor); + + return false; + }, + setCaseSensitive: + (caseSensitive: boolean) => + ({ editor }) => { + editor.storage.searchAndReplace.caseSensitive = caseSensitive; + + return false; + } + }; + }, + + addProseMirrorPlugins() { + const editor = this.editor; + const { searchResultClass, selectedResultClass, disableRegex } = + this.options; + + const setLastSearchTerm = (t: string) => { + editor.storage.searchAndReplace.lastSearchTerm = t; + }; + + const setLastSelectedResult = (r: number) => { + editor.storage.searchAndReplace.lastSelectedResult = r; + }; + + const setLastCaseSensitiveState = (s: boolean) => { + editor.storage.searchAndReplace.lastCaseSensitiveState = s; + }; + + return [ + new Plugin({ + key: searchAndReplacePluginKey, + state: { + init: () => DecorationSet.empty, + apply({ doc, docChanged }, oldState) { + const { + searchTerm, + selectedResult, + lastSearchTerm, + lastSelectedResult, + caseSensitive, + lastCaseSensitiveState + } = editor.storage.searchAndReplace as SearchAndReplaceStorage; + + if ( + !docChanged && + lastSearchTerm === searchTerm && + selectedResult === lastSelectedResult && + lastCaseSensitiveState === caseSensitive + ) { + return oldState; + } + + setLastSearchTerm(searchTerm); + setLastSelectedResult(selectedResult); + setLastCaseSensitiveState(caseSensitive); + + if (!searchTerm) { + editor.storage.searchAndReplace.selectedResult = 0; + editor.storage.searchAndReplace.results = []; + return DecorationSet.empty; + } + + const { decorationsToReturn, results } = processSearches( + doc, + getRegex(searchTerm, disableRegex, caseSensitive), + selectedResult, + searchResultClass, + selectedResultClass + ); + + editor.storage.searchAndReplace.results = results; + + if (selectedResult > results.length) { + editor.storage.searchAndReplace.selectedResult = + results.length > 0 ? results.length : 0; + } + + return decorationsToReturn; + } + }, + props: { + decorations(state) { + return this.getState(state); + } + } + }) + ]; + } +}); + +export default SearchAndReplace; diff --git a/frontend/src/lib/components/tiptap/tiptap.css b/frontend/src/lib/components/tiptap/tiptap.css new file mode 100644 index 00000000..7c09428e --- /dev/null +++ b/frontend/src/lib/components/tiptap/tiptap.css @@ -0,0 +1,441 @@ +:root { + /* Color System */ + --editor-text-default: hsl(240 10% 3.9%); + --editor-text-gray: hsl(240 3.8% 46.1%); + --editor-text-brown: hsl(25 95% 53%); + --editor-text-orange: hsl(24 95% 53%); + --editor-text-yellow: hsl(48 96% 53%); + --editor-text-green: hsl(142 71% 45%); + --editor-text-blue: hsl(221 83% 53%); + --editor-text-purple: hsl(269 97% 85%); + --editor-text-pink: hsl(336 80% 58%); + --editor-text-red: hsl(0 84% 60%); + + /* Background Colors */ + --editor-bg-default: hsl(0 0% 100%); + --editor-bg-subtle: hsl(0 0% 98%); + --editor-bg-muted: hsl(240 5% 96%); + + /* Highlight Colors */ + --editor-highlight-default: hsl(0 0% 98%); + --editor-highlight-gray: hsl(240 5% 96%); + --editor-highlight-brown: hsl(43 96% 96%); + --editor-highlight-orange: hsl(33 100% 96%); + --editor-highlight-yellow: hsl(54 100% 96%); + --editor-highlight-green: hsl(142 71% 96%); + --editor-highlight-blue: hsl(217 91% 96%); + --editor-highlight-purple: hsl(269 97% 96%); + --editor-highlight-pink: hsl(336 80% 96%); + --editor-highlight-red: hsl(0 84% 96%); + + /* Border Colors */ + --editor-border-default: hsl(240 5% 88%); + --editor-border-strong: hsl(240 5% 65%); + + /* Spacing System */ + --editor-spacing-1: 0.25rem; + --editor-spacing-2: 0.5rem; + --editor-spacing-3: 0.75rem; + --editor-spacing-4: 1rem; + --editor-spacing-6: 1.5rem; + --editor-spacing-8: 2rem; + --editor-spacing-12: 3rem; + --editor-spacing-16: 4rem; + + /* Typography */ + --editor-font-sans: + system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, sans-serif; + --editor-font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --editor-font-serif: Georgia, Cambria, 'Times New Roman', Times, serif; + + /* Animation */ + --editor-transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --editor-transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --editor-transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Shadows */ + --editor-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --editor-shadow-md: + 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --editor-shadow-lg: + 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); +} + +/* Dark Mode Custom Properties */ +.dark { + --editor-text-default: hsl(0 0% 98%); + --editor-text-gray: hsl(240 5% 64.9%); + --editor-text-brown: hsl(25 95% 53%); + --editor-text-orange: hsl(24 95% 53%); + --editor-text-yellow: hsl(48 96% 53%); + --editor-text-green: hsl(142 71% 45%); + --editor-text-blue: hsl(221 83% 53%); + --editor-text-purple: hsl(269 97% 85%); + --editor-text-pink: hsl(336 80% 58%); + --editor-text-red: hsl(0 84% 60%); + + --editor-bg-default: hsl(240 10% 3.9%); + --editor-bg-subtle: hsl(240 3.7% 15.9%); + --editor-bg-muted: hsl(240 5% 26%); + + --editor-highlight-default: hsl(240 3.7% 15.9%); + --editor-highlight-gray: hsl(240 5% 26%); + --editor-highlight-brown: hsl(43 96% 10%); + --editor-highlight-orange: hsl(33 100% 10%); + --editor-highlight-yellow: hsl(54 100% 10%); + --editor-highlight-green: hsl(142 71% 10%); + --editor-highlight-blue: hsl(217 91% 10%); + --editor-highlight-purple: hsl(269 97% 10%); + --editor-highlight-pink: hsl(336 80% 10%); + --editor-highlight-red: hsl(0 84% 10%); + + --editor-border-default: hsl(240 5% 26%); + --editor-border-strong: hsl(240 5% 64.9%); +} + +/* Core Editor Styles */ +.ProseMirror { + caret-color: var(--editor-text-default); + outline: none; + padding: var(--editor-spacing-16) var(--editor-spacing-8); + margin: 0 auto; + max-width: 90ch; + font-family: var(--editor-font-sans); + position: relative; + /* background-color: var(--editor-bg-default); */ + color: var(--editor-text-default); + transition: all var(--editor-transition-normal); + min-height: 100vh; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.ProseMirror:focus { + outline: none; + box-shadow: none; +} + +.ProseMirror .selection, +.ProseMirror *::selection { + background-color: var(--editor-highlight-blue); + /* color: var(--editor-text-default); */ +} + +.ProseMirror > .react-renderer { + margin: var(--editor-spacing-12) 0; + transition: all var(--editor-transition-normal); +} + +.ProseMirror > .react-renderer:first-child { + margin-top: 0; +} + +.ProseMirror > .react-renderer:last-child { + margin-bottom: 0; +} + +/* Typography Styles */ +.ProseMirror p { + line-height: 1.75; + margin: var(--editor-spacing-4) 0; + color: var(--editor-text-default); + font-size: 1.125rem; +} + +.ProseMirror > p { + margin: var(--editor-spacing-6) 0; +} + +.ProseMirror h1, +.ProseMirror h2, +.ProseMirror h3, +.ProseMirror h4 { + font-family: var(--editor-font-sans); + font-weight: 700; + letter-spacing: -0.025em; + color: var(--editor-text-default); + scroll-margin-top: var(--editor-spacing-16); + line-height: 1.2; +} + +.ProseMirror h1 { + font-size: 2.5rem; + margin: var(--editor-spacing-8) 0 var(--editor-spacing-4); +} + +.ProseMirror h2 { + font-size: 2rem; + margin: var(--editor-spacing-8) 0 var(--editor-spacing-4); +} + +.ProseMirror h3 { + font-size: 1.5rem; + margin: var(--editor-spacing-6) 0 var(--editor-spacing-3); +} + +.ProseMirror h4 { + font-size: 1.25rem; + margin: var(--editor-spacing-4) 0 var(--editor-spacing-2); +} + +.ProseMirror a { + color: var(--editor-text-blue); + cursor: pointer; + text-decoration: underline; + text-decoration-thickness: 0.1em; + text-underline-offset: 0.2em; + transition: all var(--editor-transition-fast); +} + +.ProseMirror a:hover { + color: var(--editor-text-blue); + text-decoration-thickness: 0.2em; +} + +.ProseMirror code { + font-family: var(--editor-font-mono); + font-size: 0.9em; + background-color: var(--editor-bg-muted); + padding: 0.2em 0.4em; + border-radius: 4px; + color: var(--editor-text-default); + border: 1px solid var(--editor-border-default); +} + +.ProseMirror pre { + margin: var(--editor-spacing-6) 0; + padding: var(--editor-spacing-4); + background-color: var(--editor-bg-subtle); + border-radius: 8px; + overflow-x: auto; + border: 1px solid var(--editor-border-default); +} + +.ProseMirror pre code { + background-color: transparent; + padding: 0; + border: none; + font-size: 0.875rem; + line-height: 1.7; + color: var(--editor-text-default); +} + +.ProseMirror blockquote { + margin: var(--editor-spacing-6) 0; + padding: var(--editor-spacing-4) var(--editor-spacing-6); + border-left: 4px solid var(--editor-border-strong); + font-style: italic; + color: var(--editor-text-gray); + background-color: var(--editor-bg-subtle); + border-radius: 0 8px 8px 0; +} + +/* Lists */ +.ProseMirror ul, +.ProseMirror ol { + margin: var(--editor-spacing-4) 0; + padding-left: var(--editor-spacing-6); +} + +.ProseMirror li { + margin: var(--editor-spacing-2) 0; + padding-left: var(--editor-spacing-2); +} + +.ProseMirror ul { + list-style-type: disc; +} + +.ProseMirror ul ul { + list-style-type: circle; +} + +.ProseMirror ul ul ul { + list-style-type: square; +} + +.ProseMirror ol { + list-style-type: decimal; +} + +.ProseMirror ol ol { + list-style-type: lower-alpha; +} + +.ProseMirror ol ol ol { + list-style-type: lower-roman; +} + +/* Tables */ +.ProseMirror table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + margin: var(--editor-spacing-6) 0; + border: 1px solid var(--editor-border-default); + border-radius: 8px; + overflow: hidden; +} + +.ProseMirror th { + background-color: var(--editor-bg-subtle); + font-weight: 600; + text-align: left; + padding: var(--editor-spacing-3) var(--editor-spacing-4); + border-bottom: 2px solid var(--editor-border-default); +} + +.ProseMirror td { + padding: var(--editor-spacing-3) var(--editor-spacing-4); + border-bottom: 1px solid var(--editor-border-default); + transition: background-color var(--editor-transition-fast); +} + +.ProseMirror tr:last-child td { + border-bottom: none; +} + +.ProseMirror tr:hover td { + background-color: var(--editor-bg-subtle); +} + +/* Images */ +.ProseMirror img { + max-width: 100%; + height: auto; + border-radius: 8px; + border: 1px solid var(--editor-border-default); + box-shadow: var(--editor-shadow-sm); + transition: all var(--editor-transition-normal); + display: block; + margin: var(--editor-spacing-1) auto; +} + +.ProseMirror img:hover { + box-shadow: var(--editor-shadow-lg); + transform: translateY(-2px); +} + +/* Horizontal Rule */ +.ProseMirror hr { + margin: var(--editor-spacing-8) 0; + border: none; + border-top: 2px solid var(--editor-border-default); +} + +/* Floating Menu & Toolbar */ +.floating-menu { + background-color: var(--editor-bg-default); + border: 1px solid var(--editor-border-default); + box-shadow: var(--editor-shadow-lg); + border-radius: 8px; + padding: var(--editor-spacing-1); + display: flex; + gap: var(--editor-spacing-1); + align-items: center; + animation: fadeIn var(--editor-transition-normal); + backdrop-filter: blur(8px); +} + +.toolbar-button { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + height: 2.25rem; + padding: 0 var(--editor-spacing-3); + transition: all var(--editor-transition-fast); + background-color: transparent; + color: var(--editor-text-default); + border: 1px solid transparent; +} + +.toolbar-button:hover { + background-color: var(--editor-bg-subtle); + color: var(--editor-text-default); +} + +.toolbar-button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--editor-border-strong); +} + +.toolbar-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.toolbar-button.active { + background-color: var(--editor-bg-muted); + color: var(--editor-text-blue); +} + +/* Placeholder Styles +.ProseMirror p.is-editor-empty:first-child::before { + content: "Start writing or press '/' for commands..."; + color: var(--editor-text-gray); + pointer-events: none; + float: left; + height: 0; +} */ + +/* Mobile Optimizations */ +@media (max-width: 640px) { + .ProseMirror { + padding: var(--editor-spacing-8) var(--editor-spacing-4); + } + + .ProseMirror h1 { + font-size: 2rem; + } + .ProseMirror h2 { + font-size: 1.75rem; + } + .ProseMirror h3 { + font-size: 1.5rem; + } + .ProseMirror h4 { + font-size: 1.25rem; + } + .ProseMirror p { + font-size: 1rem; + } +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Print Styles */ +@media print { + .ProseMirror { + padding: 0; + max-width: none; + } + + .floating-menu, + .toolbar-button { + display: none; + } +} + +.is-editor-empty::before { + color: var(--editor-text-gray); + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} diff --git a/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte new file mode 100644 index 00000000..6f3c3573 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte @@ -0,0 +1,67 @@ + + + diff --git a/frontend/src/lib/components/tiptap/toolbar/MobileToolbarItem.svelte b/frontend/src/lib/components/tiptap/toolbar/MobileToolbarItem.svelte new file mode 100644 index 00000000..4c5ac4b8 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/MobileToolbarItem.svelte @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/lib/components/tiptap/toolbar/alignment.svelte b/frontend/src/lib/components/tiptap/toolbar/alignment.svelte new file mode 100644 index 00000000..7e7f723e --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/alignment.svelte @@ -0,0 +1,118 @@ + + +{#if isMobile.current} + + {#snippet children({ closeDrawer })} + {#each alignmentOptions as option, index (index)} + {@const OptionIcon = option.icon} + handleAlign(option.value)} + active={currentTextAlign === option.value} + > + + + + {option.name} + + {/each} + {/snippet} + +{:else} + + + + {#snippet child({ props })} + {@const CurrentIcon = currentOption.icon} + + {#snippet child({ props: triggerProps })} + + {/snippet} + + {/snippet} + + Text Alignment + + e.preventDefault()}> + + {#each alignmentOptions as option, index (index)} + {@const OptionIcon = option.icon} + handleAlign(option.value)}> + + + + {option.name} + {#if option.value === currentTextAlign} + + {/if} + + {/each} + + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte b/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte new file mode 100644 index 00000000..7304322f --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte @@ -0,0 +1,54 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Blockquote + + diff --git a/frontend/src/lib/components/tiptap/toolbar/bold.svelte b/frontend/src/lib/components/tiptap/toolbar/bold.svelte new file mode 100644 index 00000000..2dc5c8b7 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/bold.svelte @@ -0,0 +1,53 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Bold + (cmd + b) + + diff --git a/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte b/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte new file mode 100644 index 00000000..000e19ba --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte @@ -0,0 +1,48 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Bullet list + + diff --git a/frontend/src/lib/components/tiptap/toolbar/code-block.svelte b/frontend/src/lib/components/tiptap/toolbar/code-block.svelte new file mode 100644 index 00000000..d7bd5317 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/code-block.svelte @@ -0,0 +1,48 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Code Block + + diff --git a/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte b/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte new file mode 100644 index 00000000..9f48252d --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte @@ -0,0 +1,202 @@ + + +{#if isMobile.current} +
+ + {#snippet children({ closeDrawer })} + {#each TEXT_COLORS as { name, color } (name)} + handleSetColor(color)} + active={currentColor === color} + > +
+
A
+ {name} +
+
+ {/each} + {/snippet} +
+ + + {#snippet children({ closeDrawer })} + {#each HIGHLIGHT_COLORS as { name, color } (name)} + handleSetHighlight(color)} + active={currentHighlight === color} + > +
+
+ A +
+ {name} +
+
+ {/each} + {/snippet} +
+
+{:else} + +
+ + + {#snippet child({ props })} + + {#snippet child({ props: triggerProps })} + + {/snippet} + + {/snippet} + + Text Color & Highlight + + + + +
Color
+ {#each TEXT_COLORS as { name, color } (name)} + + {/each} + + + +
+ Background +
+ {#each HIGHLIGHT_COLORS as { name, color } (name)} + + {/each} +
+
+
+
+{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/hard-break.svelte b/frontend/src/lib/components/tiptap/toolbar/hard-break.svelte new file mode 100644 index 00000000..cc9cc7fd --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/hard-break.svelte @@ -0,0 +1,38 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Hard break + + diff --git a/frontend/src/lib/components/tiptap/toolbar/headings.svelte b/frontend/src/lib/components/tiptap/toolbar/headings.svelte new file mode 100644 index 00000000..e9576ac0 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/headings.svelte @@ -0,0 +1,109 @@ + + +{#if isMobile.current} + + {#snippet children({ closeDrawer })} + editor.chain().focus().setParagraph().run()} + active={!editor.isActive('heading')} + > + Normal + + {#each levels as level (level)} + editor.chain().focus().toggleHeading({ level }).run()} + active={editor.isActive('heading', { level })} + > + H{level} + + {/each} + {/snippet} + +{:else} + + + {#snippet child({ props })} + + + {#snippet child({ props: triggerProps })} + + {/snippet} + + + editor.chain().focus().setParagraph().run()} + class={cn( + 'flex h-fit items-center gap-2', + !isHeadingActive && 'bg-accent' + )} + > + Normal + + {#each levels as level (level)} + + editor.chain().focus().toggleHeading({ level }).run()} + class={cn( + 'flex items-center gap-2', + editor.isActive('heading', { level }) && 'bg-accent' + )} + > + H{level} + + {/each} + + + {/snippet} + + + Headings + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/italic.svelte b/frontend/src/lib/components/tiptap/toolbar/italic.svelte new file mode 100644 index 00000000..59727352 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/italic.svelte @@ -0,0 +1,38 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Italic + (cmd + i) + + diff --git a/frontend/src/lib/components/tiptap/toolbar/link.svelte b/frontend/src/lib/components/tiptap/toolbar/link.svelte new file mode 100644 index 00000000..5d08a068 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/link.svelte @@ -0,0 +1,137 @@ + + + + + + {#snippet child({ props })} + + {#snippet child({ props: triggerProps })} + + {/snippet} + + {/snippet} + + + Link + + + + e.preventDefault()} + class="relative px-3 py-2.5" + > +
+ + + +
+ +

Attach a link to the selected text

+
+ +
+ {#if linkHref} + + {/if} + +
+
+
+
+
+
diff --git a/frontend/src/lib/components/tiptap/toolbar/mobile-toolbar-group.svelte b/frontend/src/lib/components/tiptap/toolbar/mobile-toolbar-group.svelte new file mode 100644 index 00000000..24bee7fc --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/mobile-toolbar-group.svelte @@ -0,0 +1,63 @@ + + + + + + + {#snippet child({ props })} + + {/snippet} + + + + {label} + +
+ {@render children({ closeDrawer })} +
+
+
diff --git a/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte b/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte new file mode 100644 index 00000000..27b61c5d --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte @@ -0,0 +1,37 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Ordered list + + diff --git a/frontend/src/lib/components/tiptap/toolbar/redo.svelte b/frontend/src/lib/components/tiptap/toolbar/redo.svelte new file mode 100644 index 00000000..301f7c9e --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/redo.svelte @@ -0,0 +1,36 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Redo + + diff --git a/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte new file mode 100644 index 00000000..4f63e0ef --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte @@ -0,0 +1,233 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + e.preventDefault()} + class="relative flex w-[400px] px-3 py-2.5" + > + {#if !replacing} +
+ handleSearchInput(e.currentTarget.value)} + class="w-48" + placeholder="Search..." + /> + + {results.length === 0 ? selectedResult : selectedResult + 1}/{results.length} + + + + + + +
+ {:else} +
+ +
+ +

Search and replace

+
+ +
+
+ + handleSearchInput(e.currentTarget.value)} + placeholder="Search..." + /> + {results.length === 0 ? selectedResult : selectedResult + 1}/{results.length} +
+
+ + handleReplaceInput(e.currentTarget.value)} + class="w-full" + placeholder="Replace..." + /> +
+
+ handleCaseSensitiveChange(value === true)} + id="match_case" + /> + +
+
+ +
+
+ + +
+ +
+ + +
+
+
+ {/if} +
+
diff --git a/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte b/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte new file mode 100644 index 00000000..3dab1068 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte @@ -0,0 +1,38 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Strikethrough + (cmd + shift + x) + + diff --git a/frontend/src/lib/components/tiptap/toolbar/underline.svelte b/frontend/src/lib/components/tiptap/toolbar/underline.svelte new file mode 100644 index 00000000..a910516e --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/underline.svelte @@ -0,0 +1,38 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Underline + (cmd + u) + + diff --git a/frontend/src/lib/components/tiptap/toolbar/undo.svelte b/frontend/src/lib/components/tiptap/toolbar/undo.svelte new file mode 100644 index 00000000..72722e88 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/undo.svelte @@ -0,0 +1,36 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Undo + + diff --git a/frontend/src/routes/notes/+page.svelte b/frontend/src/routes/notes/+page.svelte new file mode 100644 index 00000000..a89a0bbb --- /dev/null +++ b/frontend/src/routes/notes/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/package-lock.json b/package-lock.json index d1812da1..69bd3754 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,17 @@ "@profidev/pleiades": "^1.9.6", "@simplewebauthn/browser": "^13.3.0", "@sveltejs/enhanced-img": "0.10.4", + "@tiptap/core": "^3.26.0", + "@tiptap/extension-bubble-menu": "^3.26.0", + "@tiptap/extension-emoji": "^3.26.0", + "@tiptap/extension-highlight": "^3.26.0", + "@tiptap/extension-list": "^3.26.0", + "@tiptap/extension-text-align": "^3.26.0", + "@tiptap/extension-text-style": "^3.26.0", + "@tiptap/extension-typography": "^3.26.0", + "@tiptap/extensions": "^3.26.0", + "@tiptap/pm": "^3.26.0", + "@tiptap/starter-kit": "^3.26.0", "jsencrypt": "3.5.4", "qrcode": "^1.5.4", "valibot": "^1.2.0" @@ -2153,8 +2164,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.61.1", @@ -2167,8 +2177,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.61.1", @@ -2181,8 +2190,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.61.1", @@ -2195,8 +2203,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.61.1", @@ -2209,8 +2216,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.61.1", @@ -2223,8 +2229,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.61.1", @@ -2237,8 +2242,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.61.1", @@ -2251,8 +2255,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.61.1", @@ -2265,8 +2268,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.61.1", @@ -2279,8 +2281,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.61.1", @@ -2293,8 +2294,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.61.1", @@ -2307,8 +2307,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.61.1", @@ -2321,8 +2320,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.61.1", @@ -2335,8 +2333,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.61.1", @@ -2349,8 +2346,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.61.1", @@ -2363,8 +2359,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.61.1", @@ -2377,8 +2372,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.61.1", @@ -2391,8 +2385,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.61.1", @@ -2405,8 +2398,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.61.1", @@ -2419,8 +2411,7 @@ "optional": true, "os": [ "openbsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.61.1", @@ -2433,8 +2424,7 @@ "optional": true, "os": [ "openharmony" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.61.1", @@ -2447,8 +2437,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.61.1", @@ -2461,8 +2450,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.61.1", @@ -2475,8 +2463,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.61.1", @@ -2489,8 +2476,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@shikijs/core": { "version": "4.2.0", @@ -3362,6 +3348,484 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tiptap/core": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.26.0.tgz", + "integrity": "sha512-7jTed/RirIVsp+lLdLvGzGqF3EBGpnGHGYKOwz6t28V2BIJLAFdUhfEVdWie7xPxQNWK0TP+fPlsqZS0vxfHBg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.26.0.tgz", + "integrity": "sha512-57accpka9affjiJRjP2LMNCDJDTMjTvO23RJCxtP43sp9cTIZ7YZnyDfRxCINTRBNK0X4o4w2+emOLyRwsk3CA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.26.0.tgz", + "integrity": "sha512-j6CzTMofcGJ5iMoUgDRQpM0FkG00jBID3aKqs+UBbgtzLgtG/CI/91tMFv0XPC30LeFA895qYgvGZtHdejZhiQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.26.0.tgz", + "integrity": "sha512-H2E3Hp0lV79jQV8YGtdDJkXkUalXZeYzKCx+vCZlDpb2ChS7/rNT9YY7poRA1NlJLUO0DH1wbAnFhx9KZMUx5g==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.26.0.tgz", + "integrity": "sha512-Jv7BX+kBB2wUIvO/NhuUjv+T3kAed2Tjr664fgQ2zKT6X69jKIkYuCCedrIHuOyaOQ+SBDuH9h51wYv/E97QgQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.26.0.tgz", + "integrity": "sha512-VJYcV6rvjnENRTroOi9tDcHWW6G0pmCoRETwatlbgfDzuCmkTOwVwQjeJCXOVMMLNPzNiXZzibsRCUt+Azq/jw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.26.0.tgz", + "integrity": "sha512-WPN9iZ3UjeDD2ckDzSs9tleibXv0cLj7j575NxuvjhwZTehYGNeYDSUTi+6DQUG6bKbhGg9Wcei5H0131vvJHg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.26.0.tgz", + "integrity": "sha512-Xhd6DCjaxCN4otQNvV6qra+XuoIjk6Vyjm87E5xn5Y/BMw7UGAG7LTkk3C2IEvxKrVZwJjalfxEqdHOgXQzVfw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.26.0.tgz", + "integrity": "sha512-rhAtp5J/YVDUCUIc5T7b0XY9dLeuI72JgOr53w0QQc0VA0uwbfTn7sx0LI9PDCE9uwmDH8H3snVRZRnAvlM8oA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.26.0" + } + }, + "node_modules/@tiptap/extension-emoji": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-emoji/-/extension-emoji-3.26.0.tgz", + "integrity": "sha512-2AGUh7fdX1gwWiwd+wBSHadLMxLB6fjtrFwqTPiFoaavmrc/nKgYSvuJ3fo1Hs76gzJ5IjriGrPC89/Ok4MDVA==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.6.0", + "emojibase-data": "^17", + "is-emoji-supported": "^0.0.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0", + "@tiptap/suggestion": "3.26.0" + } + }, + "node_modules/@tiptap/extension-emoji/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.26.0.tgz", + "integrity": "sha512-SIe68SDwx2fozt/XKG0FhCwzz/yRN6Bvo4D5TqvfDg6NK3PQb1DS4GN9PilmJqbY+kXryuiWEEJOWi7HpO8SuQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.26.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.26.0.tgz", + "integrity": "sha512-baXvv/rtOTVd2Axjb7Zbb41Y9Qmy3U2fP7EHqLuhViqGxVX8LwQtP0PHUXEZkPokbBpRez10+dmOlvvsYFKAZQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.26.0.tgz", + "integrity": "sha512-qenEQEgzE5FjQay/H6iKOnwIt6DPO27cS+v0mGhXmrL1MjrNER4X0ZkATJbVd0WA6ffsAGaP44NKYDworGeidw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-highlight": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.26.0.tgz", + "integrity": "sha512-/b6pImBTdgOc8UVMJiJNQ/QAtZ0Z4+2XyxmzoNIiOPGjQDy/IxLtci8+M2BYWRIRbgjCS9XllsfnKWVyO8pe+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.26.0.tgz", + "integrity": "sha512-a+N/C4wkQV+/8x4ShdoiC2JdTW3Tw84C5cAloYLFMeaWmRa2me9ACSI+zo0SO9bbH9RJwsoRp7eaxBbk27eF1Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.26.0.tgz", + "integrity": "sha512-s8oFpH+0xmhvY19f452/2dExO3p1tjxh761g6cg4irwEUNUEAJKF2VLcjiaeOhNJ+pmnQYxb+VSkwkXvO+7vHQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.26.0.tgz", + "integrity": "sha512-FA/d157aBxyvZFvsdc5eSu46tmHWXebAsqOQSvivOMyw+deBb00VlMsf+iD2J8+sekjbMYwx/hvbsu+xUoX43Q==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.26.0.tgz", + "integrity": "sha512-EM8woyHDNKLEQ+lWUEoDtA4KrwP6fei/mYX1NxseMzKHHo7LFecx7wk6sovAXZrUvdML/yFBihgiMiO5VIsfkg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.26.0.tgz", + "integrity": "sha512-MccGyj9HY4fkl04eIiFoTCkr8067Jku/VVdJNtRWW104Spx43C/7V2zpbxPvpcDhq3dW384fDxYXfpnb186xLg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.0" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.26.0.tgz", + "integrity": "sha512-oBcj6qaNrRHQ+N0+pDuOVAQa4Nx9r8Cm5ANvyM2lTpoy60sOLOizuVvcvw1andVxbSrsZ1N/Sk+RZWyv1uoWyQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.26.0.tgz", + "integrity": "sha512-ItLdFlcMsJz2vhbs1PcUfcN7nzVqGBOwPeCrrWxjrgscp+K3JoOGD+HhVVpBACOMwivUrlh8Ry5Ohvues2nOeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.26.0.tgz", + "integrity": "sha512-h8fYLikg4qN39IghQ1y9g+zzUsgxBpDi5YS3IZbWoxWYYx1YqLL8nAvOiPr7Us14aQ0TjA2/xY7zqmyf29rX1A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.26.0.tgz", + "integrity": "sha512-jUll3Pqhq7u1JKvO0B6USW/bmVmUsO6sRcxo/d5tXqLhS0tWAobOGoGU2IgwXnQDSjf+vF73RYD5tRGDLkRC9Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.26.0.tgz", + "integrity": "sha512-yZXdevp3/8omGbb40Z52VfvID+tsRNhPQ1GNUToD56XSr2BjdJyAzAb9rWGgDKgVMUPLgJ26yT0O278RFqOKhA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-text-align": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.26.0.tgz", + "integrity": "sha512-yEtgrEJyE7sfcIAzk9cmJUQUMQZ/J0RU3k7mE+hy5o7t8j78Zs+KcsH+hrczn0MNzblg4gfX2erN+1/SGfSlpA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.26.0.tgz", + "integrity": "sha512-4IeIdiubF5/Em9AodaLkCKUNwkQaK3KuwnneXS61x4Yoyr0zK21i3kZAFK7ils7jUwSrRdGdM1FglBnB4tK8nA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-typography": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-3.26.0.tgz", + "integrity": "sha512-dNOPmVPCcC57SpTDIxSlrSSNZt4zVG0eNCQHCLyTGh3/1y9TCjdJFSEy9uffQdgfv05tjIwRakGmctENlX3SxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.26.0.tgz", + "integrity": "sha512-LlVkivH5cBwov/EMD8BL7ZRcU6YcadiSVIffLW1hyalw9YfhaFzoLxjtWhL7jiU/n2Kg+9dXSZxmV2hTeTwyrQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.26.0.tgz", + "integrity": "sha512-4wajuqnO2X0+LVvsBjW/xk3/tmdb16bNL939QhicAay4YYqXITeV2v3XJsryzmG4L5GkK1yLxvRGk4aLoxWrnA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.26.0.tgz", + "integrity": "sha512-q4RDeWwVrhOL0jJCGRgGxLSdjOYwzQ4h2InURZVhC66433ipcHd6f3bqSOhcXZ4r0sFmMNsuF7aZmUntjWLc7w==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.7", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.0", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.26.0.tgz", + "integrity": "sha512-o34EtMfqtBaljdmeElZsRG/067oGx9Zcq+j2GWo71KlZe22ga/ALexeTf1c+ETsjCxSTKR6eyQ4RZvz/2JpYfg==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.26.0", + "@tiptap/extension-blockquote": "^3.26.0", + "@tiptap/extension-bold": "^3.26.0", + "@tiptap/extension-bullet-list": "^3.26.0", + "@tiptap/extension-code": "^3.26.0", + "@tiptap/extension-code-block": "^3.26.0", + "@tiptap/extension-document": "^3.26.0", + "@tiptap/extension-dropcursor": "^3.26.0", + "@tiptap/extension-gapcursor": "^3.26.0", + "@tiptap/extension-hard-break": "^3.26.0", + "@tiptap/extension-heading": "^3.26.0", + "@tiptap/extension-horizontal-rule": "^3.26.0", + "@tiptap/extension-italic": "^3.26.0", + "@tiptap/extension-link": "^3.26.0", + "@tiptap/extension-list": "^3.26.0", + "@tiptap/extension-list-item": "^3.26.0", + "@tiptap/extension-list-keymap": "^3.26.0", + "@tiptap/extension-ordered-list": "^3.26.0", + "@tiptap/extension-paragraph": "^3.26.0", + "@tiptap/extension-strike": "^3.26.0", + "@tiptap/extension-text": "^3.26.0", + "@tiptap/extension-underline": "^3.26.0", + "@tiptap/extensions": "^3.26.0", + "@tiptap/pm": "^3.26.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/suggestion": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.26.0.tgz", + "integrity": "sha512-3jxBvjmfooQroR0eCw61kSgr+g90KFVD3dM5bANhpQbCpCYRydp/iXDQmpoPHjv8FpeU5JZgcnJ3R9vhjeIM2A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -4497,6 +4961,33 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/emojibase": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/emojibase/-/emojibase-17.0.0.tgz", + "integrity": "sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + } + }, + "node_modules/emojibase-data": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-17.0.0.tgz", + "integrity": "sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==", + "license": "MIT", + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + }, + "peerDependencies": { + "emojibase": "*" + } + }, "node_modules/enhanced-resolve": { "version": "5.23.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", @@ -4850,6 +5341,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-emoji-supported": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/is-emoji-supported/-/is-emoji-supported-0.0.5.tgz", + "integrity": "sha512-WOlXUhDDHxYqcSmFZis+xWhhqXiK2SU0iYiqmth5Ip0FHLZQAt9rKL5ahnilE8/86WH8tZ3bmNNNC+bTzamqlw==", + "license": "MIT" + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -5290,6 +5787,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -5656,6 +6159,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/oxfmt": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.53.0.tgz", @@ -6132,6 +6641,145 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.7", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz", + "integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -6346,6 +6994,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -7404,6 +8058,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 85209852..6747e5cb 100644 --- a/package.json +++ b/package.json @@ -9,5 +9,8 @@ "oxlint-tsgolint": "0.23.0", "prettier-plugin-svelte": "4.1.0", "prettier-plugin-tailwindcss": "0.8.0" + }, + "allowScripts": { + "sharp": true } } From d3831a6eccad2b1eb4afea35ac97628d8eea21c6 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:43:33 +0200 Subject: [PATCH 03/41] fix: type errors --- frontend/src/lib/components/tiptap/config.ts | 34 ++-- .../tiptap/extensions/search-and-replace.ts | 171 +++++++++--------- .../toolbar/search-and-replace-toolbar.svelte | 13 +- frontend/src/routes/notes/+page.svelte | 2 +- 4 files changed, 111 insertions(+), 109 deletions(-) diff --git a/frontend/src/lib/components/tiptap/config.ts b/frontend/src/lib/components/tiptap/config.ts index c7c1c564..664f0a0f 100644 --- a/frontend/src/lib/components/tiptap/config.ts +++ b/frontend/src/lib/components/tiptap/config.ts @@ -8,16 +8,11 @@ import { Highlight } from '@tiptap/extension-highlight'; import Typography from '@tiptap/extension-typography'; import StarterKit from '@tiptap/starter-kit'; -import type { Extension } from '@tiptap/core'; +import type { Extensions } from '@tiptap/core'; import SearchAndReplace from './extensions/search-and-replace'; -export const extensions: Extension[] = [ +export const extensions = [ StarterKit.configure({ - orderedList: { - HTMLAttributes: { - class: 'list-decimal' - } - }, bulletList: { HTMLAttributes: { class: 'list-disc' @@ -25,24 +20,33 @@ export const extensions: Extension[] = [ }, heading: { levels: [1, 2, 3, 4] + }, + orderedList: { + HTMLAttributes: { + class: 'list-decimal' + } } }), Placeholder.configure({ emptyNodeClass: 'is-editor-empty', + includeChildren: false, placeholder: ({ node }) => { switch (node.type.name) { - case 'heading': + case 'heading': { return `Heading ${node.attrs.level}`; - case 'detailsSummary': + } + case 'detailsSummary': { return 'Section title'; - case 'codeBlock': - // never show the placeholder when editing code + } + case 'codeBlock': { + // Never show the placeholder when editing code return ''; - default: + } + default: { return 'Write something...'; + } } - }, - includeChildren: false + } }), TextAlign.configure({ types: ['heading', 'paragraph'] @@ -58,4 +62,4 @@ export const extensions: Extension[] = [ TaskItem, TaskList, BubbleMenu -] as Extension[]; +] satisfies Extensions; diff --git a/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts b/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts index 7748c488..c1125dbb 100644 --- a/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts +++ b/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts @@ -1,10 +1,24 @@ -// @ts-nocheck import { type Editor as CoreEditor, Extension, type Range } from '@tiptap/core'; import type { Node as PMNode } from '@tiptap/pm/model'; import { Plugin, PluginKey } from '@tiptap/pm/state'; -import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; + +export interface SearchAndReplaceStorage { + searchTerm: string; + replaceTerm: string; + results: Range[]; + lastSearchTerm: string; + selectedResult: number; + lastSelectedResult: number; + caseSensitive: boolean; + lastCaseSensitiveState: boolean; +} declare module '@tiptap/core' { + interface Storage { + searchAndReplace: SearchAndReplaceStorage; + } + interface Commands { search: { /** @@ -50,7 +64,7 @@ const getRegex = ( caseSensitive: boolean ): RegExp => { const escapedString = disableRegex - ? searchString.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + ? searchString.replace(/[-/\\^$*+?.()|[\]{}]/g, String.raw`\$&`) : searchString; return new RegExp(escapedString, caseSensitive ? 'gu' : 'gui'); }; @@ -60,13 +74,13 @@ interface ProcessedSearches { results: Range[]; } -function processSearches( +const processSearches = ( doc: PMNode, searchTerm: RegExp, selectedResultIndex: number, searchResultClass: string, selectedResultClass: string -): ProcessedSearches { +): ProcessedSearches => { const decorations: Decoration[] = []; const results: Range[] = []; const textNodesWithPosition: TextNodeWithPosition[] = []; @@ -77,12 +91,12 @@ function processSearches( doc.descendants((node, pos) => { if (node.isText) { - textNodesWithPosition.push({ text: node.text || '', pos }); + textNodesWithPosition.push({ pos, text: node.text || '' }); } }); for (const { text, pos } of textNodesWithPosition) { - const matches = Array.from(text.matchAll(searchTerm)).filter( + const matches = [...text.matchAll(searchTerm)].filter( ([matchText]) => matchText.trim() ); @@ -96,9 +110,9 @@ function processSearches( } } - for (let i = 0; i < results.length; i++) { + for (let i = 0; i < results.length; i += 1) { const result = results[i]; - if (!result) continue; + if (!result) {continue;} const { from, to } = result; decorations.push( Decoration.inline(from, to, { @@ -112,14 +126,14 @@ function processSearches( decorationsToReturn: DecorationSet.create(doc, decorations), results }; -} +}; const replace = ( replaceTerm: string, results: Range[], { state, dispatch }: any ) => { - const firstResult = results[0]; + const [firstResult] = results; if (!firstResult) { return; @@ -137,16 +151,16 @@ const rebaseNextResult = ( index: number, lastOffset: number, results: Range[] -): [number, Range[]] | null => { +): [number, Range[]] | undefined => { const nextIndex = index + 1; if (!results[nextIndex]) { - return null; + return undefined; } const currentResult = results[index]; if (!currentResult) { - return null; + return undefined; } const { from: currentFrom, to: currentTo } = currentResult; @@ -156,8 +170,8 @@ const rebaseNextResult = ( const { from, to } = results[nextIndex]; results[nextIndex] = { - to: to - offset, - from: from - offset + from: from - offset, + to: to - offset }; return [offset, results]; @@ -174,15 +188,16 @@ const replaceAll = ( let offset = 0; - for (let i = 0; i < results.length; i++) { + for (let i = 0; i < results.length; i += 1) { const result = results[i]; - if (!result) continue; + if (!result) {continue;} const { from, to } = result; tr.insertText(replaceTerm, from, to); const rebaseResponse = rebaseNextResult(replaceTerm, i, offset, results); if (rebaseResponse) { - offset = rebaseResponse[0]; + const [nextOffset] = rebaseResponse; + offset = nextOffset; } } @@ -190,8 +205,7 @@ const replaceAll = ( }; const selectNext = (editor: CoreEditor) => { - const { results } = editor.storage - .searchAndReplace as SearchAndReplaceStorage; + const { results } = editor.storage.searchAndReplace; if (!results.length) { return; @@ -206,11 +220,11 @@ const selectNext = (editor: CoreEditor) => { } const result = results[editor.storage.searchAndReplace.selectedResult]; - if (!result) return; + if (!result) {return;} const { from } = result; - const view: EditorView | undefined = editor.view; + const {view} = editor; if (view) { view @@ -238,7 +252,7 @@ const selectPrevious = (editor: CoreEditor) => { const { from } = results[editor.storage.searchAndReplace.selectedResult]; - const view: EditorView | undefined = editor.view; + const {view} = editor; if (view) { view @@ -259,66 +273,18 @@ export interface SearchAndReplaceOptions { disableRegex: boolean; } -export interface SearchAndReplaceStorage { - searchTerm: string; - replaceTerm: string; - results: Range[]; - lastSearchTerm: string; - selectedResult: number; - lastSelectedResult: number; - caseSensitive: boolean; - lastCaseSensitiveState: boolean; -} - export const SearchAndReplace = Extension.create< SearchAndReplaceOptions, SearchAndReplaceStorage >({ - name: 'searchAndReplace', - - addOptions() { - return { - searchResultClass: ' bg-yellow-200', - selectedResultClass: 'bg-yellow-500', - disableRegex: true - }; - }, - - addStorage() { - return { - searchTerm: '', - replaceTerm: '', - results: [], - lastSearchTerm: '', - selectedResult: 0, - lastSelectedResult: 0, - caseSensitive: false, - lastCaseSensitiveState: false - }; - }, - addCommands() { return { - setSearchTerm: - (searchTerm: string) => - ({ editor }) => { - editor.storage.searchAndReplace.searchTerm = searchTerm; - - return false; - }, - setReplaceTerm: - (replaceTerm: string) => - ({ editor }) => { - editor.storage.searchAndReplace.replaceTerm = replaceTerm; - - return false; - }, replace: () => ({ editor, state, dispatch }) => { const { replaceTerm, results } = editor.storage.searchAndReplace; - replace(replaceTerm, results, { state, dispatch }); + replace(replaceTerm, results, { dispatch, state }); return false; }, @@ -327,7 +293,7 @@ export const SearchAndReplace = Extension.create< ({ editor, tr, dispatch }) => { const { replaceTerm, results } = editor.storage.searchAndReplace; - replaceAll(replaceTerm, results, { tr, dispatch }); + replaceAll(replaceTerm, results, { dispatch, tr }); return false; }, @@ -350,13 +316,35 @@ export const SearchAndReplace = Extension.create< ({ editor }) => { editor.storage.searchAndReplace.caseSensitive = caseSensitive; + return false; + }, + setReplaceTerm: + (replaceTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.replaceTerm = replaceTerm; + + return false; + }, + setSearchTerm: + (searchTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.searchTerm = searchTerm; + return false; } }; }, + addOptions() { + return { + disableRegex: true, + searchResultClass: ' bg-yellow-200', + selectedResultClass: 'bg-yellow-500' + }; + }, + addProseMirrorPlugins() { - const editor = this.editor; + const {editor} = this; const { searchResultClass, selectedResultClass, disableRegex } = this.options; @@ -375,8 +363,12 @@ export const SearchAndReplace = Extension.create< return [ new Plugin({ key: searchAndReplacePluginKey, + props: { + decorations(state) { + return this.getState(state); + } + }, state: { - init: () => DecorationSet.empty, apply({ doc, docChanged }, oldState) { const { searchTerm, @@ -385,7 +377,7 @@ export const SearchAndReplace = Extension.create< lastSelectedResult, caseSensitive, lastCaseSensitiveState - } = editor.storage.searchAndReplace as SearchAndReplaceStorage; + } = editor.storage.searchAndReplace; if ( !docChanged && @@ -422,16 +414,27 @@ export const SearchAndReplace = Extension.create< } return decorationsToReturn; - } - }, - props: { - decorations(state) { - return this.getState(state); - } + }, + init: () => DecorationSet.empty } }) ]; - } + }, + + addStorage() { + return { + caseSensitive: false, + lastCaseSensitiveState: false, + lastSearchTerm: '', + lastSelectedResult: 0, + replaceTerm: '', + results: [], + searchTerm: '', + selectedResult: 0 + }; + }, + + name: 'searchAndReplace' }); export default SearchAndReplace; diff --git a/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte index 4f63e0ef..5e421e7f 100644 --- a/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte @@ -3,7 +3,6 @@ import ArrowRightIcon from '@lucide/svelte/icons/arrow-right'; import RepeatIcon from '@lucide/svelte/icons/repeat'; import XIcon from '@lucide/svelte/icons/x'; - import type { SearchAndReplaceStorage } from '$lib/components/tiptap/extensions/search-and-replace.js'; import { Button } from '@profidev/pleiades/components/ui/button'; import { Checkbox } from '@profidev/pleiades/components/ui/checkbox'; import { Input } from '@profidev/pleiades/components/ui/input'; @@ -25,12 +24,8 @@ let replaceText = $state(''); let checked = $state(false); - const results = $derived( - (editor.storage.searchAndReplace as SearchAndReplaceStorage | undefined)?.results ?? [] - ); - const selectedResult = $derived( - (editor.storage.searchAndReplace as SearchAndReplaceStorage | undefined)?.selectedResult ?? 0 - ); + const results = $derived(editor.storage.searchAndReplace.results); + const selectedResult = $derived(editor.storage.searchAndReplace.selectedResult); function refreshSearchDecorations() { const { state, view } = editor; @@ -38,7 +33,7 @@ } function syncSearchToEditor() { - const storage = editor.storage.searchAndReplace as SearchAndReplaceStorage; + const storage = editor.storage.searchAndReplace; storage.searchTerm = searchText; storage.replaceTerm = replaceText; storage.caseSensitive = checked; @@ -51,7 +46,7 @@ checked = false; replacing = false; - const storage = editor.storage.searchAndReplace as SearchAndReplaceStorage; + const storage = editor.storage.searchAndReplace; storage.searchTerm = ''; storage.replaceTerm = ''; storage.caseSensitive = false; diff --git a/frontend/src/routes/notes/+page.svelte b/frontend/src/routes/notes/+page.svelte index a89a0bbb..6ff047d6 100644 --- a/frontend/src/routes/notes/+page.svelte +++ b/frontend/src/routes/notes/+page.svelte @@ -1,5 +1,5 @@ From e86807a9a19f556e9e1bc38dff1165ad2a72b5fd Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:42:36 +0200 Subject: [PATCH 04/41] feat: made initial text editor working --- frontend/package.json | 1 + .../src/lib/components/tiptap/TipTab.svelte | 65 ++++--------- .../tiptap/extensions/FloatingToolbar.svelte | 91 +++++++++++++++++++ package-lock.json | 37 ++++++++ 4 files changed, 149 insertions(+), 45 deletions(-) create mode 100644 frontend/src/lib/components/tiptap/extensions/FloatingToolbar.svelte diff --git a/frontend/package.json b/frontend/package.json index 1d2d563d..d4148c37 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "@tiptap/starter-kit": "^3.26.0", "jsencrypt": "3.5.4", "qrcode": "^1.5.4", + "svelte-tiptap": "^3.0.1", "valibot": "^1.2.0" }, "devDependencies": { diff --git a/frontend/src/lib/components/tiptap/TipTab.svelte b/frontend/src/lib/components/tiptap/TipTab.svelte index f83cea44..27020224 100644 --- a/frontend/src/lib/components/tiptap/TipTab.svelte +++ b/frontend/src/lib/components/tiptap/TipTab.svelte @@ -1,17 +1,15 @@ -
- {#if editorState.editor} -
- - - -
- {/if} - -
-
- - +{#if editor} +
+ + + +
+{/if} diff --git a/frontend/src/lib/components/tiptap/extensions/FloatingToolbar.svelte b/frontend/src/lib/components/tiptap/extensions/FloatingToolbar.svelte new file mode 100644 index 00000000..5ba82165 --- /dev/null +++ b/frontend/src/lib/components/tiptap/extensions/FloatingToolbar.svelte @@ -0,0 +1,91 @@ + + +{#if isMobile.current} + + + currentEditor.isEditable && currentEditor.isFocused} + class="bg-background mx-0 w-full min-w-full rounded-sm border shadow-sm" + > + +
+
+ + + + + + + + + + + + + + + + +
+
+ +
+
+
+{/if} diff --git a/package-lock.json b/package-lock.json index 69bd3754..f1568f9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,6 +97,7 @@ "@tiptap/starter-kit": "^3.26.0", "jsencrypt": "3.5.4", "qrcode": "^1.5.4", + "svelte-tiptap": "^3.0.1", "valibot": "^1.2.0" }, "devDependencies": { @@ -3496,6 +3497,22 @@ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.26.0.tgz", + "integrity": "sha512-reQ77NRYAOP7iPudsNbzLBuBTdL2aGxZzjccUFmE2lNdmwP23n9A/JhkuUhshVBs/6IozvahI+smG3Bnea0TCQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, "node_modules/@tiptap/extension-gapcursor": { "version": "3.26.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.26.0.tgz", @@ -7449,6 +7466,26 @@ "svelte": "^5.0.0" } }, + "node_modules/svelte-tiptap": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/svelte-tiptap/-/svelte-tiptap-3.0.1.tgz", + "integrity": "sha512-Vi3kVGOd01f7mslOxGbJB7z2QavdvH+6WffhB+Y5fleTiZaW0YWqIboyO2u/uh4BQeosiINmmuRJ+Qwb7mYP+A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.0.0", + "@tiptap/extension-bubble-menu": "^3.0.0", + "@tiptap/extension-floating-menu": "^3.0.0", + "@tiptap/pm": "^3.0.0", + "svelte": "^5.0.0" + } + }, "node_modules/svelte-toolbelt": { "version": "0.10.6", "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", From 7585d9edba8a4e5896619e83d8f11dcd3a216ce5 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:44:29 +0200 Subject: [PATCH 05/41] chore: fix format --- .../tiptap/toolbar/MobileToolbarItem.svelte | 52 +- .../components/tiptap/toolbar/italic.svelte | 71 +-- .../toolbar/mobile-toolbar-group.svelte | 102 ++-- .../tiptap/toolbar/ordered-list.svelte | 69 +-- .../lib/components/tiptap/toolbar/redo.svelte | 61 +-- .../toolbar/search-and-replace-toolbar.svelte | 472 +++++++++--------- .../tiptap/toolbar/strikethrough.svelte | 71 +-- .../tiptap/toolbar/underline.svelte | 71 +-- .../lib/components/tiptap/toolbar/undo.svelte | 61 +-- 9 files changed, 557 insertions(+), 473 deletions(-) diff --git a/frontend/src/lib/components/tiptap/toolbar/MobileToolbarItem.svelte b/frontend/src/lib/components/tiptap/toolbar/MobileToolbarItem.svelte index 4c5ac4b8..20c2ee08 100644 --- a/frontend/src/lib/components/tiptap/toolbar/MobileToolbarItem.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/MobileToolbarItem.svelte @@ -1,32 +1,36 @@ diff --git a/frontend/src/lib/components/tiptap/toolbar/italic.svelte b/frontend/src/lib/components/tiptap/toolbar/italic.svelte index 59727352..1f143580 100644 --- a/frontend/src/lib/components/tiptap/toolbar/italic.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/italic.svelte @@ -1,38 +1,49 @@ - - {#snippet child({ props })} - - {/snippet} - - - Italic - (cmd + i) - + + {#snippet child({ props })} + + {/snippet} + + + Italic + (cmd + i) + diff --git a/frontend/src/lib/components/tiptap/toolbar/mobile-toolbar-group.svelte b/frontend/src/lib/components/tiptap/toolbar/mobile-toolbar-group.svelte index 24bee7fc..f7092c0e 100644 --- a/frontend/src/lib/components/tiptap/toolbar/mobile-toolbar-group.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/mobile-toolbar-group.svelte @@ -1,63 +1,63 @@ - - {#snippet child({ props })} - - {/snippet} - - - - {label} - -
- {@render children({ closeDrawer })} -
-
+ + {#snippet child({ props })} + + {/snippet} + + + + {label} + +
+ {@render children({ closeDrawer })} +
+
diff --git a/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte b/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte index 27b61c5d..0b6bcfb5 100644 --- a/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte @@ -1,37 +1,48 @@ - - {#snippet child({ props })} - - {/snippet} - - - Ordered list - + + {#snippet child({ props })} + + {/snippet} + + + Ordered list + diff --git a/frontend/src/lib/components/tiptap/toolbar/redo.svelte b/frontend/src/lib/components/tiptap/toolbar/redo.svelte index 301f7c9e..224fbb5b 100644 --- a/frontend/src/lib/components/tiptap/toolbar/redo.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/redo.svelte @@ -1,36 +1,41 @@ - - {#snippet child({ props })} - - {/snippet} - - - Redo - + + {#snippet child({ props })} + + {/snippet} + + + Redo + diff --git a/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte index 5e421e7f..d13f7317 100644 --- a/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte @@ -1,228 +1,254 @@ - - {#snippet child({ props })} - - {/snippet} - - - e.preventDefault()} - class="relative flex w-[400px] px-3 py-2.5" - > - {#if !replacing} -
- handleSearchInput(e.currentTarget.value)} - class="w-48" - placeholder="Search..." - /> - - {results.length === 0 ? selectedResult : selectedResult + 1}/{results.length} - - - - - - -
- {:else} -
- -
- -

Search and replace

-
- -
-
- - handleSearchInput(e.currentTarget.value)} - placeholder="Search..." - /> - {results.length === 0 ? selectedResult : selectedResult + 1}/{results.length} -
-
- - handleReplaceInput(e.currentTarget.value)} - class="w-full" - placeholder="Replace..." - /> -
-
- handleCaseSensitiveChange(value === true)} - id="match_case" - /> - -
-
- -
-
- - -
- -
- - -
-
-
- {/if} -
+ + {#snippet child({ props })} + + {/snippet} + + + e.preventDefault()} + class="relative flex w-[400px] px-3 py-2.5" + > + {#if !replacing} +
+ handleSearchInput(e.currentTarget.value)} + class="w-48" + placeholder="Search..." + /> + + {results.length === 0 + ? selectedResult + : selectedResult + 1}/{results.length} + + + + + + +
+ {:else} +
+ +
+ +

Search and replace

+
+ +
+
+ + handleSearchInput(e.currentTarget.value)} + placeholder="Search..." + /> + {results.length === 0 + ? selectedResult + : selectedResult + 1}/{results.length} +
+
+ + handleReplaceInput(e.currentTarget.value)} + class="w-full" + placeholder="Replace..." + /> +
+
+ + handleCaseSensitiveChange(value === true)} + id="match_case" + /> + +
+
+ +
+
+ + +
+ +
+ + +
+
+
+ {/if} +
diff --git a/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte b/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte index 3dab1068..575cae7d 100644 --- a/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte @@ -1,38 +1,49 @@ - - {#snippet child({ props })} - - {/snippet} - - - Strikethrough - (cmd + shift + x) - + + {#snippet child({ props })} + + {/snippet} + + + Strikethrough + (cmd + shift + x) + diff --git a/frontend/src/lib/components/tiptap/toolbar/underline.svelte b/frontend/src/lib/components/tiptap/toolbar/underline.svelte index a910516e..7ce7e3ef 100644 --- a/frontend/src/lib/components/tiptap/toolbar/underline.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/underline.svelte @@ -1,38 +1,49 @@ - - {#snippet child({ props })} - - {/snippet} - - - Underline - (cmd + u) - + + {#snippet child({ props })} + + {/snippet} + + + Underline + (cmd + u) + diff --git a/frontend/src/lib/components/tiptap/toolbar/undo.svelte b/frontend/src/lib/components/tiptap/toolbar/undo.svelte index 72722e88..68440778 100644 --- a/frontend/src/lib/components/tiptap/toolbar/undo.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/undo.svelte @@ -1,36 +1,41 @@ - - {#snippet child({ props })} - - {/snippet} - - - Undo - + + {#snippet child({ props })} + + {/snippet} + + + Undo + From 5b95811c15e5a69a9a3e225e6a5d33f1d1bb9d75 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:03:18 +0200 Subject: [PATCH 06/41] fix: tiptap reactivity --- .../src/lib/components/tiptap/TipTab.svelte | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/tiptap/TipTab.svelte b/frontend/src/lib/components/tiptap/TipTab.svelte index 27020224..1879087d 100644 --- a/frontend/src/lib/components/tiptap/TipTab.svelte +++ b/frontend/src/lib/components/tiptap/TipTab.svelte @@ -4,12 +4,12 @@ import { extensions } from './config'; import EditorToolbar from './toolbar/EditorToolbar.svelte'; import FloatingToolbar from './extensions/FloatingToolbar.svelte'; - import { EditorContent, Editor } from 'svelte-tiptap'; + import { EditorContent, Editor, BubbleMenu } from 'svelte-tiptap'; - let editor = $state(null); + let editorState = $state<{ editor: Editor | null }>({ editor: null }); onMount(() => { - editor = new Editor({ + editorState.editor = new Editor({ extensions, content: `

Hello Svelte! 🌍️

@@ -24,24 +24,28 @@ onUpdate: ({ editor }) => { console.log(editor.getText()); }, + onTransaction: ({ editor }) => { + editorState = { editor: editor as Editor }; + }, autofocus: false }); }); onDestroy(() => { - editor?.destroy(); + editorState.editor?.destroy(); }); -{#if editor} +{#if editorState.editor}
- - + + +
{/if} From 4ee1ad135984283de71b539913782b133c18aa8d Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:38:04 +0200 Subject: [PATCH 07/41] refactor: better toolbar styling --- .../tiptap/toolbar/EditorToolbar.svelte | 24 +- .../tiptap/toolbar/alignment.svelte | 10 +- .../tiptap/toolbar/blockquote.svelte | 8 +- .../lib/components/tiptap/toolbar/bold.svelte | 10 +- .../tiptap/toolbar/bullet-list.svelte | 8 +- .../tiptap/toolbar/code-block.svelte | 8 +- .../tiptap/toolbar/color-and-highlight.svelte | 99 +++---- .../tiptap/toolbar/hard-break.svelte | 4 +- .../components/tiptap/toolbar/headings.svelte | 4 +- .../components/tiptap/toolbar/italic.svelte | 10 +- .../lib/components/tiptap/toolbar/link.svelte | 13 +- .../tiptap/toolbar/ordered-list.svelte | 8 +- .../lib/components/tiptap/toolbar/redo.svelte | 4 +- .../toolbar/search-and-replace-toolbar.svelte | 241 ++++++++---------- .../tiptap/toolbar/strikethrough.svelte | 10 +- .../tiptap/toolbar/underline.svelte | 10 +- .../lib/components/tiptap/toolbar/undo.svelte | 4 +- 17 files changed, 201 insertions(+), 274 deletions(-) diff --git a/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte index 6f3c3573..66d72952 100644 --- a/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte @@ -32,29 +32,29 @@
- + - - - + - - + + + + + + + - - - - + - - + +
diff --git a/frontend/src/lib/components/tiptap/toolbar/alignment.svelte b/frontend/src/lib/components/tiptap/toolbar/alignment.svelte index 7e7f723e..ec994fd6 100644 --- a/frontend/src/lib/components/tiptap/toolbar/alignment.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/alignment.svelte @@ -83,11 +83,11 @@ {...triggerProps} variant="ghost" size="sm" - class="h-8 w-max font-normal" + class="h-8 w-max cursor-pointer font-normal" type="button" > - + {currentOption.name} @@ -98,7 +98,11 @@ Text Alignment - e.preventDefault()}> + e.preventDefault()} + class="w-42" + > {#each alignmentOptions as option, index (index)} {@const OptionIcon = option.icon} diff --git a/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte b/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte index 7304322f..836d2389 100644 --- a/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte @@ -36,15 +36,11 @@ variant="ghost" size="icon" type="button" - class={cn( - 'h-8 w-8 p-0 sm:h-9 sm:w-9', - isActive && 'bg-accent', - className - )} + class={cn('cursor-pointer', isActive && 'bg-accent', className)} onclick={handleClick} disabled={isDisabled} > - + {/snippet} diff --git a/frontend/src/lib/components/tiptap/toolbar/bold.svelte b/frontend/src/lib/components/tiptap/toolbar/bold.svelte index 2dc5c8b7..a0fb45e4 100644 --- a/frontend/src/lib/components/tiptap/toolbar/bold.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/bold.svelte @@ -34,20 +34,16 @@ variant="ghost" size="icon" type="button" - class={cn( - 'h-8 w-8 p-0 sm:h-9 sm:w-9', - isActive && 'bg-accent', - className - )} + class={cn('cursor-pointer', isActive && 'bg-accent', className)} onclick={handleClick} disabled={isDisabled} > - + {/snippet} Bold - (cmd + b) + (cmd + b) diff --git a/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte b/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte index 000e19ba..aed47fa7 100644 --- a/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte @@ -30,15 +30,11 @@ variant="ghost" size="icon" type="button" - class={cn( - 'h-8 w-8 p-0 sm:h-9 sm:w-9', - isActive && 'bg-accent', - className - )} + class={cn('cursor-pointer', isActive && 'bg-accent', className)} onclick={handleClick} disabled={isDisabled} > - + {/snippet} diff --git a/frontend/src/lib/components/tiptap/toolbar/code-block.svelte b/frontend/src/lib/components/tiptap/toolbar/code-block.svelte index d7bd5317..e1e874f7 100644 --- a/frontend/src/lib/components/tiptap/toolbar/code-block.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/code-block.svelte @@ -30,15 +30,11 @@ variant="ghost" size="icon" type="button" - class={cn( - 'h-8 w-8 p-0 sm:h-9 sm:w-9', - isActive && 'bg-accent', - className - )} + class={cn('cursor-pointer', isActive && 'bg-accent', className)} onclick={handleClick} disabled={isDisabled} > - + {/snippet} diff --git a/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte b/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte index 9f48252d..c96f70e6 100644 --- a/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte @@ -14,6 +14,7 @@ TooltipContent, TooltipTrigger } from '@profidev/pleiades/components/ui/tooltip'; + import * as Command from '@profidev/pleiades/components/ui/command'; import { IsMobile } from '@profidev/pleiades/hooks/is-mobile.svelte'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; @@ -134,7 +135,7 @@ size="sm" type="button" style={currentColor ? `color: ${currentColor}` : undefined} - class={cn('h-8 w-14 p-0 font-normal')} + class={cn('h-8 w-14 cursor-pointer p-0 font-normal')} > A @@ -146,56 +147,56 @@ Text Color & Highlight - - -
Color
- {#each TEXT_COLORS as { name, color } (name)} - - {/each} + + + + + + {#each TEXT_COLORS as { name, color } (name)} + handleSetColor(color)} + class="flex w-full cursor-pointer items-center rounded-sm px-2 py-1 text-sm [&_svg.cn-command-item-indicator]:hidden!" + > +
+ A +
+ {name} + {#if currentColor === color} + + {/if} +
+ {/each} +
- + -
- Background -
- {#each HIGHLIGHT_COLORS as { name, color } (name)} - - {/each} -
+ + {#each HIGHLIGHT_COLORS as { name, color } (name)} + handleSetHighlight(color)} + class="flex w-full cursor-pointer items-center rounded-sm px-2 py-1 text-sm [&_svg.cn-command-item-indicator]:hidden!" + > +
+
+ A +
+ {name} +
+ {#if currentHighlight === color} + + {/if} +
+ {/each} +
+
+ +
diff --git a/frontend/src/lib/components/tiptap/toolbar/hard-break.svelte b/frontend/src/lib/components/tiptap/toolbar/hard-break.svelte index cc9cc7fd..d32bffca 100644 --- a/frontend/src/lib/components/tiptap/toolbar/hard-break.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/hard-break.svelte @@ -25,10 +25,10 @@ variant="ghost" size="icon" type="button" - class={cn('h-8 w-8 p-0 sm:h-9 sm:w-9', className)} + class={cn('cursor-pointer', className)} onclick={handleClick} > - + {/snippet} diff --git a/frontend/src/lib/components/tiptap/toolbar/headings.svelte b/frontend/src/lib/components/tiptap/toolbar/headings.svelte index e9576ac0..19cbe0d5 100644 --- a/frontend/src/lib/components/tiptap/toolbar/headings.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/headings.svelte @@ -66,7 +66,7 @@ size="sm" type="button" class={cn( - 'h-8 w-max gap-1 px-3 font-normal', + 'h-8 w-max cursor-pointer gap-1 px-3 font-normal', isHeadingActive && 'bg-accent', className )} @@ -76,7 +76,7 @@ {/snippet} - + editor.chain().focus().setParagraph().run()} class={cn( diff --git a/frontend/src/lib/components/tiptap/toolbar/italic.svelte b/frontend/src/lib/components/tiptap/toolbar/italic.svelte index 1f143580..f9aba76e 100644 --- a/frontend/src/lib/components/tiptap/toolbar/italic.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/italic.svelte @@ -30,20 +30,16 @@ variant="ghost" size="icon" type="button" - class={cn( - 'h-8 w-8 p-0 sm:h-9 sm:w-9', - isActive && 'bg-accent', - className - )} + class={cn('cursor-pointer', isActive && 'bg-accent', className)} onclick={handleClick} disabled={isDisabled} > - + {/snippet} Italic - (cmd + i) + (cmd + i) diff --git a/frontend/src/lib/components/tiptap/toolbar/link.svelte b/frontend/src/lib/components/tiptap/toolbar/link.svelte index 5d08a068..f6b9ea9f 100644 --- a/frontend/src/lib/components/tiptap/toolbar/link.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/link.svelte @@ -79,7 +79,7 @@ size="sm" type="button" class={cn( - 'h-8 w-max px-3 font-normal', + 'h-8 w-max cursor-pointer px-3 font-normal', isActive && 'bg-accent', className )} @@ -101,12 +101,8 @@ class="relative px-3 py-2.5" >
- - -
- -

Attach a link to the selected text

+

Attach a link to the selected text

- + Remove {/if} -
diff --git a/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte b/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte index 0b6bcfb5..93855a6b 100644 --- a/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte @@ -30,15 +30,11 @@ variant="ghost" size="icon" type="button" - class={cn( - 'h-8 w-8 p-0 sm:h-9 sm:w-9', - isActive && 'bg-accent', - className - )} + class={cn('cursor-pointer', isActive && 'bg-accent', className)} onclick={handleClick} disabled={isDisabled} > - + {/snippet} diff --git a/frontend/src/lib/components/tiptap/toolbar/redo.svelte b/frontend/src/lib/components/tiptap/toolbar/redo.svelte index 224fbb5b..872ad18f 100644 --- a/frontend/src/lib/components/tiptap/toolbar/redo.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/redo.svelte @@ -27,11 +27,11 @@ variant="ghost" size="icon" type="button" - class={cn('h-8 w-8 p-0 sm:h-9 sm:w-9', className)} + class={cn('cursor-pointer', className)} onclick={handleClick} disabled={isDisabled} > - + {/snippet} diff --git a/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte index d13f7317..4d290d75 100644 --- a/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte @@ -1,18 +1,22 @@ {#if editorState.editor} -
+
- -
{/if} diff --git a/frontend/src/lib/components/tiptap/config.ts b/frontend/src/lib/components/tiptap/config.ts index e4a897e8..044b7319 100644 --- a/frontend/src/lib/components/tiptap/config.ts +++ b/frontend/src/lib/components/tiptap/config.ts @@ -1,4 +1,3 @@ -import BubbleMenu from '@tiptap/extension-bubble-menu'; import TextAlign from '@tiptap/extension-text-align'; import { Color, TextStyle } from '@tiptap/extension-text-style'; import { Highlight } from '@tiptap/extension-highlight'; @@ -33,6 +32,5 @@ export const extensions = [ multicolor: true }), SearchAndReplace, - Typography, - BubbleMenu + Typography ] satisfies Extensions; diff --git a/frontend/src/lib/components/tiptap/extensions/FloatingToolbar.svelte b/frontend/src/lib/components/tiptap/extensions/FloatingToolbar.svelte deleted file mode 100644 index 5ba82165..00000000 --- a/frontend/src/lib/components/tiptap/extensions/FloatingToolbar.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - -{#if isMobile.current} - - - currentEditor.isEditable && currentEditor.isFocused} - class="bg-background mx-0 w-full min-w-full rounded-sm border shadow-sm" - > - -
-
- - - - - - - - - - - - - - - - -
-
- -
-
-
-{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte index 66d72952..06f30a49 100644 --- a/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte @@ -1,9 +1,5 @@ - {#if note.shared_with.length > 0} - · Shared with {note.shared_with + Shared with {note.shared_with .map((user) => user.name) .join(', ')} diff --git a/frontend/src/routes/notes/[id]/+page.svelte b/frontend/src/routes/notes/[id]/+page.svelte index 2936bb89..9fd41065 100644 --- a/frontend/src/routes/notes/[id]/+page.svelte +++ b/frontend/src/routes/notes/[id]/+page.svelte @@ -128,11 +128,12 @@
-
- -
+
+ - +
+
+ ({ label: user.name, value: user.id @@ -163,8 +166,12 @@ }} />
+
+ + +
- {#if note} -

Owner: {note.owner.name}

- {/if} +

Content

Date: Thu, 11 Jun 2026 20:53:14 +0200 Subject: [PATCH 21/41] feat: added websocket for note editor sync --- backend/entity/src/entities/note.rs | 4 +- .../src/m20260611_120000_create_note_table.rs | 2 +- backend/src/db/notes.rs | 42 +++-- backend/src/lib.rs | 1 + backend/src/notes/mod.rs | 124 +----------- backend/src/notes/state.rs | 178 ++++++++++++++++++ backend/src/notes/websocket.rs | 80 ++++++++ .../src/lib/components/tiptap/TipTab.svelte | 14 +- frontend/src/routes/notes/[id]/+page.svelte | 2 + 9 files changed, 309 insertions(+), 138 deletions(-) create mode 100644 backend/src/notes/state.rs create mode 100644 backend/src/notes/websocket.rs diff --git a/backend/entity/src/entities/note.rs b/backend/entity/src/entities/note.rs index 40e75792..0d60890a 100644 --- a/backend/entity/src/entities/note.rs +++ b/backend/entity/src/entities/note.rs @@ -8,8 +8,8 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub title: String, - #[sea_orm(column_type = "Text")] - pub content: String, + #[sea_orm(column_type = "VarBinary(StringLen::None)")] + pub content: Vec, pub owner: Uuid, } diff --git a/backend/migration/src/m20260611_120000_create_note_table.rs b/backend/migration/src/m20260611_120000_create_note_table.rs index f61f86b0..eaff4e59 100644 --- a/backend/migration/src/m20260611_120000_create_note_table.rs +++ b/backend/migration/src/m20260611_120000_create_note_table.rs @@ -14,7 +14,7 @@ impl MigrationTrait for Migration { .if_not_exists() .col(pk_uuid(Note::Id)) .col(string(Note::Title)) - .col(text(Note::Content).default("")) + .col(binary(Note::Content)) .col(uuid(Note::Owner)) .foreign_key( ForeignKey::create() diff --git a/backend/src/db/notes.rs b/backend/src/db/notes.rs index a96a4d55..e98e7b8c 100644 --- a/backend/src/db/notes.rs +++ b/backend/src/db/notes.rs @@ -17,20 +17,6 @@ pub struct NoteInfo { pub is_owner: bool, } -fn preview_from_content(content: &str) -> String { - let text = content.trim(); - if text.is_empty() { - return String::new(); - } - - let truncated: String = text.chars().take(150).collect(); - if text.chars().count() > 150 { - format!("{truncated}…") - } else { - truncated - } -} - struct NoteOwnerLink; impl Linked for NoteOwnerLink { @@ -123,7 +109,7 @@ impl<'db> NoteTable<'db> { Ok(NoteInfo { id: note.id, title: note.title, - preview: preview_from_content(¬e.content), + preview: "".into(), owner: SimpleUserInfo { id: owner.id, name: owner.name, @@ -170,7 +156,7 @@ impl<'db> NoteTable<'db> { Ok(Some(NoteInfo { id: note.id, title: note.title, - preview: preview_from_content(¬e.content), + preview: "".into(), owner: SimpleUserInfo { id: owner.id, name: owner.name, @@ -187,7 +173,7 @@ impl<'db> NoteTable<'db> { note::ActiveModel { id: Set(id), title: Set(title), - content: Set(String::new()), + content: Set(Vec::new()), owner: Set(owner), } .insert(&txn) @@ -229,6 +215,28 @@ impl<'db> NoteTable<'db> { txn.commit().await?; Ok(()) } + + pub async fn set_content(&self, note_id: Uuid, content: Vec) -> Result<()> { + let mut note: note::ActiveModel = Note::find_by_id(note_id) + .one(self.db) + .await? + .ok_or(DbErr::RecordNotFound("note not found".into()))? + .into(); + + note.content = Set(content); + note.update(self.db).await?; + + Ok(()) + } + + pub async fn get_content(&self, note_id: Uuid) -> Result> { + let note: note::Model = Note::find_by_id(note_id) + .one(self.db) + .await? + .ok_or(DbErr::RecordNotFound("note not found".into()))?; + + Ok(note.content) + } } async fn replace_shared_users( diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 28018a94..29fc81f5 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -69,6 +69,7 @@ async fn state(mut router: ApiRouter, config: Config) -> ApiRouter { oauth_management::init(&db).await; router = endpoints::user::state(router); + router = notes::state(router); router = auth::state(router, &config, &db).await; router = mail::state(router, &db, &config).await; router = oauth::state(router, &config).await; diff --git a/backend/src/notes/mod.rs b/backend/src/notes/mod.rs index e1bc6920..9816f3f1 100644 --- a/backend/src/notes/mod.rs +++ b/backend/src/notes/mod.rs @@ -1,126 +1,18 @@ -use std::sync::Arc; - use aide::axum::ApiRouter; -use axum::{ - Extension, - body::Bytes, - extract::{ - FromRequestParts, WebSocketUpgrade, - ws::{Message, WebSocket}, - }, - response::Response, - routing::get, -}; -use centaurus::backend::auth::jwt_auth::JwtAuth; -use image::EncodableLayout; -use tokio::sync::Mutex; -use yrs::{ - AsyncTransact, Doc, GetString, ReadTxn, - sync::{Awareness, DefaultProtocol, protocol::AsyncProtocol}, - updates::encoder::{Encode, EncoderV1}, -}; +use axum::Extension; + +use crate::notes::state::NoteEditing; mod management; +mod state; +mod websocket; pub fn router() -> ApiRouter { ApiRouter::new() .nest("/management", management::router()) - .route("/ws/test-room", get(notes_websocket)) - .layer(Extension(Test::new())) -} - -#[derive(FromRequestParts, Clone)] -#[from_request(via(Extension))] -struct Test(Arc>); - -impl Test { - pub fn new() -> Self { - Test(Arc::new(Mutex::new(Awareness::new(Doc::new())))) - } -} - -async fn notes_websocket(_auth: JwtAuth, test: Test, ws: WebSocketUpgrade) -> Response { - ws.on_upgrade(move |socket| handle_socket(socket, test)) + .nest("/websocket", websocket::router()) } -async fn handle_socket(mut socket: WebSocket, test: Test) { - let awareness = test.0.lock().await; - let Ok(msgs) = DefaultProtocol.start::(&awareness).await else { - tracing::error!("Failed to start protocol"); - return; - }; - - let doc = awareness.doc(); - let txn = doc.transact().await; - let root_keys = txn.root_refs(); - println!("Root keys:"); - for key in root_keys { - println!("{:?}", key); - } - - println!("Text content:"); - drop(txn); - - let doc = awareness.doc(); - let txn = doc.transact().await; - for (key, _) in txn.root_refs() { - // Let's try to see if it's a Text type under a different name - if let Some(text_ref) = txn.get_text(key) { - println!( - "Key '{}' is Text. Content: '{}'", - key, - text_ref.get_string(&txn) - ); - } else if let Some(_map_ref) = txn.get_map(key) { - println!("Key '{}' is a Map type.", key); - } else { - println!("Key '{}' is another type.", key); - } - } - drop(txn); - drop(awareness); - - for msg in msgs { - let payload = msg.encode_v1(); - socket - .send(Message::Binary(Bytes::copy_from_slice(payload.as_slice()))) - .await - .unwrap(); - } - - while let Some(msg) = socket.recv().await { - let Ok(Message::Binary(data)) = msg else { - continue; - }; - - let mut awareness = test.0.lock().await; - let Ok(res) = DefaultProtocol - .handle(&mut awareness, data.as_bytes()) - .await - else { - continue; - }; - let doc = awareness.doc(); - let txn = doc.transact().await; - if let Some(xml_fragment) = txn.get_xml_fragment("default") { - // 3. Extract the HTML string version of your Tiptap editor contents - let html_content = xml_fragment.get_string(&txn); - - println!("--- Current Tiptap Document State ---"); - println!("{}", html_content); - println!("-------------------------------------"); - } else { - println!("Could not find an XML Fragment named 'default'"); - } - drop(txn); - drop(awareness); - - for msg in res { - let payload = msg.encode_v1(); - socket - .send(Message::Binary(Bytes::copy_from_slice(payload.as_slice()))) - .await - .unwrap(); - } - } +pub fn state(router: ApiRouter) -> ApiRouter { + router.layer(Extension(NoteEditing::init())) } diff --git a/backend/src/notes/state.rs b/backend/src/notes/state.rs new file mode 100644 index 00000000..02305be3 --- /dev/null +++ b/backend/src/notes/state.rs @@ -0,0 +1,178 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use axum::{ + Extension, + body::Bytes, + extract::{ + FromRequestParts, + ws::{Message, WebSocket}, + }, +}; +use centaurus::{db::init::Connection, error::Result, eyre::Context}; +use dashmap::DashMap; +use image::EncodableLayout; +use tokio::sync::{ + Mutex, + broadcast::{Receiver, Sender, channel}, +}; +use uuid::Uuid; +use yrs::{ + AsyncTransact, Doc, ReadTxn, StateVector, Subscription, Update, + encoding::write::Write, + sync::{ + Awareness, DefaultProtocol, + protocol::{AsyncProtocol, MSG_SYNC, MSG_SYNC_UPDATE}, + }, + updates::{ + decoder::Decode, + encoder::{Encode, Encoder, EncoderV1}, + }, +}; + +use crate::db::DBTrait; + +#[derive(Clone, FromRequestParts)] +#[from_request(via(Extension))] +pub struct NoteEditing { + docs: Arc>>, +} + +pub struct NoteState { + doc: Mutex, + sender: Sender, + #[allow(dead_code)] + subscription: Subscription, + subscriber_count: AtomicUsize, +} + +impl NoteEditing { + pub fn init() -> Self { + Self { + docs: Arc::new(DashMap::new()), + } + } + + pub async fn get_or_open_note(&self, note_id: Uuid, db: &Connection) -> Result> { + if let Some(state) = self.docs.get(¬e_id) { + state.subscriber_count.fetch_add(1, Ordering::Relaxed); + return Ok(state.clone()); + } + + let content = db.notes().get_content(note_id).await?; + + let doc = Doc::new(); + if !content.is_empty() { + doc + .transact_mut() + .await + .apply_update(Update::decode_v1(&content).context("failed to decode note content")?) + .context("failed to apply update")?; + } + + let (sender, _) = channel(10); + + let subscription = doc + .observe_update_v1({ + let sender = sender.clone(); + + move |_txn, update| { + let mut encoder = EncoderV1::new(); + encoder.write_var(MSG_SYNC); + encoder.write_var(MSG_SYNC_UPDATE); + encoder.write_buf(&update.update); + let _ = sender.send(Message::Binary(Bytes::from_owner(encoder.to_vec()))); + } + }) + .context("failed to observe update")?; + + let awareness = Awareness::new(doc); + let doc_arc = Mutex::new(awareness); + + let state = Arc::new(NoteState { + doc: doc_arc, + subscriber_count: AtomicUsize::new(1), + subscription, + sender, + }); + + self.docs.insert(note_id, state.clone()); + + Ok(state) + } + + pub async fn close_note(&self, note_id: Uuid, db: &Connection) -> Result<()> { + let Some(state) = self.docs.get(¬e_id) else { + return Ok(()); + }; + + if state.subscriber_count.load(Ordering::Relaxed) > 1 { + state.subscriber_count.fetch_sub(1, Ordering::Relaxed); + return Ok(()); + } + drop(state); + + let Some((_, state)) = self.docs.remove(¬e_id) else { + return Ok(()); + }; + + let awareness = state.doc.lock().await; + let doc = awareness.doc(); + let content = doc + .transact() + .await + .encode_state_as_update_v1(&StateVector::default()); + + db.notes().set_content(note_id, content).await?; + + Ok(()) + } +} + +impl NoteState { + pub fn receiver(&self) -> Receiver { + self.sender.subscribe() + } + + pub async fn init_protocol(&self, ws: &mut WebSocket) -> Result<()> { + let awareness = self.doc.lock().await; + let msgs = DefaultProtocol + .start::(&awareness) + .await + .context("failed to start protocol")?; + drop(awareness); + + for msg in msgs { + let payload = msg.encode_v1(); + ws.send(Message::Binary(Bytes::from_owner(payload))) + .await + .context("failed to send message")?; + } + + Ok(()) + } + + pub async fn handle_message(&self, msg: Message, ws: &mut WebSocket) { + let Message::Binary(data) = msg else { + return; + }; + + let mut awareness = self.doc.lock().await; + let Ok(res) = DefaultProtocol + .handle(&mut awareness, data.as_bytes()) + .await + else { + return; + }; + drop(awareness); + + for msg in res { + let payload = msg.encode_v1(); + if let Err(e) = ws.send(Message::Binary(Bytes::from_owner(payload))).await { + tracing::warn!("failed to send message: {}", e); + } + } + } +} diff --git a/backend/src/notes/websocket.rs b/backend/src/notes/websocket.rs new file mode 100644 index 00000000..da6754e9 --- /dev/null +++ b/backend/src/notes/websocket.rs @@ -0,0 +1,80 @@ +use aide::axum::ApiRouter; +use axum::{ + extract::{ + Path, WebSocketUpgrade, + ws::{Message, WebSocket}, + }, + response::Response, + routing::get, +}; +use centaurus::{backend::auth::jwt_auth::JwtAuth, bail, db::init::Connection, error::Result}; +use schemars::JsonSchema; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{db::DBTrait, notes::state::NoteEditing}; + +pub fn router() -> ApiRouter { + ApiRouter::new().route("/{uuid}", get(notes_websocket)) +} + +#[derive(Deserialize, JsonSchema)] +struct NotePath { + uuid: Uuid, +} + +async fn notes_websocket( + auth: JwtAuth, + state: NoteEditing, + Path(NotePath { uuid }): Path, + db: Connection, + ws: WebSocketUpgrade, +) -> Result { + if !db.notes().has_access(auth.user_id, uuid).await? { + bail!(NOT_FOUND, "note not found"); + } + + Ok(ws.on_upgrade(move |ws| handle_socket(ws, state, db, uuid))) +} + +async fn handle_socket(mut ws: WebSocket, state: NoteEditing, db: Connection, note_id: Uuid) { + let doc_state = match state.get_or_open_note(note_id, &db).await { + Ok(arc) => arc, + Err(e) => { + tracing::warn!("failed to get or open note: {}", e); + return; + } + }; + + if let Err(e) = doc_state.init_protocol(&mut ws).await { + tracing::warn!("failed to init protocol: {}", e); + return; + } + + let mut receiver = doc_state.receiver(); + + loop { + tokio::select! { + msg = ws.recv() => { + match msg { + Some(Ok(Message::Close(_)) | Err(_)) | None => break, + Some(Ok(msg)) => { + doc_state.handle_message(msg, &mut ws).await; + } + } + } + msg = receiver.recv() => { + let Ok(msg) = msg else { + break; + }; + if let Err(e) = ws.send(msg).await { + tracing::warn!("failed to send message: {}", e); + } + } + } + } + + if let Err(e) = state.close_note(note_id, &db).await { + tracing::warn!("failed to close note: {}", e); + } +} diff --git a/frontend/src/lib/components/tiptap/TipTab.svelte b/frontend/src/lib/components/tiptap/TipTab.svelte index 986925dc..4522c41f 100644 --- a/frontend/src/lib/components/tiptap/TipTab.svelte +++ b/frontend/src/lib/components/tiptap/TipTab.svelte @@ -8,14 +8,22 @@ import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; + const { + id + }: { + id: string; + } = $props(); + let editorState = $state<{ editor: Editor | null }>({ editor: null }); const doc = new Y.Doc(); + let provider: WebsocketProvider | undefined = undefined; + let undoManager: Y.UndoManager | undefined = undefined; onMount(() => { - const provider = new WebsocketProvider('/api/notes/ws', 'test-room', doc, { + provider = new WebsocketProvider('/api/notes/websocket', id, doc, { disableBc: true }); - const undoManager = new Y.UndoManager(doc); + undoManager = new Y.UndoManager(doc); provider.awareness.setLocalStateField('user', { name: 'Anonymous', color: '#ffff00', @@ -59,6 +67,8 @@ onDestroy(() => { editorState.editor?.destroy(); + undoManager?.destroy(); + provider?.destroy(); }); diff --git a/frontend/src/routes/notes/[id]/+page.svelte b/frontend/src/routes/notes/[id]/+page.svelte index 9fd41065..38ba9bb6 100644 --- a/frontend/src/routes/notes/[id]/+page.svelte +++ b/frontend/src/routes/notes/[id]/+page.svelte @@ -17,6 +17,7 @@ type SimpleUserInfo } from '$lib/client'; import { Label } from '@profidev/pleiades/components/ui/label'; + import TipTab from '$lib/components/tiptap/TipTab.svelte'; const { data } = $props(); @@ -183,6 +184,7 @@

Content

+
Date: Thu, 11 Jun 2026 22:14:47 +0200 Subject: [PATCH 22/41] feat: propper user sync for live editing --- backend/src/notes/state.rs | 49 +++++++++++---- frontend/package.json | 1 + .../src/lib/components/tiptap/TipTab.svelte | 15 +++-- frontend/src/lib/components/tiptap/config.ts | 14 +++++ frontend/src/lib/components/tiptap/tiptap.css | 29 +++++++++ package-lock.json | 60 ++++++++++++++++++- 6 files changed, 149 insertions(+), 19 deletions(-) diff --git a/backend/src/notes/state.rs b/backend/src/notes/state.rs index 02305be3..0b88e2c4 100644 --- a/backend/src/notes/state.rs +++ b/backend/src/notes/state.rs @@ -14,9 +14,13 @@ use axum::{ use centaurus::{db::init::Connection, error::Result, eyre::Context}; use dashmap::DashMap; use image::EncodableLayout; -use tokio::sync::{ - Mutex, - broadcast::{Receiver, Sender, channel}, +use tokio::{ + spawn, + sync::{ + Mutex, + broadcast::{Receiver, Sender, channel}, + mpsc, + }, }; use uuid::Uuid; use yrs::{ @@ -41,10 +45,12 @@ pub struct NoteEditing { } pub struct NoteState { - doc: Mutex, + doc: Arc>, sender: Sender, #[allow(dead_code)] - subscription: Subscription, + doc_subscription: Subscription, + #[allow(dead_code)] + awareness_subscription: Subscription, subscriber_count: AtomicUsize, } @@ -73,8 +79,7 @@ impl NoteEditing { } let (sender, _) = channel(10); - - let subscription = doc + let doc_subscription = doc .observe_update_v1({ let sender = sender.clone(); @@ -88,13 +93,37 @@ impl NoteEditing { }) .context("failed to observe update")?; - let awareness = Awareness::new(doc); - let doc_arc = Mutex::new(awareness); + let mut awareness = Awareness::new(doc); + let (awareness_sender, mut awareness_receiver) = mpsc::channel(10); + let awareness_subscription = awareness.on_update(move |_awareness, event, _origin| { + let changes = event.all_changes(); + let _ = awareness_sender.try_send(changes); + }); + + let doc_arc = Arc::new(Mutex::new(awareness)); + + spawn({ + let sender = sender.clone(); + let doc_arc = doc_arc.clone(); + async move { + while let Some(changes) = awareness_receiver.recv().await { + let awareness = doc_arc.lock().await; + let Ok(upgrade) = awareness.update_with_clients(changes) else { + tracing::warn!("failed to update with clients"); + continue; + }; + + let payload = yrs::sync::Message::Awareness(upgrade).encode_v1(); + let _ = sender.send(Message::Binary(Bytes::from_owner(payload))); + } + } + }); let state = Arc::new(NoteState { doc: doc_arc, subscriber_count: AtomicUsize::new(1), - subscription, + doc_subscription, + awareness_subscription, sender, }); diff --git a/frontend/package.json b/frontend/package.json index 424593c9..6f599bb7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@tiptap/core": "^3.26.0", "@tiptap/extension-bubble-menu": "^3.26.0", "@tiptap/extension-collaboration": "^3.26.0", + "@tiptap/extension-collaboration-caret": "^3.26.1", "@tiptap/extension-highlight": "^3.26.0", "@tiptap/extension-text-align": "^3.26.0", "@tiptap/extension-text-style": "^3.26.0", diff --git a/frontend/src/lib/components/tiptap/TipTab.svelte b/frontend/src/lib/components/tiptap/TipTab.svelte index 4522c41f..6d82c3fb 100644 --- a/frontend/src/lib/components/tiptap/TipTab.svelte +++ b/frontend/src/lib/components/tiptap/TipTab.svelte @@ -1,10 +1,11 @@ - + diff --git a/frontend/package.json b/frontend/package.json index 896c964c..6aa9c5b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,7 @@ "api": "openapi-ts" }, "dependencies": { - "@profidev/pleiades": "1.9.6", + "@profidev/pleiades": "1.9.7", "@simplewebauthn/browser": "13.3.0", "@sveltejs/enhanced-img": "0.10.4", "@tiptap/core": "3.26.1", diff --git a/frontend/src/lib/components/tiptap/TipTab.svelte b/frontend/src/lib/components/tiptap/TipTab.svelte index c317f274..496dd596 100644 --- a/frontend/src/lib/components/tiptap/TipTab.svelte +++ b/frontend/src/lib/components/tiptap/TipTab.svelte @@ -8,6 +8,7 @@ import CollaborationCaret from '@tiptap/extension-collaboration-caret'; import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; + import { ScrollArea } from '@profidev/pleiades/components/ui/scroll-area'; const { id @@ -48,11 +49,6 @@ } }) ] as any, - content: ` -

Hello Svelte! 🌍️

-

This editor is running in Svelte.

-

Select some text to see the bubble menu popping up.

- `, editorProps: { attributes: { class: 'max-w-full focus:outline-none' @@ -76,12 +72,16 @@ {#if editorState.editor} -
+
{/* @ts-ignore */ null} - + + +
{/if} diff --git a/frontend/src/lib/components/tiptap/tiptap.css b/frontend/src/lib/components/tiptap/tiptap.css index 4375416a..5679050f 100644 --- a/frontend/src/lib/components/tiptap/tiptap.css +++ b/frontend/src/lib/components/tiptap/tiptap.css @@ -99,15 +99,15 @@ .ProseMirror { caret-color: var(--editor-text-default); outline: none; - padding: var(--editor-spacing-16) var(--editor-spacing-8); - margin: 0 auto; - max-width: 90ch; + padding: 0 var(--editor-spacing-8); + padding-top: var(--editor-spacing-4); + margin: 0; + flex-grow: 1; font-family: var(--editor-font-sans); position: relative; /* background-color: var(--editor-bg-default); */ color: var(--editor-text-default); transition: all var(--editor-transition-normal); - min-height: 100vh; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -140,13 +140,13 @@ /* Typography Styles */ .ProseMirror p { line-height: 1.75; - margin: var(--editor-spacing-4) 0; + margin: 0; color: var(--editor-text-default); font-size: 1.125rem; } .ProseMirror > p { - margin: var(--editor-spacing-6) 0; + margin: 0; } .ProseMirror h1, @@ -416,7 +416,7 @@ /* Mobile Optimizations */ @media (max-width: 640px) { .ProseMirror { - padding: var(--editor-spacing-8) var(--editor-spacing-4); + padding: 0 var(--editor-spacing-4); } .ProseMirror h1 { diff --git a/frontend/src/routes/notes/[id]/+page.svelte b/frontend/src/routes/notes/[id]/+page.svelte index 38ba9bb6..27c10b2d 100644 --- a/frontend/src/routes/notes/[id]/+page.svelte +++ b/frontend/src/routes/notes/[id]/+page.svelte @@ -181,9 +181,7 @@ Delete
-
-

Content

diff --git a/package-lock.json b/package-lock.json index c350dc69..6082c9b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "version": "0.1.0", "license": "GPL-2.0-only", "dependencies": { - "@profidev/pleiades": "^1.9.6", + "@profidev/pleiades": "1.9.7", "@sveltejs/enhanced-img": "0.10.4", "@tauri-apps/api": "^2", "@tauri-apps/plugin-barcode-scanner": "^2.4.5", @@ -84,7 +84,7 @@ "version": "0.2.7", "license": "MIT", "dependencies": { - "@profidev/pleiades": "1.9.6", + "@profidev/pleiades": "1.9.7", "@simplewebauthn/browser": "13.3.0", "@sveltejs/enhanced-img": "0.10.4", "@tiptap/core": "3.26.1", @@ -1964,9 +1964,9 @@ "optional": true }, "node_modules/@profidev/pleiades": { - "version": "1.9.6", - "resolved": "https://registry.npmjs.org/@profidev/pleiades/-/pleiades-1.9.6.tgz", - "integrity": "sha512-UsbC8hlCp7LjKI33Te83+shmt2orGh4K2ndUBUFb/x6UQVcwxxxTSSD4Ab6PgW6Ln7XdwYt56i3nikVkKP/qeQ==", + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@profidev/pleiades/-/pleiades-1.9.7.tgz", + "integrity": "sha512-3+9w7HYlciU3WBmo5MUuqFFZ6/OmM2d70CW0NVj4BXMUkzVsIqupJ30c7cBtYEvHeCp6PXREs9+7Fjh2B4WW2Q==", "dependencies": { "@emoji-mart/data": "^1.0.0", "@internationalized/date": "^3.0.0", From 67361b962f92c62959e2a68912246a88db46bcdf Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:28:11 +0200 Subject: [PATCH 25/41] fix: remove user cursor on page reload --- .../src/lib/components/tiptap/TipTab.svelte | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/tiptap/TipTab.svelte b/frontend/src/lib/components/tiptap/TipTab.svelte index 496dd596..cb72e8cf 100644 --- a/frontend/src/lib/components/tiptap/TipTab.svelte +++ b/frontend/src/lib/components/tiptap/TipTab.svelte @@ -27,10 +27,6 @@ }); undoManager = new Y.UndoManager(doc); - provider.on('status', (e) => { - console.log(e); - }); - editorState.editor = new Editor({ extensions: [ ...extensions, @@ -54,9 +50,6 @@ class: 'max-w-full focus:outline-none' } }, - onUpdate: ({ editor }) => { - console.log(editor.getText()); - }, onTransaction: ({ editor }) => { editorState = { editor: editor as Editor }; }, @@ -64,13 +57,17 @@ }); }); - onDestroy(() => { + const cleanup = () => { editorState.editor?.destroy(); undoManager?.destroy(); provider?.destroy(); - }); + }; + + onDestroy(cleanup); + + {#if editorState.editor}
From 9e71fa83ab072c879d8e709df32674ad7768cad6 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:34:50 +0200 Subject: [PATCH 26/41] fix: show correct username for notes cursor --- frontend/src/lib/components/tiptap/TipTab.svelte | 16 ++++++++++++++-- frontend/src/routes/notes/[id]/+page.svelte | 12 ++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/tiptap/TipTab.svelte b/frontend/src/lib/components/tiptap/TipTab.svelte index cb72e8cf..5e97ed88 100644 --- a/frontend/src/lib/components/tiptap/TipTab.svelte +++ b/frontend/src/lib/components/tiptap/TipTab.svelte @@ -11,9 +11,11 @@ import { ScrollArea } from '@profidev/pleiades/components/ui/scroll-area'; const { - id + id, + username }: { id: string; + username?: string; } = $props(); let editorState = $state<{ editor: Editor | null }>({ editor: null }); @@ -21,6 +23,16 @@ let provider: WebsocketProvider | undefined = undefined; let undoManager: Y.UndoManager | undefined = undefined; + $effect(() => { + username; + const localState = provider?.awareness.getLocalState(); + const currentUser = localState?.user ?? {}; + provider?.awareness.setLocalStateField('user', { + ...currentUser, + name: username ?? 'Unknown' + }); + }); + onMount(() => { provider = new WebsocketProvider('/api/notes/websocket', id, doc, { disableBc: true @@ -40,7 +52,7 @@ CollaborationCaret.configure({ provider, user: { - name: 'Anonymous', + name: username ?? 'Unknown', color: getRandomColor() } }) diff --git a/frontend/src/routes/notes/[id]/+page.svelte b/frontend/src/routes/notes/[id]/+page.svelte index 27c10b2d..e610eeb4 100644 --- a/frontend/src/routes/notes/[id]/+page.svelte +++ b/frontend/src/routes/notes/[id]/+page.svelte @@ -14,7 +14,8 @@ editNote, shareNote, type NoteInfo, - type SimpleUserInfo + type SimpleUserInfo, + type UserInfo } from '$lib/client'; import { Label } from '@profidev/pleiades/components/ui/label'; import TipTab from '$lib/components/tiptap/TipTab.svelte'; @@ -32,6 +33,7 @@ let sharedWithIds = $state([]); let sharedUpdateTimeout: ReturnType | undefined = $state(undefined); + let userInfo: UserInfo | undefined = $state(undefined); let shareableUsers = $derived( users?.filter((user) => user.id !== note?.owner.id) ?? [] @@ -60,6 +62,12 @@ }); }); + $effect(() => { + data.user.then((userInfoData) => { + userInfo = userInfoData; + }); + }); + const saveTitle = async () => { if (!note || readonly) return; @@ -182,7 +190,7 @@
- +
Date: Fri, 12 Jun 2026 17:49:27 +0200 Subject: [PATCH 27/41] fix: undo redo plugin --- frontend/src/lib/components/tiptap/TipTab.svelte | 8 +------- frontend/src/lib/components/tiptap/config.ts | 3 ++- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/tiptap/TipTab.svelte b/frontend/src/lib/components/tiptap/TipTab.svelte index 5e97ed88..e1093067 100644 --- a/frontend/src/lib/components/tiptap/TipTab.svelte +++ b/frontend/src/lib/components/tiptap/TipTab.svelte @@ -21,7 +21,6 @@ let editorState = $state<{ editor: Editor | null }>({ editor: null }); const doc = new Y.Doc(); let provider: WebsocketProvider | undefined = undefined; - let undoManager: Y.UndoManager | undefined = undefined; $effect(() => { username; @@ -37,17 +36,13 @@ provider = new WebsocketProvider('/api/notes/websocket', id, doc, { disableBc: true }); - undoManager = new Y.UndoManager(doc); editorState.editor = new Editor({ extensions: [ ...extensions, Collaboration.configure({ document: doc, - provider, - yUndoOptions: { - undoManager - } + provider }), CollaborationCaret.configure({ provider, @@ -71,7 +66,6 @@ const cleanup = () => { editorState.editor?.destroy(); - undoManager?.destroy(); provider?.destroy(); }; diff --git a/frontend/src/lib/components/tiptap/config.ts b/frontend/src/lib/components/tiptap/config.ts index 7a708203..d69d8d18 100644 --- a/frontend/src/lib/components/tiptap/config.ts +++ b/frontend/src/lib/components/tiptap/config.ts @@ -20,7 +20,8 @@ export const extensions = [ HTMLAttributes: { class: 'list-decimal' } - } + }, + undoRedo: false }), TextAlign.configure({ types: ['heading', 'paragraph'] From 74fd12dff305432915a254d3ac95624b98c92c44 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:09:36 +0200 Subject: [PATCH 28/41] fix: save doc event without websocket disconnect to prevent data loss --- backend/src/notes/state.rs | 55 ++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/backend/src/notes/state.rs b/backend/src/notes/state.rs index 0b88e2c4..959a7e3d 100644 --- a/backend/src/notes/state.rs +++ b/backend/src/notes/state.rs @@ -1,6 +1,9 @@ -use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, +use std::{ + sync::{ + Arc, + atomic::{AtomicIsize, AtomicUsize, Ordering}, + }, + time::Duration, }; use axum::{ @@ -21,6 +24,7 @@ use tokio::{ broadcast::{Receiver, Sender, channel}, mpsc, }, + time::sleep, }; use uuid::Uuid; use yrs::{ @@ -52,6 +56,7 @@ pub struct NoteState { #[allow(dead_code)] awareness_subscription: Subscription, subscriber_count: AtomicUsize, + save_counter: AtomicIsize, } impl NoteEditing { @@ -122,11 +127,14 @@ impl NoteEditing { let state = Arc::new(NoteState { doc: doc_arc, subscriber_count: AtomicUsize::new(1), + save_counter: AtomicIsize::new(0), doc_subscription, awareness_subscription, sender, }); + state.clone().start_save_task(db.clone(), note_id); + self.docs.insert(note_id, state.clone()); Ok(state) @@ -147,14 +155,8 @@ impl NoteEditing { return Ok(()); }; - let awareness = state.doc.lock().await; - let doc = awareness.doc(); - let content = doc - .transact() - .await - .encode_state_as_update_v1(&StateVector::default()); - - db.notes().set_content(note_id, content).await?; + state.save(db, note_id).await?; + state.save_counter.store(-1, Ordering::Relaxed); Ok(()) } @@ -197,6 +199,8 @@ impl NoteState { }; drop(awareness); + self.save_counter.fetch_add(1, Ordering::Relaxed); + for msg in res { let payload = msg.encode_v1(); if let Err(e) = ws.send(Message::Binary(Bytes::from_owner(payload))).await { @@ -204,4 +208,33 @@ impl NoteState { } } } + + pub async fn save(&self, db: &Connection, note_id: Uuid) -> Result<()> { + let awareness = self.doc.lock().await; + let doc = awareness.doc(); + let content = doc + .transact() + .await + .encode_state_as_update_v1(&StateVector::default()); + + db.notes().set_content(note_id, content).await?; + + Ok(()) + } + + pub fn start_save_task(self: Arc, db: Connection, note_id: Uuid) { + spawn(async move { + loop { + let count = self.save_counter.swap(0, Ordering::Relaxed); + if count > 0 { + self.save(&db, note_id).await.ok(); + } else if count < 0 { + drop(self); + return; + } + + sleep(Duration::from_secs(10)).await; + } + }); + } } From 66c9cd03b667d7a6188b8b8321da964adee2658e Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:33:00 +0200 Subject: [PATCH 29/41] fix: limit doc size --- backend/src/notes/state.rs | 8 +++++ frontend/package.json | 1 + frontend/src/lib/components/tiptap/config.ts | 8 ++++- frontend/src/lib/components/tiptap/tiptap.css | 1 - package-lock.json | 29 +++++++++++++++++++ 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/backend/src/notes/state.rs b/backend/src/notes/state.rs index 959a7e3d..72bb97f3 100644 --- a/backend/src/notes/state.rs +++ b/backend/src/notes/state.rs @@ -42,6 +42,8 @@ use yrs::{ use crate::db::DBTrait; +const MB: usize = 1024 * 1024; + #[derive(Clone, FromRequestParts)] #[from_request(via(Extension))] pub struct NoteEditing { @@ -212,11 +214,17 @@ impl NoteState { pub async fn save(&self, db: &Connection, note_id: Uuid) -> Result<()> { let awareness = self.doc.lock().await; let doc = awareness.doc(); + doc.transact_mut().await.gc(None); let content = doc .transact() .await .encode_state_as_update_v1(&StateVector::default()); + if content.len() > MB * 10 { + tracing::warn!("content size exceeds 10MB: {}", content.len()); + return Ok(()); + } + db.notes().set_content(note_id, content).await?; Ok(()) diff --git a/frontend/package.json b/frontend/package.json index 6aa9c5b3..5ac75748 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@sveltejs/enhanced-img": "0.10.4", "@tiptap/core": "3.26.1", "@tiptap/extension-bubble-menu": "3.26.1", + "@tiptap/extension-character-count": "3.26.1", "@tiptap/extension-collaboration": "3.26.1", "@tiptap/extension-collaboration-caret": "3.26.1", "@tiptap/extension-highlight": "3.26.1", diff --git a/frontend/src/lib/components/tiptap/config.ts b/frontend/src/lib/components/tiptap/config.ts index d69d8d18..722c8b4d 100644 --- a/frontend/src/lib/components/tiptap/config.ts +++ b/frontend/src/lib/components/tiptap/config.ts @@ -5,6 +5,7 @@ import Typography from '@tiptap/extension-typography'; import StarterKit from '@tiptap/starter-kit'; import type { Extensions } from '@tiptap/core'; import SearchAndReplace from './extensions/search-and-replace'; +import CharacterCount from '@tiptap/extension-character-count'; export const extensions = [ StarterKit.configure({ @@ -32,7 +33,12 @@ export const extensions = [ multicolor: true }), SearchAndReplace, - Typography + Typography, + CharacterCount.configure({ + autoTrim: true, + limit: 50_000, + mode: 'nodeSize' + }) ] satisfies Extensions; const AVATAR_COLORS = [ diff --git a/frontend/src/lib/components/tiptap/tiptap.css b/frontend/src/lib/components/tiptap/tiptap.css index 5679050f..c18c6275 100644 --- a/frontend/src/lib/components/tiptap/tiptap.css +++ b/frontend/src/lib/components/tiptap/tiptap.css @@ -142,7 +142,6 @@ line-height: 1.75; margin: 0; color: var(--editor-text-default); - font-size: 1.125rem; } .ProseMirror > p { diff --git a/package-lock.json b/package-lock.json index 6082c9b8..8639fdcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,7 @@ "@sveltejs/enhanced-img": "0.10.4", "@tiptap/core": "3.26.1", "@tiptap/extension-bubble-menu": "3.26.1", + "@tiptap/extension-character-count": "3.26.1", "@tiptap/extension-collaboration": "3.26.1", "@tiptap/extension-collaboration-caret": "3.26.1", "@tiptap/extension-highlight": "3.26.1", @@ -196,6 +197,19 @@ "@tiptap/pm": "3.26.1" } }, + "frontend/node_modules/@tiptap/extension-character-count": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-3.26.1.tgz", + "integrity": "sha512-AlCIYOJB2TORPPinCHwZ1iMyJtvcxyR8scqUnd5QOduh36DCJYaP2WDJP0rP8owDccFyZh7vFACwjSWl3JERyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.26.1" + } + }, "frontend/node_modules/@tiptap/extension-collaboration": { "version": "3.26.1", "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.26.1.tgz", @@ -279,6 +293,21 @@ "@tiptap/core": "3.26.1" } }, + "frontend/node_modules/@tiptap/extensions": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.26.1.tgz", + "integrity": "sha512-PmRaoe6bebTgz/ZQrjmzwZMST1d9js9ZTiKnUXeXl3Fm+V5U/c3TbbKDfqmL63qPQdjtShDMHi9tYuv+c77OFQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1", + "@tiptap/pm": "3.26.1" + } + }, "frontend/node_modules/@tiptap/pm": { "version": "3.26.1", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.26.1.tgz", From b8ad61a4b0b71a456f4afee0523699e5ae9af7a4 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:54:39 +0200 Subject: [PATCH 30/41] feat: added notes preview --- backend/entity/src/entities/note.rs | 1 + .../src/m20260611_120000_create_note_table.rs | 2 + backend/src/db/notes.rs | 8 ++- backend/src/notes/mod.rs | 1 + backend/src/notes/preview.rs | 61 +++++++++++++++++++ backend/src/notes/state.rs | 5 +- frontend/src/routes/notes/+page.svelte | 4 +- 7 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 backend/src/notes/preview.rs diff --git a/backend/entity/src/entities/note.rs b/backend/entity/src/entities/note.rs index 0d60890a..38f338ee 100644 --- a/backend/entity/src/entities/note.rs +++ b/backend/entity/src/entities/note.rs @@ -10,6 +10,7 @@ pub struct Model { pub title: String, #[sea_orm(column_type = "VarBinary(StringLen::None)")] pub content: Vec, + pub preview: String, pub owner: Uuid, } diff --git a/backend/migration/src/m20260611_120000_create_note_table.rs b/backend/migration/src/m20260611_120000_create_note_table.rs index eaff4e59..b2fcc40b 100644 --- a/backend/migration/src/m20260611_120000_create_note_table.rs +++ b/backend/migration/src/m20260611_120000_create_note_table.rs @@ -15,6 +15,7 @@ impl MigrationTrait for Migration { .col(pk_uuid(Note::Id)) .col(string(Note::Title)) .col(binary(Note::Content)) + .col(string(Note::Preview)) .col(uuid(Note::Owner)) .foreign_key( ForeignKey::create() @@ -77,6 +78,7 @@ enum Note { Id, Title, Content, + Preview, Owner, } diff --git a/backend/src/db/notes.rs b/backend/src/db/notes.rs index e98e7b8c..29f8eee6 100644 --- a/backend/src/db/notes.rs +++ b/backend/src/db/notes.rs @@ -109,7 +109,7 @@ impl<'db> NoteTable<'db> { Ok(NoteInfo { id: note.id, title: note.title, - preview: "".into(), + preview: note.preview, owner: SimpleUserInfo { id: owner.id, name: owner.name, @@ -156,7 +156,7 @@ impl<'db> NoteTable<'db> { Ok(Some(NoteInfo { id: note.id, title: note.title, - preview: "".into(), + preview: note.preview, owner: SimpleUserInfo { id: owner.id, name: owner.name, @@ -174,6 +174,7 @@ impl<'db> NoteTable<'db> { id: Set(id), title: Set(title), content: Set(Vec::new()), + preview: Set("".into()), owner: Set(owner), } .insert(&txn) @@ -216,7 +217,7 @@ impl<'db> NoteTable<'db> { Ok(()) } - pub async fn set_content(&self, note_id: Uuid, content: Vec) -> Result<()> { + pub async fn set_content(&self, note_id: Uuid, content: Vec, preview: String) -> Result<()> { let mut note: note::ActiveModel = Note::find_by_id(note_id) .one(self.db) .await? @@ -224,6 +225,7 @@ impl<'db> NoteTable<'db> { .into(); note.content = Set(content); + note.preview = Set(preview); note.update(self.db).await?; Ok(()) diff --git a/backend/src/notes/mod.rs b/backend/src/notes/mod.rs index 9816f3f1..c285d38f 100644 --- a/backend/src/notes/mod.rs +++ b/backend/src/notes/mod.rs @@ -4,6 +4,7 @@ use axum::Extension; use crate::notes::state::NoteEditing; mod management; +mod preview; mod state; mod websocket; diff --git a/backend/src/notes/preview.rs b/backend/src/notes/preview.rs new file mode 100644 index 00000000..1c90ec23 --- /dev/null +++ b/backend/src/notes/preview.rs @@ -0,0 +1,61 @@ +use yrs::{AsyncTransact, Doc, GetString, ReadTxn}; + +const PREVIEW_MAX_LENGTH: usize = 500; + +pub async fn render_preview(doc: &Doc) -> String { + let txn = doc.transact().await; + let Some(fragment) = txn.get_xml_fragment("default") else { + return String::new(); + }; + + let content = fragment.get_string(&txn); + xml_to_string(&content) +} + +fn xml_to_string(content: &str) -> String { + let mut result = String::new(); + + let mut in_tag = false; + let mut tag_buffer = String::new(); + let mut is_trimmed = false; + + for c in content.chars() { + if result.chars().count() >= PREVIEW_MAX_LENGTH { + is_trimmed = true; + break; + } + + match c { + '<' => { + in_tag = true; + tag_buffer.clear(); + } + '>' => { + in_tag = false; + if tag_buffer.starts_with("/") + && (tag_buffer.contains("paragraph") + || tag_buffer.contains("heading") + || tag_buffer.contains("li")) + && !result.ends_with(' ') + && !result.is_empty() + { + result.push(' '); + } + } + c => { + if in_tag { + tag_buffer.push(c); + } else { + result.push(c); + } + } + } + } + + let trimmed = result.trim(); + if is_trimmed { + format!("{}...", &trimmed[..PREVIEW_MAX_LENGTH]) + } else { + trimmed.to_string() + } +} diff --git a/backend/src/notes/state.rs b/backend/src/notes/state.rs index 72bb97f3..2b1a7dd7 100644 --- a/backend/src/notes/state.rs +++ b/backend/src/notes/state.rs @@ -40,7 +40,7 @@ use yrs::{ }, }; -use crate::db::DBTrait; +use crate::{db::DBTrait, notes::preview::render_preview}; const MB: usize = 1024 * 1024; @@ -225,7 +225,8 @@ impl NoteState { return Ok(()); } - db.notes().set_content(note_id, content).await?; + let preview = render_preview(doc).await; + db.notes().set_content(note_id, content, preview).await?; Ok(()) } diff --git a/frontend/src/routes/notes/+page.svelte b/frontend/src/routes/notes/+page.svelte index 8f279966..fe9e2f1e 100644 --- a/frontend/src/routes/notes/+page.svelte +++ b/frontend/src/routes/notes/+page.svelte @@ -81,9 +81,9 @@ {#each notes as note (note.id)} - +
{note.title} From 5938a9a4e703a327489f3c7b18d95a8d689da33a Mon Sep 17 00:00:00 2001 From: "profidev-commit-bot[bot]" <276593624+profidev-commit-bot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:56:22 +0000 Subject: [PATCH 31/41] chore: fix code style issues with oxfmt --- frontend/src/routes/notes/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/notes/+page.svelte b/frontend/src/routes/notes/+page.svelte index fe9e2f1e..68255e56 100644 --- a/frontend/src/routes/notes/+page.svelte +++ b/frontend/src/routes/notes/+page.svelte @@ -81,7 +81,7 @@ {#each notes as note (note.id)}
From b573559bdad676f087d4e48674187e1b83c021d3 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:51:01 +0200 Subject: [PATCH 32/41] refactor: remove custom mobile menu for editor toolbar --- .../tiptap/toolbar/MobileToolbarItem.svelte | 36 ---- .../tiptap/toolbar/alignment.svelte | 113 +++++----- .../tiptap/toolbar/color-and-highlight.svelte | 194 +++++++----------- .../components/tiptap/toolbar/headings.svelte | 131 +++++------- .../lib/components/tiptap/toolbar/link.svelte | 3 - .../toolbar/mobile-toolbar-group.svelte | 63 ------ .../toolbar/search-and-replace-toolbar.svelte | 1 - 7 files changed, 175 insertions(+), 366 deletions(-) delete mode 100644 frontend/src/lib/components/tiptap/toolbar/MobileToolbarItem.svelte delete mode 100644 frontend/src/lib/components/tiptap/toolbar/mobile-toolbar-group.svelte diff --git a/frontend/src/lib/components/tiptap/toolbar/MobileToolbarItem.svelte b/frontend/src/lib/components/tiptap/toolbar/MobileToolbarItem.svelte deleted file mode 100644 index 20c2ee08..00000000 --- a/frontend/src/lib/components/tiptap/toolbar/MobileToolbarItem.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/frontend/src/lib/components/tiptap/toolbar/alignment.svelte b/frontend/src/lib/components/tiptap/toolbar/alignment.svelte index ec994fd6..1f680f10 100644 --- a/frontend/src/lib/components/tiptap/toolbar/alignment.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/alignment.svelte @@ -18,14 +18,13 @@ TooltipContent, TooltipTrigger } from '@profidev/pleiades/components/ui/tooltip'; - import { IsMobile } from '@profidev/pleiades/hooks/is-mobile.svelte'; import type { Editor } from '@tiptap/core'; - import MobileToolbarGroup from './mobile-toolbar-group.svelte'; - import MobileToolbarItem from './MobileToolbarItem.svelte'; - let { editor }: { editor: Editor } = $props(); - - const isMobile = new IsMobile(); + let { + editor + }: { + editor: Editor; + } = $props(); const alignmentOptions = [ { name: 'Left Align', value: 'left', icon: AlignLeftIcon }, @@ -52,71 +51,51 @@ ); -{#if isMobile.current} - - {#snippet children({ closeDrawer })} + + + + {#snippet child({ props })} + {@const CurrentIcon = currentOption.icon} + + {#snippet child({ props: triggerProps })} + + {/snippet} + + {/snippet} + + Text Alignment + + e.preventDefault()} + class="w-42" + > + {#each alignmentOptions as option, index (index)} {@const OptionIcon = option.icon} - handleAlign(option.value)} - active={currentTextAlign === option.value} - > + handleAlign(option.value)}> {option.name} - + {#if option.value === currentTextAlign} + + {/if} + {/each} - {/snippet} - -{:else} - - - - {#snippet child({ props })} - {@const CurrentIcon = currentOption.icon} - - {#snippet child({ props: triggerProps })} - - {/snippet} - - {/snippet} - - Text Alignment - - e.preventDefault()} - class="w-42" - > - - {#each alignmentOptions as option, index (index)} - {@const OptionIcon = option.icon} - handleAlign(option.value)}> - - - - {option.name} - {#if option.value === currentTextAlign} - - {/if} - - {/each} - - - -{/if} + + + diff --git a/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte b/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte index c96f70e6..9cf89f4c 100644 --- a/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte @@ -15,11 +15,8 @@ TooltipTrigger } from '@profidev/pleiades/components/ui/tooltip'; import * as Command from '@profidev/pleiades/components/ui/command'; - import { IsMobile } from '@profidev/pleiades/hooks/is-mobile.svelte'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; - import MobileToolbarGroup from './mobile-toolbar-group.svelte'; - import MobileToolbarItem from './MobileToolbarItem.svelte'; const TEXT_COLORS = [ { name: 'Default', color: 'var(--editor-text-default)' }, @@ -47,9 +44,11 @@ { name: 'Red', color: 'var(--editor-highlight-red)' } ] as const; - let { editor }: { editor: Editor } = $props(); - - const isMobile = new IsMobile(); + let { + editor + }: { + editor: Editor; + } = $props(); const currentColor = $derived( editor.getAttributes('textStyle').color as string | undefined @@ -79,125 +78,82 @@ } -{#if isMobile.current} -
- - {#snippet children({ closeDrawer })} - {#each TEXT_COLORS as { name, color } (name)} - handleSetColor(color)} - active={currentColor === color} - > -
-
A
- {name} -
-
- {/each} - {/snippet} -
- - - {#snippet children({ closeDrawer })} - {#each HIGHLIGHT_COLORS as { name, color } (name)} - handleSetHighlight(color)} - active={currentHighlight === color} - > -
-
+
+ + + {#snippet child({ props })} + + {#snippet child({ props: triggerProps })} +
- {name} -
- - {/each} - {/snippet} - -
-{:else} - -
- - - {#snippet child({ props })} - - {#snippet child({ props: triggerProps })} - - {/snippet} - - {/snippet} - - Text Color & Highlight - + A + + + {/snippet} + + {/snippet} + + Text Color & Highlight + - - - - - - {#each TEXT_COLORS as { name, color } (name)} - handleSetColor(color)} - class="flex w-full cursor-pointer items-center rounded-sm px-2 py-1 text-sm [&_svg.cn-command-item-indicator]:hidden!" + + + + + + {#each TEXT_COLORS as { name, color } (name)} + handleSetColor(color)} + class="flex w-full cursor-pointer items-center rounded-sm px-2 py-1 text-sm [&_svg.cn-command-item-indicator]:hidden!" + > +
+ A +
+ {name} + {#if currentColor === color} + + {/if} +
+ {/each} +
+ + + + + {#each HIGHLIGHT_COLORS as { name, color } (name)} + handleSetHighlight(color)} + class="flex w-full cursor-pointer items-center rounded-sm px-2 py-1 text-sm [&_svg.cn-command-item-indicator]:hidden!" + > +
A
{name} - {#if currentColor === color} - - {/if} - - {/each} - - - - - - {#each HIGHLIGHT_COLORS as { name, color } (name)} - handleSetHighlight(color)} - class="flex w-full cursor-pointer items-center rounded-sm px-2 py-1 text-sm [&_svg.cn-command-item-indicator]:hidden!" - > -
-
- A -
- {name} -
- {#if currentHighlight === color} - - {/if} -
- {/each} -
- - - - -
- -{/if} +
+ {#if currentHighlight === color} + + {/if} + + {/each} + + + + + +
+ diff --git a/frontend/src/lib/components/tiptap/toolbar/headings.svelte b/frontend/src/lib/components/tiptap/toolbar/headings.svelte index 0c2905ae..047509ed 100644 --- a/frontend/src/lib/components/tiptap/toolbar/headings.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/headings.svelte @@ -12,18 +12,18 @@ TooltipContent, TooltipTrigger } from '@profidev/pleiades/components/ui/tooltip'; - import { IsMobile } from '@profidev/pleiades/hooks/is-mobile.svelte'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; - import MobileToolbarGroup from './mobile-toolbar-group.svelte'; - import MobileToolbarItem from './MobileToolbarItem.svelte'; const levels = [1, 2, 3, 4] as const; - let { editor, class: className }: { editor: Editor; class?: string } = - $props(); - - const isMobile = new IsMobile(); + let { + editor, + class: className + }: { + editor: Editor; + class?: string; + } = $props(); const activeLevel = $derived( levels.find((level) => editor.isActive('heading', { level })) @@ -31,78 +31,55 @@ const isHeadingActive = $derived(editor.isActive('heading')); -{#if isMobile.current} - - {#snippet children({ closeDrawer })} - editor.chain().focus().setParagraph().run()} - active={!editor.isActive('heading')} - > - Normal - - {#each levels as level (level)} - editor.chain().focus().toggleHeading({ level }).run()} - active={editor.isActive('heading', { level })} - > - H{level} - - {/each} - {/snippet} - -{:else} - - - {#snippet child({ props })} - - - {#snippet child({ props: triggerProps })} - - {/snippet} - - + + + {#snippet child({ props })} + + + {#snippet child({ props: triggerProps })} + + {/snippet} + + + editor.chain().focus().setParagraph().run()} + class={cn( + 'flex h-fit items-center gap-2', + !isHeadingActive && 'bg-accent' + )} + > + Normal + + {#each levels as level (level)} editor.chain().focus().setParagraph().run()} + onclick={() => + editor.chain().focus().toggleHeading({ level }).run()} class={cn( - 'flex h-fit items-center gap-2', - !isHeadingActive && 'bg-accent' + 'flex items-center gap-2', + editor.isActive('heading', { level }) && 'bg-accent' )} > - Normal + H{level} - {#each levels as level (level)} - - editor.chain().focus().toggleHeading({ level }).run()} - class={cn( - 'flex items-center gap-2', - editor.isActive('heading', { level }) && 'bg-accent' - )} - > - H{level} - - {/each} - - - {/snippet} - - - Headings - - -{/if} + {/each} + + + {/snippet} + + + Headings + + diff --git a/frontend/src/lib/components/tiptap/toolbar/link.svelte b/frontend/src/lib/components/tiptap/toolbar/link.svelte index f6b9ea9f..ffe9cf5e 100644 --- a/frontend/src/lib/components/tiptap/toolbar/link.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/link.svelte @@ -1,12 +1,9 @@ - - - - - - {#snippet child({ props })} - - {/snippet} - - - - {label} - -
- {@render children({ closeDrawer })} -
-
-
diff --git a/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte index 741577a3..083fcda2 100644 --- a/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte @@ -11,7 +11,6 @@ PopoverContent, PopoverTrigger } from '@profidev/pleiades/components/ui/popover'; - import { Separator } from '@profidev/pleiades/components/ui/separator'; import { Toggle } from '@profidev/pleiades/components/ui/toggle'; import { Tooltip, From 562ecdebd439bf9cf69a851dd05361550ab0b243 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:11:18 +0200 Subject: [PATCH 33/41] style: better overflow handling for editor toolbar --- .../tiptap/toolbar/EditorToolbar.svelte | 49 +---- .../tiptap/toolbar/alignment.svelte | 86 ++++++--- .../tiptap/toolbar/blockquote.svelte | 60 +++--- .../lib/components/tiptap/toolbar/bold.svelte | 62 +++--- .../tiptap/toolbar/bullet-list.svelte | 68 ++++--- .../tiptap/toolbar/code-block.svelte | 68 ++++--- .../tiptap/toolbar/color-and-highlight.svelte | 168 ++++++++++------- .../tiptap/toolbar/hard-break.svelte | 38 ---- .../components/tiptap/toolbar/headings.svelte | 128 +++++++------ .../components/tiptap/toolbar/italic.svelte | 70 ++++--- .../lib/components/tiptap/toolbar/link.svelte | 109 +++++++---- .../tiptap/toolbar/ordered-list.svelte | 68 ++++--- .../lib/components/tiptap/toolbar/redo.svelte | 66 ++++--- .../toolbar/search-and-replace-toolbar.svelte | 92 +++++---- .../tiptap/toolbar/strikethrough.svelte | 70 ++++--- .../toolbar/toolbar-overflow-container.svelte | 178 ++++++++++++++++++ .../toolbar/toolbar-overflow-trigger.svelte | 38 ++++ .../tiptap/toolbar/toolbar-overflow.ts | 67 +++++++ .../tiptap/toolbar/underline.svelte | 70 ++++--- .../lib/components/tiptap/toolbar/undo.svelte | 66 ++++--- 20 files changed, 1062 insertions(+), 559 deletions(-) delete mode 100644 frontend/src/lib/components/tiptap/toolbar/hard-break.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-container.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-trigger.svelte create mode 100644 frontend/src/lib/components/tiptap/toolbar/toolbar-overflow.ts diff --git a/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte index 06f30a49..b611b5e2 100644 --- a/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte @@ -1,58 +1,13 @@
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
+
diff --git a/frontend/src/lib/components/tiptap/toolbar/alignment.svelte b/frontend/src/lib/components/tiptap/toolbar/alignment.svelte index 1f680f10..d559b47e 100644 --- a/frontend/src/lib/components/tiptap/toolbar/alignment.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/alignment.svelte @@ -19,11 +19,14 @@ TooltipTrigger } from '@profidev/pleiades/components/ui/tooltip'; import type { Editor } from '@tiptap/core'; + import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; let { - editor + editor, + inOverflowMenu = false }: { editor: Editor; + inOverflowMenu?: boolean; } = $props(); const alignmentOptions = [ @@ -33,9 +36,9 @@ { name: 'Justify Align', value: 'justify', icon: AlignJustifyIcon } ] as const; - function handleAlign(value: string) { + const handleAlign = (value: string) => { editor.chain().focus().setTextAlign(value).run(); - } + }; const currentTextAlign = $derived.by(() => { if (editor.isActive({ textAlign: 'left' })) return 'left'; @@ -51,33 +54,7 @@ ); - - - - {#snippet child({ props })} - {@const CurrentIcon = currentOption.icon} - - {#snippet child({ props: triggerProps })} - - {/snippet} - - {/snippet} - - Text Alignment - +{#snippet alignmentMenu()} e.preventDefault()} @@ -98,4 +75,51 @@ {/each} - +{/snippet} + +{#if inOverflowMenu} + + + {#snippet child({ props })} + {@const CurrentIcon = currentOption.icon} + + {/snippet} + + {@render alignmentMenu()} + +{:else} + + + + {#snippet child({ props })} + {@const CurrentIcon = currentOption.icon} + + {#snippet child({ props: triggerProps })} + + {/snippet} + + {/snippet} + + Text Alignment + + {@render alignmentMenu()} + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte b/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte index 836d2389..11566484 100644 --- a/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte @@ -8,13 +8,16 @@ } from '@profidev/pleiades/components/ui/tooltip'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; + import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; let { editor, - class: className + class: className, + inOverflowMenu = false }: { editor: Editor; class?: string; + inOverflowMenu?: boolean; } = $props(); const isActive = $derived(editor.isActive('blockquote')); @@ -23,28 +26,39 @@ !editor.can().chain().focus().toggleBlockquote().run() ); - function handleClick() { + const handleClick = () => { editor.chain().focus().toggleBlockquote().run(); - } + }; - - - {#snippet child({ props })} - - {/snippet} - - - Blockquote - - +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + {/snippet} + + + Blockquote + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/bold.svelte b/frontend/src/lib/components/tiptap/toolbar/bold.svelte index a0fb45e4..5229915f 100644 --- a/frontend/src/lib/components/tiptap/toolbar/bold.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/bold.svelte @@ -8,42 +8,56 @@ } from '@profidev/pleiades/components/ui/tooltip'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; + import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; let { editor, - class: className + class: className, + inOverflowMenu = false }: { editor: Editor; class?: string; + inOverflowMenu?: boolean; } = $props(); const isActive = $derived(editor.isActive('bold')); const isDisabled = $derived(!editor.can().chain().focus().toggleBold().run()); - function handleClick() { + const handleClick = () => { editor.chain().focus().toggleBold().run(); - } + }; - - - {#snippet child({ props })} - - {/snippet} - - - Bold - (cmd + b) - - +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + {/snippet} + + + Bold + (cmd + b) + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte b/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte index aed47fa7..76451158 100644 --- a/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte @@ -8,37 +8,57 @@ } from '@profidev/pleiades/components/ui/tooltip'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; + import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; - let { editor, class: className }: { editor: Editor; class?: string } = - $props(); + let { + editor, + class: className, + inOverflowMenu = false + }: { + editor: Editor; + class?: string; + inOverflowMenu?: boolean; + } = $props(); const isActive = $derived(editor.isActive('bulletList')); + const isDisabled = $derived( !editor.can().chain().focus().toggleBulletList().run() ); - function handleClick() { + const handleClick = () => { editor.chain().focus().toggleBulletList().run(); - } + }; - - - {#snippet child({ props })} - - {/snippet} - - - Bullet list - - +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + {/snippet} + + + Bullet List + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/code-block.svelte b/frontend/src/lib/components/tiptap/toolbar/code-block.svelte index e1e874f7..643df397 100644 --- a/frontend/src/lib/components/tiptap/toolbar/code-block.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/code-block.svelte @@ -8,37 +8,57 @@ } from '@profidev/pleiades/components/ui/tooltip'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; + import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; - let { editor, class: className }: { editor: Editor; class?: string } = - $props(); + let { + editor, + class: className, + inOverflowMenu = false + }: { + editor: Editor; + class?: string; + inOverflowMenu?: boolean; + } = $props(); const isActive = $derived(editor.isActive('codeBlock')); + const isDisabled = $derived( !editor.can().chain().focus().toggleCodeBlock().run() ); - function handleClick() { + const handleClick = () => { editor.chain().focus().toggleCodeBlock().run(); - } + }; - - - {#snippet child({ props })} - - {/snippet} - - - Code Block - - +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + {/snippet} + + + Code Block + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte b/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte index 9cf89f4c..18a74982 100644 --- a/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte @@ -1,6 +1,7 @@ - -
- - - {#snippet child({ props })} - - {#snippet child({ props: triggerProps })} - - {/snippet} - - {/snippet} - - Text Color & Highlight - - - - - - - - {#each TEXT_COLORS as { name, color } (name)} - handleSetColor(color)} - class="flex w-full cursor-pointer items-center rounded-sm px-2 py-1 text-sm [&_svg.cn-command-item-indicator]:hidden!" +
+ A +
+ {name} + {#if currentColor === color} + + {/if} +
+ {/each} +
+ + + + + {#each HIGHLIGHT_COLORS as { name, color } (name)} + handleSetHighlight(color)} + class="flex w-full cursor-pointer items-center rounded-sm px-2 py-1 text-sm [&_svg.cn-command-item-indicator]:hidden!" + > +
A
{name} - {#if currentColor === color} - - {/if} - - {/each} - - - +
+ {#if currentHighlight === color} + + {/if} +
+ {/each} +
+
+
+
+
+{/snippet} - - {#each HIGHLIGHT_COLORS as { name, color } (name)} - handleSetHighlight(color)} - class="flex w-full cursor-pointer items-center rounded-sm px-2 py-1 text-sm [&_svg.cn-command-item-indicator]:hidden!" + + {#if inOverflowMenu} + + {#snippet child({ props })} + + {/snippet} + + {@render colorMenu()} + {:else} +
+ + + {#snippet child({ props })} + + {#snippet child({ props: triggerProps })} +
+ A + + + {/snippet} + + {/snippet} + + Text Color & Highlight + + {@render colorMenu()} +
+ {/if}
diff --git a/frontend/src/lib/components/tiptap/toolbar/hard-break.svelte b/frontend/src/lib/components/tiptap/toolbar/hard-break.svelte deleted file mode 100644 index d32bffca..00000000 --- a/frontend/src/lib/components/tiptap/toolbar/hard-break.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - - - - {#snippet child({ props })} - - {/snippet} - - - Hard break - - diff --git a/frontend/src/lib/components/tiptap/toolbar/headings.svelte b/frontend/src/lib/components/tiptap/toolbar/headings.svelte index 047509ed..007ebc23 100644 --- a/frontend/src/lib/components/tiptap/toolbar/headings.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/headings.svelte @@ -1,5 +1,6 @@ - - - {#snippet child({ props })} - - - {#snippet child({ props: triggerProps })} - - {/snippet} - - - editor.chain().focus().setParagraph().run()} - class={cn( - 'flex h-fit items-center gap-2', - !isHeadingActive && 'bg-accent' - )} - > - Normal - - {#each levels as level (level)} - - editor.chain().focus().toggleHeading({ level }).run()} - class={cn( - 'flex items-center gap-2', - editor.isActive('heading', { level }) && 'bg-accent' - )} - > - H{level} - - {/each} - - - {/snippet} - - - Headings - - +{#snippet headingMenu()} + + editor.chain().focus().setParagraph().run()} + class={cn('flex h-fit items-center gap-2', !isHeadingActive && 'bg-accent')} + > + Normal + + {#each levels as level (level)} + editor.chain().focus().toggleHeading({ level }).run()} + class={cn( + 'flex items-center gap-2', + editor.isActive('heading', { level }) && 'bg-accent' + )} + > + H{level} + + {/each} + +{/snippet} + +{#if inOverflowMenu} + + + {#snippet child({ props })} + + {/snippet} + + {@render headingMenu()} + +{:else} + + + {#snippet child({ props })} + + + {#snippet child({ props: triggerProps })} + + {/snippet} + + {@render headingMenu()} + + {/snippet} + + + Headings + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/italic.svelte b/frontend/src/lib/components/tiptap/toolbar/italic.svelte index f9aba76e..8ba22da6 100644 --- a/frontend/src/lib/components/tiptap/toolbar/italic.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/italic.svelte @@ -8,38 +8,58 @@ } from '@profidev/pleiades/components/ui/tooltip'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; + import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; - let { editor, class: className }: { editor: Editor; class?: string } = - $props(); + let { + editor, + class: className, + inOverflowMenu = false + }: { + editor: Editor; + class?: string; + inOverflowMenu?: boolean; + } = $props(); const isActive = $derived(editor.isActive('italic')); + const isDisabled = $derived( !editor.can().chain().focus().toggleItalic().run() ); - function handleClick() { + const handleClick = () => { editor.chain().focus().toggleItalic().run(); - } + }; - - - {#snippet child({ props })} - - {/snippet} - - - Italic - (cmd + i) - - +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + {/snippet} + + + Italic + (cmd + i) + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/link.svelte b/frontend/src/lib/components/tiptap/toolbar/link.svelte index ffe9cf5e..5cda9b25 100644 --- a/frontend/src/lib/components/tiptap/toolbar/link.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/link.svelte @@ -1,4 +1,5 @@ - - - - {#snippet child({ props })} - - {#snippet child({ props: triggerProps })} - - {/snippet} - - {/snippet} - - - Link - - - +{#snippet linkMenu()} e.preventDefault()} class="relative px-3 py-2.5" @@ -126,4 +104,53 @@
+{/snippet} + + + {#if inOverflowMenu} + + {#snippet child({ props })} + + {/snippet} + + {@render linkMenu()} + {:else} + + + {#snippet child({ props })} + + {#snippet child({ props: triggerProps })} + + {/snippet} + + {/snippet} + + + Link + + + {@render linkMenu()} + {/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte b/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte index 93855a6b..593e3491 100644 --- a/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte @@ -8,37 +8,57 @@ } from '@profidev/pleiades/components/ui/tooltip'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; + import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; - let { editor, class: className }: { editor: Editor; class?: string } = - $props(); + let { + editor, + class: className, + inOverflowMenu = false + }: { + editor: Editor; + class?: string; + inOverflowMenu?: boolean; + } = $props(); const isActive = $derived(editor.isActive('orderedList')); + const isDisabled = $derived( !editor.can().chain().focus().toggleOrderedList().run() ); - function handleClick() { + const handleClick = () => { editor.chain().focus().toggleOrderedList().run(); - } + }; - - - {#snippet child({ props })} - - {/snippet} - - - Ordered list - - +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + {/snippet} + + + Ordered List + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/redo.svelte b/frontend/src/lib/components/tiptap/toolbar/redo.svelte index 872ad18f..7fa28cb6 100644 --- a/frontend/src/lib/components/tiptap/toolbar/redo.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/redo.svelte @@ -8,34 +8,52 @@ } from '@profidev/pleiades/components/ui/tooltip'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; + import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; - let { editor, class: className }: { editor: Editor; class?: string } = - $props(); + let { + editor, + class: className, + inOverflowMenu = false + }: { + editor: Editor; + class?: string; + inOverflowMenu?: boolean; + } = $props(); const isDisabled = $derived(!editor.can().chain().focus().redo().run()); - function handleClick() { + const handleClick = () => { editor.chain().focus().redo().run(); - } + }; - - - {#snippet child({ props })} - - {/snippet} - - - Redo - - +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + {/snippet} + + + Redo + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte index 083fcda2..19bf1159 100644 --- a/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte @@ -20,8 +20,15 @@ import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; import { isValidSearchPattern } from '../extensions/search-and-replace'; + import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; - let { editor }: { editor: Editor } = $props(); + let { + editor, + inOverflowMenu = false + }: { + editor: Editor; + inOverflowMenu?: boolean; + } = $props(); let open = $state(false); let replacing = $state(false); @@ -38,21 +45,21 @@ !isValidSearchPattern(searchText, useRegex, caseSensitive) ); - function refreshSearchDecorations() { + const refreshSearchDecorations = () => { const { state, view } = editor; view.dispatch(state.tr); - } + }; - function syncSearchToEditor() { + const syncSearchToEditor = () => { const storage = editor.storage.searchAndReplace; storage.searchTerm = searchText; storage.replaceTerm = replaceText; storage.caseSensitive = caseSensitive; storage.useRegex = useRegex; refreshSearchDecorations(); - } + }; - function resetSearchState() { + const resetSearchState = () => { searchText = ''; replaceText = ''; caseSensitive = false; @@ -67,34 +74,34 @@ storage.selectedResult = 0; storage.results = []; refreshSearchDecorations(); - } + }; - function handleOpenChange(nextOpen: boolean) { + const handleOpenChange = (nextOpen: boolean) => { open = nextOpen; if (!nextOpen) { resetSearchState(); } - } + }; - function handleSearchInput(value: string) { + const handleSearchInput = (value: string) => { searchText = value; syncSearchToEditor(); - } + }; - function handleReplaceInput(value: string) { + const handleReplaceInput = (value: string) => { replaceText = value; syncSearchToEditor(); - } + }; - function handleCaseSensitiveChange(pressed: boolean) { + const handleCaseSensitiveChange = (pressed: boolean) => { caseSensitive = pressed; syncSearchToEditor(); - } + }; - function handleUseRegexChange(pressed: boolean) { + const handleUseRegexChange = (pressed: boolean) => { useRegex = pressed; syncSearchToEditor(); - } + }; const replace = () => editor.chain().replace().run(); const replaceAll = () => editor.chain().replaceAll().run(); @@ -102,23 +109,7 @@ const selectPrevious = () => editor.chain().selectPreviousResult().run(); - - - {#snippet child({ props })} - - {/snippet} - - +{#snippet searchMenu()} e.preventDefault()} @@ -242,4 +233,37 @@
{/if} +{/snippet} + + + {#if inOverflowMenu} + + {#snippet child({ props })} + + {/snippet} + + {@render searchMenu()} + {:else} + + {#snippet child({ props })} + + {/snippet} + + {@render searchMenu()} + {/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte b/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte index 03c6d9cc..f560eb2e 100644 --- a/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte @@ -8,38 +8,58 @@ } from '@profidev/pleiades/components/ui/tooltip'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; + import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; - let { editor, class: className }: { editor: Editor; class?: string } = - $props(); + let { + editor, + class: className, + inOverflowMenu = false + }: { + editor: Editor; + class?: string; + inOverflowMenu?: boolean; + } = $props(); const isActive = $derived(editor.isActive('strike')); + const isDisabled = $derived( !editor.can().chain().focus().toggleStrike().run() ); - function handleClick() { + const handleClick = () => { editor.chain().focus().toggleStrike().run(); - } + }; - - - {#snippet child({ props })} - - {/snippet} - - - Strikethrough - (strg + shift + x) - - +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + {/snippet} + + + Strikethrough + (cmd + shift + x) + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-container.svelte b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-container.svelte new file mode 100644 index 00000000..3fc6b2d9 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-container.svelte @@ -0,0 +1,178 @@ + + +{#snippet toolbarSeparator()} + +{/snippet} + +{#snippet renderItem(item: ToolbarEntry, inOverflowMenu: boolean)} + {#if item.isSeparator} + {#if !inOverflowMenu} + {@render toolbarSeparator()} + {/if} + {:else if item.component} + + {/if} +{/snippet} + +
+
+ {#each items as item, index (item.id)} + {#if index < layout.visibleCount && layout.visibility[index]} + {@render renderItem(item, false)} + {/if} + {/each} + + {#if layout.showOverflow} + + + + {#snippet child({ props })} + + {#snippet child({ props: triggerProps })} + + {/snippet} + + {/snippet} + + More tools + + e.preventDefault()} + > + {#each items as item, index (item.id)} + {#if index >= layout.visibleCount && !item.isSeparator} + {@render renderItem(item, true)} + {/if} + {/each} + + + {/if} +
+ + + +
diff --git a/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-trigger.svelte b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-trigger.svelte new file mode 100644 index 00000000..4c3fdb6f --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-trigger.svelte @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow.ts b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow.ts new file mode 100644 index 00000000..a69f45ca --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow.ts @@ -0,0 +1,67 @@ +export type ToolbarOverflowItem = { + id: string; + isSeparator?: boolean; +}; + +export const totalWidth = ( + itemWidths: number[], + count: number, + gap: number +): number => { + if (count <= 0) return 0; + const itemsWidth = itemWidths + .slice(0, count) + .reduce((sum, width) => sum + width, 0); + return itemsWidth + gap * (count - 1); +}; + +export const calculateVisibleCount = ( + containerWidth: number, + itemWidths: number[], + overflowButtonWidth: number, + gap: number +): { visibleCount: number; showOverflow: boolean } => { + const itemCount = itemWidths.length; + + if (itemCount === 0 || containerWidth <= 0) { + return { visibleCount: 0, showOverflow: false }; + } + + const fitsAll = totalWidth(itemWidths, itemCount, gap) <= containerWidth; + if (fitsAll) { + return { visibleCount: itemCount, showOverflow: false }; + } + + const availableWithOverflow = containerWidth - overflowButtonWidth - gap; + let visibleCount = 0; + + for (let i = 0; i < itemCount; i++) { + const nextWidth = totalWidth(itemWidths, i + 1, gap); + if (nextWidth > availableWithOverflow) { + break; + } + visibleCount = i + 1; + } + + return { visibleCount, showOverflow: true }; +}; + +export const cleanupSeparatorVisibility = ( + items: ToolbarOverflowItem[], + visibleCount: number +): boolean[] => + items.map((item, index) => { + if (index >= visibleCount) { + return false; + } + + if (!item.isSeparator) { + return true; + } + + const prevVisible = index > 0 && !items[index - 1]?.isSeparator; + const nextVisible = + index + 1 < visibleCount && !items[index + 1]?.isSeparator; + + return prevVisible && nextVisible; + }); diff --git a/frontend/src/lib/components/tiptap/toolbar/underline.svelte b/frontend/src/lib/components/tiptap/toolbar/underline.svelte index e44e8734..b01d4290 100644 --- a/frontend/src/lib/components/tiptap/toolbar/underline.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/underline.svelte @@ -8,38 +8,58 @@ } from '@profidev/pleiades/components/ui/tooltip'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; + import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; - let { editor, class: className }: { editor: Editor; class?: string } = - $props(); + let { + editor, + class: className, + inOverflowMenu = false + }: { + editor: Editor; + class?: string; + inOverflowMenu?: boolean; + } = $props(); const isActive = $derived(editor.isActive('underline')); + const isDisabled = $derived( !editor.can().chain().focus().toggleUnderline().run() ); - function handleClick() { + const handleClick = () => { editor.chain().focus().toggleUnderline().run(); - } + }; - - - {#snippet child({ props })} - - {/snippet} - - - Underline - (cmd + u) - - +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + {/snippet} + + + Underline + (cmd + u) + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/undo.svelte b/frontend/src/lib/components/tiptap/toolbar/undo.svelte index d701864a..398140c7 100644 --- a/frontend/src/lib/components/tiptap/toolbar/undo.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/undo.svelte @@ -8,34 +8,52 @@ } from '@profidev/pleiades/components/ui/tooltip'; import { cn } from '@profidev/pleiades/utils'; import type { Editor } from '@tiptap/core'; + import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; - let { editor, class: className }: { editor: Editor; class?: string } = - $props(); + let { + editor, + class: className, + inOverflowMenu = false + }: { + editor: Editor; + class?: string; + inOverflowMenu?: boolean; + } = $props(); const isDisabled = $derived(!editor.can().chain().focus().undo().run()); - function handleClick() { + const handleClick = () => { editor.chain().focus().undo().run(); - } + }; - - - {#snippet child({ props })} - - {/snippet} - - - Undo - - +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + {/snippet} + + + Undo + + +{/if} From 0996acc7cad268b8d09cf3f760839724f5dee117 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:18:51 +0200 Subject: [PATCH 34/41] refactor: correct component import --- .../tiptap/toolbar/EditorToolbar.svelte | 6 +- .../tiptap/toolbar/alignment.svelte | 58 +++++----- .../tiptap/toolbar/blockquote.svelte | 24 ++--- .../lib/components/tiptap/toolbar/bold.svelte | 24 ++--- .../tiptap/toolbar/bullet-list.svelte | 24 ++--- .../tiptap/toolbar/code-block.svelte | 24 ++--- .../tiptap/toolbar/color-and-highlight.svelte | 54 ++++------ .../components/tiptap/toolbar/headings.svelte | 59 +++++------ .../components/tiptap/toolbar/italic.svelte | 24 ++--- .../lib/components/tiptap/toolbar/link.svelte | 58 +++++----- .../tiptap/toolbar/ordered-list.svelte | 24 ++--- .../lib/components/tiptap/toolbar/redo.svelte | 24 ++--- .../toolbar/search-and-replace-toolbar.svelte | 100 ++++++++---------- .../tiptap/toolbar/strikethrough.svelte | 24 ++--- .../toolbar/toolbar-overflow-container.svelte | 48 ++++----- .../toolbar/toolbar-overflow-trigger.svelte | 8 +- .../tiptap/toolbar/underline.svelte | 24 ++--- .../lib/components/tiptap/toolbar/undo.svelte | 24 ++--- 18 files changed, 270 insertions(+), 361 deletions(-) diff --git a/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte index b611b5e2..55ce6c49 100644 --- a/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte @@ -1,13 +1,13 @@
- + - +
diff --git a/frontend/src/lib/components/tiptap/toolbar/alignment.svelte b/frontend/src/lib/components/tiptap/toolbar/alignment.svelte index d559b47e..79ebb16d 100644 --- a/frontend/src/lib/components/tiptap/toolbar/alignment.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/alignment.svelte @@ -5,19 +5,9 @@ import AlignRightIcon from '@lucide/svelte/icons/align-right'; import CheckIcon from '@lucide/svelte/icons/check'; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; - import { Button } from '@profidev/pleiades/components/ui/button'; - import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuTrigger - } from '@profidev/pleiades/components/ui/dropdown-menu'; - import { - Tooltip, - TooltipContent, - TooltipTrigger - } from '@profidev/pleiades/components/ui/tooltip'; + import * as Button from '@profidev/pleiades/components/ui/button'; + import * as DropdownMenu from '@profidev/pleiades/components/ui/dropdown-menu'; + import * as Tooltip from '@profidev/pleiades/components/ui/tooltip'; import type { Editor } from '@tiptap/core'; import ToolbarOverflowTrigger from './toolbar-overflow-trigger.svelte'; @@ -55,15 +45,15 @@ {#snippet alignmentMenu()} - e.preventDefault()} class="w-42" > - + {#each alignmentOptions as option, index (index)} {@const OptionIcon = option.icon} - handleAlign(option.value)}> + handleAlign(option.value)}> @@ -71,15 +61,15 @@ {#if option.value === currentTextAlign} {/if} - + {/each} - - + + {/snippet} {#if inOverflowMenu} - - + + {#snippet child({ props })} {@const CurrentIcon = currentOption.icon} {/snippet} - + {@render alignmentMenu()} - + {:else} - - - + + + {#snippet child({ props })} {@const CurrentIcon = currentOption.icon} - + {#snippet child({ props: triggerProps })} - + {/snippet} - + {/snippet} - - Text Alignment - + + Text Alignment + {@render alignmentMenu()} - + {/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte b/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte index 11566484..4919608c 100644 --- a/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte @@ -1,11 +1,7 @@ {#snippet colorMenu()} - + - + {#each TEXT_COLORS as { name, color } (name)} - + {#each HIGHLIGHT_COLORS as { name, color } (name)} @@ -130,15 +122,15 @@ {/each} - + - + {/snippet} - + {#if inOverflowMenu} - + {#snippet child({ props })} {/snippet} - + {@render colorMenu()} {:else}
- - + + {#snippet child({ props })} - + {#snippet child({ props: triggerProps })} - + {/snippet} - + {/snippet} - - Text Color & Highlight - + + Text Color & Highlight + {@render colorMenu()}
{/if} -
+ diff --git a/frontend/src/lib/components/tiptap/toolbar/headings.svelte b/frontend/src/lib/components/tiptap/toolbar/headings.svelte index 007ebc23..edc62926 100644 --- a/frontend/src/lib/components/tiptap/toolbar/headings.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/headings.svelte @@ -1,18 +1,9 @@ {#snippet headingMenu()} - - + editor.chain().focus().setParagraph().run()} class={cn('flex h-fit items-center gap-2', !isHeadingActive && 'bg-accent')} > Normal - + {#each levels as level (level)} - editor.chain().focus().toggleHeading({ level }).run()} class={cn( 'flex items-center gap-2', @@ -53,14 +44,14 @@ )} > H{level} - + {/each} - + {/snippet} {#if inOverflowMenu} - - + + {#snippet child({ props })} {/snippet} - + {@render headingMenu()} - + {:else} - - + + {#snippet child({ props })} - - + + {#snippet child({ props: triggerProps })} - + {/snippet} - + {@render headingMenu()} - + {/snippet} - - + + Headings - - + + {/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/italic.svelte b/frontend/src/lib/components/tiptap/toolbar/italic.svelte index 8ba22da6..9632501e 100644 --- a/frontend/src/lib/components/tiptap/toolbar/italic.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/italic.svelte @@ -1,11 +1,7 @@ {#snippet linkMenu()} - e.preventDefault()} class="relative px-3 py-2.5" > @@ -79,14 +71,14 @@

Attach a link to the selected text

-
{#if linkHref} - + {/if} - +
-
+ {/snippet} - + {#if inOverflowMenu} - + {#snippet child({ props })} {/snippet} - + {@render linkMenu()} {:else} - - + + {#snippet child({ props })} - + {#snippet child({ props: triggerProps })} - + {/snippet} - + {/snippet} - - + + Link - - + + {@render linkMenu()} {/if} - + diff --git a/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte b/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte index 593e3491..d9acd469 100644 --- a/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte @@ -1,11 +1,7 @@ {#snippet searchMenu()} - e.preventDefault()} class="flex w-[412px] flex-col gap-1.5 px-3 py-2.5" >
- handleSearchInput(e.currentTarget.value)} class="w-48" @@ -129,7 +121,7 @@ ? selectedResult : selectedResult + 1}/{results.length} - - + - - + + {#snippet child({ props })} - - + {/snippet} - - Match case - + + Match case + - - + + {#snippet child({ props })} - - + {/snippet} - - Use regular expression - + + Use regular expression + - - + + {#snippet child({ props })} - - + {/snippet} - - Replace - + + Replace +
{#if replacing}
- handleReplaceInput(e.currentTarget.value)} class="w-48" placeholder="Replace..." /> - - +
{/if} -
+ {/snippet} - + {#if inOverflowMenu} - + {#snippet child({ props })} {/snippet} - + {@render searchMenu()} {:else} - + {#snippet child({ props })} - + {/snippet} - + {@render searchMenu()} {/if} - + diff --git a/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte b/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte index f560eb2e..30427c8f 100644 --- a/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte @@ -1,11 +1,7 @@ {#snippet toolbarSeparator()} - + {/snippet} {#snippet renderItem(item: ToolbarEntry, inOverflowMenu: boolean)} @@ -120,13 +112,13 @@ {/each} {#if layout.showOverflow} - - - + + + {#snippet child({ props })} - + {#snippet child({ props: triggerProps })} - + {/snippet} - + {/snippet} - - More tools - - + More tools + + e.preventDefault()} @@ -153,8 +145,8 @@ {@render renderItem(item, true)} {/if} {/each} - - + + {/if} @@ -170,9 +162,9 @@ {/each}
- +
diff --git a/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-trigger.svelte b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-trigger.svelte index 4c3fdb6f..714ec5ac 100644 --- a/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-trigger.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-trigger.svelte @@ -1,7 +1,7 @@ - + diff --git a/frontend/src/lib/components/tiptap/toolbar/underline.svelte b/frontend/src/lib/components/tiptap/toolbar/underline.svelte index b01d4290..9b8cd87b 100644 --- a/frontend/src/lib/components/tiptap/toolbar/underline.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/underline.svelte @@ -1,11 +1,7 @@ {#snippet headingMenu()} editor.chain().focus().setParagraph().run()} - class={cn('flex h-fit items-center gap-2', !isHeadingActive && 'bg-accent')} + class={cn( + 'flex h-fit items-center gap-2', + !isHeadingActive && 'bg-accent' + )} > Normal diff --git a/frontend/src/lib/components/tiptap/toolbar/link.svelte b/frontend/src/lib/components/tiptap/toolbar/link.svelte index a4aa2036..18372c25 100644 --- a/frontend/src/lib/components/tiptap/toolbar/link.svelte +++ b/frontend/src/lib/components/tiptap/toolbar/link.svelte @@ -133,7 +133,9 @@ )} >

-

Link

+

+ Link +

{/snippet} diff --git a/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow.ts b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow.ts index a69f45ca..abb08503 100644 --- a/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow.ts +++ b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow.ts @@ -1,14 +1,16 @@ -export type ToolbarOverflowItem = { +export interface ToolbarOverflowItem { id: string; isSeparator?: boolean; -}; +} export const totalWidth = ( itemWidths: number[], count: number, gap: number ): number => { - if (count <= 0) return 0; + if (count <= 0) { + return 0; + } const itemsWidth = itemWidths .slice(0, count) .reduce((sum, width) => sum + width, 0); @@ -24,12 +26,12 @@ export const calculateVisibleCount = ( const itemCount = itemWidths.length; if (itemCount === 0 || containerWidth <= 0) { - return { visibleCount: 0, showOverflow: false }; + return { showOverflow: false, visibleCount: 0 }; } const fitsAll = totalWidth(itemWidths, itemCount, gap) <= containerWidth; if (fitsAll) { - return { visibleCount: itemCount, showOverflow: false }; + return { showOverflow: false, visibleCount: itemCount }; } const availableWithOverflow = containerWidth - overflowButtonWidth - gap; @@ -43,7 +45,7 @@ export const calculateVisibleCount = ( visibleCount = i + 1; } - return { visibleCount, showOverflow: true }; + return { showOverflow: true, visibleCount }; }; export const cleanupSeparatorVisibility = ( From d9e0ad1a31856b6ce4b81cd1128a0b8bf3b1ee3b Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:34:11 +0200 Subject: [PATCH 36/41] fix: support whitespace only search and replace --- .../lib/components/tiptap/extensions/search-and-replace.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts b/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts index 782ae5ef..d1588085 100644 --- a/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts +++ b/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts @@ -119,8 +119,8 @@ const processSearches = ( }); for (const { text, pos } of textNodesWithPosition) { - const matches = [...text.matchAll(searchTerm)].filter(([matchText]) => - matchText.trim() + const matches = [...text.matchAll(searchTerm)].filter( + ([matchText]) => matchText.length > 0 ); for (const match of matches) { From 0ecb6b4719d0bb7e5ba615ca6ea313ec43505364 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:45:47 +0200 Subject: [PATCH 37/41] refactor: better user avatar --- frontend/src/lib/components/UserAvatar.svelte | 28 +++++++++++++++++++ frontend/src/routes/auth/app/+page.svelte | 8 ++---- frontend/src/routes/oauth/+page.svelte | 8 ++---- frontend/src/routes/oauth/logout/+page.svelte | 8 ++---- frontend/src/routes/users/[uuid]/+page.svelte | 7 +++-- frontend/src/routes/users/table.svelte.ts | 9 +++--- 6 files changed, 42 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/components/UserAvatar.svelte diff --git a/frontend/src/lib/components/UserAvatar.svelte b/frontend/src/lib/components/UserAvatar.svelte new file mode 100644 index 00000000..199a310f --- /dev/null +++ b/frontend/src/lib/components/UserAvatar.svelte @@ -0,0 +1,28 @@ + + + + + {username ? initials(username) : '?'} + diff --git a/frontend/src/routes/auth/app/+page.svelte b/frontend/src/routes/auth/app/+page.svelte index 11b76247..d69bdf92 100644 --- a/frontend/src/routes/auth/app/+page.svelte +++ b/frontend/src/routes/auth/app/+page.svelte @@ -6,8 +6,7 @@ import LoaderCircle from '@lucide/svelte/icons/loader-circle'; import { logout, requestAppCode, type UserInfo } from '$lib/client'; import { toast } from '@profidev/pleiades/components/util/general'; - import SimpleAvatar from '$lib/components/SimpleAvatar.svelte'; - import { avatarUrl } from '$lib/permissions.svelte.js'; + import UserAvatar from '$lib/components/UserAvatar.svelte'; let { data } = $props(); @@ -70,10 +69,7 @@ {#if user} - +
{user.name} {user.email} diff --git a/frontend/src/routes/oauth/+page.svelte b/frontend/src/routes/oauth/+page.svelte index 20dd3d9c..510052c5 100644 --- a/frontend/src/routes/oauth/+page.svelte +++ b/frontend/src/routes/oauth/+page.svelte @@ -7,8 +7,7 @@ import LoaderCircle from '@lucide/svelte/icons/loader-circle'; import { authorizeConfirm, logout, type UserInfo } from '$lib/client'; import { toast } from '@profidev/pleiades/components/util/general'; - import SimpleAvatar from '$lib/components/SimpleAvatar.svelte'; - import { avatarUrl } from '$lib/permissions.svelte.js'; + import UserAvatar from '$lib/components/UserAvatar.svelte'; let { data } = $props(); @@ -78,10 +77,7 @@ {#if user} - +
{user.name} {user.email} diff --git a/frontend/src/routes/oauth/logout/+page.svelte b/frontend/src/routes/oauth/logout/+page.svelte index 85926f88..21b741f8 100644 --- a/frontend/src/routes/oauth/logout/+page.svelte +++ b/frontend/src/routes/oauth/logout/+page.svelte @@ -4,9 +4,8 @@ import { Skeleton } from '@profidev/pleiades/components/ui/skeleton'; import { goto } from '$app/navigation'; import type { UserInfo } from '$lib/client'; - import SimpleAvatar from '$lib/components/SimpleAvatar.svelte'; - import { avatarUrl } from '$lib/permissions.svelte.js'; import { toast } from '@profidev/pleiades/components/util/general'; + import UserAvatar from '$lib/components/UserAvatar.svelte'; let { data } = $props(); @@ -41,10 +40,7 @@ {#if user} - +
{user.name} {user.email} diff --git a/frontend/src/routes/users/[uuid]/+page.svelte b/frontend/src/routes/users/[uuid]/+page.svelte index 8f80650e..c1af1211 100644 --- a/frontend/src/routes/users/[uuid]/+page.svelte +++ b/frontend/src/routes/users/[uuid]/+page.svelte @@ -34,7 +34,7 @@ } from '$lib/client'; import { getEncrypt } from '$lib/backend/auth.svelte.js'; import { Skeleton } from '@profidev/pleiades/components/ui/skeleton'; - import SimpleAvatar from '$lib/components/SimpleAvatar.svelte'; + import UserAvatar from '$lib/components/UserAvatar.svelte'; import { Label } from '@profidev/pleiades/components/ui/label'; import { Input } from '@profidev/pleiades/components/ui/input'; @@ -231,8 +231,9 @@
- -
- - { - if (event.key === 'Enter') { - event.currentTarget.blur(); - } - }} - /> -
-
- - ({ - label: user.name, - value: user.id - }))} - label="Shared with" - selected={sharedWithIds} - disabled={readonly || shareSaving} - onSelectChange={(selected) => { - if (sharedUpdateTimeout) clearTimeout(sharedUpdateTimeout); - sharedUpdateTimeout = setTimeout(() => { - onSharedChange(selected); - }, 500); - }} - /> -
-
- - -
+ + { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }} + /> + + + {#if note} +
+ + {note.owner.name} + +
+ {/if} + +
- +
Date: Sat, 13 Jun 2026 06:32:56 +0000 Subject: [PATCH 40/41] chore: fix code style issues with oxfmt --- .../components/notes/NoteActiveEditorsIndicator.svelte | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/notes/NoteActiveEditorsIndicator.svelte b/frontend/src/lib/components/notes/NoteActiveEditorsIndicator.svelte index 57fbae8e..37676dff 100644 --- a/frontend/src/lib/components/notes/NoteActiveEditorsIndicator.svelte +++ b/frontend/src/lib/components/notes/NoteActiveEditorsIndicator.svelte @@ -58,8 +58,14 @@
    {#each editors as editor (editor.clientId)}
  • - - {editor.name} + + {editor.name} {#if editor.color} Date: Sat, 13 Jun 2026 08:43:19 +0200 Subject: [PATCH 41/41] style: better mobile notes header layout --- .../notes/NoteActiveEditorsIndicator.svelte | 4 +- .../components/notes/NoteShareControl.svelte | 62 +-- frontend/src/lib/components/tiptap/tiptap.css | 2 +- frontend/src/routes/notes/[id]/+page.svelte | 17 +- package-lock.json | 368 ++++++++++-------- package.json | 4 +- 6 files changed, 254 insertions(+), 203 deletions(-) diff --git a/frontend/src/lib/components/notes/NoteActiveEditorsIndicator.svelte b/frontend/src/lib/components/notes/NoteActiveEditorsIndicator.svelte index 37676dff..6db73117 100644 --- a/frontend/src/lib/components/notes/NoteActiveEditorsIndicator.svelte +++ b/frontend/src/lib/components/notes/NoteActiveEditorsIndicator.svelte @@ -15,7 +15,7 @@ {/if}
- {editors.length} editing +

{#if selected.length > 0} @@ -50,17 +50,17 @@ {/each}

- {selected.length} shared + {:else} - - Share + + {/if}
{:else} {/if}
- {selected.length} shared + {:else} - - Share + + {/if} - + - + No people found - {#each shareableUsers as user (user.id)} - {let checked = $derived(selectedIds.includes(user.id))} - toggleUser(user.id)} class="[&_svg.cn-command-item-indicator]:hidden!"> - - - {user.name} - - - - {/each} + {#each shareableUsers as user (user.id)} + toggleUser(user.id)} + class="[&_svg.cn-command-item-indicator]:hidden!" + > + + + {user.name} + + + + {/each} diff --git a/frontend/src/lib/components/tiptap/tiptap.css b/frontend/src/lib/components/tiptap/tiptap.css index c18c6275..e5a06657 100644 --- a/frontend/src/lib/components/tiptap/tiptap.css +++ b/frontend/src/lib/components/tiptap/tiptap.css @@ -415,7 +415,7 @@ /* Mobile Optimizations */ @media (max-width: 640px) { .ProseMirror { - padding: 0 var(--editor-spacing-4); + padding: var(--editor-spacing-4); } .ProseMirror h1 { diff --git a/frontend/src/routes/notes/[id]/+page.svelte b/frontend/src/routes/notes/[id]/+page.svelte index 1a96ff64..dc5c6143 100644 --- a/frontend/src/routes/notes/[id]/+page.svelte +++ b/frontend/src/routes/notes/[id]/+page.svelte @@ -150,7 +150,7 @@ {#if note}
- {note.owner.name} - + +
{/if} - +