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
9 changes: 5 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"@tanstack/react-query": "^5.96.2",
"@tiptap/core": "^3.7.0",
"@tiptap/extension-highlight": "^3.7.0",
"@tiptap/pm": "^3.7.0",
"@tiptap/extension-image": "^3.7.0",
"@tiptap/extension-link": "^3.7.0",
"@tiptap/extension-placeholder": "^3.7.0",
Expand Down
58 changes: 57 additions & 1 deletion packages/studio/src/lib/editor-extensions.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,76 @@
import { Extension } from "@tiptap/core";
import type { Extensions } from "@tiptap/core";
import Highlight from "@tiptap/extension-highlight";
import Link from "@tiptap/extension-link";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import Underline from "@tiptap/extension-underline";
import { Markdown } from "@tiptap/markdown";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import StarterKit from "@tiptap/starter-kit";
import type { Extensions } from "@tiptap/core";

import { MdxComponentExtension } from "./mdx-component-extension.js";

const BlurSelectionPreserver = Extension.create({
name: "blurSelectionPreserver",

addProseMirrorPlugins() {
const pluginKey = new PluginKey("blurSelectionPreserver");
let focused = true;

return [
new Plugin({
key: pluginKey,
props: {
decorations(state) {
if (focused) return DecorationSet.empty;
const { from, to } = state.selection;
if (from === to) return DecorationSet.empty;
return DecorationSet.create(state.doc, [
Decoration.inline(from, to, {
class: "ProseMirror-blur-selection",
}),
]);
},
},
view(editorView) {
const onFocus = () => {
focused = true;
editorView.dispatch(editorView.state.tr);
};
const onBlur = () => {
focused = false;
editorView.dispatch(editorView.state.tr);
};
editorView.dom.addEventListener("focus", onFocus);
editorView.dom.addEventListener("blur", onBlur);
return {
destroy() {
editorView.dom.removeEventListener("focus", onFocus);
editorView.dom.removeEventListener("blur", onBlur);
},
};
},
}),
];
},
});

export function createEditorExtensions(options?: {
mdxComponent?: Extensions[number];
}): Extensions {
return [
StarterKit,
Underline,
Highlight,
BlurSelectionPreserver,
Link.configure({
openOnClick: false,
HTMLAttributes: {
rel: "noopener noreferrer nofollow",
},
}),
TaskList,
TaskItem.configure({
nested: true,
Expand Down
166 changes: 152 additions & 14 deletions packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
import {
Bold,
Code,
CornerDownLeft,
ExternalLink,
FileCode,
Highlighter,
Image as ImageIcon,
Expand All @@ -44,6 +46,7 @@ import {
Redo,
Strikethrough,
Table2,
Trash2,
Underline as UnderlineIcon,
Undo,
} from "lucide-react";
Expand Down Expand Up @@ -74,6 +77,7 @@ import {
type SlashTriggerCoords,
} from "./mdx-component-slash.js";
import { Button } from "../ui/button.js";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.js";
import { Separator } from "../ui/separator.js";
import { cn } from "../../lib/utils.js";

Expand Down Expand Up @@ -200,6 +204,8 @@ export const TipTapEditor = forwardRef<TipTapEditorHandle, TipTapEditorProps>(
useState<MdxComponentSlashTrigger | null>(null);
const [slashPickerCoords, setSlashPickerCoords] =
useState<SlashTriggerCoords | null>(null);
const [linkPopoverOpen, setLinkPopoverOpen] = useState(false);
const [linkInputValue, setLinkInputValue] = useState("");
const editorWrapperRef = useRef<HTMLDivElement | null>(null);
const pickerSourceRef = useRef(pickerSource);
pickerSourceRef.current = pickerSource;
Expand Down Expand Up @@ -625,6 +631,15 @@ export const TipTapEditor = forwardRef<TipTapEditorHandle, TipTapEditorProps>(
return run(
() => editor?.chain().focus().setHorizontalRule().run() ?? false,
);
case "link": {
if (!editor) return;
const existingHref = editor.getAttributes("link").href as
| string
| undefined;
setLinkInputValue(existingHref ?? "");
setLinkPopoverOpen(true);
return;
}
case "insertComponent":
setPickerSource((currentSource) =>
currentSource === "toolbar" ? null : "toolbar",
Expand Down Expand Up @@ -663,11 +678,37 @@ export const TipTapEditor = forwardRef<TipTapEditorHandle, TipTapEditorProps>(
return isActive("blockquote");
case "codeBlock":
return isActive("codeBlock");
case "link":
return isActive("link");
default:
return false;
}
};

const submitLink = () => {
if (!editor) return;
const url = linkInputValue.trim();
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
setLinkPopoverOpen(false);
setLinkInputValue("");
};

const removeLink = () => {
if (!editor) return;
editor.chain().focus().unsetLink().run();
setLinkPopoverOpen(false);
setLinkInputValue("");
};

const openLink = () => {
const url = linkInputValue.trim();
if (url) {
window.open(url, "_blank", "noopener,noreferrer");
}
};

const insertSelectedComponent = (
component: (typeof catalogComponents)[number],
) => {
Expand Down Expand Up @@ -728,18 +769,8 @@ export const TipTapEditor = forwardRef<TipTapEditorHandle, TipTapEditorProps>(
{groupIndex > 0 ? (
<Separator orientation="vertical" className="mr-1 h-6" />
) : null}
{group.items.map((item) => (
<div
key={item.id}
onClick={() => {
if (
item.availability === "enabled" &&
!isEditorReadOnly
) {
triggerToolbarItem(item.id);
}
}}
>
{group.items.map((item) => {
const toolbarButton = (
<ToolbarButton
disabled={
item.availability !== "enabled" || isEditorReadOnly
Expand All @@ -762,8 +793,115 @@ export const TipTapEditor = forwardRef<TipTapEditorHandle, TipTapEditorProps>(
>
{renderToolbarItem(item.id)}
</ToolbarButton>
</div>
))}
);

if (item.id === "link") {
return (
<Popover
key={item.id}
open={linkPopoverOpen}
onOpenChange={(open) => {
setLinkPopoverOpen(open);
if (!open) setLinkInputValue("");
}}
>
<PopoverTrigger
asChild
onClick={(e) => {
if (
item.availability === "enabled" &&
!isEditorReadOnly
) {
e.preventDefault();
triggerToolbarItem(item.id);
}
}}
>
<div>{toolbarButton}</div>
</PopoverTrigger>
<PopoverContent
className="w-auto p-1.5"
side="bottom"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex items-center gap-1">
<input
type="url"
value={linkInputValue}
onChange={(e) =>
setLinkInputValue(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitLink();
}
if (e.key === "Escape") {
setLinkPopoverOpen(false);
setLinkInputValue("");
}
}}
placeholder="Paste a link..."
className="h-7 w-48 rounded border-none bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground"
/>
<Separator
orientation="vertical"
className="mx-0.5 h-5"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
title="Apply link"
onClick={submitLink}
>
<CornerDownLeft className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
title="Open link in new tab"
disabled={!linkInputValue.trim()}
onClick={openLink}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
title="Remove link"
onClick={removeLink}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</PopoverContent>
</Popover>
);
}

return (
<div
key={item.id}
onClick={() => {
if (
item.availability === "enabled" &&
!isEditorReadOnly
) {
triggerToolbarItem(item.id);
}
}}
>
{toolbarButton}
</div>
);
})}
</div>
))}
</div>
Expand Down
Loading
Loading