mirror of
https://github.com/outline/outline.git
synced 2025-12-30 07:19:52 -06:00
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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
// unexpectedly–leaving 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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
257
shared/editor/commands/link.ts
Normal file
257
shared/editor/commands/link.ts
Normal 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());
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "" }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user