Просмотр исходного кода

Notes fill their card, and links in notes open on Ctrl click (#459)

* Notes fill their card, and links in notes open on Ctrl click

* Open phone links, hide the link hint on any scroll, and test locked quote notes
Bernt Christian Egeland 2 недель назад
Родитель
Сommit
3b1b81bb14

+ 3 - 0
messages/de/common.json

@@ -131,5 +131,8 @@
     "retake": "Neu aufnehmen",
     "use": "Dieses Video verwenden",
     "limit": "Die Aufnahme endet automatisch nach {minutes} Minuten."
+  },
+  "richText": {
+    "openLinkHint": "{key} gedrückt halten und klicken, um zu öffnen"
   }
 }

+ 3 - 0
messages/en/common.json

@@ -131,5 +131,8 @@
     "retake": "Record again",
     "use": "Use this video",
     "limit": "Recording stops by itself after {minutes} minutes."
+  },
+  "richText": {
+    "openLinkHint": "Hold {key} and click to open"
   }
 }

+ 3 - 0
messages/es/common.json

@@ -131,5 +131,8 @@
     "retake": "Volver a grabar",
     "use": "Usar este vídeo",
     "limit": "La grabación se detiene sola a los {minutes} minutos."
+  },
+  "richText": {
+    "openLinkHint": "Mantén pulsado {key} y haz clic para abrir"
   }
 }

+ 3 - 0
messages/fr/common.json

@@ -131,5 +131,8 @@
     "retake": "Recommencer",
     "use": "Utiliser cette vidéo",
     "limit": "L'enregistrement s'arrête tout seul après {minutes} minutes."
+  },
+  "richText": {
+    "openLinkHint": "Maintenez {key} et cliquez pour ouvrir"
   }
 }

+ 3 - 0
messages/it/common.json

@@ -131,5 +131,8 @@
     "retake": "Registra di nuovo",
     "use": "Usa questo video",
     "limit": "La registrazione si ferma da sola dopo {minutes} minuti."
+  },
+  "richText": {
+    "openLinkHint": "Tieni premuto {key} e fai clic per aprire"
   }
 }

+ 3 - 0
messages/lt/common.json

@@ -131,5 +131,8 @@
     "retake": "Įrašyti iš naujo",
     "use": "Naudoti šį įrašą",
     "limit": "Įrašymas sustoja savaime po {minutes} min."
+  },
+  "richText": {
+    "openLinkHint": "Laikykite {key} ir spustelėkite, kad atidarytumėte"
   }
 }

+ 3 - 0
messages/nb/common.json

@@ -131,5 +131,8 @@
     "retake": "Ta opp på nytt",
     "use": "Bruk denne videoen",
     "limit": "Opptaket stopper av seg selv etter {minutes} minutter."
+  },
+  "richText": {
+    "openLinkHint": "Hold {key} og klikk for å åpne"
   }
 }

+ 3 - 0
messages/nl/common.json

@@ -131,5 +131,8 @@
     "retake": "Opnieuw opnemen",
     "use": "Deze video gebruiken",
     "limit": "De opname stopt vanzelf na {minutes} minuten."
+  },
+  "richText": {
+    "openLinkHint": "Houd {key} ingedrukt en klik om te openen"
   }
 }

+ 3 - 0
messages/pl/common.json

@@ -131,5 +131,8 @@
     "retake": "Nagraj ponownie",
     "use": "Użyj tego nagrania",
     "limit": "Nagrywanie zatrzyma się samo po {minutes} min."
+  },
+  "richText": {
+    "openLinkHint": "Przytrzymaj {key} i kliknij, aby otworzyć"
   }
 }

+ 3 - 0
messages/pt-BR/common.json

@@ -131,5 +131,8 @@
     "retake": "Gravar novamente",
     "use": "Usar este vídeo",
     "limit": "A gravação para sozinha após {minutes} minutos."
+  },
+  "richText": {
+    "openLinkHint": "Segure {key} e clique para abrir"
   }
 }

+ 3 - 0
messages/pt-PT/common.json

