This commit is contained in:
Timothy Jaeryang Baek 2026-09-08 13:25:16 -04:00
parent 56de6dd792
commit d8f27e745b
4 changed files with 152 additions and 98 deletions

View file

@ -239,6 +239,7 @@
};
export let richText = true;
export let autoFormat = true;
export let dragHandle = false;
export let link = false;
export let image = false;
@ -334,6 +335,7 @@
let element: Element | null = null;
let pendingUpdate = null;
let destroyed = false;
const options = {
throwOnError: false
@ -767,13 +769,15 @@
if (collaboration && editable && documentId && socket && user) {
const { SocketIOCollaborationProvider } = await import('./RichTextInput/Collaboration');
if (destroyed) return;
provider = new SocketIOCollaborationProvider(documentId, socket, user, content);
}
if (destroyed) return;
editor = new Editor({
element: element,
extensions: [
StarterKit.configure({
link: link,
link: link ? { autolink: autoFormat, linkOnPaste: autoFormat } : false,
code: false, // Disabled in favor of FixedCode (see workaround above)
...(messageInput ? { italic: false } : {}),
// When rich text is on, ListKit + CodeBlockLowlight provide these.
@ -1003,8 +1007,8 @@
return false;
},
handlePaste: (view, event) => {
// Force plain-text pasting when richText === false
if (!richText) {
// Paste literal text when automatic formatting is disabled.
if (!richText || !autoFormat) {
// swallow HTML completely
event.preventDefault();
const { state, dispatch } = view;
@ -1014,6 +1018,11 @@
'\n'
);
if (state.selection.$from.parent.type.spec.code) {
dispatch(state.tr.insertText(plainText).scrollIntoView());
return true;
}
const lines = plainText.split('\n');
const nodes = [];
@ -1027,7 +1036,11 @@
});
const fragment = Fragment.fromArray(nodes);
dispatch(state.tr.replaceSelectionWith(fragment, false).scrollIntoView());
dispatch(
state.tr
.replaceWith(state.selection.from, state.selection.to, fragment)
.scrollIntoView()
);
return true; // handled
}
@ -1279,8 +1292,8 @@
floatingMenuElement.style.opacity = '0';
}
},
enableInputRules: richText,
enablePasteRules: richText
enableInputRules: richText && autoFormat,
enablePasteRules: richText && autoFormat
});
provider?.setEditor(editor, () => ({ md: mdValue, html: htmlValue, json: jsonValue }));
@ -1291,6 +1304,7 @@
});
onDestroy(() => {
destroyed = true;
if (pendingUpdate) {
cancelAnimationFrame(pendingUpdate);
}

View file

@ -1,6 +1,7 @@
import * as Y from 'yjs';
import {
ySyncPlugin,
ySyncPluginKey,
yCursorPlugin,
yUndoPlugin,
undo,
@ -11,6 +12,7 @@ import type { Socket } from 'socket.io-client';
import type { SessionUser } from '$lib/stores';
import { Editor, Extension } from '@tiptap/core';
import { keymap } from 'prosemirror-keymap';
import { Plugin } from 'prosemirror-state';
import { tick } from 'svelte';
const USER_COLORS = [
@ -62,6 +64,15 @@ export class SocketIOCollaborationProvider {
if (!yXmlFragment) return [];
const plugins = [
new Plugin({
filterTransaction: (tr) => {
// Preserve literal URLs received from another editor.
if (tr.getMeta(ySyncPluginKey)?.isChangeOrigin) {
tr.setMeta('preventAutolink', true);
}
return true;
}
}),
ySyncPlugin(yXmlFragment),
yUndoPlugin(),
keymap({

View file

@ -95,6 +95,7 @@
export let id: null | string = null;
let editor = null;
let autoFormat = true;
let note = null;
const newNote = {
@ -174,6 +175,7 @@
(note?.data?.content?.md ? marked.parse(note.data.content.md) : '');
const init = async () => {
autoFormat = true;
loading = true;
const res = await getNoteById(localStorage.token, id).catch((error) => {
toast.error(`${error}`);
@ -1208,6 +1210,8 @@ ${content}
{/if}
<NoteMenu
bind:autoFormat
showAutoFormat={note?.write_access ?? false}
onUploadFiles={note?.write_access ? uploadNoteFilesHandler : null}
onDownload={(type) => {
downloadHandler(type);
@ -1356,105 +1360,108 @@ ${content}
</div>
{/if}
<RichTextInput
bind:this={inputElement}
bind:editor
id={`note-${note.id}`}
className="input-prose-sm px-0.5 flex-1 min-h-[12rem]"
json={true}
bind:value={note.data.content.json}
html={editorHtml}
documentId={`note:${note.id}`}
collaboration={true}
socket={$socket}
user={$user}
dragHandle={true}
link={true}
image={true}
{files}
placeholder={$i18n.t('Write something...')}
editable={versionIdx === null && note?.write_access}
onSelectionUpdate={({ editor }) => {
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' ');
{#key autoFormat}
<RichTextInput
{autoFormat}
bind:this={inputElement}
bind:editor
id={`note-${note.id}`}
className="input-prose-sm px-0.5 flex-1 min-h-[12rem]"
json={true}
bind:value={note.data.content.json}
html={editorHtml}
documentId={`note:${note.id}`}
collaboration={true}
socket={$socket}
user={$user}
dragHandle={true}
link={true}
image={true}
{files}
placeholder={$i18n.t('Write something...')}
editable={versionIdx === null && note?.write_access}
onSelectionUpdate={({ editor }) => {
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' ');
if (selectedText.length === 0) {
selectedContent = null;
} else {
selectedContent = {
text: selectedText,
from: from,
to: to
};
}
}}
onChange={(content) => {
lastLocalContentChangeAt = Date.now();
note.data.content.html = content.html;
note.data.content.md = content.md;
if (editor) {
wordCount = editor.storage.characterCount.words();
charCount = editor.storage.characterCount.characters();
}
}}
fileHandler={true}
onFileDrop={(currentEditor, files, pos) => {
files.forEach(async (file) => {
const fileItem = await inputFileHandler(file).catch((error) => {
return null;
});
if (fileItem?.type === 'image') {
// If the file is an image, insert it directly
currentEditor
.chain()
.insertContentAt(pos, {
type: 'image',
attrs: {
src: `data://${fileItem.id}`
}
})
.focus()
.run();
if (selectedText.length === 0) {
selectedContent = null;
} else {
selectedContent = {
text: selectedText,
from: from,
to: to
};
}
});
}}
onFilePaste={() => {}}
on:paste={async (e) => {
e = e.detail.event || e;
const clipboardData = e.clipboardData || window.clipboardData;
console.log('Clipboard data:', clipboardData);
}}
onChange={(content) => {
lastLocalContentChangeAt = Date.now();
note.data.content.html = content.html;
note.data.content.md = content.md;
if (clipboardData && clipboardData.items) {
console.log('Clipboard data items:', clipboardData.items);
for (const item of clipboardData.items) {
console.log('Clipboard item:', item);
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const fileItem = await inputFileHandler(blob);
if (editor) {
wordCount = editor.storage.characterCount.words();
charCount = editor.storage.characterCount.characters();
}
}}
fileHandler={true}
onFileDrop={(currentEditor, files, pos) => {
files.forEach(async (file) => {
const fileItem = await inputFileHandler(file).catch((error) => {
return null;
});
if (editor) {
editor
?.chain()
.insertContentAt(editor.state.selection.$anchor.pos, {
type: 'image',
attrs: {
src: `data://${fileItem.id}` // Use data URI for the image
}
})
.focus()
.run();
if (fileItem?.type === 'image') {
// If the file is an image, insert it directly
currentEditor
.chain()
.insertContentAt(pos, {
type: 'image',
attrs: {
src: `data://${fileItem.id}`
}
})
.focus()
.run();
}
});
}}
onFilePaste={() => {}}
on:paste={async (e) => {
e = e.detail.event || e;
const clipboardData = e.clipboardData || window.clipboardData;
console.log('Clipboard data:', clipboardData);
if (clipboardData && clipboardData.items) {
console.log('Clipboard data items:', clipboardData.items);
for (const item of clipboardData.items) {
console.log('Clipboard item:', item);
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const fileItem = await inputFileHandler(blob);
if (editor) {
editor
?.chain()
.insertContentAt(editor.state.selection.$anchor.pos, {
type: 'image',
attrs: {
src: `data://${fileItem.id}` // Use data URI for the image
}
})
.focus()
.run();
}
} else if (item?.kind === 'file') {
const file = item.getAsFile();
await inputFileHandler(file);
e.preventDefault();
}
} else if (item?.kind === 'file') {
const file = item.getAsFile();
await inputFileHandler(file);
e.preventDefault();
}
}
}
}}
/>
}}
/>
{/key}
</div>
</div>
{/if}

View file

@ -4,6 +4,8 @@
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import DropdownSub from '$lib/components/common/DropdownSub.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Download from '$lib/components/icons/Download.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
@ -12,6 +14,7 @@
import Pin from '$lib/components/icons/Pin.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte';
import CloudArrowUp from '$lib/components/icons/CloudArrowUp.svelte';
import Bold from '$lib/components/icons/Bold.svelte';
const i18n = getContext('i18n');
@ -23,6 +26,8 @@
export let onPin = null;
export let isPinned = false;
export let onUploadFiles = null;
export let showAutoFormat = false;
export let autoFormat = true;
export let onCopyLink = null;
export let onCopyToClipboard = null;
@ -42,6 +47,23 @@
<div slot="content">
<DropdownMenu className="min-w-[11.25rem]">
{#if showAutoFormat}
<Tooltip
className="w-full"
content={$i18n.t(
'Format Markdown as you type and paste. Turn off to keep Markdown characters and paste plain text. Existing formatting is preserved.'
)}
>
<div
class="select-none flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/60 dark:hover:bg-gray-800/60"
>
<Bold className="size-3.5 shrink-0" strokeWidth="2" />
<span class="flex-1">{$i18n.t('Formatting')}</span>
<Switch bind:state={autoFormat} ariaLabel={$i18n.t('Formatting')} />
</div>
</Tooltip>
<hr />
{/if}
<DropdownSub contentClass="select-none z-50">
<button
slot="trigger"