diff --git a/.dockerignore b/.dockerignore index 92b44c3c..794fd90b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,10 @@ node_modules .data *.md docs + +# Local verification artifacts and credentials +tmp +.playwright-mcp +.dev.vars +.dev.vars.* +logs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1c8f005..e7d57122 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Markdown editor compatibility tests + run: pnpm editor:test + - name: Test widget framing policy run: node --import tsx --test tests/widget-framing.test.ts diff --git a/AGENTS.md b/AGENTS.md index 2b1baa5c..d21124d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ pnpm dlx shadcn-vue@latest add # Add shadcn-vue component (run pnpm - `components/ui/` — shadcn-vue base components (no prefix, `pathPrefix: false`) - `composables/` — `useApiFetch` (request wrapper), `useAuth` (auth state), `usePermission` (permissions) - `stores/` — Pinia stores: `board`, `postDetail` -- `lib/md-editor-helper.ts` — Workaround for md-editor-v3 nested in shadcn Dialog (prevents internal popups from closing the outer Dialog); read this first when hitting similar issues +- `components/TiptapMarkdownEditor.client.vue` — visual Markdown editing; `MarkdownEditorShell.vue` owns the toolbar and CodeMirror source surface; `lib/markdown-render.ts` renders sanitized public content ### Backend (`server/`) diff --git a/README.md b/README.md index 26b7ef83..84fe6d70 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ overrides, and authentication toggles. | Blob storage | Cloudflare R2 binding, Vercel Blob, or S3-compatible via `aws4fetch` (AWS, MinIO, OSS) | | State | Pinia | | UI | Tailwind CSS v4, shadcn-vue (new-york), Lucide icons | -| Editor | md-editor-v3 | +| Editor | Tiptap 3 (visual) + CodeMirror 6 (Markdown source) | | Deploy targets | Cloudflare Workers, Vercel, Node.js Docker | | Package manager | pnpm | diff --git a/app/assets/css/markdown-content.css b/app/assets/css/markdown-content.css new file mode 100644 index 00000000..107c24e4 --- /dev/null +++ b/app/assets/css/markdown-content.css @@ -0,0 +1,61 @@ +/* Shared document typography for Tiptap and the read-only Markdown renderer. */ +.md-editor-preview { + --md-theme-code-block-bg-color: var(--muted); + --md-theme-code-block-color: var(--foreground); + --md-theme-code-block-radius: 6px; + color: var(--foreground); font: 14px/1.7 var(--font-body, system-ui, sans-serif); word-break: normal; overflow-wrap: anywhere; +} +.md-editor-preview :is(p, ul, ol, blockquote, pre, table, figure) { margin-block: .75em; } +.md-editor-preview > :first-child { margin-top: 0; } +.md-editor-preview > :last-child { margin-bottom: 0; } +.md-editor-preview :is(h1,h2,h3,h4,h5,h6) { margin: 1.25em 0 .5em; font-weight: 650; line-height: 1.35; } +.md-editor-preview h1 { font-size: 2em; }.md-editor-preview h2 { font-size: 1.5em; }.md-editor-preview h3 { font-size: 1.25em; } +.md-editor-preview ul { list-style: disc; padding-left: 1.5em; }.md-editor-preview ol { list-style: decimal; padding-left: 1.5em; } +.md-editor-preview li > p { margin-block: .25em; } +.md-editor-preview a { color: var(--primary); text-decoration: underline; } +.md-editor-preview blockquote { border-left: 3px solid var(--border); padding-left: 1em; color: var(--muted-foreground); } +.md-editor-preview img { max-width: 100%; height: auto; } +.md-editor-preview table { border-collapse: collapse; max-width: 100%; width: 100%; } +.md-editor-preview :is(th,td) { border: 1px solid var(--border); padding: 6px 10px; text-align: start; } +.md-editor-preview th { background: var(--muted); font-weight: 600; } +.md-editor-preview pre { overflow: auto; background: var(--muted); border-radius: 6px; padding: 14px 16px; } +.md-editor-preview pre code { color: var(--foreground); background: transparent; padding: 0; white-space: pre; } +.md-editor-preview code { font-family: ui-monospace, monospace; font-size: .9em; background: color-mix(in srgb, var(--accent) 14%, var(--background)); color: #d16c42; border-radius: 3px; padding: .12em .3em; } +.md-editor-preview hr { border: 0; border-top: 1px solid var(--border); margin: 1.25em 0; } +/* One code-block skin for editing, editor preview and public articles. */ +.md-editor-preview .fdl-code-view { + --fdl-code-background: #282c34; + --fdl-code-color: #abb2bf; + margin: .75em 0; + border: 1px solid #3b414d; + border-radius: 6px; + overflow: hidden; + color: var(--fdl-code-color); + background: var(--fdl-code-background); + font: 14px/1.8 source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; +} +.md-editor-preview .fdl-code-view .md-editor-code-head { position: relative; z-index: auto; display: flex; align-items: center; justify-content: space-between; min-height: 36px; background: var(--fdl-code-background); color: var(--fdl-code-color); } +.md-editor-preview .fdl-code-view .md-editor-code-flag { display: flex; gap: 5px; padding-left: 14px; } +.md-editor-preview .fdl-code-view .md-editor-code-flag span { display: block; width: 12px; height: 12px; border-radius: 50%; background: #ff5f57; } +.md-editor-preview .fdl-code-view .md-editor-code-flag span:nth-child(2) { background: #febc2e; } +.md-editor-preview .fdl-code-view .md-editor-code-flag span:nth-child(3) { background: #28c840; } +.md-editor-preview .fdl-code-view .fdl-code-actions { display: flex; align-items: center; gap: 12px; padding: 0 14px; font: 14px/1.5 var(--font-body, system-ui, sans-serif); } +.md-editor-preview .fdl-code-view .fdl-code-body { display: flex; overflow-x: auto; background: var(--fdl-code-background); } +.md-editor-preview .fdl-code-view .fdl-code-lines { flex: none; padding: 14px 0 14px 20px; min-width: 36px; text-align: right; white-space: pre; color: var(--fdl-code-color); opacity: .65; user-select: none; font: inherit; } +.md-editor-preview .fdl-code-view pre { flex: 1; margin: 0; padding: 14px 16px; min-height: 50px; overflow: visible; border: 0; border-radius: 0; white-space: pre; font: inherit; background: var(--fdl-code-background); } +.md-editor-preview .fdl-code-view pre code { display: inline; padding: 0; white-space: pre; overflow: visible; font: inherit; background: transparent; color: var(--fdl-code-color); border-radius: 0; } +.md-editor-preview .fdl-code-view .fdl-code-collapse { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; border: 1px solid currentColor; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; } +.md-editor-preview .fdl-code-view .fdl-code-chevron { width: 5px; height: 5px; border: solid currentColor; border-width: 0 1px 1px 0; transform: translateY(-1px) rotate(45deg); } +.md-editor-preview .fdl-code-view .fdl-code-collapse:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; } +.md-editor-preview .fdl-code-collapsed .fdl-code-body { display: none; } +.md-editor-preview .fdl-code-collapsed .fdl-code-collapse { transform: rotate(-90deg); } + +/* Historical content containers share the document typography. */ +.md-editor-preview .fdl-legacy-container { margin: 12px 0; padding: 12px 16px; border-left: 3px solid var(--border); border-radius: 4px; background: var(--muted); } +.md-editor-preview .fdl-container-title { font-weight: 600; margin-bottom: 8px; } +.md-editor-preview .fdl-container-title:empty { margin: 0; } + +.md-editor-preview .fdl-code-view .fdl-code-copy { border: 0; padding: 2px 0; background: transparent; color: inherit; font: inherit; white-space: nowrap; cursor: pointer; } +.md-editor-preview .fdl-code-view .fdl-code-copy:hover { color: #fff; } +.md-editor-preview .fdl-code-view .fdl-code-copy:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; border-radius: 2px; } +.md-editor-preview .fdl-code-view .fdl-code-copy[aria-busy='true'] { opacity: .6; cursor: wait; } diff --git a/app/assets/css/markdown-editor.css b/app/assets/css/markdown-editor.css new file mode 100644 index 00000000..7e22ce2d --- /dev/null +++ b/app/assets/css/markdown-editor.css @@ -0,0 +1,317 @@ +/* Editor frame, menus and editing surface. */ +:where(.md-editor.fdl-markdown-editor) { --fdl-editor-font-family: var(--font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif); position: relative; display: flex; flex-direction: column; width: 100%; min-height: 100px; height: 500px; border: 1px solid var(--border); background: var(--md-bk-color, #fff); color: var(--foreground); font: 14px/1.5 var(--fdl-editor-font-family); } +.md-editor-toolbar-wrapper { flex: none; border-bottom: 1px solid var(--border); } +.md-editor-toolbar, .md-editor-toolbar-left, .md-editor-toolbar-right { display: flex; align-items: center; } +.md-editor-toolbar-item { display: inline-flex; padding: 4px; border: 0; color: inherit; background: transparent; cursor: pointer; } +.md-editor-toolbar-item:disabled { opacity: .4; cursor: default; } +.fdl-editor-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } +.md-editor-input-wrapper { flex: 1 1 auto; min-height: 0; height: 100%; } +.fdl-source-mount { height: 100%; min-height: 80px; } +.fdl-rich-mount { height: 100%; min-height: 80px; } +.fdl-preview-pane { overflow: auto; padding: 12px 20px; } +.md-editor-footer { display: flex; align-items: center; flex: none; gap: 8px; height: 24px; padding: 0 10px; border-top: 1px solid var(--border); font-size: 12px; color: var(--muted-foreground); } +.md-editor-footer > :last-child:not(:first-child) { margin-inline-start: auto; } +.fdl-tool { position: relative; display: flex; } +.fdl-tool-menu { color: var(--foreground); animation: none !important; transition: none !important; } +.fdl-tool-menu [role="menuitem"]:is(:hover, :focus, [data-highlighted]) { background: var(--muted); color: var(--foreground); } +.fdl-table-picker { padding: 4px; } +.fdl-table-size { padding-bottom: 6px; text-align: center; color: var(--muted-foreground); font-size: 12px; } +.md-editor-table-shape-row { display: flex; gap: 3px; margin-bottom: 3px; } +.fdl-table-picker .md-editor-table-shape-col { flex: none; width: 18px; height: 18px; padding: 0; border: 1px solid var(--border); border-radius: 2px; background: var(--background); cursor: pointer; } +.fdl-table-picker .fdl-table-cell-selected { background: var(--muted); border-color: var(--muted-foreground); } +.fdl-table-picker .md-editor-table-shape-col:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; } +.fdl-address-dialog { max-height: calc(100dvh - 32px); overflow: auto; color: var(--foreground); } +.fdl-editor-fullscreen { position: fixed !important; inset: 12px !important; width: auto !important; height: auto !important; z-index: 40; } + +/* Keep the former crop dialog's header, dimensions and side-by-side preview. */ +.fdl-crop-dialog[data-slot='dialog-content'] { display: flex; flex-direction: column; width: 668px; max-width: calc(100vw - 20px); height: 421px; max-height: calc(100dvh - 20px); gap: 0; padding: 0; border-radius: 3px; outline: none; color: var(--foreground); } +.fdl-crop-header { display: flex; align-items: center; justify-content: space-between; height: 43px; flex: none; padding: 0 14px 0 24px; border-bottom: 1px solid var(--border); cursor: grab; touch-action: none; user-select: none; } +.fdl-crop-header [data-slot='dialog-title'] { font-size: 16px; line-height: 22px; } +.fdl-crop-header > div { display: flex; gap: 4px; } +.fdl-crop-content { display: flex; flex-direction: column; padding: 20px; flex: 1; min-height: 0; } +.fdl-crop-workspace { display: flex; gap: 14px; min-height: 0; flex: 1; } +.fdl-crop-main, .fdl-crop-preview-pane { position: relative; flex: 1; min-width: 0; height: 100%; overflow: hidden; border: 1px solid var(--border); } +.fdl-crop-preview { width: 100%; height: 100%; overflow: hidden; } +.fdl-crop-main > img { display: block; max-width: 100%; } +.fdl-crop-choose { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; cursor: pointer; color: var(--muted-foreground); background: transparent; border: 0; } +.fdl-crop-choose:focus-visible { outline: 2px solid var(--ring); outline-offset: -3px; } +.fdl-crop-main .fdl-crop-remove { position: absolute; top: 0; right: 0; z-index: 2; border-radius: 0 0 0 4px; } +.fdl-crop-footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 20px; flex: none; } +.fdl-crop-dialog.fdl-crop-fullscreen { width: calc(100vw - 20px); height: calc(100dvh - 20px); } +@media (max-width: 640px) { .fdl-crop-dialog { height: 620px !important; } .fdl-crop-workspace { flex-direction: column; } .fdl-crop-main, .fdl-crop-preview-pane { width: 100%; height: 0; min-height: 0; } } + +.fdl-footer-right { justify-content: flex-end; } + +/* Independent shell shared by the visual and source surfaces. */ +/* Keep CodeMirror's viewport measurement and scroll anchoring away from Tiptap. */ +.fdl-structured-shell .fdl-rich-mount { height: 100%; } +.fdl-rich-scroll { position: relative; height: 100%; overflow: auto; scrollbar-width: thin; scroll-padding-block: 24px; } +.fdl-editor-mount { display: flex; flex-direction: column; flex: 1 0 auto; min-height: 100%; font-family: var(--fdl-editor-font-family); } +.fdl-editor-mount > .fdl-editor-content { flex: 1 0 auto; } +.fdl-editor-content { min-height: 100%; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; } +.fdl-markdown-editor .md-editor-toolbar-right { gap: 4px; } +.help-article-styled .md-editor.fdl-markdown-editor { background: var(--md-bk-color); } +.fdl-markdown-editor .fdl-editor-content { padding: 12px 20px; min-height: 100%; cursor: text; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; } +.fdl-editor-content img { display: inline-block; max-width: 100%; height: auto; } +.fdl-editor-content table { border-collapse: collapse; width: 100%; table-layout: fixed; } +.fdl-editor-content :is(td,th) { position: relative; border: 1px solid var(--border); vertical-align: top; } +.fdl-editor-content .selectedCell { background: var(--accent); } +.fdl-editor-content ul[data-type='taskList'] { list-style: none; padding-left: 0; } +.fdl-editor-content li[data-type='taskItem'] { display: flex; gap: 8px; } +.fdl-editor-content li[data-type='taskItem'] > div { flex: 1; } +.fdl-editor-content p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; height: 0; pointer-events: none; color: var(--muted-foreground); } +.fdl-editor-content .ProseMirror-selectednode { outline: 2px solid var(--ring); } +.fdl-editor-message { padding: 4px 12px; font-size: 12px; } +.fdl-editor-content figure { margin: 12px 0; } +.fdl-editor-content figcaption { color: var(--muted-foreground); font-size: .875em; margin-top: 8px; } +.fdl-editor-content .fdl-legacy-anchor { display: block; height: 0; } +.fdl-markdown-editor .fdl-editor-content table { margin: 44px 0 24px; } +.fdl-table-controls { position: absolute; inset: 0; pointer-events: none; min-height: 0 !important; z-index: 10; } +.fdl-table-frame { position: absolute; } +.fdl-table-frame button { pointer-events: auto; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; box-sizing: border-box; font: 12px/1 sans-serif; color: var(--muted-foreground); border-radius: 4px; } +.fdl-table-frame button:hover, .fdl-table-frame button:focus-visible { color: var(--primary); background: var(--muted); outline: 1px solid var(--primary); } +.fdl-table-append { position: absolute; background: var(--muted); border: 0; } +.fdl-table-append.column { left: calc(100% + 3px); top: 0; width: 16px; height: 100%; } +.fdl-table-append.row { top: calc(100% + 3px); left: 0; height: 16px; width: 100%; } +.fdl-table-insert { position: absolute; border: 0; background: transparent; padding: 0; opacity: 0; } +.fdl-table-insert:hover, .fdl-table-insert:focus-visible { opacity: 1; z-index: 2; } +.fdl-table-insert.row { left: -19px; width: 18px; height: 18px; transform: translateY(-50%); } +.fdl-table-insert.column { top: -19px; width: 18px; height: 18px; transform: translateX(-50%); } +.fdl-table-insert.row:hover::after, .fdl-table-insert.row:focus-visible::after { content: ''; position: absolute; top: 50%; left: 19px; width: var(--table-width); border-top: 2px solid var(--primary); pointer-events: none; } +.fdl-table-insert.column:hover::after, .fdl-table-insert.column:focus-visible::after { content: ''; position: absolute; left: 50%; top: 19px; height: var(--table-height); border-left: 2px solid var(--primary); pointer-events: none; } +.fdl-table-handle { position: absolute; border: 0; background: var(--muted); padding: 0; } +.fdl-table-handle.row { left: -17px; width: 14px; height: 16px; transform: translateY(-50%); } +.fdl-table-handle.column { top: -17px; height: 14px; width: 20px; transform: translateX(-50%); } +.fdl-table-handle.corner { left: -18px; top: -40px; width: 18px; height: 18px; } +.fdl-markdown-editor .fdl-editor-content p.is-editor-empty:first-child::before { color: #999; font: 14px/20px source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } +.fdl-markdown-editor .fdl-editor-content:has(> p.is-editor-empty:only-child) { padding-top: 14px; padding-left: 16px; } +.fdl-markdown-editor .fdl-editor-content > p.is-editor-empty:only-child { margin: 0; line-height: 20px; } + +.fdl-markdown-editor .fdl-editor-content code.fdl-pending-code { + display: inline-block; + box-sizing: border-box; + width: 1em; + min-width: 1em; + height: 1em; + margin: 0; + padding: 0; + vertical-align: -.1em; + border-radius: 3px; + background: color-mix(in srgb, var(--accent) 14%, var(--background)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent); + user-select: none; +} + +.fdl-table-tooltip { position: absolute; display: none; pointer-events: none; white-space: nowrap; padding: 4px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--popover); color: var(--foreground); font-size: 12px; line-height: 20px; z-index: 4; left: 22px; top: 0; } +.fdl-table-frame button:is(:hover, :focus-visible) > .fdl-table-tooltip { display: block; } +.fdl-table-frame button.column > .fdl-table-tooltip { left: 0; right: auto; top: 22px; } +.fdl-table-append.row > .fdl-table-tooltip { left: 50%; top: -30px; transform: translateX(-50%); } +.fdl-table-append.column > .fdl-table-tooltip { left: auto !important; right: 20px !important; top: 50% !important; } +.fdl-table-append.row:is(:hover,:focus-visible)::after { content: ''; position: absolute; top: -3px; left: 0; right: 0; border-top: 2px solid var(--primary); } +.fdl-table-append.column:is(:hover,:focus-visible)::after { content: ''; position: absolute; left: -3px; top: 0; bottom: 0; border-left: 2px solid var(--primary); } + +.fdl-editor-content p.is-editor-empty:first-child:has(.fdl-pending-code)::before { content: none; } + +/* Same toolbar layout in both modes; persistent format state belongs to Tiptap. */ +.fdl-structured-shell .md-editor-toolbar-item.fdl-format-active { + color: var(--foreground, var(--md-color)); + background: var(--fdl-toolbar-selected); + box-shadow: inset 0 0 0 1px var(--border, var(--md-border-color)); + border-radius: 3px; +} + +/* A quiet writing frame shared by visual and source editing. */ +.fdl-markdown-editor.md-editor { + /* App accent/primary tokens are saturated brand colors, including in dark mode. */ + --fdl-toolbar-gap: 2px; + --fdl-toolbar-hover: color-mix(in srgb, var(--foreground) 6%, var(--md-bk-color)); + --fdl-toolbar-selected: color-mix(in srgb, var(--foreground) 10%, var(--md-bk-color)); + --md-color: var(--foreground); + --md-bk-color: #fff; + --md-border-color: var(--border); + --md-bk-color-outstand: var(--muted); + --md-bk-color-hover: var(--fdl-toolbar-hover); + border-radius: 8px; +} +.fdl-markdown-editor .md-editor-toolbar-wrapper { + position: relative; + z-index: 30; + padding: 6px; + overflow: visible; +} +.fdl-markdown-editor .md-editor-toolbar { + flex-wrap: nowrap; + gap: 8px; +} +.fdl-markdown-editor .md-editor-toolbar-left { + flex-wrap: nowrap; + flex-shrink: 0; + width: max-content; + min-width: 0; + gap: var(--fdl-toolbar-gap); + padding: 0; +} +.fdl-markdown-editor .md-editor-toolbar-right { + flex-shrink: 0; + white-space: nowrap; + margin-inline-start: auto; + padding: 0; + gap: 4px; +} +.fdl-markdown-editor .md-editor-toolbar-item { + flex-shrink: 0; + justify-content: center; + min-width: 28px; + min-height: 28px; + margin: 0; + border-radius: 4px; + transition: background-color .15s, color .15s; +} +.fdl-markdown-editor .md-editor-toolbar-item:focus-visible { + outline: 2px solid var(--ring, var(--md-color)); + outline-offset: 1px; +} +.fdl-markdown-editor .fdl-editor-modes { + display: inline-flex; + flex-shrink: 0; + min-width: max-content; + white-space: nowrap; + user-select: none; + gap: 2px; + padding: 2px; + border-radius: 6px; + background: var(--muted, var(--md-bk-color-outstand)); +} +.fdl-markdown-editor .fdl-editor-mode { + min-width: 0; + height: 26px; + padding: 0 8px; + border: 0; + border-radius: 4px; + line-height: 1.4; + font-family: inherit; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + color: var(--muted-foreground, var(--md-color)); +} +.fdl-markdown-editor .fdl-editor-mode:hover { + color: var(--foreground, var(--md-color)); + background: var(--fdl-toolbar-hover); +} +.fdl-markdown-editor .fdl-editor-mode[data-state='on'] { + color: var(--foreground, var(--md-color)); + background: var(--md-bk-color); + box-shadow: 0 1px 2px rgb(0 0 0 / .08), inset 0 0 0 1px var(--border, var(--md-border-color)); +} +.fdl-markdown-editor .md-editor-footer { border-radius: 0 0 8px 8px; } +.fdl-markdown-editor .fdl-editor-mode:disabled { opacity: .5; cursor: not-allowed; } +.fdl-markdown-editor .fdl-editor-mode:focus-visible { + outline: 2px solid var(--ring, var(--md-color)); + outline-offset: 1px; +} + +/* Override native active and hover styling without picking up branded accent colors. */ +.fdl-markdown-editor .md-editor-toolbar-active, +.fdl-markdown-editor .md-editor-toolbar-item[aria-pressed='true'] { + color: var(--foreground); + background: var(--fdl-toolbar-selected); +} +.fdl-markdown-editor .md-editor-toolbar-item:not([disabled]):hover { + color: var(--foreground); + background: var(--fdl-toolbar-hover); +} +.fdl-markdown-editor .md-editor-toolbar-item[aria-pressed='true']:hover { + background: var(--fdl-toolbar-selected); +} + +.fdl-editor-content .fdl-code-language { + min-width: 72px; + max-width: 160px; + height: 26px; + padding: 0 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} +.fdl-editor-content .fdl-code-language:focus-visible { outline: 2px solid var(--ring); } +.fdl-editor-content .fdl-code-language:disabled { cursor: default; opacity: .6; } + +.fdl-markdown-editor .fdl-toolbar-overflow-trigger { + display: none; + flex: 0 0 28px; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--foreground); + cursor: pointer; + user-select: none; +} +.fdl-markdown-editor[data-toolbar-collapsed] .fdl-toolbar-overflow-trigger { display: inline-flex; } +.fdl-markdown-editor .fdl-toolbar-overflow-trigger:hover, +.fdl-markdown-editor .fdl-toolbar-overflow-trigger[aria-expanded='true'] { background: var(--fdl-toolbar-hover); } +.fdl-markdown-editor .fdl-toolbar-overflow-trigger:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; } +.fdl-markdown-editor[data-toolbar-collapsed] .md-editor-toolbar-left { + position: absolute; + inset-block-start: calc(100% + 4px); + inset-inline-start: 6px; + z-index: 30; + flex-wrap: wrap; + max-width: min(320px, calc(100% - 12px)); + box-sizing: border-box; + padding: 6px; + gap: 4px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--popover, var(--background)); + box-shadow: 0 4px 16px rgb(0 0 0 / .12); + visibility: hidden; + opacity: 0; + pointer-events: none; +} +.fdl-markdown-editor[data-toolbar-overflow-open] .md-editor-toolbar-left { + visibility: visible; + opacity: 1; + pointer-events: auto; +} + +.fdl-markdown-editor[data-toolbar-collapsed]:not([data-toolbar-overflow-open]) .md-editor-toolbar-left * { visibility: hidden !important; } + + +/* Preserve the former editor surface colors and let caller sizing rules win. */ +.fdl-markdown-editor.md-editor.md-editor-dark { --md-bk-color: #000; } + +/* Match the former editor's quiet scrollbars in visual, source and preview. */ +.fdl-markdown-editor { --fdl-scrollbar-thumb: #999; --fdl-scrollbar-thumb-hover: #777; color-scheme: light; } +.fdl-markdown-editor.md-editor-dark { --fdl-scrollbar-thumb: #333; --fdl-scrollbar-thumb-hover: #444; color-scheme: dark; } +.fdl-markdown-editor :is(.fdl-rich-scroll, .cm-scroller, .fdl-preview-pane, pre) { + scrollbar-width: thin; + scrollbar-color: var(--fdl-scrollbar-thumb) transparent; +} +@supports selector(::-webkit-scrollbar) { + /* Chromium's standard scrollbar properties override its custom pseudo-elements. */ + .fdl-markdown-editor :is(.fdl-rich-scroll, .cm-scroller, .fdl-preview-pane, pre) { scrollbar-width: auto; scrollbar-color: auto; } + .fdl-markdown-editor :is(.fdl-rich-scroll, .cm-scroller, .fdl-preview-pane, pre)::-webkit-scrollbar { width: 8px; height: 8px; background: transparent; } + .fdl-markdown-editor :is(.fdl-rich-scroll, .cm-scroller, .fdl-preview-pane, pre)::-webkit-scrollbar-track, + .fdl-markdown-editor :is(.fdl-rich-scroll, .cm-scroller, .fdl-preview-pane, pre)::-webkit-scrollbar-corner { background: transparent; } + .fdl-markdown-editor :is(.fdl-rich-scroll, .cm-scroller, .fdl-preview-pane, pre)::-webkit-scrollbar-thumb { border: 1px solid transparent; border-radius: 5px; background: var(--fdl-scrollbar-thumb); background-clip: padding-box; } + .fdl-markdown-editor :is(.fdl-rich-scroll, .cm-scroller, .fdl-preview-pane, pre)::-webkit-scrollbar-thumb:hover { background-color: var(--fdl-scrollbar-thumb-hover); } + .fdl-markdown-editor :is(.fdl-rich-scroll, .cm-scroller, .fdl-preview-pane, pre)::-webkit-scrollbar-button { display: none; width: 0; height: 0; } +} + +.fdl-heading-level { width: 16px; height: 16px; font: 600 11px/16px var(--font-body, system-ui, sans-serif); text-align: center; } +.fdl-tool-menu [role="menuitemradio"]:is(:hover, :focus, [data-highlighted], [data-state="checked"]) { background: var(--muted); color: var(--foreground); } +.fdl-link-controls { z-index: 60; display: flex; align-items: center; gap: 2px; max-width: calc(100vw - 16px); padding: 4px; border: 1px solid var(--border); border-radius: 6px; background: var(--popover, var(--background)); color: var(--foreground); box-shadow: 0 3px 12px #0002; } +.fdl-link-controls [data-slot="button"] { height: 28px; min-width: 28px; color: inherit; border-radius: 4px; } +.fdl-link-controls [data-slot="button"]:hover { background: var(--muted); color: var(--foreground); } +.fdl-link-destination { min-width: 0; max-width: min(280px, calc(100vw - 100px)); gap: 6px; font-size: 12px; } +.fdl-link-destination span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } diff --git a/app/assets/css/md-editor-preview.css b/app/assets/css/md-editor-preview.css index 7ed05d0e..1a51c450 100644 --- a/app/assets/css/md-editor-preview.css +++ b/app/assets/css/md-editor-preview.css @@ -1,4 +1,4 @@ -/* Shared MdEditor preview styles — matches PostContent rendering */ +/* Shared Markdown preview styles — matches PostContent rendering */ /* Imported via + diff --git a/app/components/MarkdownEditorShell.vue b/app/components/MarkdownEditorShell.vue new file mode 100644 index 00000000..db3dc00d --- /dev/null +++ b/app/components/MarkdownEditorShell.vue @@ -0,0 +1,279 @@ + + diff --git a/app/components/MarkdownImageCrop.vue b/app/components/MarkdownImageCrop.vue new file mode 100644 index 00000000..12706b87 --- /dev/null +++ b/app/components/MarkdownImageCrop.vue @@ -0,0 +1,84 @@ + + diff --git a/app/components/MarkdownLinkControls.vue b/app/components/MarkdownLinkControls.vue new file mode 100644 index 00000000..e5eaea3d --- /dev/null +++ b/app/components/MarkdownLinkControls.vue @@ -0,0 +1,75 @@ + + diff --git a/app/components/MarkdownTableControls.vue b/app/components/MarkdownTableControls.vue new file mode 100644 index 00000000..9651588d --- /dev/null +++ b/app/components/MarkdownTableControls.vue @@ -0,0 +1,92 @@ + + diff --git a/app/components/PortalWelcomeBlock.vue b/app/components/PortalWelcomeBlock.vue index 66b178a3..74080df3 100644 --- a/app/components/PortalWelcomeBlock.vue +++ b/app/components/PortalWelcomeBlock.vue @@ -1,5 +1,4 @@ @@ -37,7 +35,7 @@ const hasContent = computed(() => props.title.trim().length > 0 || props.descrip diff --git a/app/components/ThemedMdEditor.vue b/app/components/ThemedMdEditor.vue index 212f573c..b24955ee 100644 --- a/app/components/ThemedMdEditor.vue +++ b/app/components/ThemedMdEditor.vue @@ -1,21 +1,37 @@ diff --git a/app/components/ThemedMdPreview.vue b/app/components/ThemedMdPreview.vue index 1a6a4f1d..cd3d4df1 100644 --- a/app/components/ThemedMdPreview.vue +++ b/app/components/ThemedMdPreview.vue @@ -1,14 +1,73 @@ - diff --git a/app/components/TiptapMarkdownEditor.client.vue b/app/components/TiptapMarkdownEditor.client.vue new file mode 100644 index 00000000..04e9ce0b --- /dev/null +++ b/app/components/TiptapMarkdownEditor.client.vue @@ -0,0 +1,419 @@ + + diff --git a/app/components/WidgetEmbed/WidgetEmbedMarkdown.vue b/app/components/WidgetEmbed/WidgetEmbedMarkdown.vue index 17eb16a8..5bf0ecbc 100644 --- a/app/components/WidgetEmbed/WidgetEmbedMarkdown.vue +++ b/app/components/WidgetEmbed/WidgetEmbedMarkdown.vue @@ -6,9 +6,13 @@ function sanitize(html: string) { const fragment = DOMPurify.sanitize(html, { USE_PROFILES: { html: true }, ADD_ATTR: ['target'], - FORBID_TAGS: ['style', 'form', 'input', 'button'], + FORBID_TAGS: ['style', 'form', 'input'], RETURN_DOM_FRAGMENT: true, }) + fragment.querySelectorAll('button').forEach(button => { + if (!button.matches('.fdl-code-copy, .fdl-code-collapse') || !button.closest('.md-editor-code-head')) button.remove() + else button.type = 'button' + }) // Links must not replace the iframe and leave the customer outside the chat. fragment.querySelectorAll('a[href]').forEach(link => { link.setAttribute('target', '_blank') diff --git a/app/components/changelog/ChangelogEditor.vue b/app/components/changelog/ChangelogEditor.vue index cfa3da47..4dceb2c5 100644 --- a/app/components/changelog/ChangelogEditor.vue +++ b/app/components/changelog/ChangelogEditor.vue @@ -1,7 +1,6 @@ + + diff --git a/tests/fixtures/markdown-editor/index.html b/tests/fixtures/markdown-editor/index.html new file mode 100644 index 00000000..c9e6ab0e --- /dev/null +++ b/tests/fixtures/markdown-editor/index.html @@ -0,0 +1 @@ +
diff --git a/tests/fixtures/markdown-editor/main.ts b/tests/fixtures/markdown-editor/main.ts new file mode 100644 index 00000000..67bc7f91 --- /dev/null +++ b/tests/fixtures/markdown-editor/main.ts @@ -0,0 +1,18 @@ +import '../../../app/assets/css/tailwind.css' +import * as Vue from 'vue' +import ThemedMdPreview from '../../../app/components/ThemedMdPreview.vue' +import { renderMarkdown } from '../../../app/lib/markdown-render' +import Fixture from './EditorFixture.vue' +import messages from '../../../i18n/locales/en.json' +import chineseMessages from '../../../i18n/locales/zh.json' +const theme = Vue.ref('light') +// Nuxt auto-imports these in production. The isolated fixture supplies the same +// Vue APIs without starting auth/database/AI services. +Object.assign(globalThis, { ...Vue, useEditorTheme: () => theme, useI18n: () => ({ t: (key: string, _params?: unknown, options?: { locale?: string }) => (options?.locale === 'zh' ? chineseMessages : messages).markdownEditor[key.split('.')[1] as keyof typeof messages.markdownEditor] }) }) +Object.assign(window, { renderMarkdown }) +const app = Vue.createApp(Fixture) +app.component('ClientOnly', { template: '' }) +app.component('Icon', { props: ['name'], template: '◇' }) +app.component('ThemedMdPreview', ThemedMdPreview) +app.mount('#app') +Object.assign(window, { theme }) diff --git a/tests/markdown-editor.test.ts b/tests/markdown-editor.test.ts new file mode 100644 index 00000000..5f834fd4 --- /dev/null +++ b/tests/markdown-editor.test.ts @@ -0,0 +1,249 @@ +import { useUploadImg } from '../app/composables/useUploadImg' +import { prepareLegacyAttachments } from '../app/utils/attachment' +import { renderMarkdown } from '../app/lib/markdown-render' +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { getSchema, type Editor } from '@tiptap/core' +import { EditorState, TextSelection } from '@tiptap/pm/state' +import { mapTextOffset, projectMarkdownDraft, markdownSelection, restoreMarkdownSelection } from '../app/lib/markdown-selection' +import { createMarkdownManager, loadMarkdown, markdownExtensions, parseMarkdownDocument, requiresSourcePreservation, serializeMarkdown, withinMarkdownLimit } from '../app/lib/markdown-editor' + +const manager = createMarkdownManager() +const fixtures = [ + '', 'Plain text', '中文输入 😀\n第二行', '## Heading\n\n**bold** *italic* ~~deleted~~ `code`', + '- first\n- second\n - nested', '1. first\n2. second', '- [ ] todo\n- [x] done', + '> quote\n>\n> - nested', '---', '| A | B |\n| --- | --- |\n| one | two |', + '[文档](https://example.com/中文)', '参考这个页面 https://example.com。后面还有话', + '```html\n\n```', '![alt](attachment:uploads/a.png)', + '![截屏](attachment:uploads/截屏 (1) #test?.png)', 'before ![alt](attachment:uploads/a.png) after', + '```md\n![alt](attachment:uploads/a b.png)\n```', 'Prices are $20 and $30.', +] +for (const source of fixtures) { + test(`preserves original bytes on untouched load: ${source.slice(0, 45)}`, () => { + const loaded = loadMarkdown(source, manager) + assert.equal(serializeMarkdown(loaded.json, loaded, manager), source) + getSchema(markdownExtensions()).nodeFromJSON(loaded.json).check() + }) +} +for (const source of ['
old HTML
', '', 'Text[^1]\n\n[^1]: footnote', '[ref]: https://example.com\n\n[ref]', '$$x^2$$', '::: unknown\nmessage\n:::']) { + test(`preserves unsupported historical source: ${source.slice(0, 40)}`, () => { + assert.equal(loadMarkdown(source, manager).sourceOnly, true) + }) +} +test('supports ordinary structural documents without source fallback', () => { + for (const source of fixtures.filter(value => !value.includes('Prices'))) { + assert.equal(loadMarkdown(source, manager).sourceOnly, false, source) + } +}) +test('keeps attachment storage keys through edits and resolves only for display', () => { + const original = '![alt](attachment:uploads/截屏 (1) #?.png)' + const loaded = loadMarkdown(original, manager) + loaded.json.content!.push({ type: 'paragraph', content: [{ type: 'text', text: 'added' }] }) + const saved = manager.serialize(loaded.json) + assert.match(saved, /attachment:uploads\/截屏 \(1\) #\?\.png/) + assert.doesNotMatch(saved, /api\/files/) + assert.match(renderMarkdown(saved), /\/api\/files\/uploads\/%E6/) + assert.match(renderMarkdown(saved), /%281%29%20%23%3F.png/) + assert.deepEqual(parseMarkdownDocument(saved, manager), loaded.json) +}) +test('does not alter attachment examples inside code', () => { + for (const source of ['`![x](attachment:a b.png)`', '```md\n![x](attachment:a b.png)\n```', '```md\n![x](attachment:a b.png)', '````md\n```\n![x](attachment:a b.png)\n````', '`example\n![x](attachment:a b.png)`', ' ![x](attachment:a b.png)']) { + assert.equal(prepareLegacyAttachments(source), source) + assert.equal(requiresSourcePreservation(source), false) + } +}) +test('does not turn Chinese prose into a link or break explicit international URLs', () => { + const bare = parseMarkdownDocument('参考 https://example.com。后面还有话', manager) + assert.doesNotMatch(JSON.stringify(bare), /"type":"link"/) + const explicit = parseMarkdownDocument('[文档](https://example.com/中文)', manager) + assert.match(JSON.stringify(explicit), /"href":"https:\/\/example.com\/中文"/) +}) +test('length constraints count stored Markdown and allow shrinking existing over-limit text', () => { + assert.equal(withinMarkdownLimit('**hello**', 'hello', 5), false) + assert.equal(withinMarkdownLimit('123456', '1234567', 5), true) + assert.equal(withinMarkdownLimit('12345678', '1234567', 5), false) +}) + +function editedRoundtrip(source: string) { + const loaded = loadMarkdown(source, manager) + assert.equal(loaded.sourceOnly, false, source) + const edited = structuredClone(loaded.json) + edited.content!.push({ type: 'paragraph', content: [{ type: 'text', text: 'Added paragraph' }] }) + const saved = manager.serialize(edited) + assert.equal(JSON.stringify(parseMarkdownDocument(saved, manager)), JSON.stringify(edited)) + return { loaded, saved } +} + +test('combined code marks and differently ordered link marks retain their semantics', () => { + for (const source of ['**`parameter`**', '[`parameter`](https://example.com)', '[**link**](https://example.com)', '**[link](https://example.com)**']) { + const { loaded } = editedRoundtrip(source) + assert.equal(loaded.json.content![0]!.content![0]!.marks!.length, 2) + } +}) +test('code fences grow beyond nested fences and preserve literal code on edits', () => { + const source = '````text\nExample:\n```ts\nconst x = 1\n```\n````' + const { loaded, saved } = editedRoundtrip(source) + assert.equal(loaded.json.content![0]!.type, 'codeBlock') + assert.equal(loaded.json.content![0]!.content![0]!.text, 'Example:\n```ts\nconst x = 1\n```') + assert.match(saved, /^````text/) +}) +test('legacy directives are editable containers with editable titles and bodies', () => { + for (const kind of ['tip', 'warning', 'info', 'danger', 'details', 'code-group']) { + const { loaded, saved } = editedRoundtrip(`::: ${kind} Title\nBody **bold**\n:::`) + assert.equal(loaded.json.content![0]!.type, 'legacyContainer') + assert.equal(loaded.json.content![0]!.content![0]!.type, 'legacyContainerTitle') + assert.match(saved, new RegExp(`^::: ${kind} Title`)) + } +}) +test('nested containers and code examples containing closing directives remain intact', () => { + editedRoundtrip('::: details Outer\n::: tip Inner\ntext\n:::\n```text\n:::\n```\n:::') +}) +test('HTML figures preserve image dimensions, captions, anchors and literal punctuation', () => { + const source = '
\nA & B\n
Literal *text* and bold
\n
\n\n' + const { loaded, saved } = editedRoundtrip(source) + assert.equal(loaded.json.content![0]!.type, 'legacyFigure') + assert.equal(loaded.json.content![0]!.content![0]!.attrs!.width, '496') + assert.equal(loaded.json.content![0]!.content![1]!.content![0]!.text, 'Literal *text* and ') + assert.equal(loaded.json.content![1]!.type, 'legacyAnchor') + assert.match(saved, /width="496" height="776"/) + assert.match(saved, /bold<\/strong>/) + assert.match(saved, /<\/span>/) +}) +test('standalone and inline HTML images remain actual images, not escaped text', () => { + for (const source of ['', 'before after']) { + const { loaded, saved } = editedRoundtrip(source) + assert.ok(loaded.json.content![0]!.content!.some(node => node.type === 'image')) + assert.match(saved, //) + } +}) +test('unimplemented HTML attributes or caption elements still preserve source', () => { + for (const source of ['
Text
', '
Text
']) { + assert.equal(loadMarkdown(source, manager).sourceOnly, true) + } +}) + +test('unfinished feedback templates retain empty numbered list items', () => { + for (const source of ['### Steps\n1. \n2. \n3. \n\n### Environment\n- Browser:']) { + const { loaded } = editedRoundtrip(source) + const lists = loaded.json.content!.filter(node => ['orderedList', 'bulletList', 'taskList'].includes(node.type!)) + assert.ok(lists.length) + for (const list of lists) for (const item of list.content!) assert.equal(item.content![0]!.type, 'paragraph') + } +}) + +test('ordinary prose mentioning directive punctuation is not a directive block', () => { + editedRoundtrip('## Help\n\n正文中提到 ::: 语法,不应触发源码回退。') + assert.equal(loadMarkdown('::: unknown\nbody\n:::', manager).sourceOnly, true) +}) + + +test('selection mapping preserves original Markdown spelling and UTF-16 offsets', () => { + const canonical = '| A | B |\n| --- | --- |\n| 😀 alpha beta | end |' + const original = '| A | B |\n| ----- | --- |\n| 😀 alpha beta | end |\n' + assert.equal(mapTextOffset(canonical, original, canonical.indexOf('beta')), original.indexOf('beta')) + assert.equal(mapTextOffset(original, canonical, original.indexOf('beta')), canonical.indexOf('beta')) + assert.equal(projectMarkdownDraft('before after', 'before ****after', 'before after\n'), 'before ****after\n') +}) + +test('source caret retains the distinction between inside and outside inline code', () => { + const schema = getSchema(markdownExtensions()) + const source = '`test`' + let state = EditorState.create({ schema, doc: schema.nodeFromJSON(parseMarkdownDocument(source, manager)) }) + const editor = { + get state() { return state }, schema, + getJSON() { return state.doc.toJSON() }, + view: { dispatch(transaction: Parameters[0]) { state = state.apply(transaction) } }, + } as unknown as Editor + restoreMarkdownSelection(editor, manager, source, source.length - 1) + assert.equal(state.selection.from, 5) + assert.deepEqual(state.storedMarks?.map(mark => mark.type.name), ['code']) + restoreMarkdownSelection(editor, manager, source, source.length) + assert.equal(state.selection.from, 5) + assert.deepEqual(state.storedMarks, []) + const mapped = markdownSelection(editor, manager) + assert.equal(mapped.from, source.length, 'explicit plain input state maps outside the code fence') + state = state.apply(state.tr.setSelection(TextSelection.create(state.doc, 3))) + assert.equal(markdownSelection(editor, manager).from, 3) +}) + +test('inline code saves Markdown delimiters rather than escaped literal backticks', () => { + for (const text of ['test', 'a`b', '`test`', "'test'", 'a ** b']) { + const json = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text, marks: [{ type: 'code' }] }] }] } + const saved = manager.serialize(json) + assert.equal(saved.startsWith('\\`'), false) + assert.deepEqual(parseMarkdownDocument(saved, manager), json) + } +}) + +// Public rendering must remain safe and deterministic without browser globals. +test('public preview renders Markdown, task lists, legacy HTML and containers on the server', () => { + const html = renderMarkdown('## Title\n\n- [x] done\n\n::: tip **Note**\nBody\n:::\n\n
Caption
') + assert.match(html, /

