flotilla/src/app/editor/index.ts

330 lines
11 KiB
TypeScript
Raw Normal View History

2025-02-05 03:00:48 +00:00
import {mount} from "svelte"
2025-01-02 18:04:28 +00:00
import type {Writable} from "svelte/store"
import {get, derived} from "svelte/store"
2026-07-28 16:16:26 +00:00
import {sortBy, uniq} from "@welshman/lib"
import {throttled} from "@welshman/store"
2026-07-28 16:16:26 +00:00
import {createSearch, splitRoomKey} from "@welshman/app"
import type {Room} from "@welshman/app"
2025-06-06 23:38:11 +00:00
import type {FileAttributes} from "@welshman/editor"
import {
2026-09-07 16:48:36 +00:00
CommandExtension,
CommandSuggestion,
Editor,
MentionSuggestion,
TippySuggestion,
WelshmanExtension,
editorProps,
} from "@welshman/editor"
2026-09-07 16:48:36 +00:00
import type {CommandScopeTarget} from "@welshman/util"
2026-02-16 16:16:03 +00:00
import {escapeHtml} from "@lib/html"
2026-07-28 16:16:26 +00:00
import {profiles, relayLists, relayMemberLists, rooms} from "@app/core"
2026-09-07 16:48:36 +00:00
import {
commands,
createCommandSearch,
getCommandByAddress,
getCommandsForTarget,
} from "@app/commands"
import {CommandNodeView} from "@app/editor/CommandNodeView"
import CommandSuggestionItem from "@app/editor/CommandSuggestion.svelte"
2026-07-28 16:16:26 +00:00
import {MentionNodeView} from "@app/editor/MentionNodeView"
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension"
import RoomSuggestion from "@app/editor/RoomSuggestion.svelte"
import {NativeClipboardPasteExtension} from "@app/editor/clipboard"
import {compressFileForUpload, makeImetaTag, uploadFile} from "@app/uploads"
2026-07-28 16:16:26 +00:00
import {userSpaceUrls} from "@app/rooms"
import {PLATFORM_RELAYS} from "@app/env"
2026-06-08 21:14:40 +00:00
import {pushToast} from "@app/toast"
2025-01-02 18:04:28 +00:00
2026-09-01 04:56:37 +00:00
const isEmpty = (editor: Editor) => editor.getText({blockSeparator: "\n"}).trim() === ""
export const makeEditor = async ({
encryptFiles = false,
2025-01-17 23:01:05 +00:00
aggressive = false,
2025-01-02 18:04:28 +00:00
charCount,
content = "",
2026-09-01 04:56:37 +00:00
empty,
onChange,
2025-01-02 18:04:28 +00:00
placeholder = "",
2025-04-15 18:03:27 +00:00
url,
2025-01-02 18:04:28 +00:00
submit,
2026-09-07 16:48:36 +00:00
text,
2025-01-02 18:04:28 +00:00
uploading,
wordCount,
2026-09-07 16:48:36 +00:00
commandTarget,
2025-01-02 18:04:28 +00:00
}: {
encryptFiles?: boolean
2025-01-17 23:01:05 +00:00
aggressive?: boolean
2025-01-02 18:04:28 +00:00
charCount?: Writable<number>
content?: string | object
2026-09-01 04:56:37 +00:00
empty?: Writable<boolean>
2026-04-07 13:06:29 +00:00
onChange?: (json: object) => void
2025-01-02 18:04:28 +00:00
placeholder?: string
2025-04-15 18:03:27 +00:00
url?: string
2025-01-02 18:04:28 +00:00
submit: () => void
2026-09-07 16:48:36 +00:00
text?: Writable<string>
2025-01-02 18:04:28 +00:00
uploading?: Writable<boolean>
wordCount?: Writable<number>
2026-09-07 16:48:36 +00:00
// When given, `/` at the start of the composer suggests commands scoped to this target
commandTarget?: CommandScopeTarget
}) => {
2026-07-28 16:16:26 +00:00
const searchProfiles = derived(
[profiles.get().profileSearch, throttled(800, relayMemberLists.get().forUrl(url ?? ""))],
([$profileSearch, $spaceMembers]) => {
const memberPubkeys = new Set($spaceMembers?.pubkeys())
2026-07-28 16:16:26 +00:00
return (term: string) =>
sortBy(pubkey => (memberPubkeys.has(pubkey) ? 0 : 1), $profileSearch.searchValues(term))
},
)
const roomReferenceSearch = derived(
2026-07-28 16:16:26 +00:00
[throttled(800, userSpaceUrls), throttled(800, rooms.get().byUrl.$)],
([$userSpaceUrls, $roomsByUrl]) => {
// When platform relays are configured, restrict suggestions to those spaces.
// Otherwise suggest rooms from the user's joined spaces plus the current one.
const spaceUrls =
PLATFORM_RELAYS.length > 0
? PLATFORM_RELAYS
: uniq(url ? [url, ...$userSpaceUrls] : $userSpaceUrls)
2026-07-28 16:16:26 +00:00
const options = spaceUrls.flatMap(spaceUrl =>
($roomsByUrl.get(spaceUrl) ?? []).map((room: Room) => ({
id: room.id,
h: room.h,
name: room.meta?.name() ?? "",
})),
)
return createSearch(options, {
2026-07-28 16:16:26 +00:00
getValue: option => option.id,
fuseOptions: {
keys: ["name", "h"],
threshold: 0.3,
shouldSort: false,
},
})
},
)
2026-09-07 16:48:36 +00:00
if (commandTarget?.url) {
commands.get().ensureLoaded(commandTarget.url)
}
const commandSearch = derived(throttled(800, commands.get().index.$), () =>
commandTarget ? createCommandSearch(commandTarget) : undefined,
)
// The spec prefers a bare trigger, so qualify with the executor's pubkey only where the
// trigger would otherwise reach more than one of them.
const getCommandAttributes = (address: string) => {
if (commandTarget) {
const command = getCommandByAddress(commandTarget.url ?? "", address)
if (command) {
const trigger = command.command() ?? ""
const matches = getCommandsForTarget(commandTarget).filter(
option => option.command() === trigger,
)
return {command: trigger, pubkey: matches.length > 1 ? command.author() : undefined}
}
}
}
// A file the editor has no node for is uploaded here instead, so its imeta has to join what
// the nostr extension collects off the document, and it has to hold `uploading` open itself.
const attachments: {url: string; tag: string[]}[] = []
let attaching = 0
const setUploading = () =>
uploading?.set(
attaching > 0 ||
ed.storage.fileUpload.uploader.getFiles().some((attrs: FileAttributes) => attrs.uploading),
)
const attachFile = async (editor: Editor, file: File) => {
attaching += 1
uploading?.set(true)
const {error, result} = await uploadFile(file, {url, encrypt: encryptFiles})
attaching -= 1
setUploading()
if (result) {
attachments.push({url: result.url, tag: makeImetaTag(file, result)})
editor
.chain()
.focus()
.insertContent({type: "text", text: result.url + " "})
.run()
} else {
pushToast({theme: "error", message: error})
}
}
const ed = new Editor({
content: typeof content === "string" ? escapeHtml(content) : content,
2025-12-01 16:45:35 +00:00
editorProps,
2025-02-05 18:47:56 +00:00
element: document.createElement("div"),
2025-01-02 18:04:28 +00:00
extensions: [
RoomReferenceExtension,
2026-09-07 16:48:36 +00:00
CommandExtension.extend({addNodeView: () => CommandNodeView}),
2025-01-02 18:04:28 +00:00
WelshmanExtension.configure({
submit,
extensions: {
placeholder: {
config: {
placeholder,
},
},
2025-01-17 23:01:05 +00:00
breakOrSubmit: {
config: {
aggressive,
},
},
2025-06-03 17:41:38 +00:00
fileUpload: {
config: {
allowedMimeTypes: ["*/*"],
upload: async (attrs: FileAttributes) =>
uploadFile(await compressFileForUpload(attrs.file), {url, encrypt: encryptFiles}),
onDrop: () => uploading?.set(true),
onComplete: () => setUploading(),
2025-06-06 23:38:11 +00:00
onUploadError(currentEditor, task) {
currentEditor.commands.removeFailedUploads()
2025-08-21 23:06:14 +00:00
pushToast({theme: "error", message: task.error})
setUploading()
2025-01-02 18:04:28 +00:00
},
2025-06-03 17:41:38 +00:00
},
2026-08-22 20:12:11 +00:00
extend: {
// The picker, a drop and a paste all reach the uploader through addFile, which
// only knows how to make an image or a video node. Everything else is uploaded on
// its own, so what you can attach is the server's call rather than a list here.
2026-08-22 20:12:11 +00:00
onCreate() {
const {uploader} = this.storage
const addFile = uploader.addFile.bind(uploader)
uploader.addFile = (file: File, pos: number) => {
if (file.type.match(/^(image|video)\//)) {
return addFile(file, pos)
}
attachFile(this.editor, file)
return true
}
// Paste filters by mime type before it ever reaches addFile.
uploader.handlePaste = (event: ClipboardEvent) => {
const files = Array.from(event.clipboardData?.items || [])
.filter(item => item.kind === "file")
.map(item => item.getAsFile())
.filter(Boolean) as File[]
2026-08-22 20:12:11 +00:00
for (const file of files) {
uploader.addFile(file, uploader.view.state.selection.from + 1)
2026-08-22 20:12:11 +00:00
}
return files.length > 0
2026-08-22 20:12:11 +00:00
}
},
},
2025-06-03 17:41:38 +00:00
},
2025-01-02 18:04:28 +00:00
nprofile: {
extend: {
2026-07-28 16:16:26 +00:00
addNodeView: () => MentionNodeView,
2025-01-02 18:04:28 +00:00
addProseMirrorPlugins() {
return [
MentionSuggestion({
editor: (this as any).editor,
2026-07-28 16:16:26 +00:00
search: (term: string) => get(searchProfiles)(term),
getRelays: (pubkey: string) => relayLists.get().writeUrls(pubkey).get(),
updateSignal: searchProfiles,
2025-02-05 03:37:59 +00:00
createSuggestion: (value: string) => {
2025-02-05 03:00:48 +00:00
const target = document.createElement("div")
2025-04-15 18:03:27 +00:00
mount(ProfileSuggestion, {target, props: {value, url}})
2025-02-05 03:00:48 +00:00
return target
},
}),
TippySuggestion({
char: "~",
name: "roomref",
editor: (this as any).editor,
search: (term: string) => get(roomReferenceSearch).searchValues(term),
updateSignal: roomReferenceSearch,
select: (id: string, props) => {
2026-07-28 16:16:26 +00:00
const [roomUrl, h] = splitRoomKey(id)
2026-07-28 16:16:26 +00:00
if (roomUrl && h) {
return props.command({url: roomUrl, h})
}
},
createSuggestion: (value: string) => {
const target = document.createElement("div")
mount(RoomSuggestion, {target, props: {value}})
2025-02-05 03:00:48 +00:00
return target
},
2025-01-02 18:04:28 +00:00
}),
2026-09-07 16:48:36 +00:00
...(commandTarget
? [
CommandSuggestion({
editor: (this as any).editor,
search: (term: string) => get(commandSearch)?.searchValues(term) ?? [],
updateSignal: commandSearch,
getAttributes: getCommandAttributes,
createSuggestion: (value: string) => {
const target = document.createElement("div")
mount(CommandSuggestionItem, {target, props: {value, url: url ?? ""}})
return target
},
}),
]
: []),
2025-01-02 18:04:28 +00:00
]
},
},
},
},
}),
NativeClipboardPasteExtension,
2025-01-02 18:04:28 +00:00
],
onUpdate({editor}) {
2026-09-07 16:48:36 +00:00
text?.set(editor.getText({blockSeparator: "\n"}))
2025-01-02 18:04:28 +00:00
wordCount?.set(editor.storage.wordCount.words)
charCount?.set(editor.storage.wordCount.chars)
2026-09-01 04:56:37 +00:00
empty?.set(isEmpty(editor))
onChange?.(editor.getJSON())
2025-01-02 18:04:28 +00:00
},
})
2026-09-01 04:56:37 +00:00
// Seed the caller's store from the document tiptap actually parsed — a restored draft is a
// document even when it holds no text, so the caller can't tell from `content` alone. Callers
// clear their draft when this reads true, so it has to be set before they render: keep every
// await in this function inside a callback, below the constructor.
empty?.set(isEmpty(ed))
2026-09-07 16:48:36 +00:00
text?.set(ed.getText({blockSeparator: "\n"}))
2026-09-01 04:56:37 +00:00
// Deleting the url an attachment was inserted as is how you remove it, since there is no node
// to delete.
const getEditorTags = ed.storage.nostr.getEditorTags
ed.storage.nostr.getEditorTags = (...args: unknown[]) => {
const content = ed.getText({blockSeparator: "\n"})
return [
...getEditorTags(...args),
...attachments.filter(({url}) => content.includes(url)).map(({tag}) => tag),
]
}
return ed
}