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(); + } + }} + > +
{ + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } + }} + > +
+

+ {copy.title} +

+ +
+ + + + {showTextField && ( + + )} + + {kind === "image" && ( + + )} + +
+ {allowRemove && ( + + )} + +
+
+
+ ); +} 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;