@@ -131,5 +131,8 @@
     "retake": "Gravar novamente",
     "use": "Usar este vídeo",
     "limit": "A gravação para sozinha ao fim de {minutes} minutos."
+  },
+  "richText": {
+    "openLinkHint": "Mantenha {key} premido e clique para abrir"
   }
 }

+ 3 - 0
messages/ru/common.json

@@ -131,5 +131,8 @@
     "retake": "Записать заново",
     "use": "Использовать это видео",
     "limit": "Запись остановится сама через {minutes} мин."
+  },
+  "richText": {
+    "openLinkHint": "Удерживайте {key} и нажмите, чтобы открыть"
   }
 }

+ 3 - 0
messages/tr/common.json

@@ -131,5 +131,8 @@
     "retake": "Yeniden kaydet",
     "use": "Bu videoyu kullan",
     "limit": "Kayıt {minutes} dakika sonra kendiliğinden durur."
+  },
+  "richText": {
+    "openLinkHint": "Açmak için {key} tuşunu basılı tutup tıklayın"
   }
 }

+ 92 - 0
src/__tests__/features/quotes/quote-notes-locked.test.tsx

@@ -0,0 +1,92 @@
+/**
+ * The notes of a locked quote cannot be typed into.
+ *
+ * The quote page turns its fields off with a disabled fieldset, which stops
+ * inputs and buttons but not a contenteditable. The notes editor is one, so
+ * on a locked quote it still took typing, showed it, and never saved it. The
+ * editor is told about the lock now, and this holds it to that.
+ */
+
+import { act, render, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { QuoteNotesEditor } from '@/features/quotes/Components/QuoteNotesEditor'
+import { TooltipProvider } from '@/components/ui/tooltip'
+
+function mount(props: { locked: boolean; noteType?: 'public' | 'internal' }) {
+  const onDescriptionChange = vi.fn()
+  const onNotesChange = vi.fn()
+  const tree = (next: typeof props) => (
+    <TooltipProvider>
+      <fieldset disabled={next.locked}>
+        <QuoteNotesEditor
+          noteType={next.noteType ?? 'public'}
+          onNoteTypeChange={vi.fn()}
+          description="<p>Front pads are at 2 mm</p>"
+          onDescriptionChange={onDescriptionChange}
+          notes="<p>Customer is price sensitive</p>"
+          onNotesChange={onNotesChange}
+          locked={next.locked}
+          t={(key) => key}
+        />
+      </fieldset>
+    </TooltipProvider>
+  )
+  const view = render(tree(props))
+  return {
+    ...view,
+    onDescriptionChange,
+    onNotesChange,
+    update: (next: typeof props) => view.rerender(tree(next)),
+  }
+}
+
+const editorIn = (container: HTMLElement) =>
+  waitFor(() => {
+    const element = container.querySelector('.tiptap-content')
+    if (!element) throw new Error('editor not mounted yet')
+    return element as HTMLElement
+  })
+
+describe('the notes of a quote', () => {
+  it('can be written while the quote is open', async () => {
+    const view = mount({ locked: false })
+    const editor = await editorIn(view.container)
+    expect(editor).toHaveAttribute('contenteditable', 'true')
+    // The toolbar is there to write with.
+    expect(view.container.querySelector('button[title="Bold"]')).not.toBeNull()
+  })
+
+  it.each([
+    'public',
+    'internal',
+  ] as const)('cannot be typed into on a locked quote (%s note)', async (noteType) => {
+    const view = mount({ locked: true, noteType })
+    const editor = await editorIn(view.container)
+    expect(editor).toHaveAttribute('contenteditable', 'false')
+    // No toolbar either: nothing on the card suggests the text can change.
+    expect(view.container.querySelector('button[title="Bold"]')).toBeNull()
+    expect(view.onDescriptionChange).not.toHaveBeenCalled()
+    expect(view.onNotesChange).not.toHaveBeenCalled()
+  })
+
+  it('follows the lock as it engages and lifts, without reporting an edit', async () => {
+    const view = mount({ locked: false })
+    const editor = await editorIn(view.container)
+
+    act(() => view.update({ locked: true }))
+    await waitFor(() => expect(editor).toHaveAttribute('contenteditable', 'false'))
+
+    act(() => view.update({ locked: false }))
+    await waitFor(() => expect(editor).toHaveAttribute('contenteditable', 'true'))
+
+    // Locking and unlocking are not edits, and must not queue a save.
+    expect(view.onDescriptionChange).not.toHaveBeenCalled()
+    expect(view.onNotesChange).not.toHaveBeenCalled()
+  })
+
+  it('keeps showing what was written when it locks', async () => {
+    const view = mount({ locked: true })
+    const editor = await editorIn(view.container)
+    expect(editor.textContent).toBe('Front pads are at 2 mm')
+  })
+})

+ 56 - 0
src/__tests__/features/vehicles/rich-text-editor-links.test.ts

@@ -0,0 +1,56 @@
+/**
+ * A link pasted into a note has to be openable. A plain click in a note
+ * being written puts the cursor in the link, so its text can be changed;
+ * Ctrl or Cmd with the click opens it. Before this, nothing opened it at all.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { linkToOpen } from '@/components/ui/rich-text-links'
+
+function clickOn(html: string, keys: { ctrlKey?: boolean; metaKey?: boolean } = {}) {
+  const host = document.createElement('div')
+  host.innerHTML = html
+  const target = host.querySelector('[data-target]') ?? host.firstElementChild
+  return { ctrlKey: false, metaKey: false, ...keys, target } as unknown as MouseEvent
+}
+
+const LINK = '<p><a href="https://example.com/part/123" data-target>the part</a></p>'
+
+describe('a click on a link in a note', () => {
+  it('opens it with Ctrl held', () => {
+    expect(linkToOpen(clickOn(LINK, { ctrlKey: true }), true)).toBe('https://example.com/part/123')
+  })
+
+  it('opens it with Cmd held, for a Mac', () => {
+    expect(linkToOpen(clickOn(LINK, { metaKey: true }), true)).toBe('https://example.com/part/123')
+  })
+
+  it('does not open it on a plain click, which is for placing the cursor', () => {
+    expect(linkToOpen(clickOn(LINK), true)).toBeNull()
+  })
+
+  it('finds the link when the click lands on formatting inside it', () => {
+    const bold = '<p><a href="https://example.com/"><strong data-target>bold</strong></a></p>'
+    expect(linkToOpen(clickOn(bold, { ctrlKey: true }), true)).toBe('https://example.com/')
+  })
+
+  it('does nothing for a Ctrl click on ordinary text', () => {
+    expect(linkToOpen(clickOn('<p data-target>plain</p>', { ctrlKey: true }), true)).toBeNull()
+  })
+
+  it('leaves a locked note to the browser, which follows the link itself', () => {
+    expect(linkToOpen(clickOn(LINK, { ctrlKey: true }), false)).toBeNull()
+  })
+
+  it('opens a phone number', () => {
+    const phone = '<p><a href="tel:+4712345678" data-target>call</a></p>'
+    expect(linkToOpen(clickOn(phone, { ctrlKey: true }), true)).toBe('tel:+4712345678')
+  })
+
+  it('opens a mail address, and refuses a script', () => {
+    const mail = '<p><a href="mailto:parts@example.com" data-target>mail</a></p>'
+    expect(linkToOpen(clickOn(mail, { ctrlKey: true }), true)).toBe('mailto:parts@example.com')
+    const script = '<p><a href="javascript:alert(1)" data-target>x</a></p>'
+    expect(linkToOpen(clickOn(script, { ctrlKey: true }), true)).toBeNull()
+  })
+})

+ 14 - 1
src/components/ui/rich-text-editor.tsx

@@ -13,6 +13,11 @@ import {
 } from 'lucide-react'
 import { cn } from '@/lib/utils'
 import { useEffect } from 'react'
+import {
+  EDITOR_LINK_OPTIONS,
+  openLinkOnModifierClick,
+  useLinkHint,
+} from '@/components/ui/rich-text-links'
 
 interface RichTextEditorProps {
   content: string
@@ -56,6 +61,8 @@ export function RichTextEditor({
       StarterKit.configure({
         bulletList: { HTMLAttributes: { class: 'list-disc pl-4' } },
         orderedList: { HTMLAttributes: { class: 'list-decimal pl-4' } },
+        // The same link behaviour as every other editor; see rich-text-links.
+        link: EDITOR_LINK_OPTIONS,
       }),
       Placeholder.configure({ placeholder }),
     ],
@@ -64,6 +71,7 @@ export function RichTextEditor({
       onChange(editor.getHTML())
     },
     editorProps: {
+      handleClick: openLinkOnModifierClick,
       attributes: {
         class:
           'tiptap-content min-h-[120px] max-h-[40vh] overflow-y-auto overflow-x-hidden px-3 py-2 text-sm outline-none break-all',
@@ -71,6 +79,8 @@ export function RichTextEditor({
     },
   })
 
+  const linkHint = useLinkHint(true)
+
   // Sync external content changes (e.g. when switching between create/edit)
   useEffect(() => {
     if (editor && content !== editor.getHTML()) {
@@ -127,7 +137,10 @@ export function RichTextEditor({
       </div>
 
       {/* Editor */}
