Backlink to toolbar menu (#10762)

* fix: port link related commands to work for image selection

* fix: selection

* fix: click img to open link

* fix: hover preview for image with link

* cleanup: hasLink not needed

* fix: we've img wrapped in an `a` tag now, so this is no more required

* fix: cover all edge cases

* fix: cleanup

* fix: zoom in action button in edit mode

* fix: separator div instead of gap

* fix: toolbar refactor

* fix: back button press

* fix: import

* fix: revert

* fix: enum

* fix: onClick on item

* fix: selection at end after link

* fix: show linkbar if link present

* fix: ReturnIcon

* fix: onClickBack

* fix: TOOLBAR -> Toolbar

* fix: show zoom in icon even when selected

* fix: isInlineMarkActive

* fix: jsdoc

* yarn.lock

* Revert "yarn.lock"

This reverts commit 5f44e5e017.

* fix: yarn.lock

* fix: link editor closes upon zoom in click action

* Update shared/editor/queries/isMarkActive.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update shared/editor/queries/getMarkRange.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update shared/editor/components/Image.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update app/editor/components/LinkEditor.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: prevent toolbar state reset

* fix: tooltip

* fix: copilot

* fix: i18n, misuse of attrs

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Apoorv Mishra
2025-12-06 22:51:35 +05:30
committed by GitHub
parent ccbc7779ea
commit 8af6fdcc4f
19 changed files with 679 additions and 212 deletions

View File

@@ -1,7 +1,12 @@
import { observer } from "mobx-react";
import { ArrowIcon, CloseIcon, DocumentIcon, OpenIcon } from "outline-icons";
import {
ArrowIcon,
CloseIcon,
DocumentIcon,
OpenIcon,
ReturnIcon,
} from "outline-icons";
import { Mark } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
@@ -28,9 +33,25 @@ type Props = {
mark?: Mark;
dictionary: Dictionary;
view: EditorView;
onLinkAdd: () => void;
onLinkUpdate: () => void;
onLinkRemove: () => void;
onEscape: () => void;
onClickOutside: (ev: MouseEvent | TouchEvent) => void;
onClickBack: () => void;
};
const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
const LinkEditor: React.FC<Props> = ({
mark,
dictionary,
view,
onLinkAdd,
onLinkUpdate,
onLinkRemove,
onEscape,
onClickOutside,
onClickBack,
}) => {
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
const initialValue = getHref();
const { commands } = useEditor();
@@ -58,7 +79,7 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
}
}, [trimmedQuery, request]);
useOnClickOutside(wrapperRef, () => {
useOnClickOutside(wrapperRef, (ev) => {
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return removeLink();
@@ -66,9 +87,14 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
// If the link in input is non-empty and same as it was when the editor opened, nothing to do
if (trimmedQuery === initialValue) {
onClickOutside(ev);
return;
}
if (!mark) {
return addLink(trimmedQuery);
}
updateLink(trimmedQuery);
});
@@ -78,26 +104,23 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
const removeLink = React.useCallback(() => {
commands["removeLink"]();
}, []);
onLinkRemove();
}, [commands, onLinkRemove]);
const updateLink = (link: string) => {
if (!link) {
return;
}
commands["updateLink"]({ href: sanitizeUrl(link) ?? "" });
onLinkUpdate();
};
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(
state.tr.doc.resolve(state.selection.to),
1,
true
);
if (nextSelection) {
dispatch(state.tr.setSelection(nextSelection));
const addLink = (link: string) => {
if (!link) {
return;
}
view.focus();
commands["addLink"]({ href: sanitizeUrl(link) ?? "" });
onLinkAdd();
};
const handleKeyDown = (event: React.KeyboardEvent) => {
@@ -119,9 +142,11 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
if (selectedIndex >= 0 && results[selectedIndex]) {
const selectedDoc = results[selectedIndex];
updateLink(selectedDoc.url);
!mark ? addLink(selectedDoc.url) : updateLink(selectedDoc.url);
} else if (!trimmedQuery) {
removeLink();
} else if (!mark) {
addLink(trimmedQuery);
} else {
updateLink(trimmedQuery);
}
@@ -135,11 +160,7 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
return removeLink();
}
// Moving selection to end causes editor state to change,
// forcing a re-render of the top-level editor component. As
// a result, the new selection, being devoid of any link mark,
// prevents LinkEditor from re-rendering.
moveSelectionToEnd();
onEscape();
return;
}
}
@@ -169,6 +190,13 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
disabled: false,
handler: removeLink,
},
{
tooltip: dictionary.formattingControls,
icon: <ReturnIcon />,
visible: view.editable,
disabled: false,
handler: onClickBack,
},
];
return (
@@ -208,7 +236,7 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
{results.map((doc, index) => (
<SuggestionsMenuItem
onPointerDown={() => {
updateLink(doc.url);
!mark ? addLink(doc.url) : updateLink(doc.url);
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}

View File

@@ -2,24 +2,43 @@ import { OpenIcon, TrashIcon } from "outline-icons";
import { Node } from "prosemirror-model";
import { Selection, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Tooltip from "~/components/Tooltip";
import Input from "~/editor/components/Input";
import { Dictionary } from "~/hooks/useDictionary";
import ToolbarButton from "./ToolbarButton";
import useOnClickOutside from "~/hooks/useOnClickOutside";
type Props = {
node: Node;
view: EditorView;
dictionary: Dictionary;
autoFocus?: boolean;
onLinkUpdate: () => void;
onLinkRemove: () => void;
onEscape: () => void;
onClickOutside: (ev: MouseEvent | TouchEvent) => void;
};
export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
export function MediaLinkEditor({
node,
view,
dictionary,
onLinkUpdate,
onLinkRemove,
onEscape,
onClickOutside,
}: Props) {
const url = (node.attrs.href ?? node.attrs.src) as string;
const [localUrl, setLocalUrl] = useState(url);
const wrapperRef = useRef<HTMLDivElement>(null);
// If we're attempting to edit an image, autofocus the input
// Not doing for embed type because it made the editor scroll to top
// unexpectedlyleaving that out for now
const isEditingImgUrl = node.type.name === "image";
const moveSelectionToEnd = useCallback(() => {
const { state, dispatch } = view;
@@ -41,6 +60,7 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
const remove = useCallback(() => {
const { state, dispatch } = view;
dispatch(state.tr.deleteSelection());
onLinkRemove();
}, [view]);
const update = useCallback(() => {
@@ -53,8 +73,11 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
view.dispatch(tr);
moveSelectionToEnd();
onLinkUpdate();
}, [localUrl, node, view, moveSelectionToEnd]);
useOnClickOutside(wrapperRef, onClickOutside);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing) {
@@ -71,6 +94,7 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
case "Escape": {
event.preventDefault();
moveSelectionToEnd();
onEscape();
return;
}
}
@@ -79,9 +103,9 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
);
return (
<Wrapper>
<Wrapper ref={wrapperRef}>
<Input
autoFocus={autoFocus}
autoFocus={isEditingImgUrl}
value={localUrl}
placeholder={dictionary.pasteLink}
onChange={(e) => setLocalUrl(e.target.value)}

View File

@@ -1,7 +1,10 @@
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import {
getMarkRange,
getMarkRangeNodeSelection,
} from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
@@ -30,6 +33,7 @@ import { MediaLinkEditor } from "./MediaLinkEditor";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
import { isModKey } from "@shared/utils/keyboard";
type Props = {
/** Whether the text direction is right-to-left */
@@ -56,6 +60,12 @@ function useIsDragging() {
return isDragging;
}
enum Toolbar {
Link = "link",
Media = "media",
Menu = "menu",
}
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
const { view, commands } = useEditor();
@@ -64,11 +74,33 @@ export function SelectionToolbar(props: Props) {
const isMobile = useMobile();
const isActive = props.isActive || isMobile;
const isDragging = useIsDragging();
const [isEditingImgUrl, setIsEditingImgUrl] = React.useState(false);
const { state } = view;
const { selection } = state;
const [activeToolbar, setActiveToolbar] = React.useState<Toolbar | null>(
null
);
React.useEffect(() => {
setIsEditingImgUrl(false);
}, [isActive]);
const linkMark =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
const isEmbedSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "embed";
if (isEmbedSelection) {
setActiveToolbar(Toolbar.Media);
} else if (linkMark && !activeToolbar) {
setActiveToolbar(Toolbar.Link);
} else if (!selection.empty) {
setActiveToolbar(Toolbar.Menu);
} else if (selection.empty) {
setActiveToolbar(null);
}
}, [selection]);
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
@@ -91,8 +123,6 @@ export function SelectionToolbar(props: Props) {
return;
}
setIsEditingImgUrl(false);
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
@@ -111,22 +141,21 @@ export function SelectionToolbar(props: Props) {
}
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const link = getMarkRange(selection.$from, state.schema.marks.link);
const isImageSelection =
selection instanceof NodeSelection && selection.node.type.name === "image";
const isAttachmentSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "attachment";
const isEmbedSelection =
selection instanceof NodeSelection && selection.node.type.name === "embed";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
const link =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
@@ -178,46 +207,98 @@ export function SelectionToolbar(props: Props) {
});
items = filterExcessSeparators(items);
items = items.map((item) => {
if (item.children) {
item.children = item.children.map((child) => {
if (child.name === "editImageUrl") {
child.onClick = () => {
setActiveToolbar(Toolbar.Media);
};
}
return child;
});
}
if (item.name === "linkOnImage" || item.name === "addLink") {
item.onClick = () => {
setActiveToolbar(Toolbar.Link);
};
}
return item;
});
if (!items.length) {
return null;
}
const showLinkToolbar =
link && link.from === selection.from && link.to === selection.to;
const handleClickOutsideLinkEditor = (ev: MouseEvent | TouchEvent) => {
if (ev.target instanceof Element && ev.target.closest(".image-wrapper")) {
return;
}
setActiveToolbar(null);
};
const isEditingMedia =
isEmbedSelection || (isImageSelection && isEditingImgUrl);
useEventListener(
"keydown",
(ev: KeyboardEvent) => {
if (
isModKey(ev) &&
ev.key.toLowerCase() === "k" &&
!view.state.selection.empty
) {
ev.stopPropagation();
if (activeToolbar === Toolbar.Link) {
setActiveToolbar(Toolbar.Menu);
} else if (activeToolbar === Toolbar.Menu) {
setActiveToolbar(Toolbar.Link);
}
}
},
view.dom,
{ capture: true }
);
if (!activeToolbar) {
return null;
}
return (
<FloatingToolbar
align={align}
active={isActive}
ref={menuRef}
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
width={
activeToolbar === Toolbar.Link || activeToolbar === Toolbar.Media
? 336
: undefined
}
>
{showLinkToolbar ? (
{activeToolbar === Toolbar.Link ? (
<LinkEditor
key={`${link.from}-${link.to}`}
key={`${selection.from}-${selection.to}`}
dictionary={dictionary}
view={view}
mark={link.mark}
mark={link ? link.mark : undefined}
onLinkAdd={() => setActiveToolbar(null)}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
onEscape={() => setActiveToolbar(Toolbar.Menu)}
onClickOutside={handleClickOutsideLinkEditor}
onClickBack={() => setActiveToolbar(Toolbar.Menu)}
/>
) : isEditingMedia ? (
) : activeToolbar === Toolbar.Media ? (
<MediaLinkEditor
key={`embed-${selection.from}`}
node={selection.node}
node={(selection as NodeSelection).node}
view={view}
dictionary={dictionary}
autoFocus={isEditingImgUrl}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
onEscape={() => setActiveToolbar(Toolbar.Menu)}
onClickOutside={handleClickOutsideLinkEditor}
/>
) : (
<ToolbarMenu
items={items}
{...rest}
handlers={{
editImageUrl: () => setIsEditingImgUrl(true),
}}
/>
<ToolbarMenu items={items} {...rest} />
)}
</FloatingToolbar>
);

View File

@@ -20,20 +20,15 @@ import EventBoundary from "@shared/components/EventBoundary";
type Props = {
items: MenuItem[];
handlers?: Record<string, (...args: any[]) => void>;
};
/*
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: {
active: boolean;
item: MenuItem;
handlers?: Record<string, Function>;
}) {
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item, handlers } = props;
const { item } = props;
const { state } = view;
const items: TMenuItem[] = useMemo(() => {
@@ -48,12 +43,8 @@ function ToolbarDropdown(props: {
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (handlers && handlers[menuItem.name]) {
handlers[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (menuItem.onClick) {
menuItem.onClick();
}
};
@@ -113,6 +104,13 @@ function ToolbarMenu(props: Props) {
return;
}
// if item has an associated onClick prop, run it
if (item.onClick) {
item.onClick();
return;
}
// otherwise, run the associated editor command
commands[item.name](
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
);
@@ -141,7 +139,6 @@ function ToolbarMenu(props: Props) {
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
handlers={props.handlers}
active={isActive && !item.label}
item={item}
/>

View File

@@ -258,6 +258,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+K`,
icon: <LinkIcon />,
attrs: { href: "" },
active: isMarkActive(schema.marks.link, undefined, { exact: true }),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{

View File

@@ -8,6 +8,7 @@ import {
AlignFullWidthIcon,
EditIcon,
CommentIcon,
LinkIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
@@ -16,6 +17,7 @@ import { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "@shared/utils/keyboard";
import { ImageSource } from "@shared/editor/lib/FileHelper";
import Desktop from "~/utils/Desktop";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
export default function imageMenuItems(
state: EditorState,
@@ -123,6 +125,13 @@ export default function imageMenuItems(
{
name: "separator",
},
{
name: "linkOnImage",
tooltip: dictionary.createLink,
shortcut: `${metaDisplay}+K`,
active: isMarkActive(schema.marks.link),
icon: <LinkIcon />,
},
{
name: "commentOnImage",
tooltip: dictionary.comment,

View File

@@ -110,6 +110,7 @@ export default function useDictionary() {
none: t("None"),
deleteEmbed: t("Delete embed"),
uploadImage: t("Upload an image"),
formattingControls: t("Formatting controls"),
distributeColumns: t("Distribute columns"),
}),
[t]

View File

@@ -182,7 +182,7 @@
"node-fetch": "2.7.0",
"nodemailer": "^7.0.11",
"octokit": "^3.2.2",
"outline-icons": "^3.14.0",
"outline-icons": "^3.15.0",
"oy-vey": "^0.12.1",
"pako": "^2.1.0",
"passport": "^0.7.0",

View File

@@ -0,0 +1,257 @@
import { chainCommands, toggleMark } from "prosemirror-commands";
import { Attrs } from "prosemirror-model";
import {
Command,
NodeSelection,
Selection,
TextSelection,
} from "prosemirror-state";
import { getMarkRange } from "../queries/getMarkRange";
import { toast } from "sonner";
import { sanitizeUrl } from "@shared/utils/urls";
import { getMarkRangeNodeSelection } from "../queries/getMarkRange";
import { NodeMarkAttr } from "@shared/editor/types";
const addLinkTextSelection =
(attrs: Attrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof TextSelection)) {
return false;
}
dispatch?.(
state.tr
.setSelection(TextSelection.create(state.doc, state.tr.selection.to))
.addMark(
state.selection.from,
state.selection.to,
state.schema.marks.link.create(attrs)
)
);
return true;
};
const addLinkNodeSelection =
(attrs: Attrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const { selection } = state;
const existingMarks = selection.node.attrs.marks ?? [];
const newMark = {
type: "link",
attrs,
};
const updatedMarks = [...existingMarks, newMark];
dispatch?.(
state.tr.setNodeAttribute(selection.from, "marks", updatedMarks)
);
return true;
};
const openLinkTextSelection =
(
onClickLink: (url: string, event: KeyboardEvent) => void,
dictionary: Record<string, string>
): Command =>
(state) => {
if (!(state.selection instanceof TextSelection)) {
return false;
}
const range = getMarkRange(state.selection.$from, state.schema.marks.link);
if (range && range.mark && onClickLink) {
try {
const event = new KeyboardEvent("keydown", { metaKey: false });
onClickLink(sanitizeUrl(range.mark.attrs.href) ?? "", event);
} catch (_err) {
toast.error(dictionary.openLinkError);
}
return true;
}
return false;
};
const openLinkNodeSelection =
(
onClickLink: (url: string, event: KeyboardEvent) => void,
dictionary: Record<string, string>
): Command =>
(state) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
if (!onClickLink) {
return false;
}
const marks = state.selection.node.attrs.marks ?? [];
const linkMark = marks.find((mark: NodeMarkAttr) => mark.type === "link");
if (!linkMark) {
return false;
}
try {
const event = new KeyboardEvent("keydown", { metaKey: false });
onClickLink(sanitizeUrl(linkMark.attrs.href) ?? "", event);
} catch (_err) {
toast.error(dictionary.openLinkError);
}
return true;
};
const updateLinkTextSelection =
(attrs: Attrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof TextSelection)) {
return false;
}
const range = getMarkRange(state.selection.$from, state.schema.marks.link);
if (range && range.mark) {
const nextSelection =
Selection.findFrom(state.doc.resolve(range.to), 1, true) ??
TextSelection.create(state.tr.doc, 0);
dispatch?.(
state.tr
.setSelection(nextSelection)
.removeMark(range.from, range.to, state.schema.marks.link)
.addMark(range.from, range.to, state.schema.marks.link.create(attrs))
);
return true;
}
return false;
};
const updateLinkNodeSelection =
(attrs: Attrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const markRange = getMarkRangeNodeSelection(
state.selection,
state.schema.marks.link
);
if (!markRange) {
return false;
}
const existingMarks = state.selection.node.attrs.marks ?? [];
const updatedMarks = existingMarks.map((mark: NodeMarkAttr) =>
mark.type === "link"
? { ...mark, attrs: { ...mark.attrs, ...attrs } }
: mark
);
const nextValidSelection =
Selection.findFrom(state.doc.resolve(markRange.to), 1, true) ??
TextSelection.create(state.tr.doc, 0);
dispatch?.(
state.tr
.setSelection(nextValidSelection)
.setNodeAttribute(state.selection.from, "marks", updatedMarks)
);
return true;
};
const removeLinkTextSelection = (): Command => (state, dispatch) => {
if (!(state.selection instanceof TextSelection)) {
return false;
}
const range = getMarkRange(state.selection.$from, state.schema.marks.link);
if (range && range.mark) {
const nextSelection =
Selection.findFrom(state.doc.resolve(range.to), 1, true) ??
TextSelection.create(state.tr.doc, 0);
dispatch?.(
state.tr
.setSelection(nextSelection)
.removeMark(range.from, range.to, range.mark)
);
return true;
}
return false;
};
const removeLinkNodeSelection = (): Command => (state, dispatch) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const markRange = getMarkRangeNodeSelection(
state.selection,
state.schema.marks.link
);
if (!markRange) {
return false;
}
const existingMarks = state.selection.node.attrs.marks ?? [];
const updatedMarks = existingMarks.filter(
(mark: NodeMarkAttr) => mark.type !== "link"
);
const nextValidSelection =
Selection.findFrom(state.doc.resolve(markRange.to), 1, true) ??
TextSelection.create(state.tr.doc, 0);
dispatch?.(
state.tr
.setSelection(nextValidSelection)
.setNodeAttribute(state.selection.from, "marks", updatedMarks)
);
return true;
};
const toggleLinkTextSelection =
(attrs: Attrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof TextSelection)) {
return false;
}
return toggleMark(state.schema.marks.link, attrs)(state, dispatch);
};
const toggleLinkNodeSelection =
(attrs: Attrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const existingMarks = state.selection.node.attrs.marks ?? [];
const linkMark = existingMarks.find(
(mark: NodeMarkAttr) => mark.type === "link"
);
if (linkMark) {
return removeLinkNodeSelection()(state, dispatch);
} else {
return addLinkNodeSelection(attrs)(state, dispatch);
}
};
export const toggleLink = (attrs: Attrs): Command =>
chainCommands(toggleLinkTextSelection(attrs), toggleLinkNodeSelection(attrs));
export const addLink = (attrs: Attrs): Command =>
chainCommands(addLinkTextSelection(attrs), addLinkNodeSelection(attrs));
export const openLink = (
onClickLink: (url: string, event: KeyboardEvent) => void,
dictionary: Record<string, string>
): Command =>
chainCommands(
openLinkTextSelection(onClickLink, dictionary),
openLinkNodeSelection(onClickLink, dictionary)
);
export const updateLink = (attrs: Attrs): Command =>
chainCommands(updateLinkTextSelection(attrs), updateLinkNodeSelection(attrs));
export const removeLink = (): Command =>
chainCommands(removeLinkTextSelection(), removeLinkNodeSelection());

View File

@@ -1,4 +1,4 @@
import { CrossIcon, DownloadIcon, GlobeIcon } from "outline-icons";
import { CrossIcon, DownloadIcon, GlobeIcon, ZoomInIcon } from "outline-icons";
import type { EditorView } from "prosemirror-view";
import * as React from "react";
import styled from "styled-components";
@@ -10,12 +10,15 @@ import { ComponentProps } from "../types";
import { ResizeLeft, ResizeRight } from "./ResizeHandle";
import useDragResize from "./hooks/useDragResize";
import { useTranslation } from "react-i18next";
import find from "lodash/find";
type Props = ComponentProps & {
/** Callback triggered when the image is clicked */
onClick: () => void;
/** Callback triggered when the download button is clicked */
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => Promise<void>;
/** Callback triggered when the zoom in button is clicked */
onZoomIn?: (event: React.MouseEvent<HTMLButtonElement>) => void;
/** Callback triggered when the image is resized */
onChangeSize?: (props: { width: number; height?: number }) => void;
/** The editor view */
@@ -66,7 +69,13 @@ const Image = (props: Props) => {
}, [node.attrs.width]);
const sanitizedSrc = sanitizeUrl(src);
const linkMarkType = props.view.state.schema.marks.link;
const imgLink =
find(node.attrs.marks ?? [], (mark) => mark.type === linkMarkType.name)
?.attrs.href ||
// Coalescing to `undefined` to avoid empty string in href because empty string
// in href still shows pointer on hover and click navigates to nowhere
undefined;
const handleOpen = React.useCallback(() => {
window.open(sanitizedSrc, "_blank");
}, [sanitizedSrc]);
@@ -120,12 +129,30 @@ const Image = (props: Props) => {
{!dragging && width > 60 && isDownloadable && (
<Actions>
{isExternalUrl(src) && (
<Button onClick={handleOpen} aria-label={t("Open")}>
<GlobeIcon />
</Button>
<>
<Button onClick={handleOpen} aria-label={t("Open")}>
<GlobeIcon />
</Button>
<Separator height={24} />
</>
)}
{imgLink && (
<>
<Button
// `mousedown` on ancestor `div.ProseMirror` was preventing the `onClick` handler from firing
onMouseDown={(e) => e.stopPropagation()}
onClick={props.onZoomIn}
aria-label={t("Zoom in")}
>
<ZoomInIcon />
</Button>
<Separator height={24} />
</>
)}
<Button
onClick={handleDownload}
// `mousedown` on ancestor `div.ProseMirror` was preventing the `onClick` handler from firing
onMouseDown={(e) => e.stopPropagation()}
aria-label={t("Download")}
disabled={isDownloading}
>
@@ -138,16 +165,18 @@ const Image = (props: Props) => {
<CrossIcon size={16} /> Image failed to load
</Error>
) : (
<>
<a
href={imgLink}
// Do not show hover preview when the image is selected
className={!isSelected ? "use-hover-preview" : ""}
target="_blank"
rel="noopener noreferrer nofollow"
>
<img
className={EditorStyleHelper.imageHandle}
style={{
...widthStyle,
display: loaded ? "block" : "none",
pointerEvents:
dragging || (!props.isSelected && props.isEditable)
? "none"
: "all",
}}
src={sanitizedSrc}
alt={node.attrs.alt || ""}
@@ -175,18 +204,18 @@ const Image = (props: Props) => {
onClick={handleImageClick}
onTouchStart={handleImageTouchStart}
/>
{!loaded && width && height && (
<img
style={{
...widthStyle,
display: "block",
}}
src={`data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
getPlaceholder(width, height)
)}`}
/>
)}
</>
</a>
)}
{!loaded && width && height && (
<img
style={{
...widthStyle,
display: "block",
}}
src={`data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
getPlaceholder(width, height)
)}`}
/>
)}
{isEditable && !isFullWidth && isResizable && (
<>
@@ -231,7 +260,6 @@ const Actions = styled.div`
display: flex;
align-items: center;
position: absolute;
gap: 1px;
top: 8px;
right: 8px;
opacity: 0;
@@ -316,4 +344,11 @@ const ImageWrapper = styled.div<{ isFullWidth: boolean }>`
}
`;
const Separator = styled.div<{ height?: number }>`
flex-shrink: 0;
width: 1px;
height: ${(props) => props.height || 28}px;
background: ${s("divider")};
`;
export default Image;

View File

@@ -785,9 +785,6 @@ img.ProseMirror-separator {
.component-image + img.ProseMirror-separator + br.ProseMirror-trailingBreak {
display: none;
}
.component-image img {
cursor: zoom-in;
}
.${EditorStyleHelper.imageCaption} {
border: 0;
@@ -888,6 +885,41 @@ h6:not(.placeholder)::before {
}
}
.ProseMirror[contenteditable="true"] {
& .image-wrapper.ProseMirror-selectednode > a {
/* force zoom-in cursor if image node is selected */
cursor: zoom-in !important;
}
&.ProseMirror-focused {
.image-wrapper:not(.ProseMirror-selectednode) > a {
/* prevents cursor from turning to pointer on pointer down */
pointer-events: none;
}
}
&:not(.ProseMirror-focused) {
.image-wrapper {
& > a[href] {
cursor: pointer;
}
& > a:not([href]) {
/* prevents cursor from turning to pointer on pointer down */
pointer-events: none;
}
}
}
}
.ProseMirror[contenteditable="false"] {
.image-wrapper {
& > a[href] {
cursor: pointer;
}
& > a:not([href]) {
cursor: zoom-in;
}
}
}
.with-emoji {
margin-${props.rtl ? "right" : "left"}: -1em;
}

View File

@@ -1,29 +1,20 @@
import { Token } from "markdown-it";
import { toggleMark } from "prosemirror-commands";
import { InputRule } from "prosemirror-inputrules";
import { MarkdownSerializerState } from "prosemirror-markdown";
import {
Attrs,
MarkSpec,
MarkType,
Node,
Mark as ProsemirrorMark,
} from "prosemirror-model";
import {
Command,
EditorState,
Plugin,
Selection,
TextSelection,
} from "prosemirror-state";
import { Command, EditorState, Plugin, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { toast } from "sonner";
import { isUrl, sanitizeUrl } from "../../utils/urls";
import { getMarkRange } from "../queries/getMarkRange";
import { isMarkActive } from "../queries/isMarkActive";
import Mark from "./Mark";
import { isInCode } from "../queries/isInCode";
import { addMark } from "../commands/addMark";
import { addLink, openLink, removeLink, updateLink } from "../commands/link";
const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/;
@@ -113,102 +104,19 @@ export default class Link extends Mark {
];
}
keys({ type }: { type: MarkType }): Record<string, Command> {
keys(): Record<string, Command> {
return {
"Mod-k": (state, dispatch) => {
if (state.selection.empty) {
return false;
}
return toggleMark(type, { href: "" })(state, dispatch);
},
"Mod-Enter": (state) => {
if (isMarkActive(type)(state)) {
const range = getMarkRange(
state.selection.$from,
state.schema.marks.link
);
if (range && range.mark && this.options.onClickLink) {
try {
const event = new KeyboardEvent("keydown", { metaKey: false });
this.options.onClickLink(
sanitizeUrl(range.mark.attrs.href),
event
);
} catch (_err) {
toast.error(this.options.dictionary.openLinkError);
}
return true;
}
}
return false;
},
"Mod-Enter": openLink(this.options.onClickLink, this.options.dictionary),
};
}
commands({ type }: { type: MarkType }) {
commands() {
return {
addLink: (attrs: Attrs): Command => addMark(type, attrs),
updateLink:
(attrs: Attrs): Command =>
(state, dispatch) => {
const range = getMarkRange(
state.selection.$from,
state.schema.marks.link
);
if (range && range.mark) {
const nextSelection =
Selection.findFrom(state.doc.resolve(range.to), 1, true) ??
TextSelection.create(state.tr.doc, 0);
dispatch?.(
state.tr
.setSelection(nextSelection)
.removeMark(range.from, range.to, state.schema.marks.link)
.addMark(
range.from,
range.to,
state.schema.marks.link.create(attrs)
)
);
return true;
}
return false;
},
openLink: (): Command => (state) => {
const range = getMarkRange(
state.selection.$from,
state.schema.marks.link
);
if (range && range.mark && this.options.onClickLink) {
try {
const event = new KeyboardEvent("keydown", { metaKey: false });
this.options.onClickLink(sanitizeUrl(range.mark.attrs.href), event);
} catch (_err) {
toast.error(this.options.dictionary.openLinkError);
}
return true;
}
return false;
},
removeLink: (): Command => (state, dispatch) => {
const range = getMarkRange(
state.selection.$from,
state.schema.marks.link
);
if (range && range.mark) {
const nextSelection =
Selection.findFrom(state.doc.resolve(range.to), 1, true) ??
TextSelection.create(state.tr.doc, 0);
dispatch?.(
state.tr
.setSelection(nextSelection)
.removeMark(range.from, range.to, range.mark)
);
return true;
}
return false;
},
addLink,
updateLink,
openLink: (): Command =>
openLink(this.options.onClickLink, this.options.dictionary),
removeLink,
};
}
@@ -268,6 +176,19 @@ export default class Link extends Mark {
return false;
}
// If an image is selected in write mode, disallow navigation to its href
const selectedDOMNode = view.nodeDOM(view.state.selection.from);
if (
view.editable &&
selectedDOMNode &&
selectedDOMNode instanceof HTMLSpanElement &&
selectedDOMNode.classList.contains("component-image") &&
event.target instanceof HTMLImageElement &&
selectedDOMNode.contains(event.target)
) {
return false;
}
// clicking a link while editing should show the link toolbar,
// clicking in read-only will navigate
if (!view.editable || (view.editable && !view.hasFocus())) {
@@ -278,7 +199,7 @@ export default class Link extends Mark {
: "");
try {
if (this.options.onClickLink) {
if (this.options.onClickLink && href) {
event.stopPropagation();
event.preventDefault();
this.options.onClickLink(sanitizeUrl(href), event);

View File

@@ -11,7 +11,6 @@ import * as React from "react";
import { sanitizeUrl } from "../../utils/urls";
import Caption from "../components/Caption";
import ImageComponent from "../components/Image";
import { addComment } from "../commands/comment";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { ComponentProps } from "../types";
@@ -19,6 +18,8 @@ import SimpleImage from "./SimpleImage";
import { LightboxImageFactory } from "../lib/Lightbox";
import { ImageSource } from "../lib/FileHelper";
import { DiagramPlaceholder } from "../components/DiagramPlaceholder";
import { addComment } from "../commands/comment";
import { addLink } from "../commands/link";
const imageSizeRegex = /\s=(\d+)?x(\d+)?$/;
@@ -335,6 +336,14 @@ export default class Image extends SimpleImage {
view.dispatch(transaction);
};
handleZoomIn =
({ getPos, view }: ComponentProps) =>
() => {
this.editor.updateActiveLightboxImage(
LightboxImageFactory.createLightboxImage(view, getPos())
);
};
handleClick =
({ getPos, view }: ComponentProps) =>
() => {
@@ -379,6 +388,7 @@ export default class Image extends SimpleImage {
{...props}
onClick={this.handleClick(props)}
onDownload={this.handleDownload(props)}
onZoomIn={this.handleZoomIn(props)}
onChangeSize={this.handleChangeSize(props)}
>
<Caption
@@ -536,6 +546,7 @@ export default class Image extends SimpleImage {
},
commentOnImage: (): Command =>
addComment({ userId: this.options.userId }),
linkOnImage: (): Command => addLink({ href: "" }),
};
}

View File

@@ -17,7 +17,9 @@ import Node from "./Node";
import { LightboxImageFactory } from "../lib/Lightbox";
export default class SimpleImage extends Node {
options: Options & { userId?: string };
options: Options & {
userId?: string;
};
get name() {
return "image";

View File

@@ -1,5 +1,15 @@
import { NodeMarkAttr } from "@shared/editor/types";
import { ResolvedPos, MarkType } from "prosemirror-model";
import { NodeSelection } from "prosemirror-state";
/**
* Returns the mark of type along with its range for a given ResolvedPos,
* or false if the mark is not found.
*
* @param $pos The ResolvedPos to check.
* @param type The MarkType to look for.
* @returns An object containing the from and to positions and the mark, or false.
*/
export function getMarkRange($pos?: ResolvedPos, type?: MarkType) {
if (!$pos || !type) {
return false;
@@ -38,3 +48,26 @@ export function getMarkRange($pos?: ResolvedPos, type?: MarkType) {
return { from: startPos, to: endPos, mark };
}
/**
* Returns the mark of type along with its range for a given NodeSelection,
* or false if the mark is not found.
*
* @param selection The NodeSelection to check.
* @param type The MarkType to look for.
* @returns An object containing the from and to positions and the mark, or false.
*/
export function getMarkRangeNodeSelection(
selection: NodeSelection,
type: MarkType
) {
const mark = (selection.node.attrs.marks ?? []).find(
(mark: NodeMarkAttr) => mark.type === type.name
);
if (!mark) {
return false;
}
return { from: selection.from, to: selection.to, mark };
}

View File

@@ -1,7 +1,8 @@
import { MarkType } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { EditorState, NodeSelection } from "prosemirror-state";
import { Primitive } from "utility-types";
import { getMarksBetween } from "./getMarksBetween";
import { getMarkRangeNodeSelection } from "./getMarkRange";
type Options = {
/** Only return match if the range and attrs is exact */
@@ -10,15 +11,28 @@ type Options = {
inclusive?: boolean;
};
/**
* Checks if a mark is active in the current selection or not.
*
* @param type The mark type to check.
* @param attrs The attributes to check.
* @param options The options to use.
* @returns A function that checks if a mark is active in the current selection or not.
*/
export const isMarkActive =
const isNodeMarkActive =
(type: MarkType) =>
(state: EditorState): boolean => {
if (!type) {
return false;
}
const { selection } = state;
if (!(selection instanceof NodeSelection)) {
return false;
}
const mark = getMarkRangeNodeSelection(selection, type);
if (!mark) {
return false;
}
return true;
};
const isInlineMarkActive =
(type: MarkType, attrs?: Record<string, Primitive>, options?: Options) =>
(state: EditorState): boolean => {
if (!type) {
@@ -49,3 +63,17 @@ export const isMarkActive =
return true;
};
/**
* Checks if a mark is active in the current selection or not.
*
* @param type The mark type to check.
* @param attrs The attributes to check.
* @param options The options to use.
* @returns A function that checks if a mark is active in the current selection or not.
*/
export const isMarkActive =
(type: MarkType, attrs?: Record<string, Primitive>, options?: Options) =>
(state: EditorState) =>
isInlineMarkActive(type, attrs, options)(state) ||
isNodeMarkActive(type)(state);

View File

@@ -42,6 +42,7 @@ export type MenuItem = {
appendSpace?: boolean;
skipIcon?: boolean;
disabled?: boolean;
onClick?: () => void;
};
export type ComponentProps = {
@@ -52,3 +53,8 @@ export type ComponentProps = {
isEditable: boolean;
getPos: () => number;
};
export interface NodeMarkAttr {
type: string;
[key: string]: any;
}

View File

@@ -599,6 +599,7 @@
"None": "None",
"Delete embed": "Delete embed",
"Upload an image": "Upload an image",
"Formatting controls": "Formatting controls",
"Distribute columns": "Distribute columns",
"Could not import file": "Could not import file",
"Unsubscribed from document": "Unsubscribed from document",

View File

@@ -11704,7 +11704,7 @@ os-tmpdir@~1.0.2:
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
integrity "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="
outline-icons@^3.14.0:
outline-icons@^3.15.0:
version "3.15.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.15.0.tgz#f8ff94981f8681cbc47f6cf89dc83f7d644fd205"
integrity sha512-32sF4mmm6ZqYhXqiDCktBeOAzSCGlJvutcVD0gq1uKFP0SKHHLlgIy4aJHCDvHVR/uiIRL059pJFCxwQ3chsIA==