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"
|
2025-10-24 16:09:59 +00:00
|
|
|
import {get, derived} from "svelte/store"
|
2026-07-28 16:16:26 +00:00
|
|
|
import {sortBy, uniq} from "@welshman/lib"
|
2025-10-24 16:09:59 +00:00
|
|
|
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"
|
2026-04-04 11:18:29 +00:00
|
|
|
import {
|
2026-09-07 16:48:36 +00:00
|
|
|
CommandExtension,
|
|
|
|
|
CommandSuggestion,
|
2026-04-04 11:18:29 +00:00
|
|
|
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"
|
2025-09-18 20:43:43 +00:00
|
|
|
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
|
2026-04-04 11:18:29 +00:00
|
|
|
import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension"
|
|
|
|
|
import RoomSuggestion from "@app/editor/RoomSuggestion.svelte"
|
|
|
|
|
import {NativeClipboardPasteExtension} from "@app/editor/clipboard"
|
2026-08-11 21:54:05 +00:00
|
|
|
import {UPLOAD_MIME_TYPES, compressFileForUpload, uploadFile} from "@app/uploads"
|
2026-07-28 16:16:26 +00:00
|
|
|
import {userSpaceUrls} from "@app/rooms"
|
2026-07-08 16:00:40 +00:00
|
|
|
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() === ""
|
|
|
|
|
|
2025-04-29 19:24:20 +00:00
|
|
|
export const makeEditor = async ({
|
2025-09-29 21:24:42 +00:00
|
|
|
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,
|
2026-04-07 12:06:29 +00:00
|
|
|
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
|
|
|
}: {
|
2025-09-29 21:24:42 +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>
|
2026-04-07 12:06:29 +00:00
|
|
|
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
|
2025-04-29 19:24:20 +00:00
|
|
|
}) => {
|
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())
|
2025-10-24 16:09:59 +00:00
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
return (term: string) =>
|
|
|
|
|
sortBy(pubkey => (memberPubkeys.has(pubkey) ? 0 : 1), $profileSearch.searchValues(term))
|
2025-10-24 16:09:59 +00:00
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-04 11:18:29 +00:00
|
|
|
const roomReferenceSearch = derived(
|
2026-07-28 16:16:26 +00:00
|
|
|
[throttled(800, userSpaceUrls), throttled(800, rooms.get().byUrl.$)],
|
2026-04-04 11:18:29 +00:00
|
|
|
([$userSpaceUrls, $roomsByUrl]) => {
|
2026-07-08 16:00:40 +00:00
|
|
|
// 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() ?? "",
|
|
|
|
|
})),
|
|
|
|
|
)
|
2026-04-04 11:18:29 +00:00
|
|
|
|
|
|
|
|
return createSearch(options, {
|
2026-07-28 16:16:26 +00:00
|
|
|
getValue: option => option.id,
|
2026-04-04 11:18:29 +00:00
|
|
|
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}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 12:06:29 +00:00
|
|
|
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: [
|
2026-04-04 11:18:29 +00:00
|
|
|
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: {
|
2026-08-11 21:54:05 +00:00
|
|
|
allowedMimeTypes: UPLOAD_MIME_TYPES,
|
2026-07-03 20:03:04 +00:00
|
|
|
upload: async (attrs: FileAttributes) =>
|
|
|
|
|
uploadFile(await compressFileForUpload(attrs.file), {url, encrypt: encryptFiles}),
|
2025-09-18 20:43:43 +00:00
|
|
|
onDrop: () => uploading?.set(true),
|
|
|
|
|
onComplete: () => uploading?.set(false),
|
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})
|
2025-06-06 23:38:11 +00:00
|
|
|
uploading?.set(false)
|
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
|
|
|
|
|
// refuses a type that isn't allowed above by returning false and saying nothing.
|
|
|
|
|
// Say it, or choosing the wrong file looks like the app simply ignored the click.
|
|
|
|
|
onCreate() {
|
|
|
|
|
const {uploader} = this.storage
|
|
|
|
|
const addFile = uploader.addFile.bind(uploader)
|
|
|
|
|
|
|
|
|
|
uploader.addFile = (file: File, pos: number) => {
|
|
|
|
|
const added = addFile(file, pos)
|
|
|
|
|
|
|
|
|
|
if (!added) {
|
|
|
|
|
pushToast({
|
|
|
|
|
theme: "error",
|
|
|
|
|
message: `${file.name} is not a type you can attach.`,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return added
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
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
|
|
|
|
2026-04-04 11:18:29 +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-04-04 11:18:29 +00:00
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
if (roomUrl && h) {
|
|
|
|
|
return props.command({url: roomUrl, h})
|
2026-04-04 11:18:29 +00:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
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
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
2026-04-11 16:38:06 +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))
|
2026-04-07 12:06:29 +00:00
|
|
|
onChange?.(editor.getJSON())
|
2025-01-02 18:04:28 +00:00
|
|
|
},
|
|
|
|
|
})
|
2026-04-07 12:06:29 +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
|
|
|
|
2026-04-07 12:06:29 +00:00
|
|
|
return ed
|
2025-04-29 19:24:20 +00:00
|
|
|
}
|