-      <EditorContent editor={editor} />
+      <div {...linkHint.handlers}>
+        <EditorContent editor={editor} />
+      </div>
+      {linkHint.hint}
     </div>
   )
 }

+ 108 - 0
src/components/ui/rich-text-links.tsx

@@ -0,0 +1,108 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+
+/**
+ * How a link behaves inside a rich text editor, shared by every editor in
+ * the app so a link in a work order note, a quote note and a vehicle note
+ * all answer the same way.
+ *
+ * A plain click in text being written puts the cursor in the link, so its
+ * words can be changed. Ctrl or Cmd with the click opens it in a new tab,
+ * and hovering the link says so, because nothing else on the page does.
+ */
+
+/** Cmd is the key on a Mac, an iPad or an iPhone; Ctrl everywhere else. */
+function isApplePlatform(): boolean {
+  if (typeof navigator === 'undefined') return false
+  return /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent)
+}
+
+/**
+ * The kinds of address a click may open: a web page, a mail address and a
+ * phone number. The link extension accepts a few more on its own list (ftp,
+ * sms and the like); a workshop's notes have no use for them, and anything
+ * that is not on this list, a script address above all, is never opened.
+ */
+const OPENABLE = /^(https?:|mailto:|tel:)/i
+
+/**
+ * The address a click in the editor should open, or null when the click is
+ * an ordinary one. Only Ctrl or Cmd with the click, only on a link, only in
+ * editable text, and only the kinds of address the link extension writes.
+ */
+export function linkToOpen(
+  event: Pick<MouseEvent, 'ctrlKey' | 'metaKey' | 'target'>,
+  editable: boolean
+): string | null {
+  if (!editable || !(event.ctrlKey || event.metaKey)) return null
+  const anchor = (event.target as HTMLElement | null)?.closest?.('a[href]')
+  if (!(anchor instanceof HTMLAnchorElement)) return null
+  return OPENABLE.test(anchor.href) ? anchor.href : null
+}
+
+/** The tiptap `handleClick` that opens a link on Ctrl or Cmd click. */
+export function openLinkOnModifierClick(
+  view: { editable: boolean },
+  _pos: number,
+  event: MouseEvent
+): boolean {
+  const href = linkToOpen(event, view.editable)
+  if (!href) return false
+  window.open(href, '_blank', 'noopener,noreferrer')
+  return true
+}
+
+/** How the link extension is set up in every editor. */
+export const EDITOR_LINK_OPTIONS = {
+  openOnClick: false,
+  autolink: true,
+  linkOnPaste: true,
+  HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
+} as const
+
+/**
+ * The "hold Ctrl and click to open" hint. Spread `handlers` on the element
+ * that holds the editor's text and render `hint` anywhere inside the editor;
+ * it is drawn in viewport coordinates, so the editor's clipping cannot cut it.
+ * Text that is not editable opens its links on a plain click and gets no hint.
+ */
+export function useLinkHint(editable: boolean) {
+  const t = useTranslations('common.richText')
+  const [at, setAt] = useState<{ left: number; top: number } | null>(null)
+  const hide = () => setAt(null)
+
+  const handlers = {
+    onMouseOver: (e: React.MouseEvent) => {
+      if (!editable) return
+      const anchor = (e.target as HTMLElement).closest?.('a[href]')
+      if (!anchor) return
+      const box = anchor.getBoundingClientRect()
+      setAt({ left: box.left, top: box.bottom + 6 })
+    },
+    onMouseOut: (e: React.MouseEvent) => {
+      if ((e.target as HTMLElement).closest?.('a[href]')) hide()
+    },
+    // On the way down, not up: scroll events do not bubble, and the element
+    // that scrolls is the editor's own, somewhere inside whatever these are
+    // spread on. Caught in the capture phase, a scroll of any of them hides
+    // a hint that would otherwise stay where the link used to be.
+    onScrollCapture: hide,
+    onKeyDown: hide,
+  }
+
+  const hint =
+    at && editable ? (
+      <div
+        role="tooltip"
+        data-testid="link-open-hint"
+        className="pointer-events-none fixed z-50 rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-md"
+        style={{ left: at.left, top: at.top }}
+      >
+        {t('openLinkHint', { key: isApplePlatform() ? '⌘' : 'Ctrl' })}
+      </div>
+    ) : null
+
+  return { handlers, hint }
+}

