diff --git a/bun.lock b/bun.lock index 380d0d0..aabbc41 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "apps/cli": { "name": "@mdcms/cli", - "version": "0.1.4", + "version": "0.1.5", "bin": { "mdcms": "./dist/bin/mdcms.js", }, @@ -110,7 +110,7 @@ }, "packages/shared": { "name": "@mdcms/shared", - "version": "0.1.3", + "version": "0.1.4", "dependencies": { "elysia": "^1.4.25", "tslib": "^2.3.0", @@ -120,14 +120,14 @@ }, "packages/studio": { "name": "@mdcms/studio", - "version": "0.1.3", + "version": "0.1.4", "dependencies": { "@elysiajs/eden": "^1.4.8", "@floating-ui/react-dom": "^2.1.8", "@fontsource-variable/geist-mono": "^5.2.7", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/space-grotesk": "^5.2.10", - "@mdcms/shared": "^0.1.3", + "@mdcms/shared": "^0.1.4", "@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", @@ -154,6 +154,7 @@ "@tiptap/extension-typography": "^3.7.0", "@tiptap/extension-underline": "^3.7.0", "@tiptap/markdown": "^3.7.0", + "@tiptap/pm": "^3.7.0", "@tiptap/react": "^3.7.0", "@tiptap/starter-kit": "^3.7.0", "class-variance-authority": "^0.7.1", diff --git a/packages/studio/package.json b/packages/studio/package.json index 013a1b4..0bc1f54 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -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", diff --git a/packages/studio/src/lib/editor-extensions.ts b/packages/studio/src/lib/editor-extensions.ts index 35e5833..2797785 100644 --- a/packages/studio/src/lib/editor-extensions.ts +++ b/packages/studio/src/lib/editor-extensions.ts @@ -1,13 +1,62 @@ +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 { @@ -15,6 +64,13 @@ export function createEditorExtensions(options?: { StarterKit, Underline, Highlight, + BlurSelectionPreserver, + Link.configure({ + openOnClick: false, + HTMLAttributes: { + rel: "noopener noreferrer nofollow", + }, + }), TaskList, TaskItem.configure({ nested: true, diff --git a/packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx b/packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx index 71aefd0..51d793a 100644 --- a/packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx +++ b/packages/studio/src/lib/runtime-ui/components/editor/tiptap-editor.tsx @@ -30,6 +30,8 @@ import { import { Bold, Code, + CornerDownLeft, + ExternalLink, FileCode, Highlighter, Image as ImageIcon, @@ -44,6 +46,7 @@ import { Redo, Strikethrough, Table2, + Trash2, Underline as UnderlineIcon, Undo, } from "lucide-react"; @@ -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"; @@ -200,6 +204,8 @@ export const TipTapEditor = forwardRef( useState(null); const [slashPickerCoords, setSlashPickerCoords] = useState(null); + const [linkPopoverOpen, setLinkPopoverOpen] = useState(false); + const [linkInputValue, setLinkInputValue] = useState(""); const editorWrapperRef = useRef(null); const pickerSourceRef = useRef(pickerSource); pickerSourceRef.current = pickerSource; @@ -625,6 +631,15 @@ export const TipTapEditor = forwardRef( 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", @@ -663,11 +678,37 @@ export const TipTapEditor = forwardRef( 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], ) => { @@ -728,18 +769,8 @@ export const TipTapEditor = forwardRef( {groupIndex > 0 ? ( ) : null} - {group.items.map((item) => ( -
{ - if ( - item.availability === "enabled" && - !isEditorReadOnly - ) { - triggerToolbarItem(item.id); - } - }} - > + {group.items.map((item) => { + const toolbarButton = ( ( > {renderToolbarItem(item.id)} -
- ))} + ); + + if (item.id === "link") { + return ( + { + setLinkPopoverOpen(open); + if (!open) setLinkInputValue(""); + }} + > + { + if ( + item.availability === "enabled" && + !isEditorReadOnly + ) { + e.preventDefault(); + triggerToolbarItem(item.id); + } + }} + > +
{toolbarButton}
+
+ e.preventDefault()} + > +
+ + 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" + /> + + + + +
+
+
+ ); + } + + return ( +
{ + if ( + item.availability === "enabled" && + !isEditorReadOnly + ) { + triggerToolbarItem(item.id); + } + }} + > + {toolbarButton} +
+ ); + })} ))} diff --git a/packages/studio/src/lib/runtime-ui/styles.css b/packages/studio/src/lib/runtime-ui/styles.css index b0b39cf..69fe9d2 100644 --- a/packages/studio/src/lib/runtime-ui/styles.css +++ b/packages/studio/src/lib/runtime-ui/styles.css @@ -291,6 +291,200 @@ text-decoration: none; } + /* ── TipTap editor content ────────────────────────────────── */ + + .mdcms-studio-runtime .tiptap p { + margin-top: 0; + margin-bottom: 0.5em; + } + + .mdcms-studio-runtime .tiptap > *:first-child { + margin-top: 0; + } + + .mdcms-studio-runtime .tiptap > *:last-child { + margin-bottom: 0; + } + + /* Headings */ + .mdcms-studio-runtime .tiptap h1 { + font-size: 2rem; + line-height: 1.2; + letter-spacing: -0.02em; + margin-top: 0.75em; + margin-bottom: 0.25em; + } + + .mdcms-studio-runtime .tiptap h2 { + font-size: 1.5rem; + line-height: 1.25; + letter-spacing: -0.01em; + margin-top: 0.75em; + margin-bottom: 0.25em; + } + + /* Inline marks */ + .mdcms-studio-runtime .tiptap mark { + background-color: #fef08a; + border-radius: 0.125rem; + padding: 0.125em 0; + } + + .dark .mdcms-studio-runtime .tiptap mark { + background-color: rgba(250, 204, 21, 0.3); + } + + .mdcms-studio-runtime .tiptap :not(pre) > code { + background-color: var(--code-bg); + border-radius: 0.25rem; + padding: 0.125rem 0.375rem; + font-family: var(--font-mono); + font-size: 0.8125rem; + } + + /* Links */ + .mdcms-studio-runtime .tiptap a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; + } + + /* Selection highlight preserved when the editor loses focus */ + .mdcms-studio-runtime .tiptap .ProseMirror-blur-selection { + background-color: color-mix(in srgb, var(--primary) 25%, transparent); + border-radius: 2px; + } + + /* Unordered list */ + .mdcms-studio-runtime .tiptap ul:not([data-type="taskList"]) { + list-style-type: disc; + padding-left: 1.5rem; + margin-top: 0.25em; + margin-bottom: 0.5em; + } + + .mdcms-studio-runtime .tiptap ul:not([data-type="taskList"]) li { + margin-bottom: 0.125em; + } + + .mdcms-studio-runtime .tiptap ul:not([data-type="taskList"]) li p { + margin-bottom: 0; + } + + /* Ordered list */ + .mdcms-studio-runtime .tiptap ol { + list-style-type: decimal; + padding-left: 1.5rem; + margin-top: 0.25em; + margin-bottom: 0.5em; + } + + .mdcms-studio-runtime .tiptap ol li { + margin-bottom: 0.125em; + } + + .mdcms-studio-runtime .tiptap ol li p { + margin-bottom: 0; + } + + /* Task list */ + .mdcms-studio-runtime .tiptap ul[data-type="taskList"] { + list-style: none; + padding-left: 0; + margin-top: 0.25em; + margin-bottom: 0.5em; + } + + .mdcms-studio-runtime .tiptap ul[data-type="taskList"] li { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 0.125em; + } + + .mdcms-studio-runtime .tiptap ul[data-type="taskList"] li > label { + display: flex; + align-items: center; + flex-shrink: 0; + margin-top: 0.25rem; + user-select: none; + } + + .mdcms-studio-runtime + .tiptap + ul[data-type="taskList"] + li + > label + input[type="checkbox"] { + cursor: pointer; + accent-color: var(--primary); + } + + .mdcms-studio-runtime .tiptap ul[data-type="taskList"] li > div { + flex: 1; + min-width: 0; + } + + .mdcms-studio-runtime .tiptap ul[data-type="taskList"] li > div p { + margin-bottom: 0; + } + + .mdcms-studio-runtime + .tiptap + ul[data-type="taskList"] + li[data-checked="true"] + > div { + text-decoration: line-through; + opacity: 0.6; + } + + /* Blockquote */ + .mdcms-studio-runtime .tiptap blockquote { + border-left: 3px solid var(--border); + padding-left: 1rem; + margin-left: 0; + margin-top: 0.5em; + margin-bottom: 0.5em; + color: var(--foreground-muted); + } + + /* Code block */ + .mdcms-studio-runtime .tiptap pre { + background-color: var(--code-bg); + padding: 0.75rem 1rem; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.6; + margin-top: 0.5em; + margin-bottom: 0.5em; + border: 1px solid var(--border); + } + + .mdcms-studio-runtime .tiptap pre code { + background: none; + padding: 0; + border-radius: 0; + font-size: inherit; + color: inherit; + } + + /* Horizontal rule */ + .mdcms-studio-runtime .tiptap hr { + border: none; + border-top: 1px solid var(--divider); + margin-top: 1em; + margin-bottom: 1em; + } + + /* Image */ + .mdcms-studio-runtime .tiptap img { + max-width: 100%; + height: auto; + margin-top: 0.5em; + margin-bottom: 0.5em; + } + .mdcms-studio-runtime :is(button, input, textarea, select) { font: inherit; }