diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9fcc3d7..4c3d422 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,11 @@ All notable changes to SourceDraft are documented here. The project uses [Semant
and Settings, a consistent button system, and the Publish action as a large,
anchored, high-contrast primary button. Reduced visual noise via tokens.
- Added `.claude/rules/ui-standards.md` as the authoritative Studio UI bar.
+- **Editor link/image/file insertion (Phase 4b)** — replaced the blocking
+ `window.prompt` link, image, and file-link flows (toolbar and slash commands)
+ with an accessible in-Studio dialog (labelled fields, Enter to submit, Escape
+ to cancel, bare-domain URLs gain `https://`). Removed the unused legacy
+ `MarkdownToolbar`.
## v0.1.0
diff --git a/apps/studio/e2e/smoke.spec.ts b/apps/studio/e2e/smoke.spec.ts
index 50e2a63..4369f04 100644
--- a/apps/studio/e2e/smoke.spec.ts
+++ b/apps/studio/e2e/smoke.spec.ts
@@ -110,6 +110,62 @@ test.describe("Studio smoke", () => {
await expect(page.getByRole("button", { name: "Insert file link" })).toBeVisible();
});
+ test("link toolbar button opens an inline dialog and inserts a link", async ({
+ page,
+ }) => {
+ await enterDemoMode(page);
+ await page.getByRole("button", { name: "New article" }).click();
+ await fillPostBody(page, "Intro paragraph.");
+
+ await page.getByRole("button", { name: "Insert or edit link" }).click();
+ const dialog = page.getByRole("dialog", { name: "Insert link" });
+ await expect(dialog).toBeVisible();
+
+ await dialog.getByLabel("Link URL").fill("example.com");
+ await dialog.getByLabel("Link text").fill("Example site");
+ await dialog.getByRole("button", { name: "Insert link" }).click();
+
+ await expect(dialog).toBeHidden();
+ const link = postBodyEditor(page).getByRole("link", { name: "Example site" });
+ await expect(link).toBeVisible();
+ await expect(link).toHaveAttribute("href", "https://example.com");
+ });
+
+ test("image toolbar button opens an inline dialog and inserts an image", async ({
+ page,
+ }) => {
+ await enterDemoMode(page);
+ await page.getByRole("button", { name: "New article" }).click();
+ await fillPostBody(page, "Before image.");
+
+ await page.getByRole("button", { name: "Insert image", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Insert image" });
+ await expect(dialog).toBeVisible();
+
+ await dialog.getByLabel("Image path or URL").fill("/images/example.jpg");
+ await dialog.getByLabel("Alt text (for accessibility)").fill("Example");
+ await dialog.getByRole("button", { name: "Insert image" }).click();
+
+ await expect(dialog).toBeHidden();
+ await expect(postBodyEditor(page).locator("img")).toHaveAttribute(
+ "src",
+ "/images/example.jpg",
+ );
+ });
+
+ test("insert dialog closes on Escape without inserting", async ({ page }) => {
+ await enterDemoMode(page);
+ await page.getByRole("button", { name: "New article" }).click();
+ await fillPostBody(page, "Body text.");
+
+ await page.getByRole("button", { name: "Insert or edit link" }).click();
+ const dialog = page.getByRole("dialog", { name: "Insert link" });
+ await expect(dialog).toBeVisible();
+ await dialog.getByLabel("Link URL").press("Escape");
+ await expect(dialog).toBeHidden();
+ await expect(postBodyEditor(page).getByRole("link")).toHaveCount(0);
+ });
+
test("autosave status appears after edits", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New article" }).click();
diff --git a/apps/studio/src/components/MarkdownToolbar.tsx b/apps/studio/src/components/MarkdownToolbar.tsx
deleted file mode 100644
index 7524833..0000000
--- a/apps/studio/src/components/MarkdownToolbar.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-import { useState, type RefObject } from "react";
-import type { PostSummary } from "../lib/posts.js";
-import {
- applyMarkdownAction,
- applyResultToTextarea,
- selectionFromTextarea,
- type MarkdownAction,
-} from "../lib/markdownEditor.js";
-import { InternalLinkPicker } from "./InternalLinkPicker.js";
-
-type ToolbarButton = {
- action: MarkdownAction;
- label: string;
- ariaLabel: string;
- text: string;
-};
-
-const TOOLBAR_BUTTONS: ToolbarButton[] = [
- { action: "h1", label: "H1", ariaLabel: "Heading 1", text: "H1" },
- { action: "h2", label: "H2", ariaLabel: "Heading 2", text: "H2" },
- { action: "h3", label: "H3", ariaLabel: "Heading 3", text: "H3" },
- { action: "bold", label: "Bold", ariaLabel: "Bold", text: "B" },
- { action: "italic", label: "Italic", ariaLabel: "Italic", text: "I" },
- { action: "link", label: "Link", ariaLabel: "Insert link", text: "Link" },
- {
- action: "bullet-list",
- label: "Bullet list",
- ariaLabel: "Bullet list",
- text: "• List",
- },
- {
- action: "numbered-list",
- label: "Numbered list",
- ariaLabel: "Numbered list",
- text: "1. List",
- },
- {
- action: "blockquote",
- label: "Blockquote",
- ariaLabel: "Blockquote",
- text: "Quote",
- },
- {
- action: "inline-code",
- label: "Inline code",
- ariaLabel: "Inline code",
- text: "Code",
- },
- {
- action: "code-block",
- label: "Code block",
- ariaLabel: "Code block",
- text: "```",
- },
- {
- action: "image",
- label: "Image",
- ariaLabel: "Insert image",
- text: "Image",
- },
-];
-
-type MarkdownToolbarProps = {
- body: string;
- bodyFieldId: string;
- latestImagePath: string | null;
- imageAlt: string;
- posts: PostSummary[];
- editingPath: string | null;
- textareaRef: RefObject;
- onBodyChange: (body: string) => void;
-};
-
-export function MarkdownToolbar({
- body,
- bodyFieldId,
- latestImagePath,
- imageAlt,
- posts,
- editingPath,
- textareaRef,
- onBodyChange,
-}: MarkdownToolbarProps) {
- const [internalLinkOpen, setInternalLinkOpen] = useState(false);
- const [savedSelection, setSavedSelection] = useState({ start: 0, end: 0 });
-
- function runAction(action: MarkdownAction) {
- const textarea = textareaRef.current;
- if (!textarea) {
- return;
- }
-
- const selection = selectionFromTextarea(textarea);
-
- if (action === "image") {
- const path =
- latestImagePath?.trim() ||
- window.prompt("Image path (public URL or repo path)", "/images/")?.trim() ||
- "";
-
- if (path.length === 0) {
- return;
- }
-
- const result = applyMarkdownAction(body, selection, action, {
- imagePath: path,
- imageAlt,
- });
- onBodyChange(result.value);
- requestAnimationFrame(() => {
- applyResultToTextarea(textarea, result);
- });
- return;
- }
-
- const result = applyMarkdownAction(body, selection, action);
- onBodyChange(result.value);
- requestAnimationFrame(() => {
- applyResultToTextarea(textarea, result);
- });
- }
-
- function openInternalLinkPicker() {
- const textarea = textareaRef.current;
- if (textarea) {
- setSavedSelection(selectionFromTextarea(textarea));
- }
- setInternalLinkOpen(true);
- }
-
- return (
-
-
- {TOOLBAR_BUTTONS.map((button) => (
-
- ))}
-
-
-
- {internalLinkOpen && (
-
{
- setInternalLinkOpen(false);
- }}
- />
- )}
-
- );
-}
diff --git a/apps/studio/src/editor/EditorInsertDialog.tsx b/apps/studio/src/editor/EditorInsertDialog.tsx
new file mode 100644
index 0000000..bfe128c
--- /dev/null
+++ b/apps/studio/src/editor/EditorInsertDialog.tsx
@@ -0,0 +1,206 @@
+import { useEffect, useId, useRef, useState } from "react";
+import { hasUrl } from "./editorInsert.js";
+
+export type EditorInsertDialogKind = "link" | "image" | "file";
+
+export type EditorInsertValues = {
+ url: string;
+ text?: string;
+ alt?: string;
+};
+
+type EditorInsertDialogProps = {
+ kind: EditorInsertDialogKind;
+ initialUrl?: string;
+ initialText?: string;
+ initialAlt?: string;
+ /** Show a "Link text" field (used when there is no text selection). */
+ showTextField?: boolean;
+ /** Allow submitting an empty URL to remove an existing link. */
+ allowRemove?: boolean;
+ onSubmit: (values: EditorInsertValues) => void;
+ onClose: () => void;
+};
+
+const COPY: Record<
+ EditorInsertDialogKind,
+ { title: string; urlLabel: string; urlPlaceholder: string; submit: string }
+> = {
+ link: {
+ title: "Insert link",
+ urlLabel: "Link URL",
+ urlPlaceholder: "https://example.com",
+ submit: "Insert link",
+ },
+ image: {
+ title: "Insert image",
+ urlLabel: "Image path or URL",
+ urlPlaceholder: "/images/photo.jpg",
+ submit: "Insert image",
+ },
+ file: {
+ title: "Insert file link",
+ urlLabel: "File path or URL",
+ urlPlaceholder: "/files/document.pdf",
+ submit: "Insert file link",
+ },
+};
+
+export function EditorInsertDialog({
+ kind,
+ initialUrl = "",
+ initialText = "",
+ initialAlt = "",
+ showTextField = false,
+ allowRemove = false,
+ onSubmit,
+ onClose,
+}: EditorInsertDialogProps) {
+ const titleId = useId();
+ const urlId = useId();
+ const textId = useId();
+ const altId = useId();
+ const firstFieldRef = useRef(null);
+
+ const [url, setUrl] = useState(initialUrl);
+ const [text, setText] = useState(initialText);
+ const [alt, setAlt] = useState(initialAlt);
+
+ const copy = COPY[kind];
+ const canSubmit = hasUrl(url) || (allowRemove && url.trim().length === 0);
+
+ useEffect(() => {
+ firstFieldRef.current?.focus();
+ firstFieldRef.current?.select();
+ }, []);
+
+ function handleSubmit() {
+ if (!canSubmit) {
+ return;
+ }
+ onSubmit({
+ url: url.trim(),
+ ...(showTextField ? { text: text.trim() } : {}),
+ ...(kind === "image" ? { alt: alt.trim() } : {}),
+ });
+ }
+
+ return (
+ {
+ if (event.target === event.currentTarget) {
+ onClose();
+ }
+ }}
+ >
+
+
+ );
+}
diff --git a/apps/studio/src/editor/EditorToolbar.tsx b/apps/studio/src/editor/EditorToolbar.tsx
index 1994410..02e02e3 100644
--- a/apps/studio/src/editor/EditorToolbar.tsx
+++ b/apps/studio/src/editor/EditorToolbar.tsx
@@ -63,30 +63,30 @@ type EditorToolbarProps = {
editor: Editor | null;
editorMode: "rich" | "source";
bodyFieldId: string;
- latestImagePath: string | null;
- latestUpload: LatestMediaUpload | null;
- imageAlt: string;
mediaUploadAvailable: boolean;
posts: PostSummary[];
editingPath: string | null;
onBodyChange: (body: string) => void;
onModeChange: (mode: "rich" | "source") => void;
onSelectInternalLink: (post: PostSummary) => void;
+ onRequestLink: () => void;
+ onRequestImage: () => void;
+ onRequestFile: () => void;
};
export function EditorToolbar({
editor,
editorMode,
bodyFieldId,
- latestImagePath,
- latestUpload,
- imageAlt,
mediaUploadAvailable,
posts,
editingPath,
onBodyChange,
onModeChange,
onSelectInternalLink,
+ onRequestLink,
+ onRequestImage,
+ onRequestFile,
}: EditorToolbarProps) {
const [internalLinkOpen, setInternalLinkOpen] = useState(false);
@@ -129,78 +129,6 @@ export function EditorToolbar({
onBodyChange(editorDocToBody(editor.getJSON()));
}
- function insertOrEditLink(currentEditor: Editor) {
- const previousHref = currentEditor.getAttributes("link").href as
- | string
- | undefined;
- const input = window.prompt(
- "Link URL (leave empty to remove the link)",
- previousHref ?? "https://",
- );
- if (input === null) {
- return;
- }
-
- const href = input.trim();
- if (href.length === 0) {
- currentEditor.chain().focus().extendMarkRange("link").unsetLink().run();
- return;
- }
-
- if (currentEditor.state.selection.empty && !state.link) {
- currentEditor
- .chain()
- .focus()
- .insertContent({
- type: "text",
- text: "link text",
- marks: [{ type: "link", attrs: { href } }],
- })
- .run();
- return;
- }
-
- currentEditor
- .chain()
- .focus()
- .extendMarkRange("link")
- .setLink({ href })
- .run();
- }
-
- function insertFileLink(currentEditor: Editor) {
- let path: string;
- let filename: string;
-
- if (latestUpload?.kind === "pdf") {
- path = latestUpload.publicPath;
- filename = latestUpload.filename;
- } else {
- const prompted = window
- .prompt(
- "File path or URL — upload PDFs in “Images & files” first, then paste the path here",
- "/files/",
- )
- ?.trim();
- if (!prompted) {
- return;
- }
- path = prompted;
- filename = path.split("/").pop() || path;
- }
-
- const label = filename.replace(/\.pdf$/iu, "") || filename;
- currentEditor
- .chain()
- .focus()
- .insertContent({
- type: "text",
- text: label,
- marks: [{ type: "link", attrs: { href: path } }],
- })
- .run();
- }
-
const groups: { name: string; buttons: ToolbarButton[] }[] =
editor && editorMode === "rich"
? [
@@ -334,32 +262,13 @@ export function EditorToolbar({
ariaLabel: "Insert or edit link",
text: "Link",
active: state.link,
- action: () => insertOrEditLink(editor),
+ action: onRequestLink,
},
{
label: "Image",
ariaLabel: "Insert image",
text: "Image",
- action: () => {
- const path =
- latestImagePath?.trim() ||
- (latestUpload?.kind === "image" ? latestUpload.publicPath : "") ||
- window
- .prompt("Image path (public URL or repo path)", "/images/")
- ?.trim() ||
- "";
- if (path.length === 0) {
- return;
- }
- const alt =
- window.prompt("Alt text (for accessibility)", imageAlt)?.trim() ||
- imageAlt;
- editor
- .chain()
- .focus()
- .setImage({ src: path, alt, title: alt })
- .run();
- },
+ action: onRequestImage,
},
{
label: "File link",
@@ -369,7 +278,7 @@ export function EditorToolbar({
title: mediaUploadAvailable
? "Insert a link to an uploaded PDF or file"
: "File attachments are not enabled for this media provider yet.",
- action: () => insertFileLink(editor),
+ action: onRequestFile,
},
],
},
diff --git a/apps/studio/src/editor/SourceDraftEditor.tsx b/apps/studio/src/editor/SourceDraftEditor.tsx
index 6c703d6..26fad8f 100644
--- a/apps/studio/src/editor/SourceDraftEditor.tsx
+++ b/apps/studio/src/editor/SourceDraftEditor.tsx
@@ -27,9 +27,23 @@ import {
} from "./slashCommands.js";
import { SlashCommandMenu } from "./SlashCommandMenu.js";
import { EditorToolbar, type LatestMediaUpload } from "./EditorToolbar.js";
+import {
+ EditorInsertDialog,
+ type EditorInsertValues,
+} from "./EditorInsertDialog.js";
+import { fileLabelFromPath, normalizeUrl } from "./editorInsert.js";
export type { LatestMediaUpload };
+type DialogState = {
+ kind: "link" | "image" | "file";
+ initialUrl: string;
+ initialText: string;
+ initialAlt: string;
+ showTextField: boolean;
+ allowRemove: boolean;
+};
+
type SourceDraftEditorProps = {
body: string;
latestImagePath: string | null;
@@ -69,6 +83,7 @@ export function SourceDraftEditor({
const [editorMode, setEditorMode] = useState<"rich" | "source">("rich");
const [sourceValue, setSourceValue] = useState(body);
const [slashMenu, setSlashMenu] = useState(null);
+ const [dialog, setDialog] = useState(null);
const bodyVersion = useRef(body);
const slashHandlerRef = useRef<(command: SlashCommandId) => void>(() => {});
@@ -186,6 +201,110 @@ export function SourceDraftEditor({
[editor, syncBodyFromEditor],
);
+ const openLinkDialog = useCallback(() => {
+ if (!editor) {
+ return;
+ }
+ const linkActive = editor.isActive("link");
+ const previousHref =
+ (editor.getAttributes("link").href as string | undefined) ?? "";
+ const selectionEmpty = editor.state.selection.empty;
+ setDialog({
+ kind: "link",
+ initialUrl: previousHref,
+ initialText: "",
+ initialAlt: "",
+ showTextField: selectionEmpty && !linkActive,
+ allowRemove: linkActive,
+ });
+ }, [editor]);
+
+ const openImageDialog = useCallback(() => {
+ if (!editor) {
+ return;
+ }
+ const defaultSrc =
+ latestImagePath?.trim() ||
+ (latestUpload?.kind === "image" ? latestUpload.publicPath : "") ||
+ "";
+ setDialog({
+ kind: "image",
+ initialUrl: defaultSrc,
+ initialText: "",
+ initialAlt: imageAlt,
+ showTextField: false,
+ allowRemove: false,
+ });
+ }, [editor, imageAlt, latestImagePath, latestUpload]);
+
+ const openFileDialog = useCallback(() => {
+ if (!editor) {
+ return;
+ }
+ const defaultPath =
+ latestUpload?.kind === "pdf" ? latestUpload.publicPath : "/files/";
+ setDialog({
+ kind: "file",
+ initialUrl: defaultPath,
+ initialText: "",
+ initialAlt: "",
+ showTextField: false,
+ allowRemove: false,
+ });
+ }, [editor, latestUpload]);
+
+ const handleDialogSubmit = useCallback(
+ (values: EditorInsertValues) => {
+ if (!editor || !dialog) {
+ return;
+ }
+
+ if (dialog.kind === "link") {
+ const href = normalizeUrl(values.url);
+ if (href.length === 0) {
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
+ } else if (dialog.showTextField) {
+ const text = values.text?.trim() || href;
+ editor
+ .chain()
+ .focus()
+ .insertContent({
+ type: "text",
+ text,
+ marks: [{ type: "link", attrs: { href } }],
+ })
+ .run();
+ } else {
+ editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
+ }
+ } else if (dialog.kind === "image") {
+ const src = normalizeUrl(values.url);
+ if (src.length > 0) {
+ const alt = values.alt?.trim() || imageAlt;
+ editor.chain().focus().setImage({ src, alt, title: alt }).run();
+ }
+ } else {
+ const path = values.url.trim();
+ if (path.length > 0) {
+ const label = fileLabelFromPath(path);
+ editor
+ .chain()
+ .focus()
+ .insertContent({
+ type: "text",
+ text: label,
+ marks: [{ type: "link", attrs: { href: path } }],
+ })
+ .run();
+ }
+ }
+
+ syncBodyFromEditor(editor);
+ setDialog(null);
+ },
+ [dialog, editor, imageAlt, syncBodyFromEditor],
+ );
+
const handleSlashCommand = useCallback(
(command: SlashCommandId) => {
if (!editor) {
@@ -212,31 +331,14 @@ export function SourceDraftEditor({
editor.chain().focus().setHorizontalRule().run();
break;
case "link": {
- const href = window.prompt("Link URL", "https://")?.trim() || "https://";
- editor
- .chain()
- .focus()
- .insertContent({
- type: "text",
- text: "link text",
- marks: [{ type: "link", attrs: { href } }],
- })
- .run();
- break;
+ setSlashMenu(null);
+ openLinkDialog();
+ return;
}
case "image": {
- const path =
- latestImagePath?.trim() ||
- (latestUpload?.kind === "image" ? latestUpload.publicPath : "") ||
- window.prompt("Image path (public URL or repo path)", "/images/")?.trim() ||
- "";
- if (path.length > 0) {
- const alt =
- window.prompt("Alt text (for accessibility)", imageAlt)?.trim() ||
- imageAlt;
- editor.chain().focus().setImage({ src: path, alt, title: alt }).run();
- }
- break;
+ setSlashMenu(null);
+ openImageDialog();
+ return;
}
case "internal": {
const firstPost = posts[0];
@@ -272,7 +374,7 @@ export function SourceDraftEditor({
syncBodyFromEditor(editor);
setSlashMenu(null);
},
- [editor, imageAlt, insertInternalLink, latestImagePath, latestUpload, posts, syncBodyFromEditor],
+ [editor, insertInternalLink, openImageDialog, openLinkDialog, posts, syncBodyFromEditor],
);
useEffect(() => {
@@ -325,15 +427,15 @@ export function SourceDraftEditor({
editor={editor}
editorMode={editorMode}
bodyFieldId={bodyFieldId}
- latestImagePath={latestImagePath}
- latestUpload={latestUpload}
- imageAlt={imageAlt}
mediaUploadAvailable={mediaUploadAvailable}
posts={posts}
editingPath={editingPath}
onBodyChange={onBodyChange}
onModeChange={switchMode}
onSelectInternalLink={insertInternalLink}
+ onRequestLink={openLinkDialog}
+ onRequestImage={openImageDialog}
+ onRequestFile={openFileDialog}
/>
{editorMode === "rich" ? (
@@ -387,6 +489,19 @@ export function SourceDraftEditor({
{fieldError}
)}
+
+ {dialog && (
+ setDialog(null)}
+ />
+ )}
);
}
diff --git a/apps/studio/src/editor/editorInsert.test.ts b/apps/studio/src/editor/editorInsert.test.ts
new file mode 100644
index 0000000..ea46fd4
--- /dev/null
+++ b/apps/studio/src/editor/editorInsert.test.ts
@@ -0,0 +1,47 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { fileLabelFromPath, hasUrl, normalizeUrl } from "./editorInsert.ts";
+
+test("normalizeUrl keeps absolute and protocol URLs", () => {
+ assert.equal(normalizeUrl("https://example.com"), "https://example.com");
+ assert.equal(normalizeUrl("http://example.com/x"), "http://example.com/x");
+ assert.equal(normalizeUrl("mailto:a@b.com"), "mailto:a@b.com");
+ assert.equal(normalizeUrl("tel:+15551234"), "tel:+15551234");
+});
+
+test("normalizeUrl keeps relative paths and anchors", () => {
+ assert.equal(normalizeUrl("/post/hello/"), "/post/hello/");
+ assert.equal(normalizeUrl("#section"), "#section");
+ assert.equal(normalizeUrl("./local.md"), "./local.md");
+ assert.equal(normalizeUrl("../up.md"), "../up.md");
+});
+
+test("normalizeUrl adds https:// to bare domains", () => {
+ assert.equal(normalizeUrl("example.com"), "https://example.com");
+ assert.equal(normalizeUrl("example.com/path?q=1"), "https://example.com/path?q=1");
+ assert.equal(normalizeUrl("sub.example.co.uk"), "https://sub.example.co.uk");
+});
+
+test("normalizeUrl trims and handles blanks", () => {
+ assert.equal(normalizeUrl(" https://x.com "), "https://x.com");
+ assert.equal(normalizeUrl(""), "");
+ assert.equal(normalizeUrl(" "), "");
+});
+
+test("normalizeUrl leaves plain text untouched", () => {
+ assert.equal(normalizeUrl("not a url"), "not a url");
+});
+
+test("hasUrl reflects whether a URL would be inserted", () => {
+ assert.equal(hasUrl(""), false);
+ assert.equal(hasUrl(" "), false);
+ assert.equal(hasUrl("example.com"), true);
+ assert.equal(hasUrl("/x"), true);
+});
+
+test("fileLabelFromPath strips directories and .pdf", () => {
+ assert.equal(fileLabelFromPath("/files/report.pdf"), "report");
+ assert.equal(fileLabelFromPath("report.PDF"), "report");
+ assert.equal(fileLabelFromPath("/docs/spec"), "spec");
+ assert.equal(fileLabelFromPath("guide"), "guide");
+});
diff --git a/apps/studio/src/editor/editorInsert.ts b/apps/studio/src/editor/editorInsert.ts
new file mode 100644
index 0000000..05b310a
--- /dev/null
+++ b/apps/studio/src/editor/editorInsert.ts
@@ -0,0 +1,36 @@
+// Pure helpers for the editor insert dialogs (link / image / file). Kept free
+// of React and Tiptap so they can be unit-tested directly.
+
+const ABSOLUTE_OR_RELATIVE =
+ /^(https?:|mailto:|tel:|\/|#|\.\/|\.\.\/)/iu;
+const BARE_DOMAIN = /^[\w-]+(?:\.[\w-]+)+(?:[/?#].*)?$/u;
+
+/**
+ * Normalize a user-entered URL. Bare domains (example.com/x) gain an https://
+ * scheme; absolute URLs, mailto:/tel:, anchors and relative paths are kept.
+ * Returns "" for blank input.
+ */
+export function normalizeUrl(input: string): string {
+ const trimmed = input.trim();
+ if (trimmed.length === 0) {
+ return "";
+ }
+ if (ABSOLUTE_OR_RELATIVE.test(trimmed)) {
+ return trimmed;
+ }
+ if (BARE_DOMAIN.test(trimmed)) {
+ return `https://${trimmed}`;
+ }
+ return trimmed;
+}
+
+/** Human-friendly label for a file path: basename without a .pdf suffix. */
+export function fileLabelFromPath(path: string): string {
+ const base = path.split("/").pop() ?? path;
+ return base.replace(/\.pdf$/iu, "") || base;
+}
+
+/** Whether a normalized URL is non-empty (i.e. safe to insert). */
+export function hasUrl(input: string): boolean {
+ return normalizeUrl(input).length > 0;
+}
diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css
index 331dc06..cbfc003 100644
--- a/apps/studio/src/index.css
+++ b/apps/studio/src/index.css
@@ -1097,6 +1097,87 @@ code {
line-height: 1.45;
}
+/* ——— Editor insert dialog (link / image / file) ——— */
+
+.editor-dialog-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: var(--space-8) var(--space-4);
+ background: rgba(0, 0, 0, 0.4);
+}
+
+.editor-dialog {
+ width: min(100%, 460px);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ padding: var(--space-5);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-lg);
+ background: var(--bg-panel);
+ box-shadow: var(--shadow-lg);
+}
+
+.editor-dialog__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+}
+
+.editor-dialog__title {
+ font-size: var(--text-lg);
+ font-weight: 600;
+ color: var(--text);
+}
+
+.editor-dialog__field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.editor-dialog__label {
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--text-muted);
+}
+
+.editor-dialog__input {
+ min-height: 38px;
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+}
+
+.editor-dialog__input:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+ border-color: var(--accent);
+}
+
+.editor-dialog__actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--space-2);
+ margin-top: var(--space-1);
+}
+
+.editor-dialog__remove {
+ margin-right: auto;
+ color: var(--danger);
+ border-color: var(--error-border);
+}
+
.internal-link-picker {
display: flex;
flex-direction: column;