diff --git a/app/components/ContextMenu/MenuItem.tsx b/app/components/ContextMenu/MenuItem.tsx index 96d8c964c1bf..1febb9b7eed6 100644 --- a/app/components/ContextMenu/MenuItem.tsx +++ b/app/components/ContextMenu/MenuItem.tsx @@ -23,7 +23,7 @@ type Props = { as?: string | React.ComponentType; hide?: () => void; level?: number; - icon?: React.ReactElement; + icon?: React.ReactNode; children?: React.ReactNode; ref?: React.LegacyRef | undefined; }; diff --git a/app/editor/components/PasteMenu.tsx b/app/editor/components/PasteMenu.tsx new file mode 100644 index 000000000000..3d12b4096943 --- /dev/null +++ b/app/editor/components/PasteMenu.tsx @@ -0,0 +1,67 @@ +import { LinkIcon } from "outline-icons"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { EmbedDescriptor } from "@shared/editor/embeds"; +import SuggestionsMenu, { + Props as SuggestionsMenuProps, +} from "./SuggestionsMenu"; +import SuggestionsMenuItem from "./SuggestionsMenuItem"; + +type Props = Omit< + SuggestionsMenuProps, + "renderMenuItem" | "items" | "embeds" +> & { + pastedText: string; + embeds: EmbedDescriptor[]; +}; + +const PasteMenu = ({ embeds, ...props }: Props) => { + const { t } = useTranslation(); + + const embed = React.useMemo(() => { + for (const e of embeds) { + const matches = e.matcher(props.pastedText); + if (matches) { + return e; + } + } + return; + }, [embeds, props.pastedText]); + + const items = React.useMemo( + () => [ + { + name: "link", + title: t("Keep as link"), + icon: , + }, + { + name: "embed", + title: t("Embed"), + icon: embed?.icon, + keywords: embed?.keywords, + }, + ], + [embed, t] + ); + + return ( + ( + { + props.onSelect?.(item); + }} + selected={options.selected} + title={item.title} + icon={item.icon} + /> + )} + items={items} + /> + ); +}; + +export default PasteMenu; diff --git a/app/editor/components/SuggestionsMenuItem.tsx b/app/editor/components/SuggestionsMenuItem.tsx index 23b32ee854f7..0769475e8981 100644 --- a/app/editor/components/SuggestionsMenuItem.tsx +++ b/app/editor/components/SuggestionsMenuItem.tsx @@ -12,7 +12,7 @@ export type Props = { /** Callback when the item is clicked */ onClick: (event: React.SyntheticEvent) => void; /** An optional icon for the item */ - icon?: React.ReactElement; + icon?: React.ReactNode; /** The title of the item */ title: React.ReactNode; /** An optional subtitle for the item */ diff --git a/app/editor/extensions/PasteHandler.ts b/app/editor/extensions/PasteHandler.tsx similarity index 65% rename from app/editor/extensions/PasteHandler.ts rename to app/editor/extensions/PasteHandler.tsx index 218597f250b8..992802606a70 100644 --- a/app/editor/extensions/PasteHandler.ts +++ b/app/editor/extensions/PasteHandler.tsx @@ -1,18 +1,29 @@ +import { action, observable } from "mobx"; import { toggleMark } from "prosemirror-commands"; import { Slice } from "prosemirror-model"; -import { Plugin } from "prosemirror-state"; +import { + EditorState, + Plugin, + PluginKey, + TextSelection, +} from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import * as React from "react"; import { v4 } from "uuid"; import { LANGUAGES } from "@shared/editor/extensions/Prism"; -import Extension from "@shared/editor/lib/Extension"; +import Extension, { WidgetProps } from "@shared/editor/lib/Extension"; import isMarkdown from "@shared/editor/lib/isMarkdown"; import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize"; +import { isRemoteTransaction } from "@shared/editor/lib/multiplayer"; +import { recreateTransform } from "@shared/editor/lib/prosemirror-recreate-transform"; import { isInCode } from "@shared/editor/queries/isInCode"; -import { isInList } from "@shared/editor/queries/isInList"; +import { MenuItem } from "@shared/editor/types"; import { IconType, MentionType } from "@shared/types"; import { determineIconType } from "@shared/utils/icon"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; import { isDocumentUrl, isUrl } from "@shared/utils/urls"; import stores from "~/stores"; +import PasteMenu from "../components/PasteMenu"; /** * Checks if the HTML string is likely coming from Dropbox Paper. @@ -61,13 +72,26 @@ function parseSingleIframeSrc(html: string) { } export default class PasteHandler extends Extension { + state: { + open: boolean; + query: string; + pastedText: string; + } = observable({ + open: false, + query: "", + pastedText: "", + }); + get name() { return "paste-handler"; } + private key = new PluginKey(this.name); + get plugins() { return [ new Plugin({ + key: this.key, props: { transformPastedHTML(html: string) { if (isDropboxPaper(html)) { @@ -107,23 +131,6 @@ export default class PasteHandler extends Extension { const html = event.clipboardData.getData("text/html"); const vscode = event.clipboardData.getData("vscode-editor-data"); - function insertLink(href: string, title?: string) { - // If it's not an embed and there is no text selected – just go ahead and insert the - // link directly - const transaction = view.state.tr - .insertText( - title ?? href, - state.selection.from, - state.selection.to - ) - .addMark( - state.selection.from, - state.selection.to + (title ?? href).length, - state.schema.marks.link.create({ href }) - ); - view.dispatch(transaction); - } - // If the users selection is currently in a code block then paste // as plain text, ignore all formatting and HTML content. if (isInCode(state)) { @@ -152,28 +159,6 @@ export default class PasteHandler extends Extension { return true; } - // Is this link embeddable? Create an embed! - const { embeds } = this.editor.props; - if ( - embeds && - this.editor.commands.embed && - !isInCode(state) && - !isInList(state) - ) { - for (const embed of embeds) { - if (!embed.matchOnInput) { - continue; - } - const matches = embed.matcher(text); - if (matches) { - this.editor.commands.embed({ - href: text, - }); - return true; - } - } - } - // Is the link a link to a document? If so, we can grab the title and insert it. if (isDocumentUrl(text)) { const slug = parseDocumentSlug(text); @@ -209,7 +194,7 @@ export default class PasteHandler extends Extension { hasEmoji ? document.icon + " " : "" }${document.titleWithDefault}`; - insertLink(`${document.path}${hash}`, title); + this.insertLink(`${document.path}${hash}`, title); } } }) @@ -217,11 +202,11 @@ export default class PasteHandler extends Extension { if (view.isDestroyed) { return; } - insertLink(text); + this.insertLink(text); }); } } else { - insertLink(text); + this.insertLink(text); } return true; @@ -323,10 +308,171 @@ export default class PasteHandler extends Extension { return false; }, }, + state: { + init: () => DecorationSet.empty, + apply: (tr, set) => { + let mapping = tr.mapping; + + // See if the transaction adds or removes any placeholders + const meta = tr.getMeta(this.key); + const hasDecorations = set.find().length; + + // We only want a single paste placeholder at a time, so if we're adding a new + // placeholder we can just return a new DecorationSet and avoid mapping logic. + if (meta?.add) { + const { from, to, id } = meta.add; + const decorations = [ + Decoration.inline( + from, + to, + { + class: "paste-placeholder", + }, + { + id, + } + ), + ]; + return DecorationSet.create(tr.doc, decorations); + } + + if (hasDecorations && (isRemoteTransaction(tr) || meta)) { + try { + mapping = recreateTransform(tr.before, tr.doc, { + complexSteps: true, + wordDiffs: false, + simplifyDiff: true, + }).mapping; + } catch (err) { + // eslint-disable-next-line no-console + console.warn("Failed to recreate transform: ", err); + } + } + + set = set.map(mapping, tr.doc); + + if (meta?.remove) { + const { id } = meta.remove; + const decorations = set.find( + undefined, + undefined, + (spec) => spec.id === id + ); + return set.remove(decorations); + } + + return set; + }, + }, }), ]; } - /** Tracks whether the Shift key is currently held down */ private shiftKey = false; + + private showPasteMenu = action((text: string) => { + this.state.pastedText = text; + this.state.open = true; + }); + + private hidePasteMenu = action(() => { + this.state.open = false; + }); + + private insertLink(href: string, title?: string) { + const { view } = this.editor; + const { state } = view; + const { from } = state.selection; + const to = from + (title ?? href).length; + + const transaction = view.state.tr + .insertText(title ?? href, state.selection.from, state.selection.to) + .addMark(from, to, state.schema.marks.link.create({ href })) + .setMeta(this.key, { add: { from, to, id: href } }); + view.dispatch(transaction); + this.showPasteMenu(href); + } + + private insertEmbed = () => { + const { view } = this.editor; + const { state } = view; + const result = this.findPlaceholder(state, this.state.pastedText); + + if (result) { + const tr = state.tr.deleteRange(result[0], result[1]); + view.dispatch( + tr.setSelection(TextSelection.near(tr.doc.resolve(result[0]))) + ); + } + + this.editor.commands.embed({ + href: this.state.pastedText, + }); + }; + + private removePlaceholder = () => { + const { view } = this.editor; + const { state } = view; + const result = this.findPlaceholder(state, this.state.pastedText); + + if (result) { + view.dispatch( + state.tr.setMeta(this.key, { + remove: { id: this.state.pastedText }, + }) + ); + } + }; + + private findPlaceholder = ( + state: EditorState, + id: string + ): [number, number] | null => { + const decos = this.key.getState(state) as DecorationSet; + const found = decos?.find(undefined, undefined, (spec) => spec.id === id); + return found?.length ? [found[0].from, found[0].to] : null; + }; + + private handleSelect = (item: MenuItem) => { + switch (item.name) { + case "link": { + this.hidePasteMenu(); + this.removePlaceholder(); + break; + } + case "embed": { + this.hidePasteMenu(); + this.insertEmbed(); + break; + } + default: + break; + } + }; + + keys() { + return { + Backspace: () => { + this.hidePasteMenu(); + return false; + }, + "Mod-z": () => { + this.hidePasteMenu(); + return false; + }, + }; + } + + widget = ({ rtl }: WidgetProps) => ( + + ); } diff --git a/app/types.ts b/app/types.ts index 4a2e1abca5c7..8431e63ad432 100644 --- a/app/types.ts +++ b/app/types.ts @@ -26,7 +26,7 @@ export type MenuItemButton = { visible?: boolean; selected?: boolean; disabled?: boolean; - icon?: React.ReactElement; + icon?: React.ReactNode; }; export type MenuItemWithChildren = { @@ -38,7 +38,7 @@ export type MenuItemWithChildren = { hover?: boolean; items: MenuItem[]; - icon?: React.ReactElement; + icon?: React.ReactNode; }; export type MenuSeparator = { @@ -59,7 +59,7 @@ export type MenuInternalLink = { visible?: boolean; selected?: boolean; disabled?: boolean; - icon?: React.ReactElement; + icon?: React.ReactNode; }; export type MenuExternalLink = { @@ -70,7 +70,7 @@ export type MenuExternalLink = { selected?: boolean; disabled?: boolean; level?: number; - icon?: React.ReactElement; + icon?: React.ReactNode; }; export type MenuItem = @@ -108,7 +108,7 @@ export type Action = { /** Higher number is higher in results, default is 0. */ priority?: number; iconInContextMenu?: boolean; - icon?: React.ReactElement | React.FC; + icon?: React.ReactNode; placeholder?: ((context: ActionContext) => string) | string; selected?: (context: ActionContext) => boolean; visible?: (context: ActionContext) => boolean; @@ -127,7 +127,7 @@ export type CommandBarAction = { shortcut: string[]; keywords: string; placeholder?: string; - icon?: React.ReactElement; + icon?: React.ReactNode; perform?: () => void; children?: string[]; parent?: string; diff --git a/package.json b/package.json index 7c022b05bd1d..b729339c8214 100644 --- a/package.json +++ b/package.json @@ -241,7 +241,7 @@ "utility-types": "^3.11.0", "uuid": "^8.3.2", "validator": "13.12.0", - "vite": "^5.4.11", + "vite": "^5.4.12", "vite-plugin-pwa": "^0.20.3", "winston": "^3.13.0", "ws": "^7.5.10", diff --git a/server/commands/documentCreator.ts b/server/commands/documentCreator.ts index e90f91f5c877..fe4bfa3ce0ac 100644 --- a/server/commands/documentCreator.ts +++ b/server/commands/documentCreator.ts @@ -35,8 +35,8 @@ type Props = Optional< }; export default async function documentCreator({ - title = "", - text = "", + title, + text, icon, color, state, @@ -101,14 +101,20 @@ export default async function documentCreator({ fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth, icon: templateDocument ? templateDocument.icon : icon, color: templateDocument ? templateDocument.color : color, - title: TextHelper.replaceTemplateVariables( - templateDocument ? templateDocument.title : title, - user - ), - text: TextHelper.replaceTemplateVariables( - templateDocument ? templateDocument.text : text, - user - ), + title: + title ?? + (templateDocument + ? template + ? templateDocument.title + : TextHelper.replaceTemplateVariables(templateDocument.title, user) + : ""), + text: + text ?? + (templateDocument + ? template + ? templateDocument.text + : TextHelper.replaceTemplateVariables(templateDocument.text, user) + : ""), content: templateDocument ? ProsemirrorHelper.replaceTemplateVariables( await DocumentHelper.toJSON(templateDocument), diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index 14ebc2d91a25..d43f786372cf 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -6,6 +6,7 @@ import { StatusFilter, UserRole, } from "@shared/types"; +import { TextHelper } from "@shared/utils/TextHelper"; import { createContext } from "@server/context"; import { Document, @@ -3357,6 +3358,127 @@ describe("#documents.import", () => { }); describe("#documents.create", () => { + it("should replace template variables when a doc is created from a template", async () => { + const user = await buildUser(); + const template = await buildDocument({ + userId: user.id, + teamId: user.teamId, + template: true, + title: "template title", + text: "Created by user {author} on {date}", + }); + const res = await server.post("/api/documents.create", { + body: { + token: user.getJwtToken(), + templateId: template.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.title).toEqual( + TextHelper.replaceTemplateVariables(template.title, user) + ); + expect(body.data.text).toEqual( + TextHelper.replaceTemplateVariables(template.text, user) + ); + }); + + it("should retain template variables when a template is created from another template", async () => { + const user = await buildUser(); + const template = await buildDocument({ + userId: user.id, + teamId: user.teamId, + template: true, + title: "template title", + text: "Created by user {author} on {date}", + }); + const res = await server.post("/api/documents.create", { + body: { + token: user.getJwtToken(), + templateId: template.id, + template: true, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.title).toEqual(template.title); + expect(body.data.text).toEqual(template.text); + }); + + it("should create a document with empty title if no title is explicitly passed", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.create", { + body: { + token: user.getJwtToken(), + text: "hello", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.title).toEqual(""); + }); + + it("should use template title when doc is supposed to be created using the template and title is not explicitly passed", async () => { + const user = await buildUser(); + const template = await buildDocument({ + userId: user.id, + teamId: user.teamId, + template: true, + title: "template title", + text: "template text", + }); + const res = await server.post("/api/documents.create", { + body: { + token: user.getJwtToken(), + templateId: template.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.title).toEqual(template.title); + expect(body.data.text).toEqual(template.text); + }); + + it("should override template title when doc title is explicitly passed", async () => { + const user = await buildUser(); + const template = await buildDocument({ + userId: user.id, + teamId: user.teamId, + template: true, + title: "template title", + }); + const res = await server.post("/api/documents.create", { + body: { + token: user.getJwtToken(), + templateId: template.id, + title: "doc title", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.title).toEqual("doc title"); + }); + + it("should override template text when doc text is explicitly passed", async () => { + const user = await buildUser(); + const template = await buildDocument({ + userId: user.id, + teamId: user.teamId, + template: true, + text: "template text", + }); + const res = await server.post("/api/documents.create", { + body: { + token: user.getJwtToken(), + templateId: template.id, + text: "doc text", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.text).toEqual("doc text"); + }); + it("should fail for invalid collectionId", async () => { const user = await buildUser(); const res = await server.post("/api/documents.create", { diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index a2af629c7828..592edf29f4c2 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -6,6 +6,7 @@ import JSZip from "jszip"; import Router from "koa-router"; import escapeRegExp from "lodash/escapeRegExp"; import has from "lodash/has"; +import isNil from "lodash/isNil"; import remove from "lodash/remove"; import uniq from "lodash/uniq"; import mime from "mime-types"; @@ -1644,7 +1645,9 @@ router.post( const document = await documentCreator({ id, title, - text: await TextHelper.replaceImagesWithAttachments(ctx, text, user), + text: !isNil(text) + ? await TextHelper.replaceImagesWithAttachments(ctx, text, user) + : text, icon, color, createdAt, diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index ee9043cb3dff..f8f97421cf38 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -331,10 +331,10 @@ export const DocumentsCreateSchema = BaseSchema.extend({ id: z.string().uuid().optional(), /** Document title */ - title: z.string().default(""), + title: z.string().optional(), /** Document text */ - text: z.string().default(""), + text: z.string().optional(), /** Icon displayed alongside doc title */ icon: zodIconType().optional(), diff --git a/shared/editor/embeds/index.tsx b/shared/editor/embeds/index.tsx index 14a776cc6139..6a29fc246296 100644 --- a/shared/editor/embeds/index.tsx +++ b/shared/editor/embeds/index.tsx @@ -31,9 +31,9 @@ export type EmbedProps = { }; const Img = styled(Image)` - border-radius: 2px; + border-radius: 3px; background: #fff; - box-shadow: 0 0 0 1px #fff; + box-shadow: 0 0 0 1px ${(props) => props.theme.divider}; margin: 3px; width: 18px; height: 18px; diff --git a/shared/editor/types/index.ts b/shared/editor/types/index.ts index 5282982b8d49..8e8687ab9dd1 100644 --- a/shared/editor/types/index.ts +++ b/shared/editor/types/index.ts @@ -15,7 +15,7 @@ export enum TableLayout { type Section = ({ t }: { t: TFunction }) => string; export type MenuItem = { - icon?: React.ReactElement; + icon?: React.ReactNode; name?: string; title?: string; section?: Section; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 37558b628ce7..3a7706a3789c 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -419,6 +419,8 @@ "Profile picture": "Profile picture", "Create a new doc": "Create a new doc", "{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document", + "Keep as link": "Keep as link", + "Embed": "Embed", "Add column after": "Add column after", "Add column before": "Add column before", "Add row after": "Add row after", diff --git a/yarn.lock b/yarn.lock index e45bd564fc23..d7fae70c6a97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15488,10 +15488,10 @@ vite-plugin-static-copy@^0.17.0: fs-extra "^11.1.0" picocolors "^1.0.0" -vite@^5.4.11: - version "5.4.11" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.11.tgz#3b415cd4aed781a356c1de5a9ebafb837715f6e5" - integrity sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q== +vite@^5.4.12: + version "5.4.12" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.12.tgz#627d12ff06de3942557dfe8632fd712a12a072c7" + integrity sha512-KwUaKB27TvWwDJr1GjjWthLMATbGEbeWYZIbGZ5qFIsgPP3vWzLu4cVooqhm5/Z2SPDUMjyPVjTztm5tYKwQxA== dependencies: esbuild "^0.21.3" postcss "^8.4.43"