Title<\/h2>/) + assert.match(html, /type="checkbox" disabled checked/) + assert.match(html, /data-md-container="tip"/) + assert.match(html, /Note<\/strong>/) + assert.match(html, /src="\/api\/files\/uploads\/image.png" width="320"/) + assert.match(html, /
Caption<\/figcaption>/) +}) +test('public preview preserves the heading-id callback contract and indexes', () => { + const seen: unknown[] = [] + const html = renderMarkdown('# A\n\n## B', heading => { seen.push(heading); return `custom-${heading.index}` }) + assert.deepEqual(seen, [{ text: 'A', level: 1, index: 0 }, { text: 'B', level: 2, index: 1 }]) + assert.match(html, /id="custom-1"/) +}) +test('public preview removes executable HTML, unsafe URLs and caller-injected HTML', () => { + const html = renderMarkdown('', undefined, value => value + 'unsafe') + assert.doesNotMatch(html, /script|iframe|javascript:|onerror|onfocus|autofocus/) + assert.match(html, / { + const html = renderMarkdown('```html\n\n\n```') + assert.match(html, /<script>example\(\)<\/script>/) + assert.match(html, /attachment:uploads\/a.png/) +}) +test('historical superscript and subscript still render in public previews', () => { + assert.match(renderMarkdown('H~2~O and x^2^'), /H2<\/sub>O and x2<\/sup>/) +}) +test('server-rendered code retains frame, language, line numbers and nested fence text', () => { + const html = renderMarkdown('````text\n嵌套代码示例:\n```js\nconsole.log("hello")\n```\n````') + assert.match(html, /class="md-editor-code fdl-code-view"/) + assert.match(html, /class="md-editor-code-head"/) + assert.match(html, /class="fdl-code-language-label">Plain text<\/span>/) + assert.match(html, /class="fdl-code-lines" aria-hidden="true">1\n2\n3\n4<\/div>/) + assert.match(html, /console.log\("hello"\)/) + assert.match(html, /