Skip to content

Commit 8067ac6

Browse files
committed
refactor(rich-editor): fold inline comments into TSDoc; display-only chip; polish
- Convert the editor's inline `//` comments to TSDoc on the nearest declaration and drop the self-explanatory ones (no logic change) - Mention chip is now display-only (icon + label), matching the chat input exactly: removes select-none (so a range selection highlights the label), the cursor-pointer over-promise, and the cmd-click nav that could route away from a modal mid-edit - Don't log a deliberate generate-abort as an error - Selected strike-through text reads in the primary color so the selection is uniform
1 parent 5c1b1dc commit 8067ac6

15 files changed

Lines changed: 106 additions & 100 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ function selectAdjacentLeaf(editor: Editor, direction: 'up' | 'down'): boolean {
3939
/**
4040
* Editor-specific keyboard behavior layered on top of StarterKit's defaults:
4141
*
42-
* - **Backspace** at the start of a heading reverts it to a paragraph; at the start of a block whose
43-
* previous sibling is a horizontal rule it deletes the rule (ProseMirror's default `joinBackward`
44-
* can't cross a leaf node, so without this pressing Backspace below a divider is a confusing no-op).
42+
* - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or
43+
* no-ops, stranding the heading style; a second Backspace then merges as usual); at the start of a
44+
* block whose previous sibling is a horizontal rule it deletes the rule (ProseMirror's default
45+
* `joinBackward` can't cross a leaf node, so without this pressing Backspace below a divider is a
46+
* confusing no-op).
4547
* - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the
4648
* block is already fully selected) falls through to the default whole-document select-all, the
4749
* same scoped behavior as a code editor.
@@ -56,8 +58,6 @@ export const RichMarkdownKeymap = Extension.create({
5658
Backspace: ({ editor }) => {
5759
const { selection, doc } = editor.state
5860
if (!selection.empty || selection.$from.parentOffset !== 0) return false
59-
// At the start of a heading, revert it to a paragraph (ProseMirror's default joins or
60-
// no-ops, stranding the heading style); a second Backspace then merges as usual.
6161
if (selection.$from.parent.type.name === 'heading') {
6262
return editor.commands.setParagraph()
6363
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ function isYamlFrontmatterBlock(block: string): boolean {
3939
for (const rawLine of interior.split('\n')) {
4040
const line = rawLine.trim()
4141
if (line === '') continue
42-
if (line.startsWith('---')) return true // reached the closing fence — empty frontmatter
43-
return /^[A-Za-z0-9_-]+[ \t]*:/.test(line) // first content line must be a YAML key
42+
if (line.startsWith('---')) return true
43+
return /^[A-Za-z0-9_-]+[ \t]*:/.test(line)
4444
}
4545
return true
4646
}
@@ -58,24 +58,23 @@ const HOST_PORT = /^[a-z0-9.-]+:\d+(?:[/?#]|$)/i
5858

5959
/**
6060
* Normalize a user-entered link target: prefix a bare domain with `https://` so it doesn't resolve
61-
* as an in-app relative URL, while leaving already-qualified, relative, and protocol-relative URLs
62-
* intact. Dangerous schemes are rejected outright rather than trusted or mangled: any `scheme:`
63-
* without `//` other than `mailto:`/`tel:` (so `javascript:`, `data:`, `vbscript:`, `blob:`, …), and
64-
* `file://` (local file access). Other network `scheme://` URLs (`http(s)`, `ftp`, …) pass through.
61+
* as an in-app relative URL, while leaving already-qualified, relative (`./other.md`, `../doc.md`), and
62+
* protocol-relative URLs intact. Dangerous schemes are rejected outright rather than trusted or mangled:
63+
* any `scheme:` without `//` other than `mailto:`/`tel:` (so `javascript:`, `data:`, `vbscript:`,
64+
* `blob:`, …), and `file://` (local file access). Other network `scheme://` URLs (`http(s)`, `ftp`, …)
65+
* pass through. A bare `host:port` (digits after the colon) is a domain, not a scheme, so it still gets
66+
* the `https://` prefix.
6567
*/
6668
export function normalizeLinkHref(href: string): string {
6769
const trimmed = href.trim()
6870
if (!trimmed) return ''
6971
if (/^[#?]/.test(trimmed)) return trimmed
7072
if (trimmed.startsWith('//')) return `https:${trimmed}`
7173
if (trimmed.startsWith('/')) return trimmed
72-
// Relative paths (`./other.md`, `../doc.md`) stay relative — never prefixed into `https://./…`.
7374
if (trimmed.startsWith('./') || trimmed.startsWith('../')) return trimmed
7475
if (/^(?:mailto|tel):/i.test(trimmed)) return trimmed
7576
const schemed = trimmed.match(SCHEME_URL)
7677
if (schemed) return /^file$/i.test(schemed[1]) ? '' : trimmed
77-
// A `scheme:` without `//` (and not mailto/tel) is a script/data scheme — reject it. A bare
78-
// host:port (digits after the colon) is a domain, not a scheme, so it falls through to https.
7978
if (HAS_SCHEME.test(trimmed) && !HOST_PORT.test(trimmed)) return ''
8079
return `https://${trimmed}`
8180
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,13 @@ export const MentionList = forwardRef<MentionListHandle, MentionListProps>(funct
5050
const rawItems = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
5151
const containerRef = useRef<HTMLDivElement>(null)
5252

53-
/** Filtered, group-capped, flattened in category order; `index` is the flat position for nav. */
53+
/**
54+
* Filtered, group-capped, flattened in category order; `index` is the flat position for nav. A single
55+
* pass over the full set filters by label and buckets by group (capped), then reads the buckets in
56+
* category order — avoiding a separate filter pass per group.
57+
*/
5458
const { flat, groups } = useMemo(() => {
5559
const q = query.trim().toLowerCase()
56-
// One pass over the full set: filter by label and bucket by group (capped), then read the
57-
// buckets in category order — avoids a separate filter pass per group.
5860
const byGroup = new Map<string, MentionItem[]>()
5961
for (const item of rawItems) {
6062
if (q && !item.label.toLowerCase().includes(q)) continue

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import type { MouseEvent } from 'react'
21
import type { JSONContent, MarkdownToken } from '@tiptap/core'
32
import { Node } from '@tiptap/core'
43
import type { ReactNodeViewProps } from '@tiptap/react'
54
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
6-
import { useParams, useRouter } from 'next/navigation'
75
import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color'
86
import { mentionIcon } from './mention-icon'
9-
import { simLinkPath, toSimHref } from './sim-link'
7+
import { toSimHref } from './sim-link'
108
import type { MentionKind } from './types'
119

1210
interface MentionAttrs {
@@ -44,7 +42,9 @@ interface MentionTokenFields {
4442
* to the portable `[label](sim:<kind>/<id>)` markdown link — so the saved content is identical to a
4543
* plain link (agent-readable, round-trips through the chat's `chip-clipboard-codec`) while the editor
4644
* shows it as a chip rather than a blue link. Shared by the headless round-trip path (no node view)
47-
* and the live {@link MentionChip}, mirroring the image node's split.
45+
* and the live {@link MentionChip}, mirroring the image node's split. `renderText` emits the same
46+
* portable link (an atom otherwise contributes no text), so copying a chip into a plain-text target —
47+
* e.g. the chat composer — pastes back as a mention.
4848
*/
4949
export const MarkdownMention = Node.create({
5050
name: 'mention',
@@ -102,8 +102,6 @@ export const MarkdownMention = Node.create({
102102
return `[${escapeLabel(label)}](${toSimHref(kind, id)})`
103103
},
104104

105-
// Copy/`getText` emit the portable link (an atom otherwise contributes no text), so copying a chip
106-
// into a plain-text target — e.g. the chat composer — pastes back as a mention.
107105
renderText: ({ node }) => {
108106
const { kind, id, label } = node.attrs as MentionAttrs
109107
return `[${escapeLabel(label)}](${toSimHref(kind, id)})`
@@ -119,27 +117,16 @@ export const MarkdownMention = Node.create({
119117
* monochrome through the `--text-icon` fallback below.
120118
*/
121119
const CHIP_CLASS =
122-
'mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] cursor-pointer select-none [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]'
120+
'mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]'
123121

124-
/** Live chip: the entity icon + label. Cmd/Ctrl-click navigates to the resource. */
122+
/** Live chip: a display-only entity icon + label, matching the chat input's mention rendering. */
125123
function MentionChipView({ node }: ReactNodeViewProps) {
126-
const router = useRouter()
127-
const params = useParams()
128-
const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined
129124
const { kind, id, label } = node.attrs as MentionAttrs
130125
const Icon = mentionIcon(kind, id) as StyleableIcon | undefined
131126
const iconStyle = Icon ? getBareIconStyle(Icon) : undefined
132127

133-
const handleClick = (event: MouseEvent) => {
134-
if (!(event.metaKey || event.ctrlKey) || !workspaceId) return
135-
const path = simLinkPath(workspaceId, kind, id)
136-
if (!path) return
137-
event.preventDefault()
138-
router.push(path)
139-
}
140-
141128
return (
142-
<NodeViewWrapper as='span' className={CHIP_CLASS} onClick={handleClick} title={label}>
129+
<NodeViewWrapper as='span' className={CHIP_CLASS} title={label}>
143130
{Icon && <Icon style={iconStyle} />}
144131
<span>{label}</span>
145132
</NodeViewWrapper>

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,12 @@ declare module '@tiptap/core' {
2828
}
2929

3030
/**
31-
* Adds the `@` mention menu to the editor. Typing `@` at the start of a block — or after whitespace —
32-
* opens {@link MentionList}; selecting an entity inserts it as a portable `sim:<kind>/<id>` markdown
33-
* link (same wire format as the chat composer's `chip-clipboard-codec`), so it round-trips natively
34-
* through the editor's link + markdown machinery. The menu's data is supplied by the host via the
35-
* extension's `mention` storage.
31+
* Adds the `@` mention menu to the editor. Typing `@` at the start of a block — or after whitespace, so
32+
* `@` inside an email/handle (`name@host`) stays literal — opens {@link MentionList}; selecting an
33+
* entity inserts it as a portable `sim:<kind>/<id>` markdown link (same wire format as the chat
34+
* composer's `chip-clipboard-codec`), so it round-trips natively through the editor's link + markdown
35+
* machinery. The plugin's `items` is an empty gate; the real list is sourced reactively from the store
36+
* inside {@link MentionList}, populated by the host via the extension's `mention` storage.
3637
*/
3738
export const Mention = Extension.create<Record<string, never>, MentionStorage>({
3839
name: 'mention',
@@ -56,10 +57,8 @@ export const Mention = Extension.create<Record<string, never>, MentionStorage>({
5657
}
5758
const $from = editor.state.doc.resolve(range.from)
5859
if ($from.parentOffset === 0) return true
59-
// Only after whitespace, so `@` inside an email/handle (`name@host`) never triggers.
6060
return /\s/.test($from.parent.textBetween($from.parentOffset - 1, $from.parentOffset))
6161
},
62-
// Items are sourced reactively from the store inside MentionList; this only gates the plugin.
6362
items: () => [],
6463
command: ({ editor, range, props }) => {
6564
editor

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ interface SuggestionPopupConfig<P, H extends SuggestionListHandle> {
3939
* floating-ui-positioned body element, repositions on update/scroll, forwards keys to the list's
4040
* imperative handle, and tears everything down on exit / Escape / editor-destroy. Shared by the `/`
4141
* slash command and the `@` mention menu so the popup mechanics live in exactly one place.
42+
*
43+
* `onStart` runs after an async suggestion update (it awaits `items()`), so it can fire once the editor
44+
* is already destroyed — e.g. a modal closed while the menu was opening — and bails in that case before
45+
* touching the now-unavailable view/storage. The popup mounts inside the host `[role="dialog"]` when
46+
* the editor is in a modal: Radix's scroll-lock blocks wheel events outside the dialog subtree, so a
47+
* body-level popup couldn't be scrolled; `position: fixed` keeps it viewport-positioned (the modal
48+
* centers via flex, no transform) so it isn't clipped.
4249
*/
4350
export function createSuggestionPopupRenderer<P, H extends SuggestionListHandle>(
4451
config: SuggestionPopupConfig<P, H>
@@ -63,22 +70,15 @@ export function createSuggestionPopupRenderer<P, H extends SuggestionListHandle>
6370
return {
6471
onStart: (props) => {
6572
teardown()
66-
// The suggestion update is async (it awaits `items()`), so onStart can fire after the editor
67-
// was destroyed — e.g. a modal closed while the menu was opening. Bail before touching its
68-
// now-unavailable view/storage.
6973
if (props.editor.isDestroyed) return
7074
config.onOpen?.(props)
7175
component = new ReactRenderer(config.component, {
72-
// ReactRenderer types its props option loosely; the component still enforces P.
7376
props: config.mapProps(props) as Record<string, unknown>,
7477
editor: props.editor,
7578
})
7679
popup = document.createElement('div')
7780
popup.className = 'fixed top-0 left-0 z-[var(--z-popover)]'
7881
popup.appendChild(component.element)
79-
// Mount inside the host dialog when the editor is in a modal: Radix's scroll-lock blocks wheel
80-
// events outside the dialog subtree, so a body-level popup can't be scrolled. `position: fixed`
81-
// keeps it viewport-positioned (the modal centers via flex, no transform) so it isn't clipped.
8282
const host = props.editor.view.dom.closest('[role="dialog"]') ?? document.body
8383
host.appendChild(popup)
8484
boundEditor = props.editor

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ interface SuggestionKeyboard extends SuggestionKeyDownHandler {
2323
* resets it when the items change, scrolls the active row into view, and exposes an `onKeyDown` handle
2424
* for the suggestion plugin. Up/Down wrap; Enter and Tab both accept the active item (Tab matches the
2525
* chat composer). The handle is stable and reads live values through a ref, because the suggestion
26-
* plugin captures it once via `ReactRenderer.ref` while the items may still be loading.
26+
* plugin captures it once via `ReactRenderer.ref` while the items may still be loading. Enter/Tab clamp
27+
* the active index in case a filter shrank the list this frame before the active-index reset committed.
2728
*/
2829
export function useSuggestionKeyboard<T>(
2930
items: T[],
@@ -57,7 +58,6 @@ export function useSuggestionKeyboard<T>(
5758
return true
5859
}
5960
if (event.key === 'Enter' || event.key === 'Tab') {
60-
// Clamp in case a filter shrank the list this frame before the active-index reset committed.
6161
const item = items[Math.min(activeIndex, items.length - 1)]
6262
if (!item) return false
6363
onSelect(item)

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@
102102
text-decoration: line-through;
103103
}
104104

105+
/* Selecting struck-through text reads in the primary color like any other highlighted text, so the
106+
selection looks uniform (it returns to the muted strike color when deselected). */
107+
.rich-markdown-prose del::selection,
108+
.rich-markdown-prose s::selection {
109+
color: var(--text-primary);
110+
}
111+
105112
.rich-markdown-prose a {
106113
color: var(--brand-secondary);
107114
cursor: pointer;

0 commit comments

Comments
 (0)