Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,20 @@ import { EditorState } from "@tiptap/pm/state";
import { type Editor, EditorContent, useEditor } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import { type MutableRefObject, useEffect, useRef } from "react";
import {
type LinkAction,
useInlineUrlPolicy,
useTerminalUrlPolicy,
} from "renderer/lib/clickPolicy";
import { electronTrpcClient } from "renderer/lib/trpc-client";
import { useMarkdownStyle } from "renderer/stores";
import { defaultConfig } from "../../styles/default/config";
import { tufteConfig } from "../../styles/tufte/config";
import { SelectionContextMenu } from "../SelectionContextMenu";
import { BubbleMenuToolbar } from "./components/BubbleMenuToolbar";
import { createMarkdownExtensions } from "./createMarkdownExtensions";
import { createMarkdownMerger } from "./mergeMarkdownEdits";
import { resolveLinkClick } from "./resolveLinkClick";

const styleConfigs = {
default: defaultConfig,
Expand Down Expand Up @@ -41,6 +48,13 @@ interface TipTapMarkdownRendererProps {
* change and resets the editor content and merge baseline.
*/
preserveSourceFormatting?: boolean;
/**
* Routes a link click to the host's panes. With it, links follow the 4-tier
* URL click policy; without it there is no pane to open into, so any bound
* tier opens the system browser, as in MarkdownEditor.
*/
onOpenUrl?: (url: string, action: LinkAction) => void;
onUnboundLinkClick?: (clientX: number, clientY: number) => void;
}

interface SourceTracking {
Expand Down Expand Up @@ -119,6 +133,8 @@ export function TipTapMarkdownRenderer({
onChange,
onSave,
preserveSourceFormatting = false,
onOpenUrl,
onUnboundLinkClick,
}: TipTapMarkdownRendererProps) {
const globalStyle = useMarkdownStyle();
const style = styleProp ?? globalStyle;
Expand All @@ -128,8 +144,21 @@ export function TipTapMarkdownRenderer({
const onSaveRef = useRef(onSave);
const sourceTrackingRef = useRef<SourceTracking | null>(null);

const paneUrlPolicy = useTerminalUrlPolicy();
const inlineUrlPolicy = useInlineUrlPolicy();
const linkClickRef = useRef({
getAction: inlineUrlPolicy.getAction,
onOpenUrl,
onUnboundLinkClick,
});

onChangeRef.current = onChange;
onSaveRef.current = onSave;
linkClickRef.current = {
getAction: (onOpenUrl ? paneUrlPolicy : inlineUrlPolicy).getAction,
onOpenUrl,
onUnboundLinkClick,
};

const editor = useEditor({
immediatelyRender: false,
Expand All @@ -143,6 +172,37 @@ export function TipTapMarkdownRenderer({
attributes: {
class: cn("focus:outline-none", editable && "min-h-[100px]"),
},
handleDOMEvents: {
// ProseMirror never calls handleClick for a shift-click (it lets the
// browser extend the selection), so the shift tiers are only
// reachable from the DOM click.
click: (_view, event) => {
const target = event.target as HTMLElement | null;
if (!target?.closest?.("a")) return false;
event.preventDefault();
const link = linkClickRef.current;
const click = resolveLinkClick(event, link.getAction);
if (click.kind === "none") return false;
if (click.kind === "unbound") {
link.onUnboundLinkClick?.(event.clientX, event.clientY);
return false;
}
if (link.onOpenUrl) {
link.onOpenUrl(click.url, click.action);
return true;
}
electronTrpcClient.external.openUrl
.mutate(click.url)
.catch((error) => {
console.error(
"[TipTapMarkdownRenderer] Failed to open URL:",
click.url,
error,
);
});
return true;
},
},
},
onCreate: ({ editor: createdEditor }) => {
sourceTrackingRef.current = createSourceTracking(createdEditor, value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { GlobalRegistrator } from "@happy-dom/global-registrator";
const alreadyRegistered = GlobalRegistrator.isRegistered;
if (!alreadyRegistered) GlobalRegistrator.register();

const { afterAll, describe, expect, it } = await import("bun:test");
const { afterAll, describe, expect, it, spyOn } = await import("bun:test");
const { Editor } = await import("@tiptap/core");
const { createMarkdownExtensions } = await import("./createMarkdownExtensions");

Expand Down Expand Up @@ -42,6 +42,39 @@ function roundTrip(markdown: string): string {
}
}

describe("preview links", () => {
it.each([
true,
false,
])("never opens a link itself when editable is %p", (editable) => {
const editor = new Editor({
editable,
extensions: createMarkdownExtensions({
editable,
onSaveRef: { current: undefined },
}),
content: "[**Example**](https://example.com/)",
});
const open = spyOn(window, "open").mockReturnValue(null);
try {
const event = new MouseEvent("click", { button: 0 });
Object.defineProperty(event, "target", {
value: editor.view.dom.querySelector("strong"),
});

const handled = editor.view.someProp("handleClick", (handler) =>
handler(editor.view, 1, event),
);

expect(handled).toBeFalsy();
expect(open).not.toHaveBeenCalled();
} finally {
open.mockRestore();
editor.destroy();
}
});
});

describe("image attribute parsing", () => {
// @tiptap/core's default attribute parser (fromString) coerces
// numeric/boolean-looking strings, so ![123](x.png) used to load as
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ export function createMarkdownExtensions({
HardBreak,
History,
Link.configure({
openOnClick: !editable,
openOnClick: false,
HTMLAttributes: {
class:
"text-primary underline underline-offset-2 hover:text-primary/80",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";

const alreadyRegistered = GlobalRegistrator.isRegistered;
if (!alreadyRegistered) GlobalRegistrator.register();

const { afterAll, describe, expect, it } = await import("bun:test");
const { Editor } = await import("@tiptap/core");
const { tierFor } = await import("renderer/lib/clickPolicy/tiers");
const { createMarkdownExtensions } = await import("./createMarkdownExtensions");
const { resolveLinkClick } = await import("./resolveLinkClick");

type LinkTierMap = import("renderer/lib/clickPolicy").LinkTierMap;
type ModifierEvent = import("renderer/lib/clickPolicy").ModifierEvent;

afterAll(async () => {
if (!alreadyRegistered) await GlobalRegistrator.unregister();
});

const DEFAULT_URL_LINKS: LinkTierMap = {
plain: null,
shift: "newTab",
meta: "pane",
metaShift: "external",
};

const FIXTURE = [
"[**web**](https://example.com/a)",
"[mail](mailto:a@b.c)",
"[file](file:///etc/passwd)",
"[js](javascript:alert(1))",
"[vscode](vscode://file/x)",
"[relative](./other.md)",
"[anchor](#heading)",
"plain text",
].join("\n\n");

function fourTier(map: LinkTierMap) {
return (event: ModifierEvent) => map[tierFor(event, "4-tier")];
}

function click(
selector: string,
modifiers: Partial<ModifierEvent> & { button?: number },
map: LinkTierMap = DEFAULT_URL_LINKS,
) {
const editor = new Editor({
editable: true,
extensions: createMarkdownExtensions({
editable: true,
onSaveRef: { current: undefined },
}),
content: FIXTURE,
});
try {
const target = editor.view.dom.querySelector(selector);
expect(target).not.toBeNull();
return resolveLinkClick(
{
button: 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
...modifiers,
target,
},
fourTier(map),
);
} finally {
editor.destroy();
}
}

describe("resolveLinkClick", () => {
it("leaves a plain click unbound so the caret can land in the link", () => {
expect(click("a[href^='https'] strong", {})).toEqual({ kind: "unbound" });
});

it.each([
[{ shiftKey: true }, "newTab"],
[{ metaKey: true }, "pane"],
[{ ctrlKey: true }, "pane"],
[{ metaKey: true, shiftKey: true }, "external"],
] as const)("maps %p to %s", (modifiers, action) => {
expect(click("a[href^='https'] strong", modifiers)).toEqual({
kind: "open",
url: "https://example.com/a",
action,
});
});

it("follows a custom tier map", () => {
const map: LinkTierMap = {
plain: "external",
shift: null,
meta: "newTab",
metaShift: "pane",
};
expect(click("a[href^='https']", {}, map)).toMatchObject({
action: "external",
});
expect(click("a[href^='https']", { shiftKey: true }, map)).toEqual({
kind: "unbound",
});
expect(click("a[href^='https']", { metaKey: true }, map)).toMatchObject({
action: "newTab",
});
});

it.each([
"a[href^='mailto']",
"a[href^='./']",
"a[href^='#']",
])("ignores the non-web link %s on every tier", (selector) => {
for (const modifiers of [
{},
{ shiftKey: true },
{ metaKey: true },
{ metaKey: true, shiftKey: true },
]) {
expect(click(selector, modifiers)).toEqual({ kind: "none" });
}
});

it("never renders file, javascript or custom-scheme links as anchors", () => {
const editor = new Editor({
extensions: createMarkdownExtensions({
editable: true,
onSaveRef: { current: undefined },
}),
content: FIXTURE,
});
try {
const hrefs = [...editor.view.dom.querySelectorAll("a")].map((a) =>
a.getAttribute("href"),
);
expect(hrefs).toEqual([
"https://example.com/a",
"mailto:a@b.c",
"./other.md",
"#heading",
]);
} finally {
editor.destroy();
}
});

it("ignores right-clicks and clicks outside a link", () => {
expect(click("a[href^='https']", { button: 2, metaKey: true })).toEqual({
kind: "none",
});
expect(click("p:last-child", { metaKey: true })).toEqual({ kind: "none" });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type {
ClickPolicy,
LinkAction,
ModifierEvent,
} from "renderer/lib/clickPolicy";

export interface LinkClickEvent extends ModifierEvent {
button: number;
target: EventTarget | null;
}

export type ResolvedLinkClick =
| { kind: "none" }
| { kind: "unbound" }
| { kind: "open"; url: string; action: LinkAction };

const WEB_URL = /^https?:\/\//i;

export function resolveLinkClick(
event: LinkClickEvent,
getAction: ClickPolicy["getAction"],
): ResolvedLinkClick {
if (event.button !== 0) return { kind: "none" };
const target = event.target as HTMLElement | null;
const href = target?.closest?.("a")?.getAttribute("href");
if (!href || !WEB_URL.test(href)) return { kind: "none" };
const action = getAction(event);
if (action === null) return { kind: "unbound" };
return { kind: "open", url: href, action };
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { useLingui } from "@lingui/react/macro";
import type { RendererContext } from "@superset/panes";
import { FEATURE_FLAGS } from "@superset/shared/constants";
import { alert } from "@superset/ui/atoms/Alert";
import { useWorkspaceClient, workspaceTrpc } from "@superset/workspace-client";
import { useFeatureFlagEnabled } from "posthog-js/react";
import { useCallback, useEffect } from "react";
import { MarkdownResourceProvider } from "renderer/components/MarkdownRenderer/providers/MarkdownResourceProvider";
import type { LinkAction } from "renderer/lib/clickPolicy";
import { getBaseName } from "renderer/lib/pathBasename";
import { getPathDirectory } from "shared/absolute-paths";
import {
decodeBase64,
useSharedFileDocument,
} from "../../../../state/fileDocumentStore";
import type { FilePaneData, PaneViewerData } from "../../../../types";
import { runUrlLinkAction } from "../../utils/runTerminalLinkAction";
import { ErrorState } from "./components/ErrorState";
import { LoadingState } from "./components/LoadingState";
import { OrphanedBanner } from "./components/OrphanedBanner";
Expand All @@ -24,6 +28,7 @@ interface FilePaneProps {

export function FilePane({ context, workspaceId }: FilePaneProps) {
const { t } = useLingui();
const isPagesEnabled = useFeatureFlagEnabled(FEATURE_FLAGS.PAGES) ?? false;
const data = context.pane.data as FilePaneData;
const { filePath } = data;

Expand Down Expand Up @@ -124,6 +129,13 @@ export function FilePane({ context, workspaceId }: FilePaneProps) {
[context.actions, data],
);

const handleOpenUrl = useCallback(
(url: string, action: LinkAction) => {
runUrlLinkAction({ store: context.store, isPagesEnabled }, url, action);
},
[context.store, isPagesEnabled],
);

// Content gating — LoadingState/ErrorState rendered before view resolution when
// there's nothing for the view to consume.
if (document.content.kind === "loading") {
Expand Down Expand Up @@ -196,6 +208,7 @@ export function FilePane({ context, workspaceId }: FilePaneProps) {
isActive={context.isActive}
onChangeView={handleChangeView}
onForceView={handleForceView}
onOpenUrl={handleOpenUrl}
/>
</MarkdownResourceProvider>
</div>
Expand Down
Loading
Loading