Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[pull] main from outline:main #159

Merged
merged 3 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/components/ContextMenu/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type Props = {
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactElement;
icon?: React.ReactNode;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
Expand Down
67 changes: 67 additions & 0 deletions app/editor/components/PasteMenu.tsx
Original file line number Diff line number Diff line change
@@ -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: <LinkIcon />,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
],
[embed, t]
);

return (
<SuggestionsMenu
{...props}
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={() => {
props.onSelect?.(item);
}}
selected={options.selected}
title={item.title}
icon={item.icon}
/>
)}
items={items}
/>
);
};

export default PasteMenu;
2 changes: 1 addition & 1 deletion app/editor/components/SuggestionsMenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -209,19 +194,19 @@ export default class PasteHandler extends Extension {
hasEmoji ? document.icon + " " : ""
}${document.titleWithDefault}`;

insertLink(`${document.path}${hash}`, title);
this.insertLink(`${document.path}${hash}`, title);
}
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
insertLink(text);
this.insertLink(text);
});
}
} else {
insertLink(text);
this.insertLink(text);
}

return true;
Expand Down Expand Up @@ -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) => (
<PasteMenu
rtl={rtl}
trigger=""
embeds={this.editor.props.embeds}
pastedText={this.state.pastedText}
isActive={this.state.open}
search={this.state.query}
onClose={this.hidePasteMenu}
onSelect={this.handleSelect}
/>
);
}
Loading