+ 27 - 12
src/features/quotes/Components/QuoteNotesEditor.tsx

@@ -18,6 +18,12 @@ interface QuoteNotesEditorProps {
   onDescriptionChange: (v: string) => void
   notes: string
   onNotesChange: (v: string) => void
+  /**
+   * A locked quote refuses edits. The fieldset around this turns off inputs
+   * and buttons but not a contenteditable, so the editor has to be told, or
+   * text typed into a locked quote looks accepted and is never saved.
+   */
+  locked?: boolean
   t: (key: string) => string
 }
 
@@ -28,11 +34,15 @@ export const QuoteNotesEditor = memo(function QuoteNotesEditor({
   onDescriptionChange,
   notes,
   onNotesChange,
+  locked = false,
   t,
 }: QuoteNotesEditorProps) {
   return (
-    <div className="rounded-lg border p-3 space-y-3">
-      <div className="flex items-center justify-between">
+    // The panel is the field: its header, then the editor to the panel's
+    // edges, then one line saying who reads the note. It used to be a frame
+    // around a second, smaller bordered box.
+    <div className="overflow-hidden rounded-lg border">
+      <div className="flex items-center justify-between px-3 py-2">
         <h3 className="flex items-center gap-2 text-sm font-semibold">
           <FileText className="h-3.5 w-3.5" />
           {t('notes.title')}
@@ -50,26 +60,31 @@ export const QuoteNotesEditor = memo(function QuoteNotesEditor({
           </SelectContent>
         </Select>
       </div>
-      {noteType === 'public' && (
-        <div className="space-y-1">
+      <div className="border-t">
+        {noteType === 'public' && (
           <RichTextEditor
+            variant="flush"
+            gutter="panel"
+            editable={!locked}
             content={description}
             onChange={onDescriptionChange}
             placeholder={t('notes.publicPlaceholder')}
           />
-          <p className="text-xs text-muted-foreground">{t('notes.publicHelper')}</p>
-        </div>
-      )}
-      {noteType === 'internal' && (
-        <div className="space-y-1">
+        )}
+        {noteType === 'internal' && (
           <RichTextEditor
+            variant="flush"
+            gutter="panel"
+            editable={!locked}
             content={notes}
             onChange={onNotesChange}
             placeholder={t('notes.internalPlaceholder')}
           />
-          <p className="text-xs text-muted-foreground">{t('notes.internalHelper')}</p>
-        </div>
-      )}
+        )}
+      </div>
+      <p className="border-t bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
+        {noteType === 'public' ? t('notes.publicHelper') : t('notes.internalHelper')}
+      </p>
     </div>
   )
 })

+ 1 - 0
src/features/quotes/Components/QuotePageClient.tsx

@@ -273,6 +273,7 @@ export function QuotePageClient({
         onDescriptionChange={handleDescriptionChange}
         notes={state.notes}
         onNotesChange={handleNotesChange}
+        locked={lockState.locked}
         t={t}
       />
     </fieldset>

+ 23 - 7
src/features/vehicles/Components/service-edit/NotesSection.tsx

@@ -200,8 +200,12 @@ export function NotesSection({
             description={t('internalHelper')}
             action={aiButton('internal')}
             className="border-amber-500/30 bg-amber-500/5 hover:border-amber-500/40"
+            // The editor fills the card to its edges: the card is the field,
+            // where it used to be a frame around a second, smaller box.
+            contentClassName="p-0"
           >
             <RichTextEditor
+              variant="flush"
               content={internalNotes}
               onChange={handleInternalChange}
               editable={!internalLocked}
@@ -215,8 +219,10 @@ export function NotesSection({
             title={t('publicTitle')}
             description={t('publicHelper')}
             action={aiButton('public')}
+            contentClassName="p-0"
           >
             <RichTextEditor
+              variant="flush"
               content={publicNotes}
               onChange={handlePublicChange}
               editable={!publicLocked}
@@ -230,14 +236,16 @@ export function NotesSection({
   }
 
   return (
-    <div className="rounded-lg border p-3 space-y-3">
-      <h3 className="flex items-center gap-2 text-sm font-semibold">
+    // The panel is the field, as on the quote: header, tabs, then the editor
+    // to the panel's edges and one line saying who reads the note.
+    <div className="overflow-hidden rounded-lg border">
+      <h3 className="flex items-center gap-2 px-3 pt-3 text-sm font-semibold">
         <FileText className="h-3.5 w-3.5" />
         {t('title')}
       </h3>
 
       <Tabs value={noteType} onValueChange={(v) => setNoteType(v as 'public' | 'internal')}>
-        <div className="flex items-center justify-between gap-2">
+        <div className="flex items-center justify-between gap-2 px-3 py-2">
           <TabsList className="h-8">
             <TabsTrigger value="public" className="text-xs">
               {t('public')}
@@ -271,24 +279,32 @@ export function NotesSection({
           )}
         </div>
 
-        <TabsContent value="public" className="space-y-1">
+        <TabsContent value="public" className="mt-0 border-t">
           <RichTextEditor
+            variant="flush"
+            gutter="panel"
             content={publicNotes}
             onChange={handlePublicChange}
             editable={!publicLocked}
             placeholder={t('publicPlaceholder')}
           />
-          <p className="text-xs text-muted-foreground">{t('publicHelper')}</p>
+          <p className="border-t bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
+            {t('publicHelper')}
+          </p>
         </TabsContent>
 
-        <TabsContent value="internal" className="space-y-1">
+        <TabsContent value="internal" className="mt-0 border-t">
           <RichTextEditor
+            variant="flush"
+            gutter="panel"
             content={internalNotes}
             onChange={handleInternalChange}
             editable={!internalLocked}
             placeholder={t('internalPlaceholder')}
           />
-          <p className="text-xs text-muted-foreground">{t('internalHelper')}</p>
+          <p className="border-t bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
+            {t('internalHelper')}
+          </p>
         </TabsContent>
       </Tabs>
 

+ 52 - 13
src/features/vehicles/Components/service-edit/RichTextEditor.tsx

@@ -19,6 +19,11 @@ import {
   Redo,
 } from 'lucide-react'
 import { cn } from '@/lib/utils'
+import {
+  EDITOR_LINK_OPTIONS,
+  openLinkOnModifierClick,
+  useLinkHint,
+} from '@/components/ui/rich-text-links'
 
 interface RichTextEditorProps {
   content: string
@@ -30,6 +35,20 @@ interface RichTextEditorProps {
    * be typed into and was then quietly never saved.
    */
   editable?: boolean
+  /**
+   * `boxed` (the default) draws the editor as a field of its own, for a form
+   * with other fields beside it. `flush` is for a card that holds nothing
+   * but the editor: no border, no fill and no rounding, the toolbar a strip
+   * under the card's header and the text on the card's own margins, so the
+   * card is the field rather than a frame around one.
+   */
+  variant?: 'boxed' | 'flush'
+  /**
+   * For a flush editor: how far in the text starts, to match the container
+   * it fills. `card` is the house card's margin, `panel` the tighter one of
+   * the plain bordered panels on the quote page and the classic work order.
+   */
+  gutter?: 'card' | 'panel'
 }
 
 export function RichTextEditor({
@@ -37,7 +56,11 @@ export function RichTextEditor({
   onChange,
   placeholder,
   editable = true,
+  variant = 'boxed',
+  gutter = 'card',
 }: RichTextEditorProps) {
+  const flush = variant === 'flush'
+  const linkHint = useLinkHint(editable)
   const lastContentRef = useRef(content)
 
   const editor = useEditor({
@@ -50,15 +73,7 @@ export function RichTextEditor({
       // warns about and which makes the toolbar's active state unreliable.
       StarterKit.configure({
         heading: { levels: [2, 3] },
-        link: {
-          openOnClick: false,
-          autolink: true,
-          linkOnPaste: true,
-          HTMLAttributes: {
-            rel: 'noopener noreferrer',
-            target: '_blank',
-          },
-        },
+        link: EDITOR_LINK_OPTIONS,
       }),
       Placeholder.configure({
         placeholder: placeholder || 'Write something...',
@@ -75,8 +90,14 @@ export function RichTextEditor({
       onChange(html)
     },
     editorProps: {
+      // Ctrl or Cmd with a click opens a link; see rich-text-links.
+      handleClick: openLinkOnModifierClick,
       attributes: {
-        class: 'tiptap-content px-3 py-2 text-sm outline-none h-full',
+        class: cn(
+          'tiptap-content text-sm outline-none h-full',
+          // In a card the text starts where the card's title does.
+          flush && gutter === 'card' ? 'px-5 py-3' : 'px-3 py-2'
+        ),
       },
     },
   })
@@ -117,13 +138,29 @@ export function RichTextEditor({
   return (
     <div
       className={cn(
-        'overflow-hidden rounded-md border border-input flex flex-col resize-y',
-        editable ? 'bg-background' : 'bg-muted/40 text-muted-foreground'
+        'flex flex-col overflow-hidden resize-y',
+        flush
+          ? // The card is the field: nothing drawn around the editor, and a
+            // locked one is told apart by its muted text alone.
+            !editable && 'text-muted-foreground'
+          : cn(
+              'rounded-md border border-input',
+              editable ? 'bg-background' : 'bg-muted/40 text-muted-foreground'
+            )
       )}
       style={{ minHeight: 180 }}
     >
       {editable && (
-        <div className="flex flex-wrap items-center gap-0.5 border-b bg-muted/30 px-1 py-1 shrink-0">
+        <div
+          className={cn(
+            'flex shrink-0 flex-wrap items-center gap-0.5 border-b py-1',
+            // Lined up with the card's title; the first button's own inset
+            // makes up the rest of the margin.
+            flush
+              ? cn('border-border/70', gutter === 'card' ? 'px-3.5' : 'px-1.5')
+              : 'bg-muted/30 px-1'
+          )}
+        >
           <ToolbarButton
             active={editor.isActive('bold')}
             onClick={() => editor.chain().focus().toggleBold().run()}
@@ -214,6 +251,7 @@ export function RichTextEditor({
       <div
         data-testid="rich-text-area"
         className={cn('flex-1 overflow-y-auto', editable && 'cursor-text')}
+        {...linkHint.handlers}
         onMouseDown={(e) => {
           if (!editable) return
           if ((e.target as HTMLElement).closest('.ProseMirror')) return
@@ -223,6 +261,7 @@ export function RichTextEditor({
       >
         <EditorContent editor={editor} className="h-full [&>.tiptap]:h-full" />
       </div>
+      {linkHint.hint}
     </div>
   )
 }