mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1bad8dbf4 | |||
| 58a70ab62e | |||
| aff26aa809 | |||
| e1cc6395f5 | |||
| 38186d64e5 | |||
| 28d5d0da5d | |||
| 5eb95f7bd9 | |||
| c3ba07cee4 |
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) => (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+6
-6
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user