mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
feat(markdown): support HTML superscript links (#8307)
* feat(markdown): support HTML superscript links * Add HTML superscript citation links to the rich markdown editor - Introduce marked-based tokenizer support for `<sup><a>` citation links, with a shared editor context so key/link handlers can resolve selection status and open an action bubble for citation atoms (which markdown setLink/unsetLink can't edit). - Extract clipboard-write logic shared by cut and cut-visual-line into rich-markdown-clipboard-write.ts, and surface a cut-limit error when clipboard readback fails. - Fix MarkdownPreview same-file anchor scrolling to run before the unknown-ownership guard so ambiguous folder-workspace ownership still scrolls within the open document. - Keep search's replace-disabled state derived from live matches instead of a stale snapshot. * Update lockfile to dedupe stale package versions after dependency resolu - Removes duplicate/superseded resolutions (older @babel/* versions, fs-extra 11.3.4/11.3.5, plist 3.1.0, semver 7.7.4) left over from a prior install - Aligns transitive deps to the single resolved versions already in use elsewhere in the tree
This commit is contained in:
@@ -1026,6 +1026,27 @@
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
.rich-markdown-editor sup[data-rich-markdown-html-superscript-link] {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.rich-markdown-editor .rich-markdown-html-superscript-link {
|
||||
color: #0969da;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.dark .rich-markdown-editor .rich-markdown-html-superscript-link {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
.rich-markdown-mod-held
|
||||
.rich-markdown-editor
|
||||
.rich-markdown-html-superscript-link[data-actionable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rich-markdown-editor img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -7,6 +7,10 @@ import { LoaderCircle } from 'lucide-react'
|
||||
|
||||
import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html'
|
||||
import {
|
||||
createRichMarkdownEditorCodec,
|
||||
type RichMarkdownEditorCodec
|
||||
} from '@/components/editor/rich-markdown-source-transport'
|
||||
import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar'
|
||||
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -26,8 +30,8 @@ type LinearIssueMarkdownDescriptionEditorProps = {
|
||||
submitShortcutLabel: string
|
||||
}
|
||||
|
||||
function createLinearIssueMarkdownExtensions() {
|
||||
const extensions = createRichMarkdownExtensions()
|
||||
function createLinearIssueMarkdownExtensions(codec: RichMarkdownEditorCodec) {
|
||||
const extensions = createRichMarkdownExtensions({ codec })
|
||||
return [
|
||||
...extensions,
|
||||
Placeholder.configure({
|
||||
@@ -54,18 +58,24 @@ export function LinearIssueMarkdownDescriptionEditor({
|
||||
const richMarkdownSpellcheckEnabled = useAppStore(
|
||||
(s) => s.settings?.richMarkdownSpellcheckEnabled ?? true
|
||||
)
|
||||
// Why: changing language recreates Tiptap and re-registers tokenizers, so it
|
||||
// must also receive a fresh private Marked registry instead of growing one.
|
||||
const codec = useMemo(() => {
|
||||
void language
|
||||
return createRichMarkdownEditorCodec()
|
||||
}, [language])
|
||||
const linearIssueMarkdownExtensions = useMemo(() => {
|
||||
// Why: Tiptap freezes extension options when the editor is created; the
|
||||
// language value is the recreation key for translated extension options.
|
||||
void language
|
||||
return createLinearIssueMarkdownExtensions()
|
||||
}, [language])
|
||||
return createLinearIssueMarkdownExtensions(codec)
|
||||
}, [codec, language])
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
immediatelyRender: false,
|
||||
extensions: linearIssueMarkdownExtensions,
|
||||
content: encodeRawMarkdownHtmlForRichEditor(value),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(value, codec),
|
||||
contentType: 'markdown',
|
||||
editable: !disabled,
|
||||
editorProps: {
|
||||
@@ -99,7 +109,7 @@ export function LinearIssueMarkdownDescriptionEditor({
|
||||
onChange(nextValue)
|
||||
}
|
||||
},
|
||||
[language]
|
||||
[codec, language]
|
||||
)
|
||||
useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled)
|
||||
|
||||
@@ -124,12 +134,12 @@ export function LinearIssueMarkdownDescriptionEditor({
|
||||
|
||||
// Why: Linear remains the source of truth when the selected issue changes
|
||||
// or an optimistic save is reverted; keep the rich view aligned with it.
|
||||
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), {
|
||||
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), {
|
||||
contentType: 'markdown',
|
||||
emitUpdate: false
|
||||
})
|
||||
lastEditorMarkdownRef.current = value
|
||||
}, [editor, value])
|
||||
}, [codec, editor, value])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
+99
-10
@@ -15,6 +15,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const createBrowserTabMock = vi.fn()
|
||||
const setActiveWorktreeMock = vi.fn()
|
||||
const openUrlMock = vi.fn()
|
||||
const openFileUriMock = vi.fn()
|
||||
const pathExistsMock = vi.fn(async () => true)
|
||||
const connectionOwner = vi.hoisted(() => ({ value: null as string | null | undefined }))
|
||||
const targetConnectionOwners = vi.hoisted(() => new Map<string, string | null | undefined>())
|
||||
const worktreeLookup = vi.hoisted(() => ({
|
||||
value: [] as { id: string; path: string; diffComments: never[] }[]
|
||||
}))
|
||||
const statRuntimePathMock = vi.hoisted(() => vi.fn(async () => ({ isDirectory: false })))
|
||||
|
||||
// Minimal store: MarkdownPreview reads settings/worktreesByRepo plus a handful
|
||||
// of action functions. None of the actions fire on the http path under test.
|
||||
@@ -31,6 +39,9 @@ const storeState = {
|
||||
clearDeliveredDiffComments: vi.fn(),
|
||||
keybindings: {},
|
||||
worktreesByRepo: {},
|
||||
repos: [],
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
openFiles: [],
|
||||
activeFileIdByWorktree: {},
|
||||
settings: { openLinksInApp: true },
|
||||
@@ -44,14 +55,22 @@ vi.mock('@/store', () => {
|
||||
)
|
||||
return { useAppStore }
|
||||
})
|
||||
vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null }))
|
||||
vi.mock('@/store/slices/worktree-helpers', () => ({
|
||||
findWorktreeById: (_worktrees: unknown, id: string) =>
|
||||
worktreeLookup.value.find((worktree) => worktree.id === id) ?? null
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
settingsForRuntimeOwner: (settings: unknown) => settings
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-file-client', () => ({
|
||||
statRuntimePath: vi.fn(async () => ({ isDirectory: false }))
|
||||
statRuntimePath: statRuntimePathMock
|
||||
}))
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionIdForFile: (worktreeId: string) => targetConnectionOwners.get(worktreeId)
|
||||
}))
|
||||
vi.mock('@/lib/connection-owner-resolution', () => ({
|
||||
createConnectionIdForFileSelector: () => () => connectionOwner.value
|
||||
}))
|
||||
vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src }))
|
||||
vi.mock('./MermaidBlock', () => ({ default: () => null }))
|
||||
@@ -77,8 +96,8 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
|
||||
;(window as unknown as { api: unknown }).api = {
|
||||
shell: {
|
||||
openUrl: openUrlMock,
|
||||
openFileUri: vi.fn(),
|
||||
pathExists: vi.fn(async () => true)
|
||||
openFileUri: openFileUriMock,
|
||||
pathExists: pathExistsMock
|
||||
},
|
||||
ui: { writeClipboardText: vi.fn(async () => true) }
|
||||
}
|
||||
@@ -91,6 +110,14 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
|
||||
createBrowserTabMock.mockClear()
|
||||
setActiveWorktreeMock.mockClear()
|
||||
openUrlMock.mockClear()
|
||||
openFileUriMock.mockClear()
|
||||
pathExistsMock.mockClear()
|
||||
connectionOwner.value = null
|
||||
targetConnectionOwners.clear()
|
||||
worktreeLookup.value = []
|
||||
storeState.worktreesByRepo = {}
|
||||
storeState.openMarkdownPreview.mockClear()
|
||||
statRuntimePathMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -100,21 +127,28 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function render(): HTMLAnchorElement {
|
||||
function render(
|
||||
content = '[example](https://example.com)',
|
||||
expectedHref = 'https://example.com',
|
||||
sourceWorktreeId = 'wt-1',
|
||||
filePath = '/repo/docs/README.md'
|
||||
): HTMLAnchorElement {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(
|
||||
<MarkdownPreview
|
||||
content="[example](https://example.com)"
|
||||
filePath="/repo/docs/README.md"
|
||||
sourceWorktreeId="wt-1"
|
||||
content={content}
|
||||
filePath={filePath}
|
||||
sourceWorktreeId={sourceWorktreeId}
|
||||
scrollCacheKey="test-key"
|
||||
/>
|
||||
)
|
||||
})
|
||||
const anchor = container.querySelector<HTMLAnchorElement>('a[href="https://example.com"]')
|
||||
const anchor = Array.from(container.querySelectorAll<HTMLAnchorElement>('a')).find(
|
||||
(candidate) => candidate.getAttribute('href') === expectedHref
|
||||
)
|
||||
if (!anchor) {
|
||||
throw new Error('expected a rendered http anchor')
|
||||
}
|
||||
@@ -144,4 +178,59 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
|
||||
expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps system-browser HTTP and file links inert while ownership is unknown', () => {
|
||||
connectionOwner.value = undefined
|
||||
const httpAnchor = render()
|
||||
click(httpAnchor, { metaKey: true, shiftKey: true })
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
const fileAnchor = render('[file](file:///tmp/example.md)', 'file:///tmp/example.md')
|
||||
click(fileAnchor, { metaKey: true, shiftKey: true })
|
||||
expect(pathExistsMock).not.toHaveBeenCalled()
|
||||
expect(openFileUriMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps same-path file links on the explicit SSH source worktree', async () => {
|
||||
const localWorktree = { id: 'local-wt', path: '/srv/repo', diffComments: [] as never[] }
|
||||
const sshWorktree = { id: 'ssh-wt', path: '/srv/repo', diffComments: [] as never[] }
|
||||
worktreeLookup.value = [localWorktree, sshWorktree]
|
||||
storeState.worktreesByRepo = { repo: [localWorktree, sshWorktree] }
|
||||
connectionOwner.value = 'ssh-1'
|
||||
targetConnectionOwners.set('local-wt', null)
|
||||
targetConnectionOwners.set('ssh-wt', 'ssh-1')
|
||||
const anchor = render('[child](child.md)', 'child.md', 'ssh-wt', '/srv/repo/README.md')
|
||||
|
||||
await act(async () => {
|
||||
anchor.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
})
|
||||
|
||||
expect(statRuntimePathMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ worktreeId: 'ssh-wt', connectionId: 'ssh-1' }),
|
||||
'/srv/repo/child.md'
|
||||
)
|
||||
expect(storeState.openMarkdownPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ worktreeId: 'ssh-wt', filePath: '/srv/repo/child.md' }),
|
||||
{ anchor: null }
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a raw HTML superscript citation and routes it like a Markdown link', () => {
|
||||
const source = '<sup><a href="https://example.com">[12]</a></sup>'
|
||||
const anchor = render(source)
|
||||
|
||||
expect(anchor.parentElement?.tagName).toBe('SUP')
|
||||
expect(anchor.textContent).toBe('[12]')
|
||||
expect(container.textContent).not.toContain('<sup>')
|
||||
click(anchor, { metaKey: true })
|
||||
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
|
||||
activate: true
|
||||
})
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,9 @@ const storeState = {
|
||||
clearDeliveredDiffComments: vi.fn(),
|
||||
keybindings: {},
|
||||
worktreesByRepo: {},
|
||||
repos: [],
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
openFiles: [],
|
||||
activeFileIdByWorktree: {},
|
||||
settings: { openLinksInApp: true },
|
||||
@@ -48,7 +51,10 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
vi.mock('@/runtime/runtime-file-client', () => ({
|
||||
statRuntimePath: vi.fn(async () => ({ isDirectory: false }))
|
||||
}))
|
||||
vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
|
||||
vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null }))
|
||||
vi.mock('@/lib/connection-owner-resolution', () => ({
|
||||
createConnectionIdForFileSelector: () => () => null
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src }))
|
||||
vi.mock('./MermaidBlock', () => ({ default: () => null }))
|
||||
|
||||
@@ -38,7 +38,8 @@ import { Input } from '@/components/ui/input'
|
||||
import { useAppStore } from '@/store'
|
||||
import { toast } from 'sonner'
|
||||
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getConnectionIdForFile } from '@/lib/connection-context'
|
||||
import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution'
|
||||
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types'
|
||||
@@ -68,7 +69,7 @@ import {
|
||||
setActiveMarkdownPreviewSearchMatch
|
||||
} from './markdown-preview-search'
|
||||
import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
|
||||
import { markdownPreviewUrlTransform } from './markdown-preview-url-transform'
|
||||
@@ -390,14 +391,18 @@ export function deriveMarkdownPreviewSourceRoot(
|
||||
|
||||
function findWorktreeForMarkdownPreviewPath(
|
||||
worktreesByRepo: Record<string, Worktree[]>,
|
||||
absolutePath: string
|
||||
absolutePath: string,
|
||||
acceptsWorktree: (worktree: Worktree) => boolean = () => true
|
||||
): Worktree | null {
|
||||
let bestMatch: Worktree | null = null
|
||||
let bestMatchLength = -1
|
||||
|
||||
for (const worktrees of Object.values(worktreesByRepo)) {
|
||||
for (const worktree of worktrees) {
|
||||
if (relativePathInsideRoot(worktree.path, absolutePath) !== null) {
|
||||
if (
|
||||
acceptsWorktree(worktree) &&
|
||||
relativePathInsideRoot(worktree.path, absolutePath) !== null
|
||||
) {
|
||||
const normalizedWorktreePathLength = normalizeMarkdownPreviewAbsolutePath(
|
||||
worktree.path
|
||||
).length
|
||||
@@ -412,6 +417,27 @@ function findWorktreeForMarkdownPreviewPath(
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
function findMarkdownPreviewTargetWorktree(
|
||||
worktreesByRepo: Record<string, Worktree[]>,
|
||||
absolutePath: string,
|
||||
sourceWorktree: Worktree | null,
|
||||
sourceOwner: HttpLinkSourceOwner
|
||||
): Worktree | null {
|
||||
if (sourceWorktree && relativePathInsideRoot(sourceWorktree.path, absolutePath) !== null) {
|
||||
return sourceWorktree
|
||||
}
|
||||
return findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath, (worktree) => {
|
||||
const connectionId = getConnectionIdForFile(worktree.id, absolutePath)
|
||||
if (sourceOwner.kind === 'local') {
|
||||
return connectionId === null
|
||||
}
|
||||
if (sourceOwner.kind === 'ssh') {
|
||||
return connectionId === sourceOwner.connectionId
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveMarkdownPreviewSourceWorktree(
|
||||
worktreesByRepo: Record<string, Worktree[]>,
|
||||
sourceWorktreeId: string | null | undefined,
|
||||
@@ -498,9 +524,26 @@ export default function MarkdownPreview({
|
||||
)
|
||||
const allDiffComments = sourceWorktree?.diffComments
|
||||
const sourceRoutingWorktreeId = sourceWorktree?.id ?? resolvedSourceWorktreeId
|
||||
const sourceConnectionId = sourceRoutingWorktreeId
|
||||
? (getConnectionId(sourceRoutingWorktreeId) ?? null)
|
||||
: null
|
||||
const runtimeOwnerId = resolvedSourceRuntimeEnvironmentId?.trim()
|
||||
const sourceConnectionIdSelector = useMemo(
|
||||
() =>
|
||||
createConnectionIdForFileSelector(sourceRoutingWorktreeId, filePath, {
|
||||
skip: Boolean(runtimeOwnerId)
|
||||
}),
|
||||
[filePath, runtimeOwnerId, sourceRoutingWorktreeId]
|
||||
)
|
||||
const sourceConnectionId = useAppStore(sourceConnectionIdSelector)
|
||||
const sourceOwner = useMemo<HttpLinkSourceOwner>(
|
||||
() =>
|
||||
runtimeOwnerId
|
||||
? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId }
|
||||
: sourceConnectionId === undefined
|
||||
? { kind: 'unknown' }
|
||||
: sourceConnectionId === null
|
||||
? { kind: 'local' }
|
||||
: { kind: 'ssh', connectionId: sourceConnectionId },
|
||||
[runtimeOwnerId, sourceConnectionId]
|
||||
)
|
||||
const worktreeRoot =
|
||||
sourceWorktree?.path ??
|
||||
(sourceRoutingWorktreeId
|
||||
@@ -1254,6 +1297,9 @@ export default function MarkdownPreview({
|
||||
// dangling in-worktree .md, pre-check existence so the user sees a
|
||||
// toast instead of the silent no-op from shell.openFileUri.
|
||||
if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) {
|
||||
if (sourceOwner.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
const osTarget = getMarkdownPreviewLinkTarget(href, filePath)
|
||||
if (!osTarget) {
|
||||
return
|
||||
@@ -1267,7 +1313,12 @@ export default function MarkdownPreview({
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
openHttpLink(
|
||||
parsed.toString(),
|
||||
resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId)
|
||||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
event,
|
||||
isMac,
|
||||
sourceRoutingWorktreeId,
|
||||
sourceOwner
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -1326,7 +1377,12 @@ export default function MarkdownPreview({
|
||||
// handled above; this path only sees non-escape-hatch clicks.)
|
||||
openHttpLink(
|
||||
target.toString(),
|
||||
resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId)
|
||||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
event,
|
||||
isMac,
|
||||
sourceRoutingWorktreeId,
|
||||
sourceOwner
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -1347,12 +1403,25 @@ export default function MarkdownPreview({
|
||||
? { line: classifiedFileTarget.line, column: classifiedFileTarget.column }
|
||||
: parseLineTarget(target.hash)
|
||||
|
||||
// Why: same-file anchors need no ownership/filesystem resolution (e.g.
|
||||
// `./README.md#heading` when this file is README.md). Run before the
|
||||
// unknown-ownership guard so ambiguous folder-workspace ownership still
|
||||
// scrolls within the open document.
|
||||
if (absolutePath === filePath && target.hash && !lineTarget) {
|
||||
void scrollToAnchor(target.hash.slice(1))
|
||||
return
|
||||
}
|
||||
|
||||
const targetWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath)
|
||||
if (sourceOwner.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
|
||||
const targetWorktree = findMarkdownPreviewTargetWorktree(
|
||||
worktreesByRepo,
|
||||
absolutePath,
|
||||
sourceWorktree,
|
||||
sourceOwner
|
||||
)
|
||||
if (!targetWorktree) {
|
||||
if (sourceRoutingWorktreeId && worktreeRoot) {
|
||||
// Why: floating markdown files are owned by a synthetic workspace,
|
||||
@@ -1362,7 +1431,8 @@ export default function MarkdownPreview({
|
||||
sourceFilePath: filePath,
|
||||
worktreeId: sourceRoutingWorktreeId,
|
||||
worktreeRoot,
|
||||
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId
|
||||
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
|
||||
sourceOwner
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1384,8 +1454,15 @@ export default function MarkdownPreview({
|
||||
return
|
||||
}
|
||||
|
||||
const relativePath = absolutePath.slice(targetWorktree.path.length + 1)
|
||||
const relativePath = relativePathInsideRoot(targetWorktree.path, absolutePath)
|
||||
if (relativePath === null) {
|
||||
return
|
||||
}
|
||||
const language = detectLanguage(absolutePath)
|
||||
const targetConnectionId = getConnectionIdForFile(targetWorktree.id, absolutePath)
|
||||
if (targetConnectionId === undefined) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const stats = await statRuntimePath(
|
||||
{
|
||||
@@ -1395,7 +1472,7 @@ export default function MarkdownPreview({
|
||||
),
|
||||
worktreeId: targetWorktree.id,
|
||||
worktreePath: targetWorktree.path,
|
||||
connectionId: getConnectionId(targetWorktree.id) ?? undefined
|
||||
connectionId: targetConnectionId ?? undefined
|
||||
},
|
||||
absolutePath
|
||||
)
|
||||
@@ -1512,7 +1589,8 @@ export default function MarkdownPreview({
|
||||
sourceFilePath: filePath,
|
||||
worktreeId: sourceRoutingWorktreeId,
|
||||
worktreeRoot,
|
||||
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId
|
||||
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
|
||||
sourceOwner
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1673,6 +1751,8 @@ export default function MarkdownPreview({
|
||||
setMarkdownViewMode,
|
||||
setPendingEditorReveal,
|
||||
sourceConnectionId,
|
||||
sourceOwner,
|
||||
sourceWorktree,
|
||||
resolvedSourceRuntimeEnvironmentId,
|
||||
sourceRoutingWorktreeId,
|
||||
worktreeRoot,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { useEditorState, type Editor } from '@tiptap/react'
|
||||
import type { DiffComment, MarkdownDocument } from '../../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector'
|
||||
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
|
||||
import { useLocalImagePick } from './useLocalImagePick'
|
||||
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
@@ -25,6 +24,11 @@ import {
|
||||
runRichMarkdownContextCommand
|
||||
} from './rich-markdown-context-command-routing'
|
||||
import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck'
|
||||
import { useRichMarkdownSuperscriptLinkSetup } from './useRichMarkdownSuperscriptLinkSetup'
|
||||
import {
|
||||
formatSelectedHtmlSuperscriptLinkStatus,
|
||||
getSelectedHtmlSuperscriptLinkStatus
|
||||
} from './rich-markdown-selected-link-actions'
|
||||
|
||||
type RichMarkdownEditorProps = {
|
||||
fileId: string
|
||||
@@ -86,9 +90,11 @@ export default function RichMarkdownEditor({
|
||||
const allDiffComments = useAppStore((s): DiffComment[] | undefined =>
|
||||
selectWorktreeDiffComments(s, worktreeId)
|
||||
)
|
||||
const worktreeRoot = useAppStore(
|
||||
(s) => getIndexedWorktreeById(s.worktreesByRepo, worktreeId)?.path ?? null
|
||||
)
|
||||
const { codec, htmlSuperscriptLinkContext, worktreeRoot } = useRichMarkdownSuperscriptLinkSetup({
|
||||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
worktreeId
|
||||
})
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const menu = useRichMarkdownMenuController({ markdownDocuments })
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
@@ -196,6 +202,8 @@ export default function RichMarkdownEditor({
|
||||
)
|
||||
|
||||
const editor = useRichMarkdownEditorInstance({
|
||||
codec,
|
||||
htmlSuperscriptLinkContext,
|
||||
content,
|
||||
filePath,
|
||||
worktreeId,
|
||||
@@ -240,6 +248,13 @@ export default function RichMarkdownEditor({
|
||||
setSlashMenu: menu.setSlashMenu,
|
||||
setDocLinkMenu: menu.setDocLinkMenu
|
||||
})
|
||||
// Why: useEditor defaults shouldRerenderOnTransaction to false, so selection-only
|
||||
// citation NodeSelections would leave aria status stale without useEditorState.
|
||||
const selectedCitationStatus = useEditorState({
|
||||
editor,
|
||||
selector: (snapshot) =>
|
||||
getSelectedHtmlSuperscriptLinkStatus(snapshot.editor, htmlSuperscriptLinkContext)
|
||||
})
|
||||
useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled)
|
||||
|
||||
// Why: use useLayoutEffect (synchronous cleanup) so the pending serialization
|
||||
@@ -265,6 +280,7 @@ export default function RichMarkdownEditor({
|
||||
})
|
||||
|
||||
useRichMarkdownProgrammaticSync({
|
||||
codec,
|
||||
content,
|
||||
docLinkMenuSetter: menu.setDocLinkMenu,
|
||||
editor,
|
||||
@@ -289,12 +305,14 @@ export default function RichMarkdownEditor({
|
||||
handleLinkRemove,
|
||||
handleLinkEditCancel,
|
||||
handleLinkOpen,
|
||||
handleLinkCopy,
|
||||
toggleLinkFromToolbar
|
||||
} = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink, {
|
||||
sourceFilePath: filePath,
|
||||
worktreeId,
|
||||
worktreeRoot,
|
||||
runtimeEnvironmentId
|
||||
runtimeEnvironmentId,
|
||||
htmlSuperscriptLinkContext
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -370,6 +388,12 @@ export default function RichMarkdownEditor({
|
||||
showTableOfContents={showTableOfContents}
|
||||
searchState={searchState}
|
||||
searchActions={searchActions}
|
||||
citationStatus={
|
||||
selectedCitationStatus
|
||||
? formatSelectedHtmlSuperscriptLinkStatus(selectedCitationStatus)
|
||||
: ''
|
||||
}
|
||||
linkBubbleOwnerId={codec.transport.key}
|
||||
linkBubbleActions={{
|
||||
dismissLinkBubble: () => {
|
||||
setLinkBubble(null)
|
||||
@@ -379,6 +403,7 @@ export default function RichMarkdownEditor({
|
||||
handleLinkRemove,
|
||||
handleLinkEditCancel,
|
||||
handleLinkOpen,
|
||||
handleLinkCopy,
|
||||
setIsEditingLink
|
||||
}}
|
||||
onToggleLink={toggleLinkFromToolbar}
|
||||
|
||||
@@ -76,6 +76,7 @@ type RichMarkdownEditorSurfaceProps = {
|
||||
matchCase: boolean
|
||||
matchCount: number
|
||||
replaceQuery: string
|
||||
replaceDisabled: boolean
|
||||
searchQuery: string
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
wholeWord: boolean
|
||||
@@ -91,12 +92,15 @@ type RichMarkdownEditorSurfaceProps = {
|
||||
toggleReplaceMode: () => void
|
||||
toggleWholeWord: () => void
|
||||
}
|
||||
citationStatus: string
|
||||
linkBubbleOwnerId: string
|
||||
linkBubbleActions: {
|
||||
dismissLinkBubble: () => void
|
||||
handleLinkSave: (href: string) => void
|
||||
handleLinkRemove: () => void
|
||||
handleLinkEditCancel: () => void
|
||||
handleLinkOpen: () => void
|
||||
handleLinkCopy: () => void
|
||||
setIsEditingLink: (editing: boolean) => void
|
||||
}
|
||||
onToggleLink: () => void
|
||||
@@ -155,6 +159,8 @@ export function RichMarkdownEditorSurface({
|
||||
showTableOfContents,
|
||||
searchState,
|
||||
searchActions,
|
||||
citationStatus,
|
||||
linkBubbleOwnerId,
|
||||
linkBubbleActions,
|
||||
onToggleLink,
|
||||
onImagePick,
|
||||
@@ -237,6 +243,7 @@ export function RichMarkdownEditorSurface({
|
||||
matchCount={searchState.matchCount}
|
||||
query={searchState.searchQuery}
|
||||
replaceQuery={searchState.replaceQuery}
|
||||
replaceDisabled={searchState.replaceDisabled}
|
||||
searchInputRef={searchState.searchInputRef}
|
||||
wholeWord={searchState.wholeWord}
|
||||
onClose={searchActions.closeSearch}
|
||||
@@ -262,8 +269,13 @@ export function RichMarkdownEditorSurface({
|
||||
onEditStart={() => linkBubbleActions.setIsEditingLink(true)}
|
||||
onEditCancel={linkBubbleActions.handleLinkEditCancel}
|
||||
onOpen={linkBubbleActions.handleLinkOpen}
|
||||
onCopy={linkBubbleActions.handleLinkCopy}
|
||||
ownerId={linkBubbleOwnerId}
|
||||
/>
|
||||
) : null}
|
||||
<span className="sr-only" role="status" aria-live="polite">
|
||||
{citationStatus}
|
||||
</span>
|
||||
{slashMenu ? (
|
||||
<RichMarkdownSlashMenu
|
||||
editor={editor}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { ExternalLink, Pencil, Unlink } from 'lucide-react'
|
||||
import { Copy, ExternalLink, Pencil, Unlink } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
export type LinkBubbleState = {
|
||||
kind: 'markdown' | 'html-superscript'
|
||||
href: string
|
||||
left: number
|
||||
top: number
|
||||
openEnabled: boolean
|
||||
copyEnabled: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
const LINK_BUBBLE_VIEWPORT_MARGIN = 8
|
||||
@@ -160,6 +166,8 @@ type RichMarkdownLinkBubbleProps = {
|
||||
onEditStart: () => void
|
||||
onEditCancel: () => void
|
||||
onOpen: () => void
|
||||
onCopy: () => void
|
||||
ownerId?: string
|
||||
portalToDocument?: boolean
|
||||
}
|
||||
|
||||
@@ -173,6 +181,8 @@ export function RichMarkdownLinkBubble({
|
||||
onEditStart,
|
||||
onEditCancel,
|
||||
onOpen,
|
||||
onCopy,
|
||||
ownerId,
|
||||
portalToDocument = false
|
||||
}: RichMarkdownLinkBubbleProps): React.JSX.Element {
|
||||
const bubbleRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -270,6 +280,8 @@ export function RichMarkdownLinkBubble({
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
className="rich-markdown-link-bubble"
|
||||
data-rich-markdown-link-bubble=""
|
||||
data-rich-markdown-link-bubble-owner={ownerId}
|
||||
style={positionStyle}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent editor blur when clicking bubble buttons, but let inputs
|
||||
@@ -278,7 +290,28 @@ export function RichMarkdownLinkBubble({
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onDismiss()
|
||||
anchorElement?.querySelector<HTMLElement>('[contenteditable="true"]')?.focus()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
|
||||
return
|
||||
}
|
||||
const buttons = Array.from(
|
||||
bubbleRef.current?.querySelectorAll<HTMLButtonElement>('button:not([disabled])') ?? []
|
||||
)
|
||||
const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
||||
if (currentIndex === -1 || buttons.length === 0) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const direction = event.key === 'ArrowRight' ? 1 : -1
|
||||
buttons[(currentIndex + direction + buttons.length) % buttons.length]?.focus()
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<LinkEditInput initialHref={linkBubble.href} onSave={onSave} onCancel={onEditCancel} />
|
||||
@@ -287,39 +320,45 @@ export function RichMarkdownLinkBubble({
|
||||
<span className="rich-markdown-link-url" title={linkBubble.href}>
|
||||
{linkBubble.href.length > 40 ? `${linkBubble.href.slice(0, 40)}…` : linkBubble.href}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-link-button"
|
||||
onClick={onOpen}
|
||||
title={translate(
|
||||
<LinkBubbleAction
|
||||
label={translate(
|
||||
'auto.components.editor.RichMarkdownLinkBubble.bfc813e909',
|
||||
'Open link'
|
||||
)}
|
||||
disabled={!linkBubble.openEnabled}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-link-button"
|
||||
onClick={onEditStart}
|
||||
title={translate(
|
||||
'auto.components.editor.RichMarkdownLinkBubble.cdfe166f6f',
|
||||
'Edit link'
|
||||
)}
|
||||
</LinkBubbleAction>
|
||||
<LinkBubbleAction
|
||||
label={translate('auto.components.editor.RichMarkdownLinkBubble.copyLink', 'Copy link')}
|
||||
disabled={!linkBubble.copyEnabled}
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-link-button"
|
||||
onClick={onRemove}
|
||||
title={translate(
|
||||
'auto.components.editor.RichMarkdownLinkBubble.1c99b726e0',
|
||||
'Remove link'
|
||||
)}
|
||||
>
|
||||
<Unlink size={14} />
|
||||
</button>
|
||||
<Copy size={14} />
|
||||
</LinkBubbleAction>
|
||||
{linkBubble.kind === 'markdown' ? (
|
||||
<>
|
||||
<LinkBubbleAction
|
||||
label={translate(
|
||||
'auto.components.editor.RichMarkdownLinkBubble.cdfe166f6f',
|
||||
'Edit link'
|
||||
)}
|
||||
onClick={onEditStart}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</LinkBubbleAction>
|
||||
<LinkBubbleAction
|
||||
label={translate(
|
||||
'auto.components.editor.RichMarkdownLinkBubble.1c99b726e0',
|
||||
'Remove link'
|
||||
)}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Unlink size={14} />
|
||||
</LinkBubbleAction>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -329,3 +368,36 @@ export function RichMarkdownLinkBubble({
|
||||
// actions must portal to the app layer to remain above the right sidebar.
|
||||
return portalToDocument ? createPortal(bubble, document.body) : bubble
|
||||
}
|
||||
|
||||
function LinkBubbleAction({
|
||||
children,
|
||||
disabled = false,
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onClick: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="rich-markdown-link-button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type RichMarkdownSearchBarProps = {
|
||||
matchCount: number
|
||||
query: string
|
||||
replaceQuery: string
|
||||
replaceDisabled: boolean
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
wholeWord: boolean
|
||||
onClose: () => void
|
||||
@@ -43,6 +44,7 @@ export function RichMarkdownSearchBar({
|
||||
matchCount,
|
||||
query,
|
||||
replaceQuery,
|
||||
replaceDisabled,
|
||||
searchInputRef,
|
||||
wholeWord,
|
||||
onClose,
|
||||
@@ -58,6 +60,7 @@ export function RichMarkdownSearchBar({
|
||||
// Why: surface the same replace shortcut the source editor uses so the toggle
|
||||
// is discoverable; reads the user's effective binding, formatted per platform.
|
||||
const replaceShortcut = useOptionalShortcutLabel('editor.replace')
|
||||
const readOnlyExplanationId = React.useId()
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
@@ -71,6 +74,10 @@ export function RichMarkdownSearchBar({
|
||||
}
|
||||
|
||||
const noMatches = matchCount === 0
|
||||
const readOnlyReplaceExplanation = translate(
|
||||
'auto.components.editor.RichMarkdownSearchBar.preservedRichContentReadOnly',
|
||||
'Preserved rich content is read-only in rich mode.'
|
||||
)
|
||||
const toggleReplaceLabel = isReplaceMode
|
||||
? translate('auto.components.editor.RichMarkdownSearchBar.e8c147435f', 'Hide replace')
|
||||
: translate('auto.components.editor.RichMarkdownSearchBar.9cdc38be33', 'Toggle replace')
|
||||
@@ -259,6 +266,7 @@ export function RichMarkdownSearchBar({
|
||||
'auto.components.editor.RichMarkdownSearchBar.44682b4159',
|
||||
'Replace in rich markdown editor'
|
||||
)}
|
||||
aria-describedby={replaceDisabled ? readOnlyExplanationId : undefined}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -267,11 +275,12 @@ export function RichMarkdownSearchBar({
|
||||
size="icon-xs"
|
||||
onMouseDown={keepSearchFocus}
|
||||
onClick={onReplaceCurrent}
|
||||
disabled={noMatches}
|
||||
title={translate(
|
||||
'auto.components.editor.RichMarkdownSearchBar.fd97c7e585',
|
||||
'Replace'
|
||||
)}
|
||||
disabled={noMatches || replaceDisabled}
|
||||
title={
|
||||
replaceDisabled
|
||||
? readOnlyReplaceExplanation
|
||||
: translate('auto.components.editor.RichMarkdownSearchBar.fd97c7e585', 'Replace')
|
||||
}
|
||||
aria-label={translate(
|
||||
'auto.components.editor.RichMarkdownSearchBar.fd97c7e585',
|
||||
'Replace'
|
||||
@@ -286,11 +295,15 @@ export function RichMarkdownSearchBar({
|
||||
size="icon-xs"
|
||||
onMouseDown={keepSearchFocus}
|
||||
onClick={onReplaceAll}
|
||||
disabled={noMatches}
|
||||
title={translate(
|
||||
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
|
||||
'Replace all'
|
||||
)}
|
||||
disabled={noMatches || replaceDisabled}
|
||||
title={
|
||||
replaceDisabled
|
||||
? readOnlyReplaceExplanation
|
||||
: translate(
|
||||
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
|
||||
'Replace all'
|
||||
)
|
||||
}
|
||||
aria-label={translate(
|
||||
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
|
||||
'Replace all'
|
||||
@@ -299,6 +312,11 @@ export function RichMarkdownSearchBar({
|
||||
>
|
||||
<ReplaceAll size={14} />
|
||||
</Button>
|
||||
{replaceDisabled ? (
|
||||
<span id={readOnlyExplanationId} className="sr-only" role="status">
|
||||
{readOnlyReplaceExplanation}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export type HtmlAttributeQuote = '"' | "'" | null
|
||||
|
||||
export function decodeHtmlTextCharacterReferences(value: string): string {
|
||||
// Why: a <template> parses markup inertly (no script/resource execution), so
|
||||
// reading textContent safely decodes character references without XSS risk.
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = value
|
||||
return template.content.textContent ?? ''
|
||||
}
|
||||
|
||||
export function decodeHtmlAttributeCharacterReferences(
|
||||
value: string,
|
||||
quote: HtmlAttributeQuote
|
||||
): string {
|
||||
// Why: inert <template> decoding; attribute write uses the caller-supplied
|
||||
// quote and parser-constrained value, so this is not an injection sink.
|
||||
const template = document.createElement('template')
|
||||
const delimiter = quote ?? ''
|
||||
template.innerHTML = `<span data-orca-value=${delimiter}${value}${delimiter}></span>`
|
||||
const element = template.content.firstElementChild
|
||||
return element?.getAttribute('data-orca-value') ?? ''
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
|
||||
export function createIsolatedMarkdownExtensionForTests() {
|
||||
return Markdown.configure({
|
||||
marked: createRichMarkdownEditorCodec().marked,
|
||||
markedOptions: { gfm: true }
|
||||
})
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Table } from '@tiptap/extension-table'
|
||||
import { TableCell } from '@tiptap/extension-table-cell'
|
||||
import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import { TableRow } from '@tiptap/extension-table-row'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import { normalizeEmptyListItems } from './rich-markdown-normalize'
|
||||
|
||||
const testExtensions = [
|
||||
@@ -18,7 +18,7 @@ const testExtensions = [
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown.configure({ markedOptions: { gfm: true } })
|
||||
createIsolatedMarkdownExtensionForTests()
|
||||
]
|
||||
|
||||
function createEditor(markdown: string): Editor {
|
||||
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
filesystemPathToFileUri,
|
||||
fileUriToFilesystemPath
|
||||
} from '../../../../shared/file-uri-path'
|
||||
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
|
||||
import {
|
||||
isWindowsAbsolutePathLike,
|
||||
relativePathInsideRoot
|
||||
} from '../../../../shared/cross-platform-path'
|
||||
|
||||
// Pure classifier for markdown link targets. Called by the link-activation
|
||||
// dispatcher (activateMarkdownLink slice action) from three call sites —
|
||||
@@ -31,13 +34,6 @@ export type MarkdownLinkTarget =
|
||||
column?: number
|
||||
}
|
||||
|
||||
// Why: renderer runs with sandbox + contextIsolation, so process.platform is
|
||||
// unavailable. navigator.userAgent is the portable fallback (AGENTS.md).
|
||||
const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent
|
||||
const isMacLike = ua.includes('Mac')
|
||||
const isWindowsLike = ua.includes('Windows')
|
||||
const caseInsensitiveFs = isMacLike || isWindowsLike
|
||||
|
||||
const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx', '.markdown'])
|
||||
|
||||
export function absolutePathToFileUri(filePath: string): string {
|
||||
@@ -60,17 +56,6 @@ function normalizePathForCompare(p: string): string {
|
||||
return np
|
||||
}
|
||||
|
||||
function isDescendantOf(childAbs: string, parentAbs: string): boolean {
|
||||
const child = normalizePathForCompare(childAbs)
|
||||
const parent = normalizePathForCompare(parentAbs)
|
||||
if (caseInsensitiveFs) {
|
||||
const lc = child.toLowerCase()
|
||||
const lp = parent.toLowerCase()
|
||||
return lc === lp || lc.startsWith(`${lp}/`)
|
||||
}
|
||||
return child === parent || child.startsWith(`${parent}/`)
|
||||
}
|
||||
|
||||
function hasMarkdownExtension(p: string): boolean {
|
||||
const lastDot = p.lastIndexOf('.')
|
||||
if (lastDot === -1) {
|
||||
@@ -125,25 +110,7 @@ function resolveRelativeToSource(rawHref: string, sourceFilePath: string): URL |
|
||||
}
|
||||
|
||||
function computeRelativePath(absolutePath: string, worktreeRoot: string): string {
|
||||
const parent = normalizePathForCompare(worktreeRoot)
|
||||
const child = normalizePathForCompare(absolutePath)
|
||||
const prefix = `${parent}/`
|
||||
if (caseInsensitiveFs) {
|
||||
if (child.toLowerCase() === parent.toLowerCase()) {
|
||||
return ''
|
||||
}
|
||||
if (child.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
return child.slice(prefix.length)
|
||||
}
|
||||
} else {
|
||||
if (child === parent) {
|
||||
return ''
|
||||
}
|
||||
if (child.startsWith(prefix)) {
|
||||
return child.slice(prefix.length)
|
||||
}
|
||||
}
|
||||
return child
|
||||
return relativePathInsideRoot(worktreeRoot, absolutePath) ?? normalizePathForCompare(absolutePath)
|
||||
}
|
||||
|
||||
export function resolveMarkdownLinkTarget(
|
||||
@@ -195,7 +162,7 @@ export function resolveMarkdownLinkTarget(
|
||||
if (
|
||||
worktreeRoot !== null &&
|
||||
hasMarkdownExtension(pathForClassification) &&
|
||||
isDescendantOf(pathForClassification, worktreeRoot)
|
||||
relativePathInsideRoot(worktreeRoot, pathForClassification) !== null
|
||||
) {
|
||||
const relativePath = computeRelativePath(pathForClassification, worktreeRoot)
|
||||
return {
|
||||
@@ -208,7 +175,7 @@ export function resolveMarkdownLinkTarget(
|
||||
}
|
||||
|
||||
const relativePath =
|
||||
worktreeRoot !== null && isDescendantOf(pathForClassification, worktreeRoot)
|
||||
worktreeRoot !== null && relativePathInsideRoot(worktreeRoot, pathForClassification) !== null
|
||||
? computeRelativePath(pathForClassification, worktreeRoot)
|
||||
: undefined
|
||||
|
||||
@@ -226,3 +193,52 @@ export function resolveMarkdownLinkTarget(
|
||||
column
|
||||
}
|
||||
}
|
||||
|
||||
const HTML_ATTRIBUTE_WHITESPACE = /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g
|
||||
|
||||
export function projectMarkdownHrefForClipboard(href: string): string | null {
|
||||
const projected = href.replace(HTML_ATTRIBUTE_WHITESPACE, '')
|
||||
if (!projected || containsAsciiControl(projected)) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
isWindowsAbsolutePathLike(projected) ||
|
||||
projected.startsWith('\\\\') ||
|
||||
projected.startsWith('//')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (projected.startsWith('#')) {
|
||||
return projected
|
||||
}
|
||||
|
||||
const scheme = /^([A-Za-z][A-Za-z\d+.-]*):/.exec(projected)?.[1]?.toLowerCase()
|
||||
if (!scheme) {
|
||||
return projected
|
||||
}
|
||||
if (scheme !== 'http' && scheme !== 'https' && scheme !== 'file') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(projected)
|
||||
if (parsed.protocol !== `${scheme}:`) {
|
||||
return null
|
||||
}
|
||||
if (scheme === 'file' && !parsed.pathname) {
|
||||
return null
|
||||
}
|
||||
return projected
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function containsAsciiControl(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code <= 31 || code === 127) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -149,9 +149,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
||||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: true, ctrlKey: false, shiftKey: true },
|
||||
true,
|
||||
'wt-1'
|
||||
'wt-1',
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ forceSystemBrowser: true })
|
||||
).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
|
||||
it('forces the system browser on Ctrl+Shift-click on Linux/Windows', () => {
|
||||
@@ -159,9 +160,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
||||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: false, ctrlKey: true, shiftKey: true },
|
||||
false,
|
||||
'wt-1'
|
||||
'wt-1',
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ forceSystemBrowser: true })
|
||||
).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
|
||||
it('routes a plain Cmd-click through the worktree so it can open in Orca', () => {
|
||||
@@ -169,9 +171,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
||||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: true, ctrlKey: false, shiftKey: false },
|
||||
true,
|
||||
'wt-1'
|
||||
'wt-1',
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ worktreeId: 'wt-1' })
|
||||
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
|
||||
it('routes a plain click (no modifier) through the worktree', () => {
|
||||
@@ -179,9 +182,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
||||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: false, ctrlKey: false, shiftKey: false },
|
||||
true,
|
||||
'wt-1'
|
||||
'wt-1',
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ worktreeId: 'wt-1' })
|
||||
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
|
||||
it('does not force the system browser for Shift without the platform mod key', () => {
|
||||
@@ -190,9 +194,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
||||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: false, ctrlKey: false, shiftKey: true },
|
||||
true,
|
||||
'wt-1'
|
||||
'wt-1',
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ worktreeId: 'wt-1' })
|
||||
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
|
||||
it('does not treat Mac Ctrl+Shift-click as the escape hatch', () => {
|
||||
@@ -201,9 +206,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
||||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: false, ctrlKey: true, shiftKey: true },
|
||||
true,
|
||||
'wt-1'
|
||||
'wt-1',
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ worktreeId: 'wt-1' })
|
||||
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
|
||||
it('passes through a null worktree (openHttpLink then falls back to system browser)', () => {
|
||||
@@ -211,9 +217,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
|
||||
resolveMarkdownPreviewHttpOpenOptions(
|
||||
{ metaKey: false, ctrlKey: false, shiftKey: false },
|
||||
true,
|
||||
null
|
||||
null,
|
||||
{ kind: 'local' }
|
||||
)
|
||||
).toEqual({ worktreeId: null })
|
||||
).toEqual({ worktreeId: null, sourceOwner: { kind: 'local' } })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
fileUriToFilesystemPath
|
||||
} from '../../../../shared/file-uri-path'
|
||||
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
|
||||
import type { OpenHttpLinkOptions } from '@/lib/http-link-routing'
|
||||
import type { HttpLinkSourceOwner, OpenHttpLinkOptions } from '@/lib/http-link-routing'
|
||||
|
||||
function toFileUrl(filePath: string): string {
|
||||
return filesystemPathToFileUri(filePath)
|
||||
@@ -120,12 +120,13 @@ export function isMarkdownPreviewSystemBrowserModifier(
|
||||
export function resolveMarkdownPreviewHttpOpenOptions(
|
||||
event: Pick<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>,
|
||||
isMac: boolean,
|
||||
worktreeId: string | null
|
||||
worktreeId: string | null,
|
||||
sourceOwner: HttpLinkSourceOwner
|
||||
): OpenHttpLinkOptions {
|
||||
if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) {
|
||||
return { forceSystemBrowser: true }
|
||||
return { forceSystemBrowser: true, sourceOwner }
|
||||
}
|
||||
return { worktreeId }
|
||||
return { worktreeId, sourceOwner }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,14 +2,16 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import type { SlashCommandId } from './rich-markdown-slash-commands'
|
||||
import { slashCommands } from './rich-markdown-slash-commands'
|
||||
|
||||
function roundTripMarkdown(content: string): string {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content),
|
||||
extensions: createRichMarkdownExtensions({ codec }),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
||||
@@ -21,10 +23,11 @@ function roundTripMarkdown(content: string): string {
|
||||
}
|
||||
|
||||
function markdownAfterTextReplace(content: string, search: string, replacement: string): string {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content),
|
||||
extensions: createRichMarkdownExtensions({ codec }),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
||||
@@ -50,9 +53,10 @@ function markdownAfterTextReplace(content: string, search: string, replacement:
|
||||
}
|
||||
|
||||
function slashCommandMarkdown(commandId: SlashCommandId): string {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
extensions: createRichMarkdownExtensions({ codec }),
|
||||
content: '',
|
||||
contentType: 'markdown'
|
||||
})
|
||||
@@ -71,9 +75,10 @@ function slashCommandMarkdown(commandId: SlashCommandId): string {
|
||||
}
|
||||
|
||||
function slashCommandSelectionParent(commandId: SlashCommandId): string {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
extensions: createRichMarkdownExtensions({ codec }),
|
||||
content: '',
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
|
||||
// Why: extensions are lazily created on first use to avoid eager instantiation
|
||||
// at import time. A single shared instance is safe here because only one
|
||||
// round-trip Editor is alive at a time (created and destroyed synchronously).
|
||||
let roundTripExtensions: ReturnType<typeof createRichMarkdownExtensions> | null = null
|
||||
const roundTripCache = new Map<string, string | null>()
|
||||
const MAX_CACHE_ENTRIES = 20
|
||||
|
||||
@@ -23,13 +21,23 @@ export function getRichMarkdownRoundTripOutput(content: string): string | null {
|
||||
let output: string | null = null
|
||||
|
||||
try {
|
||||
if (!roundTripExtensions) {
|
||||
roundTripExtensions = createRichMarkdownExtensions()
|
||||
}
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
const context = createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '',
|
||||
worktreeId: '',
|
||||
worktreeRoot: null,
|
||||
sourceOwner: { kind: 'unknown' }
|
||||
})
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
extensions: roundTripExtensions,
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content),
|
||||
extensions: createRichMarkdownExtensions({
|
||||
codec,
|
||||
htmlSuperscriptLinks: true,
|
||||
htmlSuperscriptLinkContext: context
|
||||
}),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
|
||||
htmlSuperscriptLinks: true
|
||||
}),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
try {
|
||||
|
||||
@@ -2,53 +2,16 @@ import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { isEditableDetailsHtmlBlock, matchDetailsHtmlBlock } from './details-markdown-html'
|
||||
import { formatMarkdownDocLinkBody, parseMarkdownDocLink } from './markdown-doc-links'
|
||||
import { normalizeMarkdownReferenceLinks } from './markdown-reference-link-normalization'
|
||||
|
||||
const INLINE_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_INLINE:'
|
||||
const BLOCK_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_BLOCK:'
|
||||
const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
|
||||
const PLACEHOLDER_SUFFIX = ']]'
|
||||
import type {
|
||||
RichMarkdownEditorCodec,
|
||||
RichMarkdownSourceKind,
|
||||
RichMarkdownSourceTransport
|
||||
} from './rich-markdown-source-transport'
|
||||
import { isReservedRichMarkdownTransportBody } from './rich-markdown-source-transport'
|
||||
import { matchHtmlSuperscriptLinkSource } from './rich-markdown-html-superscript-link-source'
|
||||
|
||||
const INLINE_HTML_PATTERN = /^<!--[\s\S]*?-->|^<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/
|
||||
|
||||
function encodeHtmlPayload(raw: string): string {
|
||||
return encodeURIComponent(raw)
|
||||
}
|
||||
|
||||
function decodeHtmlPayload(payload: string): string {
|
||||
try {
|
||||
return decodeURIComponent(payload)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function createPlaceholder(kind: 'inline' | 'block', raw: string): string {
|
||||
const prefix = kind === 'inline' ? INLINE_PLACEHOLDER_PREFIX : BLOCK_PLACEHOLDER_PREFIX
|
||||
return `${prefix}${encodeHtmlPayload(raw)}${PLACEHOLDER_SUFFIX}`
|
||||
}
|
||||
|
||||
function matchPlaceholder(
|
||||
src: string,
|
||||
kind: 'inline' | 'block'
|
||||
): { placeholder: string; value: string } | null {
|
||||
const prefix = kind === 'inline' ? INLINE_PLACEHOLDER_PREFIX : BLOCK_PLACEHOLDER_PREFIX
|
||||
if (!src.startsWith(prefix)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const endIndex = src.indexOf(PLACEHOLDER_SUFFIX, prefix.length)
|
||||
if (endIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const placeholder = src.slice(0, endIndex + PLACEHOLDER_SUFFIX.length)
|
||||
const payload = src.slice(prefix.length, endIndex)
|
||||
return {
|
||||
placeholder,
|
||||
value: decodeHtmlPayload(payload)
|
||||
}
|
||||
}
|
||||
|
||||
function matchInlineHtml(src: string): string | null {
|
||||
const match = src.match(INLINE_HTML_PATTERN)
|
||||
return match?.[0] ?? null
|
||||
@@ -90,8 +53,13 @@ function matchBlockHtml(content: string, start: number): string | null {
|
||||
return line
|
||||
}
|
||||
|
||||
export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
|
||||
export function encodeRawMarkdownHtmlForRichEditor(
|
||||
content: string,
|
||||
codec: RichMarkdownEditorCodec,
|
||||
{ htmlSuperscriptLinks = false }: { htmlSuperscriptLinks?: boolean } = {}
|
||||
): string {
|
||||
const normalizedContent = normalizeMarkdownReferenceLinks(content)
|
||||
const { transport } = codec
|
||||
let index = 0
|
||||
let isLineStart = true
|
||||
let activeFence: '`' | '~' | null = null
|
||||
@@ -170,47 +138,65 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
|
||||
}
|
||||
|
||||
if (detailsHtml) {
|
||||
result += createPlaceholder('block', detailsHtml.raw)
|
||||
result += transport.create('block-html', detailsHtml.raw)
|
||||
index += detailsHtml.raw.length
|
||||
continue
|
||||
}
|
||||
|
||||
const blockHtml = matchBlockHtml(normalizedContent, index)
|
||||
if (blockHtml) {
|
||||
result += createPlaceholder('block', blockHtml)
|
||||
result += transport.create('block-html', blockHtml)
|
||||
index += blockHtml.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Why: authored text that happens to contain this editor's random envelope
|
||||
// prefix must remain literal even in HTML-free documents and after edits.
|
||||
if (normalizedContent.startsWith(transport.authoredPrefix, index)) {
|
||||
const authoredEnd = normalizedContent.indexOf(']]', index + transport.authoredPrefix.length)
|
||||
const authoredOccurrence =
|
||||
authoredEnd === -1
|
||||
? transport.authoredPrefix
|
||||
: normalizedContent.slice(index, authoredEnd + 2)
|
||||
result += transport.create('literal', authoredOccurrence)
|
||||
index += authoredOccurrence.length
|
||||
continue
|
||||
}
|
||||
|
||||
if (normalizedContent[index] === '<' && !isEscaped(normalizedContent, index)) {
|
||||
if (htmlSuperscriptLinks) {
|
||||
const superscriptLink = matchHtmlSuperscriptLinkSource(normalizedContent, index)
|
||||
if (superscriptLink) {
|
||||
result += transport.create('html-superscript-link', JSON.stringify(superscriptLink.value))
|
||||
index = superscriptLink.end
|
||||
continue
|
||||
}
|
||||
}
|
||||
const inlineHtml = matchInlineHtml(normalizedContent.slice(index))
|
||||
if (inlineHtml) {
|
||||
result += createPlaceholder('inline', inlineHtml)
|
||||
result += transport.create('inline-html', inlineHtml)
|
||||
index += inlineHtml.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Why: doc link encoding runs inside the same while loop (not a separate
|
||||
// pre-pass) so that fenced code blocks and backtick code spans are already
|
||||
// skipped by the guards above. The [[ORCA_ prefix check prevents re-encoding
|
||||
// sibling placeholders that were already emitted earlier in this pass.
|
||||
// Why: doc link encoding runs inside this loop so fenced code and backtick
|
||||
// spans have already been excluded from semantic preprocessing.
|
||||
if (
|
||||
normalizedContent[index] === '[' &&
|
||||
normalizedContent[index + 1] === '[' &&
|
||||
!normalizedContent.startsWith('[[ORCA_', index) &&
|
||||
!isEscaped(normalizedContent, index)
|
||||
) {
|
||||
const closingIndex = normalizedContent.indexOf(']]', index + 2)
|
||||
if (closingIndex !== -1) {
|
||||
const rawTarget = normalizedContent.slice(index + 2, closingIndex)
|
||||
const link = parseMarkdownDocLink(rawTarget)
|
||||
if (link) {
|
||||
result += `${DOC_LINK_PLACEHOLDER_PREFIX}${formatMarkdownDocLinkBody(
|
||||
link.target,
|
||||
link.alias
|
||||
)}${PLACEHOLDER_SUFFIX}`
|
||||
if (link && !isReservedRichMarkdownTransportBody(rawTarget)) {
|
||||
result += transport.create(
|
||||
'document-link',
|
||||
formatMarkdownDocLinkBody(link.target, link.alias)
|
||||
)
|
||||
index = closingIndex + 2
|
||||
continue
|
||||
}
|
||||
@@ -226,129 +212,123 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
|
||||
return result
|
||||
}
|
||||
|
||||
export const RawMarkdownHtmlInline = Node.create({
|
||||
name: 'rawMarkdownHtmlInline',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
export function createRichMarkdownLiteral(transport: RichMarkdownSourceTransport) {
|
||||
return createRawSourceNode({
|
||||
name: 'richMarkdownLiteral',
|
||||
kind: 'literal',
|
||||
inline: true,
|
||||
transport,
|
||||
marker: 'data-rich-markdown-literal'
|
||||
})
|
||||
}
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
value: {
|
||||
default: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Why: converting embedded HTML tags into placeholder tokens before the
|
||||
// markdown parser runs keeps marked's built-in paragraph tokenization intact
|
||||
// while still letting Orca round-trip the raw markup verbatim.
|
||||
markdownTokenName: 'rawMarkdownHtmlInline',
|
||||
markdownTokenizer: {
|
||||
export function createRawMarkdownHtmlInline(transport: RichMarkdownSourceTransport) {
|
||||
return createRawSourceNode({
|
||||
name: 'rawMarkdownHtmlInline',
|
||||
level: 'inline',
|
||||
start: INLINE_PLACEHOLDER_PREFIX,
|
||||
tokenize(src) {
|
||||
const matched = matchPlaceholder(src, 'inline')
|
||||
if (!matched) {
|
||||
return undefined
|
||||
}
|
||||
kind: 'inline-html',
|
||||
inline: true,
|
||||
transport,
|
||||
marker: 'data-raw-markdown-html-inline',
|
||||
className: 'raw-markdown-html-inline'
|
||||
})
|
||||
}
|
||||
|
||||
function createRawSourceNode({
|
||||
name,
|
||||
kind,
|
||||
inline,
|
||||
transport,
|
||||
marker,
|
||||
className
|
||||
}: {
|
||||
name: string
|
||||
kind: RichMarkdownSourceKind
|
||||
inline: boolean
|
||||
transport: RichMarkdownSourceTransport
|
||||
marker: string
|
||||
className?: string
|
||||
}) {
|
||||
return Node.create({
|
||||
name,
|
||||
inline,
|
||||
group: inline ? 'inline' : 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
type: 'rawMarkdownHtmlInline',
|
||||
raw: matched.placeholder,
|
||||
text: matched.value
|
||||
value: {
|
||||
default: '',
|
||||
rendered: false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
parseMarkdown: (token, helpers) => {
|
||||
if (token.type !== 'rawMarkdownHtmlInline') {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
return helpers.createNode('rawMarkdownHtmlInline', {
|
||||
value: typeof token.text === 'string' ? token.text : ''
|
||||
})
|
||||
},
|
||||
renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
|
||||
// Why: converting embedded HTML tags into placeholder tokens before the
|
||||
// markdown parser runs keeps marked's built-in paragraph tokenization intact
|
||||
// while still letting Orca round-trip the raw markup verbatim.
|
||||
markdownTokenName: name,
|
||||
markdownTokenizer: {
|
||||
name,
|
||||
level: inline ? 'inline' : 'block',
|
||||
start: transport.startFor(kind),
|
||||
tokenize(src) {
|
||||
const matched = transport.match(src, kind)
|
||||
if (!matched) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-raw-markdown-html-inline]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-raw-markdown-html-inline': '',
|
||||
contenteditable: 'false',
|
||||
class: 'raw-markdown-html-inline'
|
||||
}),
|
||||
value
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
export const RawMarkdownHtmlBlock = Node.create({
|
||||
name: 'rawMarkdownHtmlBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
value: {
|
||||
default: ''
|
||||
return {
|
||||
type: name,
|
||||
raw: matched.raw,
|
||||
text: matched.value,
|
||||
block: !inline
|
||||
}
|
||||
}
|
||||
},
|
||||
parseMarkdown: (token, helpers) => {
|
||||
if (token.type !== name) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
markdownTokenName: 'rawMarkdownHtmlBlock',
|
||||
markdownTokenizer: {
|
||||
return helpers.createNode(name, {
|
||||
value: typeof token.text === 'string' ? token.text : ''
|
||||
})
|
||||
},
|
||||
renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
|
||||
renderText: ({ node }) => (typeof node.attrs.value === 'string' ? node.attrs.value : ''),
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `${inline ? 'span' : 'div'}[${marker}]`,
|
||||
getAttrs: (element: HTMLElement) => ({ value: element.textContent ?? '' })
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
|
||||
return [
|
||||
inline ? 'span' : 'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
[marker]: '',
|
||||
contenteditable: 'false',
|
||||
class: className
|
||||
}),
|
||||
inline ? value : ['pre', value]
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createRawMarkdownHtmlBlock(transport: RichMarkdownSourceTransport) {
|
||||
return createRawSourceNode({
|
||||
name: 'rawMarkdownHtmlBlock',
|
||||
level: 'block',
|
||||
start: BLOCK_PLACEHOLDER_PREFIX,
|
||||
tokenize(src) {
|
||||
const matched = matchPlaceholder(src, 'block')
|
||||
if (!matched) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'rawMarkdownHtmlBlock',
|
||||
raw: matched.placeholder,
|
||||
text: matched.value,
|
||||
block: true
|
||||
}
|
||||
}
|
||||
},
|
||||
parseMarkdown: (token, helpers) => {
|
||||
if (token.type !== 'rawMarkdownHtmlBlock') {
|
||||
return []
|
||||
}
|
||||
|
||||
return helpers.createNode('rawMarkdownHtmlBlock', {
|
||||
value: typeof token.text === 'string' ? token.text : ''
|
||||
})
|
||||
},
|
||||
renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-raw-markdown-html-block]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-raw-markdown-html-block': '',
|
||||
contenteditable: 'false',
|
||||
class: 'raw-markdown-html-block'
|
||||
}),
|
||||
['pre', value]
|
||||
]
|
||||
}
|
||||
})
|
||||
kind: 'block-html',
|
||||
inline: false,
|
||||
transport,
|
||||
marker: 'data-raw-markdown-html-block',
|
||||
className: 'raw-markdown-html-block'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { NodeSelection } from '@tiptap/pm/state'
|
||||
|
||||
export function handleRichMarkdownCitationKey({
|
||||
editor,
|
||||
event,
|
||||
linkBubbleOwnerId,
|
||||
onOpen
|
||||
}: {
|
||||
editor: Editor | null
|
||||
event: KeyboardEvent
|
||||
linkBubbleOwnerId: string
|
||||
onOpen?: () => boolean
|
||||
}): boolean {
|
||||
const selection = editor?.state?.selection
|
||||
if (
|
||||
!(selection instanceof NodeSelection) ||
|
||||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink' ||
|
||||
event.isComposing ||
|
||||
editor?.view.composing === true
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
return onOpen?.() ?? true
|
||||
}
|
||||
if (event.key !== 'Tab' || event.shiftKey) {
|
||||
return false
|
||||
}
|
||||
const firstAction = document.querySelector<HTMLButtonElement>(
|
||||
`[data-rich-markdown-link-bubble-owner="${linkBubbleOwnerId}"] button:not([disabled])`
|
||||
)
|
||||
if (!firstAction) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
firstAction.focus()
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { DOMSerializer, type Slice } from '@tiptap/pm/model'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
|
||||
export function serializeRichMarkdownSliceForClipboard(
|
||||
view: EditorView,
|
||||
slice: Slice
|
||||
): { html: string } {
|
||||
if (typeof view.serializeForClipboard === 'function') {
|
||||
return { html: view.serializeForClipboard(slice).dom.innerHTML }
|
||||
}
|
||||
// Why: lightweight unit-test views predate ProseMirror's public clipboard
|
||||
// serializer; production always takes the metadata-preserving branch above.
|
||||
const fragment = DOMSerializer.fromSchema(view.state.schema).serializeFragment(slice.content)
|
||||
const container = document.createElement('div')
|
||||
container.appendChild(fragment)
|
||||
return { html: container.innerHTML }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Slice } from '@tiptap/pm/model'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { serializeRichMarkdownSliceForClipboard } from './rich-markdown-clipboard-serialization'
|
||||
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
|
||||
import { showRichMarkdownSourceOwningCutLimitError } from './rich-markdown-source-owning-cut-feedback'
|
||||
|
||||
/**
|
||||
* Writes a rich-markdown slice to the clipboard (HTML + plain), verifying
|
||||
* readback. Returns false when the write must not be followed by a delete
|
||||
* (source-owning limit or clipboard rejection).
|
||||
*/
|
||||
export function writeRichMarkdownSliceToClipboard(
|
||||
clipboardData: DataTransfer,
|
||||
view: EditorView,
|
||||
slice: Slice,
|
||||
visibleText: string
|
||||
): boolean {
|
||||
const status = inspectRichMarkdownSourceOwningSlice(slice)
|
||||
if (status.containsSourceOwningNode && !status.canPreserve) {
|
||||
showRichMarkdownSourceOwningCutLimitError()
|
||||
return false
|
||||
}
|
||||
const serialized = serializeRichMarkdownSliceForClipboard(view, slice)
|
||||
clipboardData.setData('text/html', serialized.html)
|
||||
clipboardData.setData('text/plain', visibleText)
|
||||
// Why: if the clipboard rejected the write we must not delete, and we must
|
||||
// surface the same cut-limit feedback so the no-op is not silent.
|
||||
if (
|
||||
typeof clipboardData.getData === 'function' &&
|
||||
(clipboardData.getData('text/html') !== serialized.html ||
|
||||
clipboardData.getData('text/plain') !== visibleText)
|
||||
) {
|
||||
showRichMarkdownSourceOwningCutLimitError()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { runSlashCommand, slashCommands, type SlashCommandId } from './rich-markdown-slash-commands'
|
||||
|
||||
function createEditor(content = '/'): Editor {
|
||||
return new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
|
||||
content,
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { DOMSerializer } from '@tiptap/pm/model'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { writeRichMarkdownSliceToClipboard } from './rich-markdown-clipboard-write'
|
||||
import { cutVisualLine, getVisualLineRange } from './rich-markdown-visual-line'
|
||||
import { createRichMarkdownVisibleTextMap } from './rich-markdown-visible-text-map'
|
||||
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
|
||||
import { showRichMarkdownSourceOwningCutLimitError } from './rich-markdown-source-owning-cut-feedback'
|
||||
|
||||
function deleteBlockAndRestoreSelection(view: EditorView, from: number, to: number): void {
|
||||
let tr = view.state.tr.delete(from, to)
|
||||
@@ -24,6 +27,12 @@ function deleteBlockAndRestoreSelection(view: EditorView, from: number, to: numb
|
||||
export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent): boolean {
|
||||
const { selection } = view.state
|
||||
if (!selection.empty) {
|
||||
const status = inspectRichMarkdownSourceOwningSlice(selection.content())
|
||||
if (status.containsSourceOwningNode && !status.canPreserve) {
|
||||
event.preventDefault()
|
||||
showRichMarkdownSourceOwningCutLimitError()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -54,7 +63,9 @@ export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent):
|
||||
}
|
||||
|
||||
const cutNode = $from.node(cutDepth)
|
||||
const text = cutNode.textContent
|
||||
const contentFrom = $from.start(cutDepth)
|
||||
const contentTo = $from.end(cutDepth)
|
||||
const text = createRichMarkdownVisibleTextMap(view.state.doc, contentFrom, contentTo).text
|
||||
|
||||
// Why: for paragraphs that word-wrap across multiple visual lines, cut
|
||||
// only the visual line the cursor is on rather than the entire paragraph.
|
||||
@@ -85,15 +96,10 @@ export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent):
|
||||
}
|
||||
event.preventDefault()
|
||||
|
||||
// Why: writing both text/html and text/plain preserves inline formatting
|
||||
// (bold, italic, links) on round-trip cut-then-paste, while still giving
|
||||
// a plain-text fallback for external targets.
|
||||
const serializer = DOMSerializer.fromSchema(view.state.schema)
|
||||
const fragment = serializer.serializeFragment(cutNode.content)
|
||||
const div = document.createElement('div')
|
||||
div.appendChild(fragment)
|
||||
event.clipboardData.setData('text/html', div.innerHTML)
|
||||
event.clipboardData.setData('text/plain', text)
|
||||
const slice = view.state.doc.slice($from.before(cutDepth), $from.after(cutDepth))
|
||||
if (!writeRichMarkdownSliceToClipboard(event.clipboardData, view, slice, text)) {
|
||||
return true
|
||||
}
|
||||
|
||||
deleteBlockAndRestoreSelection(view, $from.before(cutDepth), $from.after(cutDepth))
|
||||
|
||||
|
||||
@@ -10,10 +10,16 @@ import { Table } from '@tiptap/extension-table'
|
||||
import { TableCell } from '@tiptap/extension-table-cell'
|
||||
import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import { TableRow } from '@tiptap/extension-table-row'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
|
||||
import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
|
||||
|
||||
const showCutLimitErrorMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./rich-markdown-source-owning-cut-feedback', () => ({
|
||||
showRichMarkdownSourceOwningCutLimitError: showCutLimitErrorMock
|
||||
}))
|
||||
|
||||
/**
|
||||
* Minimal extensions matching the rich editor schema without UI dependencies.
|
||||
*/
|
||||
@@ -25,7 +31,7 @@ const testExtensions = [
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown.configure({ markedOptions: { gfm: true } })
|
||||
createIsolatedMarkdownExtensionForTests()
|
||||
]
|
||||
|
||||
function createEditor(markdown: string): Editor {
|
||||
@@ -38,6 +44,7 @@ function createEditor(markdown: string): Editor {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
showCutLimitErrorMock.mockReset()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -109,7 +116,7 @@ function countParagraphs(editor: Editor): number {
|
||||
return count
|
||||
}
|
||||
|
||||
function createClipboardEventMock(): {
|
||||
function createClipboardEventMock(options?: { failReadback?: boolean }): {
|
||||
data: Map<string, string>
|
||||
event: ClipboardEvent
|
||||
preventDefault: ReturnType<typeof vi.fn>
|
||||
@@ -120,7 +127,10 @@ function createClipboardEventMock(): {
|
||||
clipboardData: {
|
||||
setData: vi.fn((type: string, value: string) => {
|
||||
data.set(type, value)
|
||||
})
|
||||
}),
|
||||
getData: options?.failReadback
|
||||
? vi.fn(() => '')
|
||||
: vi.fn((type: string) => data.get(type) ?? '')
|
||||
},
|
||||
preventDefault
|
||||
} as unknown as ClipboardEvent
|
||||
@@ -243,6 +253,38 @@ describe('rich markdown cut handler behavior', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('surfaces cut-limit feedback when clipboard readback fails', () => {
|
||||
const editor = createEditor('Body text to cut.\n')
|
||||
try {
|
||||
const pos = 1
|
||||
let viewState = editor.state.apply(
|
||||
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
|
||||
)
|
||||
const view = {
|
||||
get state() {
|
||||
return viewState
|
||||
},
|
||||
dispatch: vi.fn((tr) => {
|
||||
viewState = viewState.apply(tr)
|
||||
}),
|
||||
domAtPos: vi.fn(() => ({ node: document.createElement('p'), offset: 0 })),
|
||||
coordsAtPos: vi.fn(() => ({ top: 0, bottom: 20, left: 0, right: 20 })),
|
||||
posAtCoords: vi.fn(() => null)
|
||||
} as unknown as EditorView
|
||||
|
||||
const clipboard = createClipboardEventMock({ failReadback: true })
|
||||
const handled = handleRichMarkdownCut(view, clipboard.event)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(clipboard.preventDefault).toHaveBeenCalled()
|
||||
expect(showCutLimitErrorMock).toHaveBeenCalledTimes(1)
|
||||
expect(view.dispatch).not.toHaveBeenCalled()
|
||||
expect(view.state.doc.textContent).toBe('Body text to cut.')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('paragraphs separated by blank lines are separate blocks', () => {
|
||||
const editor = createEditor('First paragraph.\n\nSecond paragraph.\n\nThird paragraph.\n')
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
moveFromEmptyDetailsBodyToSummary
|
||||
} from './rich-markdown-details-extension'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
|
||||
function createEditor(content: string | JSONContent) {
|
||||
return new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
|
||||
content,
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import type { DOMOutputSpec } from '@tiptap/pm/model'
|
||||
|
||||
export function renderRichMarkdownDocLinkHtml(
|
||||
node: { attrs: Record<string, unknown> },
|
||||
htmlAttributes: Record<string, unknown>
|
||||
): DOMOutputSpec {
|
||||
const target = typeof node.attrs.target === 'string' ? node.attrs.target : ''
|
||||
const label = typeof node.attrs.label === 'string' ? node.attrs.label : null
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(htmlAttributes, {
|
||||
'data-doc-link-target': target,
|
||||
...(label ? { 'data-doc-link-label': label } : {}),
|
||||
contenteditable: 'false',
|
||||
class: 'rich-markdown-doc-link'
|
||||
}),
|
||||
label ?? target
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { Node } from '@tiptap/core'
|
||||
import { type EditorState, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import type { MarkdownDocument } from '../../../../shared/types'
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
resolveMarkdownDocLink
|
||||
} from './markdown-doc-links'
|
||||
import { isDocLinkLiteralCodeTextNode } from './rich-markdown-doc-link-code-context'
|
||||
|
||||
const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
|
||||
const DOC_LINK_PLACEHOLDER_SUFFIX = ']]'
|
||||
import {
|
||||
isReservedRichMarkdownTransportBody,
|
||||
type RichMarkdownSourceTransport
|
||||
} from './rich-markdown-source-transport'
|
||||
import { renderRichMarkdownDocLinkHtml } from './rich-markdown-doc-link-dom'
|
||||
|
||||
// Why: `.matchAll()` at each call site creates a fresh iterator so the shared
|
||||
// `/g` regex never leaks `lastIndex` state across nested or concurrent scans.
|
||||
@@ -55,7 +57,9 @@ function buildPreviewDecorations(state: EditorState, storage: DocLinkStorage): D
|
||||
return
|
||||
}
|
||||
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
|
||||
const link = parseMarkdownDocLink(match[1])
|
||||
const link = isReservedRichMarkdownTransportBody(match[1])
|
||||
? null
|
||||
: parseMarkdownDocLink(match[1])
|
||||
if (!link || match.index === undefined) {
|
||||
continue
|
||||
}
|
||||
@@ -96,262 +100,252 @@ function getDocLinkDisplayText(node: { attrs: Record<string, unknown> }): string
|
||||
return getDocLinkAlias(node) ?? getDocLinkTarget(node)
|
||||
}
|
||||
|
||||
export const MarkdownDocLink = Node.create({
|
||||
name: 'markdownDocLink',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
documents: [] as MarkdownDocument[],
|
||||
_cachedDocs: null as MarkdownDocument[] | null,
|
||||
_cachedIndex: null as MarkdownDocumentIndex | null
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
target: {
|
||||
default: '',
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-target') ?? ''
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-label')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
markdownTokenName: 'markdownDocLink',
|
||||
markdownTokenizer: {
|
||||
export function createMarkdownDocLink(transport: RichMarkdownSourceTransport) {
|
||||
return Node.create({
|
||||
name: 'markdownDocLink',
|
||||
level: 'inline',
|
||||
start: DOC_LINK_PLACEHOLDER_PREFIX,
|
||||
tokenize(src: string) {
|
||||
if (!src.startsWith(DOC_LINK_PLACEHOLDER_PREFIX)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const endIndex = src.indexOf(DOC_LINK_PLACEHOLDER_SUFFIX, DOC_LINK_PLACEHOLDER_PREFIX.length)
|
||||
if (endIndex === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const placeholder = src.slice(0, endIndex + DOC_LINK_PLACEHOLDER_SUFFIX.length)
|
||||
const link = parseMarkdownDocLink(src.slice(DOC_LINK_PLACEHOLDER_PREFIX.length, endIndex))
|
||||
if (!link) {
|
||||
return undefined
|
||||
}
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
type: 'markdownDocLink',
|
||||
raw: placeholder,
|
||||
text: link.target,
|
||||
label: link.alias ?? undefined
|
||||
documents: [] as MarkdownDocument[],
|
||||
_cachedDocs: null as MarkdownDocument[] | null,
|
||||
_cachedIndex: null as MarkdownDocumentIndex | null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parseMarkdown: (token, helpers) => {
|
||||
if (token.type !== 'markdownDocLink') {
|
||||
return []
|
||||
}
|
||||
return helpers.createNode('markdownDocLink', {
|
||||
target: typeof token.text === 'string' ? token.text : '',
|
||||
label:
|
||||
typeof (token as { label?: unknown }).label === 'string'
|
||||
? (token as { label: string }).label
|
||||
: null
|
||||
})
|
||||
},
|
||||
|
||||
renderMarkdown: (node) =>
|
||||
formatMarkdownDocLink(
|
||||
typeof node.attrs?.target === 'string' ? node.attrs.target : '',
|
||||
typeof node.attrs?.label === 'string' ? node.attrs.label : null
|
||||
),
|
||||
|
||||
addNodeView() {
|
||||
const storage = this.storage as DocLinkStorage
|
||||
return ({ node }: { node: { type: { name: string }; attrs: Record<string, unknown> } }) => {
|
||||
const target = getDocLinkTarget(node)
|
||||
const dom = document.createElement('span')
|
||||
dom.setAttribute('data-doc-link-target', target)
|
||||
const alias = getDocLinkAlias(node)
|
||||
if (alias) {
|
||||
dom.setAttribute('data-doc-link-label', alias)
|
||||
}
|
||||
dom.setAttribute('contenteditable', 'false')
|
||||
dom.textContent = getDocLinkDisplayText(node)
|
||||
|
||||
const applyResolutionClass = (t: string): void => {
|
||||
const resolved = resolveAgainstIndex(t, getDocIndex(storage))
|
||||
dom.className = resolved
|
||||
? 'rich-markdown-doc-link'
|
||||
: 'rich-markdown-doc-link rich-markdown-doc-link--missing'
|
||||
}
|
||||
|
||||
applyResolutionClass(target)
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
dom,
|
||||
// Why: this fires on every transaction, including the no-op dispatched
|
||||
// when the document list changes in storage. Re-checking resolution
|
||||
// here keeps the blue/grey styling current without a full re-render.
|
||||
update: (updatedNode: { type: { name: string }; attrs: Record<string, unknown> }) => {
|
||||
if (updatedNode.type.name !== 'markdownDocLink') {
|
||||
return false
|
||||
}
|
||||
const newTarget = getDocLinkTarget(updatedNode)
|
||||
const newAlias = getDocLinkAlias(updatedNode)
|
||||
dom.setAttribute('data-doc-link-target', newTarget)
|
||||
if (newAlias) {
|
||||
dom.setAttribute('data-doc-link-label', newAlias)
|
||||
} else {
|
||||
dom.removeAttribute('data-doc-link-label')
|
||||
}
|
||||
dom.textContent = getDocLinkDisplayText(updatedNode)
|
||||
applyResolutionClass(newTarget)
|
||||
return true
|
||||
target: {
|
||||
default: '',
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-target') ?? ''
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-label')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Why: a ProseMirror plugin (not an input rule) so that [[target]] typed in
|
||||
// any order — brackets first then target, paste, etc. — converts to a doc
|
||||
// link node. Input rules only fire on sequential append at the cursor.
|
||||
addProseMirrorPlugins() {
|
||||
const nodeType = this.type
|
||||
const storage = this.storage as DocLinkStorage
|
||||
return [
|
||||
// Why: when the cursor is adjacent to a doc link atom and the user presses
|
||||
// an arrow key toward it, dissolve the atom back to editable [[target]] text.
|
||||
// Without this, atom nodes are un-enterable — the cursor jumps over them.
|
||||
// Uses handleKeyDown (not addKeyboardShortcuts) so we can check modifier
|
||||
// keys and let Shift+Arrow extend the selection normally.
|
||||
new Plugin({
|
||||
key: docLinkDissolveKey,
|
||||
props: {
|
||||
handleKeyDown(view, event) {
|
||||
if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) {
|
||||
markdownTokenName: 'markdownDocLink',
|
||||
markdownTokenizer: {
|
||||
name: 'markdownDocLink',
|
||||
level: 'inline',
|
||||
start: transport.startFor('document-link'),
|
||||
tokenize(src: string) {
|
||||
const matched = transport.match(src, 'document-link')
|
||||
if (!matched) {
|
||||
return undefined
|
||||
}
|
||||
const link = isReservedRichMarkdownTransportBody(matched.value)
|
||||
? null
|
||||
: parseMarkdownDocLink(matched.value)
|
||||
if (!link) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'markdownDocLink',
|
||||
raw: matched.raw,
|
||||
text: link.target,
|
||||
label: link.alias ?? undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parseMarkdown: (token, helpers) => {
|
||||
if (token.type !== 'markdownDocLink') {
|
||||
return []
|
||||
}
|
||||
return helpers.createNode('markdownDocLink', {
|
||||
target: typeof token.text === 'string' ? token.text : '',
|
||||
label:
|
||||
typeof (token as { label?: unknown }).label === 'string'
|
||||
? (token as { label: string }).label
|
||||
: null
|
||||
})
|
||||
},
|
||||
|
||||
renderMarkdown: (node) =>
|
||||
formatMarkdownDocLink(
|
||||
typeof node.attrs?.target === 'string' ? node.attrs.target : '',
|
||||
typeof node.attrs?.label === 'string' ? node.attrs.label : null
|
||||
),
|
||||
renderText: ({ node }) => getDocLinkDisplayText(node),
|
||||
|
||||
addNodeView() {
|
||||
const storage = this.storage as DocLinkStorage
|
||||
return ({ node }: { node: { type: { name: string }; attrs: Record<string, unknown> } }) => {
|
||||
const target = getDocLinkTarget(node)
|
||||
const dom = document.createElement('span')
|
||||
dom.setAttribute('data-doc-link-target', target)
|
||||
const alias = getDocLinkAlias(node)
|
||||
if (alias) {
|
||||
dom.setAttribute('data-doc-link-label', alias)
|
||||
}
|
||||
dom.setAttribute('contenteditable', 'false')
|
||||
dom.textContent = getDocLinkDisplayText(node)
|
||||
|
||||
const applyResolutionClass = (t: string): void => {
|
||||
const resolved = resolveAgainstIndex(t, getDocIndex(storage))
|
||||
dom.className = resolved
|
||||
? 'rich-markdown-doc-link'
|
||||
: 'rich-markdown-doc-link rich-markdown-doc-link--missing'
|
||||
}
|
||||
|
||||
applyResolutionClass(target)
|
||||
|
||||
return {
|
||||
dom,
|
||||
// Why: this fires on every transaction, including the no-op dispatched
|
||||
// when the document list changes in storage. Re-checking resolution
|
||||
// here keeps the blue/grey styling current without a full re-render.
|
||||
update: (updatedNode: { type: { name: string }; attrs: Record<string, unknown> }) => {
|
||||
if (updatedNode.type.name !== 'markdownDocLink') {
|
||||
return false
|
||||
}
|
||||
let direction: 'left' | 'right'
|
||||
if (event.key === 'ArrowLeft') {
|
||||
direction = 'left'
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
direction = 'right'
|
||||
const newTarget = getDocLinkTarget(updatedNode)
|
||||
const newAlias = getDocLinkAlias(updatedNode)
|
||||
dom.setAttribute('data-doc-link-target', newTarget)
|
||||
if (newAlias) {
|
||||
dom.setAttribute('data-doc-link-label', newAlias)
|
||||
} else {
|
||||
return false
|
||||
dom.removeAttribute('data-doc-link-label')
|
||||
}
|
||||
const { state } = view
|
||||
if (!(state.selection instanceof TextSelection)) {
|
||||
return false
|
||||
}
|
||||
const { $from } = state.selection
|
||||
const adjacent = direction === 'left' ? $from.nodeBefore : $from.nodeAfter
|
||||
if (!adjacent || adjacent.type.name !== 'markdownDocLink') {
|
||||
return false
|
||||
}
|
||||
const target = getDocLinkTarget(adjacent)
|
||||
const text = formatMarkdownDocLink(target, getDocLinkAlias(adjacent))
|
||||
const nodeStart = direction === 'left' ? $from.pos - adjacent.nodeSize : $from.pos
|
||||
const nodeEnd = nodeStart + adjacent.nodeSize
|
||||
const tr = state.tr.replaceWith(nodeStart, nodeEnd, state.schema.text(text))
|
||||
const cursorPos = direction === 'left' ? nodeStart + text.length - 2 : nodeStart + 2
|
||||
tr.setSelection(TextSelection.create(tr.doc, cursorPos))
|
||||
view.dispatch(tr)
|
||||
dom.textContent = getDocLinkDisplayText(updatedNode)
|
||||
applyResolutionClass(newTarget)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
new Plugin({
|
||||
key: docLinkAutoConvertKey,
|
||||
appendTransaction(_transactions, _oldState, newState) {
|
||||
const { tr } = newState
|
||||
const cursor = newState.selection.from
|
||||
let modified = false
|
||||
|
||||
newState.doc.descendants((node, pos, parent) => {
|
||||
if (node.type.name !== 'text' || !node.text) {
|
||||
return
|
||||
}
|
||||
if (isDocLinkLiteralCodeTextNode(node, parent)) {
|
||||
return
|
||||
// Why: a ProseMirror plugin (not an input rule) so that [[target]] typed in
|
||||
// any order — brackets first then target, paste, etc. — converts to a doc
|
||||
// link node. Input rules only fire on sequential append at the cursor.
|
||||
addProseMirrorPlugins() {
|
||||
const nodeType = this.type
|
||||
const storage = this.storage as DocLinkStorage
|
||||
return [
|
||||
// Why: when the cursor is adjacent to a doc link atom and the user presses
|
||||
// an arrow key toward it, dissolve the atom back to editable [[target]] text.
|
||||
// Without this, atom nodes are un-enterable — the cursor jumps over them.
|
||||
// Uses handleKeyDown (not addKeyboardShortcuts) so we can check modifier
|
||||
// keys and let Shift+Arrow extend the selection normally.
|
||||
new Plugin({
|
||||
key: docLinkDissolveKey,
|
||||
props: {
|
||||
handleKeyDown(view, event) {
|
||||
if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) {
|
||||
return false
|
||||
}
|
||||
let direction: 'left' | 'right'
|
||||
if (event.key === 'ArrowLeft') {
|
||||
direction = 'left'
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
direction = 'right'
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
const { state } = view
|
||||
if (!(state.selection instanceof TextSelection)) {
|
||||
return false
|
||||
}
|
||||
const { $from } = state.selection
|
||||
const adjacent = direction === 'left' ? $from.nodeBefore : $from.nodeAfter
|
||||
if (!adjacent || adjacent.type.name !== 'markdownDocLink') {
|
||||
return false
|
||||
}
|
||||
const target = getDocLinkTarget(adjacent)
|
||||
const text = formatMarkdownDocLink(target, getDocLinkAlias(adjacent))
|
||||
const nodeStart = direction === 'left' ? $from.pos - adjacent.nodeSize : $from.pos
|
||||
const nodeEnd = nodeStart + adjacent.nodeSize
|
||||
const tr = state.tr.replaceWith(nodeStart, nodeEnd, state.schema.text(text))
|
||||
const cursorPos = direction === 'left' ? nodeStart + text.length - 2 : nodeStart + 2
|
||||
tr.setSelection(TextSelection.create(tr.doc, cursorPos))
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
|
||||
const link = parseMarkdownDocLink(match[1])
|
||||
if (!link || match.index === undefined) {
|
||||
continue
|
||||
new Plugin({
|
||||
key: docLinkAutoConvertKey,
|
||||
appendTransaction(_transactions, _oldState, newState) {
|
||||
const { tr } = newState
|
||||
const cursor = newState.selection.from
|
||||
let modified = false
|
||||
|
||||
newState.doc.descendants((node, pos, parent) => {
|
||||
if (node.type.name !== 'text' || !node.text) {
|
||||
return
|
||||
}
|
||||
if (isDocLinkLiteralCodeTextNode(node, parent)) {
|
||||
return
|
||||
}
|
||||
|
||||
const from = pos + match.index
|
||||
const to = from + match[0].length
|
||||
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
|
||||
const link = isReservedRichMarkdownTransportBody(match[1])
|
||||
? null
|
||||
: parseMarkdownDocLink(match[1])
|
||||
if (!link || match.index === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Why: skip when the cursor is anywhere from just inside [[
|
||||
// through the closing ]]. The inline preview decoration gives
|
||||
// real-time resolution feedback while the user is still editing.
|
||||
if (cursor > from && cursor <= to) {
|
||||
continue
|
||||
const from = pos + match.index
|
||||
const to = from + match[0].length
|
||||
|
||||
// Why: skip when the cursor is anywhere from just inside [[
|
||||
// through the closing ]]. The inline preview decoration gives
|
||||
// real-time resolution feedback while the user is still editing.
|
||||
if (cursor > from && cursor <= to) {
|
||||
continue
|
||||
}
|
||||
|
||||
const docLinkNode = nodeType.create({ target: link.target, label: link.alias })
|
||||
tr.replaceWith(tr.mapping.map(from), tr.mapping.map(to), docLinkNode)
|
||||
modified = true
|
||||
}
|
||||
})
|
||||
|
||||
const docLinkNode = nodeType.create({ target: link.target, label: link.alias })
|
||||
tr.replaceWith(tr.mapping.map(from), tr.mapping.map(to), docLinkNode)
|
||||
modified = true
|
||||
return modified ? tr : null
|
||||
}
|
||||
}),
|
||||
|
||||
// Why: while the cursor is inside [[target]], the text hasn't converted
|
||||
// to an atom node yet. This decoration gives real-time blue/grey feedback
|
||||
// so the user knows whether the target resolves before moving the cursor out.
|
||||
new Plugin({
|
||||
key: docLinkInlinePreviewKey,
|
||||
state: {
|
||||
init(_, state) {
|
||||
return buildPreviewDecorations(state, storage)
|
||||
},
|
||||
apply(tr, prev, oldState, newState) {
|
||||
const selectionMoved = !oldState.selection.eq(newState.selection)
|
||||
if (!tr.docChanged && !selectionMoved && !tr.getMeta('docLinksUpdated')) {
|
||||
return prev
|
||||
}
|
||||
return buildPreviewDecorations(newState, storage)
|
||||
}
|
||||
})
|
||||
|
||||
return modified ? tr : null
|
||||
}
|
||||
}),
|
||||
|
||||
// Why: while the cursor is inside [[target]], the text hasn't converted
|
||||
// to an atom node yet. This decoration gives real-time blue/grey feedback
|
||||
// so the user knows whether the target resolves before moving the cursor out.
|
||||
new Plugin({
|
||||
key: docLinkInlinePreviewKey,
|
||||
state: {
|
||||
init(_, state) {
|
||||
return buildPreviewDecorations(state, storage)
|
||||
},
|
||||
apply(tr, prev, oldState, newState) {
|
||||
const selectionMoved = !oldState.selection.eq(newState.selection)
|
||||
if (!tr.docChanged && !selectionMoved && !tr.getMeta('docLinksUpdated')) {
|
||||
return prev
|
||||
props: {
|
||||
decorations(state) {
|
||||
return docLinkInlinePreviewKey.getState(state)
|
||||
}
|
||||
return buildPreviewDecorations(newState, storage)
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return docLinkInlinePreviewKey.getState(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
},
|
||||
})
|
||||
]
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-doc-link-target]' }]
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-doc-link-target]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const target = typeof node.attrs.target === 'string' ? node.attrs.target : ''
|
||||
const label = typeof node.attrs.label === 'string' ? node.attrs.label : null
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-doc-link-target': target,
|
||||
...(label ? { 'data-doc-link-label': label } : {}),
|
||||
contenteditable: 'false',
|
||||
class: 'rich-markdown-doc-link'
|
||||
}),
|
||||
label ?? target
|
||||
]
|
||||
}
|
||||
})
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
return renderRichMarkdownDocLinkHtml(node, HTMLAttributes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { MutableRefObject } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { toast } from 'sonner'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
@@ -14,6 +13,10 @@ import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
|
||||
import { getRichMarkdownCommentAtPos } from './rich-markdown-review-annotations'
|
||||
import type { DiffComment } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
classifyHtmlSuperscriptLinkAction,
|
||||
type RichMarkdownHtmlSuperscriptLinkContext
|
||||
} from './rich-markdown-html-superscript-link-context'
|
||||
|
||||
export type ActivateMarkdownLink = (
|
||||
href: string,
|
||||
@@ -22,6 +25,7 @@ export type ActivateMarkdownLink = (
|
||||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
}
|
||||
) => void | Promise<unknown>
|
||||
|
||||
@@ -33,6 +37,7 @@ type RichMarkdownEditorClickRoutingOptions = {
|
||||
event: MouseEvent
|
||||
filePath: string
|
||||
isMac: boolean
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
markdownCommentsRef: MutableRefObject<DiffComment[]>
|
||||
markdownSourceLineOffsetRef: MutableRefObject<number>
|
||||
onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined>
|
||||
@@ -52,6 +57,7 @@ export function handleRichMarkdownEditorClick({
|
||||
event,
|
||||
filePath,
|
||||
isMac,
|
||||
htmlSuperscriptLinkContext,
|
||||
markdownCommentsRef,
|
||||
markdownSourceLineOffsetRef,
|
||||
onOpenDocLinkRef,
|
||||
@@ -65,6 +71,8 @@ export function handleRichMarkdownEditorClick({
|
||||
worktreeRoot
|
||||
}: RichMarkdownEditorClickRoutingOptions): boolean {
|
||||
const editor = editorRef.current
|
||||
const sourceSnapshot = htmlSuperscriptLinkContext.getSnapshot()
|
||||
const sourceOwner = sourceSnapshot.sourceOwner
|
||||
const modKey = isMac ? event.metaKey : event.ctrlKey
|
||||
if (!editor) {
|
||||
return false
|
||||
@@ -88,6 +96,7 @@ export function handleRichMarkdownEditorClick({
|
||||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
src: (clickedNode.attrs.src as string | undefined) ?? '',
|
||||
sourceOwner,
|
||||
worktreeId,
|
||||
worktreeRoot
|
||||
})
|
||||
@@ -96,7 +105,16 @@ export function handleRichMarkdownEditorClick({
|
||||
onOpenDocLinkRef.current?.(clickedNode.attrs.target as string)
|
||||
return true
|
||||
}
|
||||
const href = getClickedLinkHref(view, pos)
|
||||
const href =
|
||||
clickedNode?.type.name === 'richMarkdownHtmlSuperscriptLink'
|
||||
? String(clickedNode.attrs.href ?? '')
|
||||
: getClickedLinkHref(view, pos)
|
||||
if (
|
||||
clickedNode?.type.name === 'richMarkdownHtmlSuperscriptLink' &&
|
||||
!classifyHtmlSuperscriptLinkAction(href, sourceSnapshot)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (!href) {
|
||||
return false
|
||||
}
|
||||
@@ -109,8 +127,8 @@ export function handleRichMarkdownEditorClick({
|
||||
href,
|
||||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
sourceOwner,
|
||||
settings,
|
||||
worktreeId,
|
||||
worktreeRoot
|
||||
})
|
||||
return true
|
||||
@@ -119,7 +137,8 @@ export function handleRichMarkdownEditorClick({
|
||||
sourceFilePath: filePath,
|
||||
worktreeId,
|
||||
worktreeRoot,
|
||||
runtimeEnvironmentId
|
||||
runtimeEnvironmentId,
|
||||
sourceOwner
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -128,6 +147,7 @@ function activateMarkdownImageClick({
|
||||
activateMarkdownLink,
|
||||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
sourceOwner,
|
||||
src,
|
||||
worktreeId,
|
||||
worktreeRoot
|
||||
@@ -135,6 +155,7 @@ function activateMarkdownImageClick({
|
||||
activateMarkdownLink: ActivateMarkdownLink
|
||||
filePath: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
src: string
|
||||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
@@ -146,7 +167,8 @@ function activateMarkdownImageClick({
|
||||
sourceFilePath: filePath,
|
||||
worktreeId,
|
||||
worktreeRoot,
|
||||
runtimeEnvironmentId
|
||||
runtimeEnvironmentId,
|
||||
sourceOwner
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -162,24 +184,27 @@ function getClickedLinkHref(view: EditorView, pos: number): string {
|
||||
function openMarkdownLinkInClientOs({
|
||||
href,
|
||||
filePath,
|
||||
worktreeId,
|
||||
worktreeRoot,
|
||||
runtimeEnvironmentId,
|
||||
sourceOwner,
|
||||
settings
|
||||
}: {
|
||||
href: string
|
||||
filePath: string
|
||||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
sourceOwner: HttpLinkSourceOwner
|
||||
settings: RichMarkdownRuntimeSettings
|
||||
}): void {
|
||||
if (sourceOwner.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot)
|
||||
if (!classified) {
|
||||
return
|
||||
}
|
||||
if (classified.kind === 'external') {
|
||||
openHttpLink(classified.url, { forceSystemBrowser: true })
|
||||
openHttpLink(classified.url, { forceSystemBrowser: true, sourceOwner })
|
||||
return
|
||||
}
|
||||
if (classified.kind === 'anchor') {
|
||||
@@ -187,7 +212,7 @@ function openMarkdownLinkInClientOs({
|
||||
}
|
||||
if (
|
||||
isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, runtimeEnvironmentId), {
|
||||
connectionId: getConnectionId(worktreeId)
|
||||
connectionId: sourceOwner.kind === 'ssh' ? sourceOwner.connectionId : undefined
|
||||
})
|
||||
) {
|
||||
// Why: Shift-click opens through the client OS, which cannot safely resolve
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type EditorConfigParams
|
||||
} from './rich-markdown-editor-config'
|
||||
import type { SlashMenuState } from './rich-markdown-slash-commands'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
|
||||
function ref<T>(current: T): MutableRefObject<T> {
|
||||
return { current }
|
||||
@@ -25,7 +27,15 @@ function getSpellcheckAttribute(config: ReturnType<typeof createRichMarkdownEdit
|
||||
}
|
||||
|
||||
function createConfigParams(overrides: Partial<EditorConfigParams> = {}): EditorConfigParams {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
return {
|
||||
codec,
|
||||
htmlSuperscriptLinkContext: createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' }
|
||||
}),
|
||||
content: '',
|
||||
filePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type DocLinkMenuState
|
||||
} from './rich-markdown-commands'
|
||||
import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation'
|
||||
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import {
|
||||
handleRichMarkdownEditorClick,
|
||||
type ActivateMarkdownLink,
|
||||
@@ -29,8 +29,16 @@ import {
|
||||
import { getRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck'
|
||||
import type { MutableRefObject, Dispatch, SetStateAction } from 'react'
|
||||
import type { DiffComment } from '../../../../shared/types'
|
||||
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
import {
|
||||
getRichMarkdownSelectionLinkBubble,
|
||||
openSelectedHtmlSuperscriptLink
|
||||
} from './rich-markdown-selected-link-actions'
|
||||
|
||||
export type EditorConfigParams = {
|
||||
codec: RichMarkdownEditorCodec
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
content: string
|
||||
filePath: string
|
||||
worktreeId: string
|
||||
@@ -79,6 +87,8 @@ export type EditorConfigParams = {
|
||||
export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseEditorOptions {
|
||||
const {
|
||||
content,
|
||||
codec,
|
||||
htmlSuperscriptLinkContext,
|
||||
filePath,
|
||||
worktreeId,
|
||||
worktreeRoot,
|
||||
@@ -125,7 +135,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
||||
|
||||
return {
|
||||
immediatelyRender: false,
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }),
|
||||
contentType: 'markdown' as const,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
@@ -135,13 +145,15 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
||||
handleDOMEvents: {
|
||||
cut: handleRichMarkdownCut
|
||||
},
|
||||
handlePaste: (_view, event) =>
|
||||
handlePaste: (view, event, slice) =>
|
||||
handleRichMarkdownPaste({
|
||||
editor: editorRef.current,
|
||||
event,
|
||||
filePath,
|
||||
worktreeId,
|
||||
runtimeEnvironmentId
|
||||
runtimeEnvironmentId,
|
||||
slice,
|
||||
view
|
||||
}),
|
||||
handleTextInput: (view, from, to, text) => {
|
||||
typedEmptyOrderedListMarkerRef.current = false
|
||||
@@ -172,12 +184,22 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
||||
typedEmptyOrderedListMarkerRef,
|
||||
flushPendingSerialization,
|
||||
openSearchRef,
|
||||
linkBubbleOwnerId: codec.transport.key,
|
||||
htmlSuperscriptLinkContext,
|
||||
setIsEditingLink,
|
||||
setLinkBubble,
|
||||
setSelectedCommandIndex,
|
||||
setSelectedDocLinkIndex,
|
||||
setSlashMenu,
|
||||
setDocLinkMenu
|
||||
setDocLinkMenu,
|
||||
openSelectedHtmlSuperscriptLink: () =>
|
||||
openSelectedHtmlSuperscriptLink({
|
||||
activateMarkdownLink,
|
||||
context: htmlSuperscriptLinkContext,
|
||||
editor: editorRef.current,
|
||||
root: rootRef.current,
|
||||
runtimeEnvironmentId
|
||||
})
|
||||
}),
|
||||
handleClick: (view, pos, event) => {
|
||||
return handleRichMarkdownEditorClick({
|
||||
@@ -185,6 +207,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
||||
editorRef,
|
||||
event,
|
||||
filePath,
|
||||
htmlSuperscriptLinkContext,
|
||||
isMac,
|
||||
markdownCommentsRef,
|
||||
markdownSourceLineOffsetRef,
|
||||
@@ -259,13 +282,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
||||
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
|
||||
syncAnnotationTarget(nextEditor)
|
||||
setIsEditingLink(false)
|
||||
if (nextEditor.isActive('link')) {
|
||||
const attrs = nextEditor.getAttributes('link')
|
||||
const pos = getLinkBubblePosition(nextEditor, rootRef.current)
|
||||
setLinkBubble(pos ? { href: (attrs.href as string) || '', ...pos } : null)
|
||||
} else {
|
||||
setLinkBubble(null)
|
||||
}
|
||||
setLinkBubble(
|
||||
getRichMarkdownSelectionLinkBubble(nextEditor, rootRef.current, htmlSuperscriptLinkContext)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
|
||||
|
||||
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
|
||||
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
|
||||
|
||||
const hardWrappedMarkdown =
|
||||
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
|
||||
@@ -16,16 +16,23 @@ import { Markdown } from '@tiptap/markdown'
|
||||
import { createLowlight, common } from 'lowlight'
|
||||
import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html'
|
||||
import {
|
||||
createRawMarkdownHtmlBlock,
|
||||
createRawMarkdownHtmlInline,
|
||||
createRichMarkdownLiteral
|
||||
} from './raw-markdown-html'
|
||||
import {
|
||||
createOrcaDetailsExtensions,
|
||||
getRichMarkdownPlaceholder
|
||||
} from './rich-markdown-details-extension'
|
||||
import { MarkdownDocLink } from './rich-markdown-doc-link'
|
||||
import { createMarkdownDocLink } from './rich-markdown-doc-link'
|
||||
import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock'
|
||||
import { safeReactNodeViewRenderer } from './safe-react-node-view-renderer'
|
||||
import { DragSelectionGuard } from './drag-selection-guard'
|
||||
import { createRichMarkdownAnnotationHighlightExtension } from './rich-markdown-annotation-highlight'
|
||||
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { createRichMarkdownHtmlSuperscriptLink } from './rich-markdown-html-superscript-link'
|
||||
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
|
||||
const lowlight = createLowlight(common)
|
||||
|
||||
@@ -42,10 +49,19 @@ const RichMarkdownCode = Code.extend({
|
||||
})
|
||||
|
||||
export function createRichMarkdownExtensions({
|
||||
includePlaceholder = false
|
||||
codec,
|
||||
includePlaceholder = false,
|
||||
htmlSuperscriptLinks = false,
|
||||
htmlSuperscriptLinkContext
|
||||
}: {
|
||||
codec: RichMarkdownEditorCodec
|
||||
includePlaceholder?: boolean
|
||||
} = {}): AnyExtension[] {
|
||||
htmlSuperscriptLinks?: boolean
|
||||
htmlSuperscriptLinkContext?: RichMarkdownHtmlSuperscriptLinkContext
|
||||
}): AnyExtension[] {
|
||||
if (htmlSuperscriptLinks && !htmlSuperscriptLinkContext) {
|
||||
throw new Error('HTML superscript links require a document interaction context')
|
||||
}
|
||||
const extensions: AnyExtension[] = [
|
||||
// Why: rich-mode detection must use the exact same markdown extension set as
|
||||
// the live editor. If these drift, Orca can claim a document is editable in
|
||||
@@ -196,11 +212,16 @@ export function createRichMarkdownExtensions({
|
||||
throwOnError: false
|
||||
}
|
||||
}),
|
||||
RawMarkdownHtmlInline,
|
||||
RawMarkdownHtmlBlock,
|
||||
MarkdownDocLink,
|
||||
createRichMarkdownLiteral(codec.transport),
|
||||
...(htmlSuperscriptLinks
|
||||
? [createRichMarkdownHtmlSuperscriptLink(codec.transport, htmlSuperscriptLinkContext!)]
|
||||
: []),
|
||||
createRawMarkdownHtmlInline(codec.transport),
|
||||
createRawMarkdownHtmlBlock(codec.transport),
|
||||
createMarkdownDocLink(codec.transport),
|
||||
DragSelectionGuard,
|
||||
Markdown.configure({
|
||||
marked: codec.marked,
|
||||
markedOptions: {
|
||||
gfm: true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import { resolveMarkdownLinkTarget } from './markdown-internal-links'
|
||||
|
||||
export type RichMarkdownHtmlSuperscriptLinkContextSnapshot = {
|
||||
version: number
|
||||
sourceFilePath: string
|
||||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
sourceOwner: HttpLinkSourceOwner
|
||||
}
|
||||
|
||||
export type RichMarkdownHtmlSuperscriptLinkContext = {
|
||||
getSnapshot: () => RichMarkdownHtmlSuperscriptLinkContextSnapshot
|
||||
subscribe: (listener: () => void) => () => void
|
||||
update: (snapshot: Omit<RichMarkdownHtmlSuperscriptLinkContextSnapshot, 'version'>) => void
|
||||
}
|
||||
|
||||
export function createRichMarkdownHtmlSuperscriptLinkContext(
|
||||
initial: Omit<RichMarkdownHtmlSuperscriptLinkContextSnapshot, 'version'>
|
||||
): RichMarkdownHtmlSuperscriptLinkContext {
|
||||
let snapshot = { ...initial, version: 0 }
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
update: (next) => {
|
||||
if (
|
||||
next.sourceFilePath === snapshot.sourceFilePath &&
|
||||
next.worktreeId === snapshot.worktreeId &&
|
||||
next.worktreeRoot === snapshot.worktreeRoot &&
|
||||
sameOwner(next.sourceOwner, snapshot.sourceOwner)
|
||||
) {
|
||||
return
|
||||
}
|
||||
snapshot = { ...next, version: snapshot.version + 1 }
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyHtmlSuperscriptLinkAction(
|
||||
href: string,
|
||||
snapshot: RichMarkdownHtmlSuperscriptLinkContextSnapshot
|
||||
): boolean {
|
||||
if (snapshot.sourceOwner.kind === 'unknown' || /^[\t\n\f\r ]*$/.test(href)) {
|
||||
return false
|
||||
}
|
||||
const target = resolveMarkdownLinkTarget(href, snapshot.sourceFilePath, snapshot.worktreeRoot)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
return !(
|
||||
target.kind === 'file' &&
|
||||
target.relativePath === undefined &&
|
||||
(snapshot.sourceOwner.kind === 'runtime' || snapshot.sourceOwner.kind === 'ssh')
|
||||
)
|
||||
}
|
||||
|
||||
function sameOwner(left: HttpLinkSourceOwner, right: HttpLinkSourceOwner): boolean {
|
||||
if (left.kind !== right.kind) {
|
||||
return false
|
||||
}
|
||||
if (left.kind === 'runtime' && right.kind === 'runtime') {
|
||||
return left.runtimeEnvironmentId === right.runtimeEnvironmentId
|
||||
}
|
||||
if (left.kind === 'ssh' && right.kind === 'ssh') {
|
||||
return left.connectionId === right.connectionId
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
decodeHtmlAttributeCharacterReferences,
|
||||
decodeHtmlTextCharacterReferences,
|
||||
type HtmlAttributeQuote
|
||||
} from './html-character-reference-decoder'
|
||||
|
||||
export const HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT = 16 * 1024
|
||||
export const HTML_SUPERSCRIPT_LINK_HREF_LIMIT = 8 * 1024
|
||||
export const HTML_SUPERSCRIPT_LINK_TEXT_LIMIT = 2 * 1024
|
||||
|
||||
export type HtmlSuperscriptLinkSource = {
|
||||
source: string
|
||||
href: string
|
||||
label: string
|
||||
title: string | null
|
||||
}
|
||||
|
||||
export type HtmlSuperscriptLinkMatch = {
|
||||
end: number
|
||||
value: HtmlSuperscriptLinkSource
|
||||
}
|
||||
|
||||
export type HtmlSuperscriptLinkParseStats = {
|
||||
transitions: number
|
||||
}
|
||||
|
||||
type ParsedAttribute = {
|
||||
name: 'href' | 'title'
|
||||
rawValue: string
|
||||
quote: HtmlAttributeQuote
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
export function matchHtmlSuperscriptLinkSource(
|
||||
input: string,
|
||||
start = 0,
|
||||
stats?: HtmlSuperscriptLinkParseStats
|
||||
): HtmlSuperscriptLinkMatch | null {
|
||||
const limit = Math.min(input.length, start + HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT + 1)
|
||||
let index = matchSimpleTag(input, start, 'sup', false, limit, stats)
|
||||
if (index === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const anchor = matchAnchorStart(input, index, limit, stats)
|
||||
if (!anchor) {
|
||||
return null
|
||||
}
|
||||
index = anchor.end
|
||||
|
||||
const labelStart = index
|
||||
while (index < limit) {
|
||||
step(stats)
|
||||
const code = input.charCodeAt(index)
|
||||
if (code === 60 || code === 10 || code === 13) {
|
||||
break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
if (index === labelStart || index >= limit || input.charCodeAt(index) !== 60) {
|
||||
return null
|
||||
}
|
||||
const rawLabel = input.slice(labelStart, index)
|
||||
index = matchSimpleTag(input, index, 'a', true, limit, stats) ?? -1
|
||||
if (index < 0) {
|
||||
return null
|
||||
}
|
||||
index = matchSimpleTag(input, index, 'sup', true, limit, stats) ?? -1
|
||||
if (index < 0 || index - start > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hrefAttribute = anchor.attributes.find((attribute) => attribute.name === 'href')
|
||||
const titleAttribute = anchor.attributes.find((attribute) => attribute.name === 'title')
|
||||
if (!hrefAttribute) {
|
||||
return null
|
||||
}
|
||||
|
||||
const href = decodeHtmlAttributeCharacterReferences(hrefAttribute.rawValue, hrefAttribute.quote)
|
||||
const label = decodeHtmlTextCharacterReferences(rawLabel)
|
||||
const title = titleAttribute
|
||||
? decodeHtmlAttributeCharacterReferences(titleAttribute.rawValue, titleAttribute.quote)
|
||||
: null
|
||||
const source = input.slice(start, index)
|
||||
if (
|
||||
byteLength(source) > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT ||
|
||||
byteLength(href) > HTML_SUPERSCRIPT_LINK_HREF_LIMIT ||
|
||||
byteLength(label) > HTML_SUPERSCRIPT_LINK_TEXT_LIMIT ||
|
||||
(title !== null && byteLength(title) > HTML_SUPERSCRIPT_LINK_TEXT_LIMIT)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { end: index, value: { source, href, label, title } }
|
||||
}
|
||||
|
||||
export function parseHtmlSuperscriptLinkSource(source: string): HtmlSuperscriptLinkSource | null {
|
||||
const match = matchHtmlSuperscriptLinkSource(source)
|
||||
return match?.end === source.length ? match.value : null
|
||||
}
|
||||
|
||||
function matchAnchorStart(
|
||||
input: string,
|
||||
start: number,
|
||||
limit: number,
|
||||
stats?: HtmlSuperscriptLinkParseStats
|
||||
): { end: number; attributes: ParsedAttribute[] } | null {
|
||||
let index = start
|
||||
if (input.charCodeAt(index) !== 60 || lowerCode(input.charCodeAt(index + 1)) !== 97) {
|
||||
return null
|
||||
}
|
||||
step(stats, 2)
|
||||
index += 2
|
||||
if (!isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const attributes: ParsedAttribute[] = []
|
||||
while (index < limit) {
|
||||
const whitespaceStart = index
|
||||
while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
|
||||
step(stats)
|
||||
index += 1
|
||||
}
|
||||
if (input.charCodeAt(index) === 62) {
|
||||
step(stats)
|
||||
return attributes.some((attribute) => attribute.name === 'href')
|
||||
? { end: index + 1, attributes }
|
||||
: null
|
||||
}
|
||||
if (index === whitespaceStart) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nameStart = index
|
||||
while (isAttributeNameCode(input.charCodeAt(index))) {
|
||||
step(stats)
|
||||
index += 1
|
||||
}
|
||||
if (index === nameStart) {
|
||||
return null
|
||||
}
|
||||
const normalizedName = input.slice(nameStart, index).toLowerCase()
|
||||
if (normalizedName !== 'href' && normalizedName !== 'title') {
|
||||
return null
|
||||
}
|
||||
if (attributes.some((attribute) => attribute.name === normalizedName)) {
|
||||
return null
|
||||
}
|
||||
while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
|
||||
step(stats)
|
||||
index += 1
|
||||
}
|
||||
if (input.charCodeAt(index) !== 61) {
|
||||
return null
|
||||
}
|
||||
step(stats)
|
||||
index += 1
|
||||
while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
|
||||
step(stats)
|
||||
index += 1
|
||||
}
|
||||
|
||||
const parsedValue = parseAttributeValue(input, index, limit, stats)
|
||||
if (!parsedValue) {
|
||||
return null
|
||||
}
|
||||
attributes.push({
|
||||
name: normalizedName,
|
||||
rawValue: parsedValue.rawValue,
|
||||
quote: parsedValue.quote
|
||||
})
|
||||
index = parsedValue.end
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseAttributeValue(
|
||||
input: string,
|
||||
start: number,
|
||||
limit: number,
|
||||
stats?: HtmlSuperscriptLinkParseStats
|
||||
): { end: number; rawValue: string; quote: HtmlAttributeQuote } | null {
|
||||
const first = input[start]
|
||||
if (first === '"' || first === "'") {
|
||||
let index = start + 1
|
||||
while (index < limit && input[index] !== first) {
|
||||
step(stats)
|
||||
const code = input.charCodeAt(index)
|
||||
if (code === 10 || code === 13) {
|
||||
return null
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
if (index >= limit) {
|
||||
return null
|
||||
}
|
||||
step(stats)
|
||||
return {
|
||||
end: index + 1,
|
||||
rawValue: input.slice(start + 1, index),
|
||||
quote: first
|
||||
}
|
||||
}
|
||||
|
||||
let index = start
|
||||
while (index < limit) {
|
||||
const code = input.charCodeAt(index)
|
||||
if (isHtmlWhitespace(code) || code === 62) {
|
||||
break
|
||||
}
|
||||
step(stats)
|
||||
if (code === 34 || code === 39 || code === 60 || code === 61 || code === 96) {
|
||||
return null
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return index === start ? null : { end: index, rawValue: input.slice(start, index), quote: null }
|
||||
}
|
||||
|
||||
function matchSimpleTag(
|
||||
input: string,
|
||||
start: number,
|
||||
name: 'a' | 'sup',
|
||||
closing: boolean,
|
||||
limit: number,
|
||||
stats?: HtmlSuperscriptLinkParseStats
|
||||
): number | null {
|
||||
let index = start
|
||||
if (input.charCodeAt(index) !== 60) {
|
||||
return null
|
||||
}
|
||||
step(stats)
|
||||
index += 1
|
||||
if (closing) {
|
||||
if (input.charCodeAt(index) !== 47) {
|
||||
return null
|
||||
}
|
||||
step(stats)
|
||||
index += 1
|
||||
}
|
||||
for (let nameIndex = 0; nameIndex < name.length; nameIndex += 1) {
|
||||
step(stats)
|
||||
if (lowerCode(input.charCodeAt(index)) !== name.charCodeAt(nameIndex)) {
|
||||
return null
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
while (index < limit && isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
|
||||
step(stats)
|
||||
index += 1
|
||||
}
|
||||
if (input.charCodeAt(index) !== 62) {
|
||||
return null
|
||||
}
|
||||
step(stats)
|
||||
return index + 1
|
||||
}
|
||||
|
||||
function isAttributeNameCode(code: number): boolean {
|
||||
return (
|
||||
(code >= 65 && code <= 90) ||
|
||||
(code >= 97 && code <= 122) ||
|
||||
(code >= 48 && code <= 57) ||
|
||||
code === 95 ||
|
||||
code === 46 ||
|
||||
code === 58 ||
|
||||
code === 45
|
||||
)
|
||||
}
|
||||
|
||||
function isHtmlWhitespace(code: number): boolean {
|
||||
return code === 9 || code === 10 || code === 12 || code === 13 || code === 32
|
||||
}
|
||||
|
||||
function isSingleLineHtmlWhitespace(code: number): boolean {
|
||||
return code === 9 || code === 12 || code === 32
|
||||
}
|
||||
|
||||
function lowerCode(code: number): number {
|
||||
return code >= 65 && code <= 90 ? code + 32 : code
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return encoder.encode(value).byteLength
|
||||
}
|
||||
|
||||
function step(stats: HtmlSuperscriptLinkParseStats | undefined, count = 1): void {
|
||||
if (stats) {
|
||||
stats.transitions += count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { marked } from 'marked'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import {
|
||||
createRichMarkdownEditorCodec,
|
||||
createRichMarkdownSourceTransport
|
||||
} from './rich-markdown-source-transport'
|
||||
import {
|
||||
classifyHtmlSuperscriptLinkAction,
|
||||
createRichMarkdownHtmlSuperscriptLinkContext
|
||||
} from './rich-markdown-html-superscript-link-context'
|
||||
import {
|
||||
HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT,
|
||||
matchHtmlSuperscriptLinkSource,
|
||||
parseHtmlSuperscriptLinkSource
|
||||
} from './rich-markdown-html-superscript-link-source'
|
||||
import { projectMarkdownHrefForClipboard } from './markdown-internal-links'
|
||||
import { findRichMarkdownSearchMatches } from './rich-markdown-search'
|
||||
import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges'
|
||||
import { getRichMarkdownVisibleText } from './rich-markdown-visible-text-map'
|
||||
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
|
||||
import { getSelectedHtmlSuperscriptLinkStatus } from './rich-markdown-selected-link-actions'
|
||||
import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
|
||||
import { resolveRichMarkdownWorktreeRoot } from './useRichMarkdownSuperscriptLinkSetup'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
inspectRichMarkdownSourceOwningSlice,
|
||||
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
|
||||
} from './rich-markdown-source-owning-slice'
|
||||
|
||||
const TEST_KEY = '0123456789abcdef0123456789abcdef'
|
||||
|
||||
function createEditor(content: string, key = TEST_KEY, element: HTMLElement | null = null): Editor {
|
||||
const codec = createRichMarkdownEditorCodec(key)
|
||||
const context = createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
return new Editor({
|
||||
element,
|
||||
extensions: createRichMarkdownExtensions({
|
||||
codec,
|
||||
htmlSuperscriptLinks: true,
|
||||
htmlSuperscriptLinkContext: context
|
||||
}),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
|
||||
htmlSuperscriptLinks: true
|
||||
}),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
}
|
||||
|
||||
function nodeNames(editor: Editor): string[] {
|
||||
const names: string[] = []
|
||||
editor.state.doc.descendants((node) => {
|
||||
names.push(node.type.name)
|
||||
})
|
||||
return names
|
||||
}
|
||||
|
||||
describe('rich Markdown HTML superscript links', () => {
|
||||
it('parses the reported fragment and preserves its exact source', () => {
|
||||
const source = '<sup><a href="https://example.com/source">[12]</a></sup>'
|
||||
const editor = createEditor(`研究结果${source}。`)
|
||||
try {
|
||||
expect(nodeNames(editor)).toContain('richMarkdownHtmlSuperscriptLink')
|
||||
expect(editor.getMarkdown()).toBe(`研究结果${source}。`)
|
||||
const citation = editor.state.doc.firstChild?.child(1)
|
||||
expect(citation?.attrs).toMatchObject({
|
||||
source,
|
||||
href: 'https://example.com/source',
|
||||
label: '[12]',
|
||||
title: null
|
||||
})
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains casing, whitespace, quote style, entities, title, and adjacency', () => {
|
||||
const first =
|
||||
"<SUP ><A title='A & B' href='https://example.com/?a=1&b=2'>[¬it;]</A ></SUP >"
|
||||
const second = '<sup><a href=#section>[13]</a></sup>'
|
||||
const editor = createEditor(`${first}${second}`)
|
||||
try {
|
||||
expect(
|
||||
nodeNames(editor).filter((name) => name === 'richMarkdownHtmlSuperscriptLink')
|
||||
).toHaveLength(2)
|
||||
expect(editor.getMarkdown()).toBe(`${first}${second}`)
|
||||
const citation = editor.state.doc.firstChild?.firstChild
|
||||
expect(citation?.attrs.href).toBe('https://example.com/?a=1&b=2')
|
||||
expect(citation?.attrs.title).toBe('A & B')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves malformed and broadened HTML on the inert raw-source path', () => {
|
||||
const rejected = [
|
||||
'<sup><a href="">[12]<a></sup>',
|
||||
'<sup><a href="x" onclick="go()">[12]</a></sup>',
|
||||
'<sup><a href="x"><b>[12]</b></a></sup>',
|
||||
'<sup class="x"><a href="x">[12]</a></sup>',
|
||||
'<sup><a href="x">[12]\n</a></sup>',
|
||||
'<sup\n><a href="x">[12]</a></sup>',
|
||||
'<sup><a\n href="x">[12]</a></sup>',
|
||||
'<sup><a href =\n "x">[12]</a></sup>',
|
||||
'<sup><a href="x">[12]</a\n></sup>'
|
||||
]
|
||||
for (const source of rejected) {
|
||||
const editor = createEditor(source)
|
||||
try {
|
||||
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
|
||||
if (!source.includes('\n')) {
|
||||
expect(editor.getMarkdown()).toBe(source)
|
||||
}
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not recognize citations inside inline or fenced code', () => {
|
||||
const source = '<sup><a href="https://example.com">[12]</a></sup>'
|
||||
const markdown = `\`${source}\`\n\n\`\`\`html\n${source}\n\`\`\``
|
||||
const editor = createEditor(markdown)
|
||||
try {
|
||||
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
|
||||
expect(editor.getMarkdown()).toBe(markdown)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves authored current-key and legacy transport-looking text', () => {
|
||||
const transport = createRichMarkdownSourceTransport(TEST_KEY)
|
||||
const authored = `${transport.create('inline-html', '<b>authored</b>')} [[ORCA_RAW_HTML_INLINE:%3Ci%3Ex%3C%2Fi%3E]]`
|
||||
const editor = createEditor(authored)
|
||||
try {
|
||||
editor.commands.insertContentAt(editor.state.doc.content.size - 1, '!')
|
||||
expect(editor.getMarkdown()).toBe(`${authored}!`)
|
||||
expect(nodeNames(editor)).not.toContain('markdownDocLink')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps sequential codecs isolated and exposes the complete marked surface', () => {
|
||||
const first = createRichMarkdownEditorCodec(TEST_KEY)
|
||||
const second = createRichMarkdownEditorCodec('fedcba9876543210fedcba9876543210')
|
||||
expect(Object.getOwnPropertyNames(first.marked).sort()).toEqual(
|
||||
Object.getOwnPropertyNames(marked).sort()
|
||||
)
|
||||
const authoredFirstToken = first.transport.create('inline-html', '<b>K1</b>')
|
||||
const editor = createEditor(authoredFirstToken, second.transport.key)
|
||||
try {
|
||||
expect(editor.getMarkdown()).toBe(authoredFirstToken)
|
||||
expect(nodeNames(editor)).not.toContain('rawMarkdownHtmlInline')
|
||||
expect(nodeNames(editor)).not.toContain('markdownDocLink')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps source matching linear across many rejected candidates', () => {
|
||||
const input = '<sup>'.repeat(2_000)
|
||||
let transitions = 0
|
||||
for (let index = 0; index < input.length; index += 5) {
|
||||
const stats = { transitions: 0 }
|
||||
expect(matchHtmlSuperscriptLinkSource(input, index, stats)).toBeNull()
|
||||
transitions += stats.transitions
|
||||
}
|
||||
expect(transitions).toBeLessThan(input.length * 4)
|
||||
})
|
||||
|
||||
it('projects only browser-safe clipboard hrefs', () => {
|
||||
expect(projectMarkdownHrefForClipboard(' https://example.com ')).toBe('https://example.com')
|
||||
expect(projectMarkdownHrefForClipboard('#section')).toBe('#section')
|
||||
expect(projectMarkdownHrefForClipboard('./guide.md')).toBe('./guide.md')
|
||||
expect(projectMarkdownHrefForClipboard('javascript:alert(1)')).toBeNull()
|
||||
expect(projectMarkdownHrefForClipboard('java\nscript:alert(1)')).toBeNull()
|
||||
expect(projectMarkdownHrefForClipboard('C:\\repo\\guide.md')).toBeNull()
|
||||
expect(projectMarkdownHrefForClipboard('\\\\server\\share\\guide.md')).toBeNull()
|
||||
expect(projectMarkdownHrefForClipboard('//example.com/path')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects duplicate attributes and parses quoted greater-than characters', () => {
|
||||
expect(
|
||||
parseHtmlSuperscriptLinkSource('<sup><a href="https://example.com/?q=>">[1]</a></sup>')?.href
|
||||
).toBe('https://example.com/?q=>')
|
||||
expect(parseHtmlSuperscriptLinkSource('<sup><a href="a" href="b">[1]</a></sup>')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders inert live DOM while exposing link semantics through Orca context', () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const source = '<sup><a href="https://example.com">[12]</a></sup>'
|
||||
const codec = createRichMarkdownEditorCodec(TEST_KEY)
|
||||
const context = createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
const editor = new Editor({
|
||||
element: host,
|
||||
extensions: createRichMarkdownExtensions({
|
||||
codec,
|
||||
htmlSuperscriptLinks: true,
|
||||
htmlSuperscriptLinkContext: context
|
||||
}),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(source, codec, {
|
||||
htmlSuperscriptLinks: true
|
||||
}),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
try {
|
||||
const liveLabel = host.querySelector<HTMLElement>(
|
||||
'sup[data-rich-markdown-html-superscript-link] > span'
|
||||
)
|
||||
expect(liveLabel?.textContent).toBe('[12]')
|
||||
expect(liveLabel?.getAttribute('role')).toBe('link')
|
||||
expect(host.querySelector('sup a')).toBeNull()
|
||||
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
|
||||
expect(getSelectedHtmlSuperscriptLinkStatus(editor, context)).toEqual({
|
||||
href: 'https://example.com',
|
||||
label: '[12]',
|
||||
openEnabled: true
|
||||
})
|
||||
context.update({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'unknown' }
|
||||
})
|
||||
expect(liveLabel?.hasAttribute('role')).toBe(false)
|
||||
expect(getSelectedHtmlSuperscriptLinkStatus(editor, context)?.openEnabled).toBe(false)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps whitespace and remote outside-root destinations non-actionable', () => {
|
||||
const local = {
|
||||
version: 0,
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' as const }
|
||||
}
|
||||
expect(classifyHtmlSuperscriptLinkAction(' ', local)).toBe(false)
|
||||
expect(classifyHtmlSuperscriptLinkAction('file:///etc/passwd', local)).toBe(true)
|
||||
expect(
|
||||
classifyHtmlSuperscriptLinkAction('file:///etc/passwd', {
|
||||
...local,
|
||||
sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
classifyHtmlSuperscriptLinkAction('file:///etc/passwd', {
|
||||
...local,
|
||||
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'runtime-1' }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('scopes citation Tab focus to the selected editor bubble', () => {
|
||||
const editor = createEditor('<sup><a href="https://example.com">[12]</a></sup>')
|
||||
const firstBubble = document.createElement('div')
|
||||
firstBubble.dataset.richMarkdownLinkBubbleOwner = 'first-owner'
|
||||
const firstButton = document.createElement('button')
|
||||
firstBubble.appendChild(firstButton)
|
||||
const secondBubble = document.createElement('div')
|
||||
secondBubble.dataset.richMarkdownLinkBubbleOwner = 'second-owner'
|
||||
const secondButton = document.createElement('button')
|
||||
secondBubble.appendChild(secondButton)
|
||||
document.body.append(firstBubble, secondBubble)
|
||||
try {
|
||||
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
|
||||
const event = {
|
||||
key: 'Tab',
|
||||
shiftKey: false,
|
||||
isComposing: false,
|
||||
preventDefault: () => {}
|
||||
} as KeyboardEvent
|
||||
expect(
|
||||
handleRichMarkdownCitationKey({
|
||||
editor,
|
||||
event,
|
||||
linkBubbleOwnerId: 'second-owner'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(document.activeElement).toBe(secondButton)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
firstBubble.remove()
|
||||
secondBubble.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses a folder workspace path as the citation source root', () => {
|
||||
const state = {
|
||||
folderWorkspaces: [{ id: 'folder-1', folderPath: '/workspace/platform' }],
|
||||
worktreesByRepo: {}
|
||||
} as Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>
|
||||
expect(resolveRichMarkdownWorktreeRoot(state, folderWorkspaceKey('folder-1'))).toBe(
|
||||
'/workspace/platform'
|
||||
)
|
||||
})
|
||||
|
||||
it('self-validates clipboard HTML and rejects a forged semantic mismatch', () => {
|
||||
const source = '<sup><a title="Source" href="./guide.md">[12]</a></sup>'
|
||||
const host = document.createElement('div')
|
||||
const editor = createEditor(source, TEST_KEY, host)
|
||||
try {
|
||||
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
|
||||
const html = editor.view.serializeForClipboard(editor.state.selection.content()).dom.innerHTML
|
||||
const pasted = createEditor('')
|
||||
try {
|
||||
pasted.commands.setContent(html, { contentType: 'html' })
|
||||
expect(pasted.getMarkdown()).toBe(source)
|
||||
} finally {
|
||||
pasted.destroy()
|
||||
}
|
||||
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = html
|
||||
const anchor = template.content.querySelector('a')
|
||||
if (!anchor) {
|
||||
throw new Error('expected serialized citation anchor')
|
||||
}
|
||||
anchor.textContent = '[99]'
|
||||
const forged = createEditor('')
|
||||
try {
|
||||
forged.commands.setContent(template.innerHTML, { contentType: 'html' })
|
||||
expect(nodeNames(forged)).not.toContain('richMarkdownHtmlSuperscriptLink')
|
||||
expect(forged.state.doc.textContent).toContain('[99]')
|
||||
} finally {
|
||||
forged.destroy()
|
||||
}
|
||||
|
||||
anchor.textContent = '[12]'
|
||||
anchor.before('hidden sibling')
|
||||
const siblingForged = createEditor('')
|
||||
try {
|
||||
siblingForged.commands.setContent(template.innerHTML, { contentType: 'html' })
|
||||
expect(nodeNames(siblingForged)).not.toContain('richMarkdownHtmlSuperscriptLink')
|
||||
expect(siblingForged.state.doc.textContent).toContain('hidden sibling')
|
||||
} finally {
|
||||
siblingForged.destroy()
|
||||
}
|
||||
} finally {
|
||||
editor.destroy()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects oversized clipboard source before UTF-8 encoding', () => {
|
||||
const editor = createEditor('')
|
||||
const oversizedSource = 'x'.repeat(HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT + 1)
|
||||
const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode')
|
||||
try {
|
||||
editor.commands.setContent(
|
||||
`<sup data-rich-markdown-html-superscript-link="1" data-orca-superscript-link-source="${oversizedSource}"><a>x</a></sup>`,
|
||||
{ contentType: 'html' }
|
||||
)
|
||||
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
|
||||
expect(encodeSpy.mock.calls.some(([value]) => value === oversizedSource)).toBe(false)
|
||||
} finally {
|
||||
encodeSpy.mockRestore()
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('maps citation labels into search, review, and empty-selection Cut', () => {
|
||||
const source = 'Before <sup><a href="https://example.com">[12]</a></sup> after'
|
||||
const editor = createEditor(source)
|
||||
try {
|
||||
const matches = findRichMarkdownSearchMatches(editor.state.doc, 'e [12] a')
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]?.touchesReadOnlyAtom).toBe(true)
|
||||
let citationPosition = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'richMarkdownHtmlSuperscriptLink') {
|
||||
citationPosition = pos
|
||||
}
|
||||
})
|
||||
const reviewRanges = findRichMarkdownSelectedTextRanges({
|
||||
editor,
|
||||
selectedText: 'Before [12] after'
|
||||
})
|
||||
expect(
|
||||
reviewRanges.some(
|
||||
(range) => range.from <= citationPosition && range.to >= citationPosition + 1
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
const citationHost = document.createElement('div')
|
||||
const citationOnly = createEditor(
|
||||
'<sup><a href="https://example.com">[12]</a></sup>',
|
||||
TEST_KEY,
|
||||
citationHost
|
||||
)
|
||||
try {
|
||||
citationOnly.view.dispatch(
|
||||
citationOnly.state.tr.setSelection(TextSelection.create(citationOnly.state.doc, 1))
|
||||
)
|
||||
const clipboard = new Map<string, string>()
|
||||
const event = {
|
||||
clipboardData: {
|
||||
setData: (type: string, value: string) => clipboard.set(type, value),
|
||||
getData: (type: string) => clipboard.get(type) ?? ''
|
||||
},
|
||||
preventDefault: () => {}
|
||||
} as unknown as ClipboardEvent
|
||||
expect(handleRichMarkdownCut(citationOnly.view, event)).toBe(true)
|
||||
expect(clipboard.get('text/plain')).toBe('[12]')
|
||||
expect(clipboard.get('text/html')).toContain('data-orca-superscript-link-source')
|
||||
expect(citationOnly.getMarkdown()).toBe('')
|
||||
} finally {
|
||||
citationOnly.destroy()
|
||||
citationHost.remove()
|
||||
}
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces aggregate UTF-8 bounds even when oversized text precedes the citation', () => {
|
||||
const citation = '<sup><a href="https://example.com">[1]</a></sup>'
|
||||
const exactText = 'a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 3)
|
||||
const exact = createEditor(`${exactText}${citation}`)
|
||||
try {
|
||||
expect(
|
||||
inspectRichMarkdownSourceOwningSlice(exact.state.doc.slice(0, exact.state.doc.content.size))
|
||||
).toEqual({ containsSourceOwningNode: true, canPreserve: true })
|
||||
} finally {
|
||||
exact.destroy()
|
||||
}
|
||||
|
||||
const over = createEditor(`${exactText}a${citation}`)
|
||||
try {
|
||||
expect(
|
||||
inspectRichMarkdownSourceOwningSlice(over.state.doc.slice(0, over.state.doc.content.size))
|
||||
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
|
||||
} finally {
|
||||
over.destroy()
|
||||
}
|
||||
|
||||
const astralOver = createEditor(
|
||||
`${'😀'.repeat(Math.floor(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT / 4))}${citation}`
|
||||
)
|
||||
try {
|
||||
expect(
|
||||
inspectRichMarkdownSourceOwningSlice(
|
||||
astralOver.state.doc.slice(0, astralOver.state.doc.content.size)
|
||||
)
|
||||
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
|
||||
} finally {
|
||||
astralOver.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('counts visible leaf serializers in source-owning slice bounds', () => {
|
||||
const editor = createEditor('')
|
||||
try {
|
||||
const schema = editor.state.schema
|
||||
const hardBreak = schema.nodes.hardBreak
|
||||
const docLink = schema.nodes.markdownDocLink
|
||||
const citation = schema.nodes.richMarkdownHtmlSuperscriptLink
|
||||
if (!hardBreak || !docLink || !citation) {
|
||||
throw new Error('Expected rich Markdown leaf node types')
|
||||
}
|
||||
const citationAttrs = {
|
||||
source: '<sup><a href="x">[1]</a></sup>',
|
||||
href: 'x',
|
||||
label: '[1]',
|
||||
title: null
|
||||
}
|
||||
const exactDoc = schema.node('doc', null, [
|
||||
schema.node('paragraph', null, [
|
||||
schema.text('a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 4)),
|
||||
hardBreak.create(),
|
||||
citation.create(citationAttrs)
|
||||
])
|
||||
])
|
||||
expect(
|
||||
inspectRichMarkdownSourceOwningSlice(exactDoc.slice(0, exactDoc.content.size))
|
||||
).toEqual({ containsSourceOwningNode: true, canPreserve: true })
|
||||
const overHardBreakDoc = schema.node('doc', null, [
|
||||
schema.node('paragraph', null, [
|
||||
schema.text('a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 3)),
|
||||
hardBreak.create(),
|
||||
citation.create(citationAttrs)
|
||||
])
|
||||
])
|
||||
expect(
|
||||
inspectRichMarkdownSourceOwningSlice(
|
||||
overHardBreakDoc.slice(0, overHardBreakDoc.content.size)
|
||||
)
|
||||
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
|
||||
|
||||
const oversizedLeafDoc = schema.node('doc', null, [
|
||||
schema.node('paragraph', null, [
|
||||
docLink.create({ target: 'Guide', label: 'x'.repeat(300_000) }),
|
||||
citation.create(citationAttrs)
|
||||
])
|
||||
])
|
||||
expect(
|
||||
inspectRichMarkdownSourceOwningSlice(
|
||||
oversizedLeafDoc.slice(0, oversizedLeafDoc.content.size)
|
||||
)
|
||||
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('remaps review text across adjacent citation atoms without inventing spaces', () => {
|
||||
const editor = createEditor(
|
||||
'<sup><a href="https://one.example">[1]</a></sup><sup><a href="https://two.example">[2]</a></sup>'
|
||||
)
|
||||
try {
|
||||
const ranges = findRichMarkdownSelectedTextRanges({ editor, selectedText: '[1][2]' })
|
||||
expect(ranges).toHaveLength(1)
|
||||
expect(ranges[0]).toEqual({ from: 1, to: 3 })
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves block separators in visible text and review remapping', () => {
|
||||
const editor = createEditor('foo\n\nbar')
|
||||
try {
|
||||
expect(getRichMarkdownVisibleText(editor.state.doc)).toBe('foo\nbar')
|
||||
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'foo bar' })).toEqual([
|
||||
{ from: 1, to: 4 },
|
||||
{ from: 6, to: 9 }
|
||||
])
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('maps dense search matches with a monotonic segment walk', () => {
|
||||
const citation = '<sup><a href="https://example.com">[1]</a></sup>'
|
||||
const editor = createEditor(`x${citation}`.repeat(200))
|
||||
try {
|
||||
const stats = { segmentVisits: 0 }
|
||||
expect(findRichMarkdownSearchMatches(editor.state.doc, 'x', undefined, stats)).toHaveLength(
|
||||
200
|
||||
)
|
||||
expect(stats.segmentVisits).toBeLessThan(1_000)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let search bridge omitted atoms or block boundaries', () => {
|
||||
const docLink = createEditor('foo[[Guide]]bar')
|
||||
try {
|
||||
expect(findRichMarkdownSearchMatches(docLink.state.doc, 'foobar')).toEqual([])
|
||||
expect(
|
||||
findRichMarkdownSearchMatches(docLink.state.doc, 'Guide')[0]?.touchesReadOnlyAtom
|
||||
).toBe(true)
|
||||
} finally {
|
||||
docLink.destroy()
|
||||
}
|
||||
|
||||
const rawHtml = createEditor('foo<kbd>bar')
|
||||
try {
|
||||
expect(findRichMarkdownSearchMatches(rawHtml.state.doc, 'foobar')).toEqual([])
|
||||
} finally {
|
||||
rawHtml.destroy()
|
||||
}
|
||||
|
||||
const blocks = createEditor('foo\n\nbar')
|
||||
try {
|
||||
expect(findRichMarkdownSearchMatches(blocks.state.doc, 'foobar')).toEqual([])
|
||||
} finally {
|
||||
blocks.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Node } from '@tiptap/core'
|
||||
import type { RichMarkdownSourceTransport } from './rich-markdown-source-transport'
|
||||
import {
|
||||
HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT,
|
||||
parseHtmlSuperscriptLinkSource,
|
||||
type HtmlSuperscriptLinkSource
|
||||
} from './rich-markdown-html-superscript-link-source'
|
||||
import { projectMarkdownHrefForClipboard } from './markdown-internal-links'
|
||||
import {
|
||||
classifyHtmlSuperscriptLinkAction,
|
||||
type RichMarkdownHtmlSuperscriptLinkContext
|
||||
} from './rich-markdown-html-superscript-link-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const CLIPBOARD_VERSION = '1'
|
||||
const MARKER_ATTRIBUTE = 'data-rich-markdown-html-superscript-link'
|
||||
const SOURCE_ATTRIBUTE = 'data-orca-superscript-link-source'
|
||||
const clipboardEncoder = new TextEncoder()
|
||||
|
||||
export function createRichMarkdownHtmlSuperscriptLink(
|
||||
transport: RichMarkdownSourceTransport,
|
||||
context: RichMarkdownHtmlSuperscriptLinkContext
|
||||
) {
|
||||
return Node.create({
|
||||
name: 'richMarkdownHtmlSuperscriptLink',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
source: { default: '', rendered: false },
|
||||
href: { default: '', rendered: false },
|
||||
label: { default: '', rendered: false },
|
||||
title: { default: null, rendered: false }
|
||||
}
|
||||
},
|
||||
|
||||
markdownTokenName: 'richMarkdownHtmlSuperscriptLink',
|
||||
markdownTokenizer: {
|
||||
name: 'richMarkdownHtmlSuperscriptLink',
|
||||
level: 'inline',
|
||||
start: transport.startFor('html-superscript-link'),
|
||||
tokenize(source) {
|
||||
const matched = transport.match(source, 'html-superscript-link')
|
||||
if (!matched) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = parseStructuredPayload(matched.value)
|
||||
if (!parsed) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
type: 'richMarkdownHtmlSuperscriptLink',
|
||||
raw: matched.raw,
|
||||
citation: parsed
|
||||
}
|
||||
}
|
||||
},
|
||||
parseMarkdown: (token, helpers) => {
|
||||
const citation = (token as { citation?: HtmlSuperscriptLinkSource }).citation
|
||||
if (token.type !== 'richMarkdownHtmlSuperscriptLink' || !citation) {
|
||||
return []
|
||||
}
|
||||
return helpers.createNode('richMarkdownHtmlSuperscriptLink', citation)
|
||||
},
|
||||
renderMarkdown: (node) => String(node.attrs?.source ?? ''),
|
||||
renderText: ({ node }) => String(node.attrs.label ?? ''),
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `sup[${MARKER_ATTRIBUTE}]`,
|
||||
getAttrs: (element: HTMLElement) => validateClipboardElement(element)
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
const citation = node.attrs as HtmlSuperscriptLinkSource
|
||||
const projectedHref = projectMarkdownHrefForClipboard(citation.href)
|
||||
const anchorAttributes: Record<string, string> = {}
|
||||
if (projectedHref !== null) {
|
||||
anchorAttributes.href = projectedHref
|
||||
}
|
||||
if (citation.title !== null) {
|
||||
anchorAttributes.title = citation.title
|
||||
}
|
||||
return [
|
||||
'sup',
|
||||
{
|
||||
[MARKER_ATTRIBUTE]: CLIPBOARD_VERSION,
|
||||
[SOURCE_ATTRIBUTE]: citation.source
|
||||
},
|
||||
['a', anchorAttributes, citation.label]
|
||||
]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const dom = document.createElement('sup')
|
||||
dom.setAttribute(MARKER_ATTRIBUTE, '')
|
||||
dom.setAttribute('contenteditable', 'false')
|
||||
const label = document.createElement('span')
|
||||
label.className = 'rich-markdown-html-superscript-link'
|
||||
label.textContent = String(node.attrs.label ?? '')
|
||||
dom.appendChild(label)
|
||||
|
||||
const updateActionability = (): void => {
|
||||
const href = String(node.attrs.href ?? '')
|
||||
const actionable = classifyHtmlSuperscriptLinkAction(href, context.getSnapshot())
|
||||
label.setAttribute(
|
||||
'aria-label',
|
||||
actionable
|
||||
? translate(
|
||||
'auto.components.editor.richMarkdownHtmlSuperscriptLink.availableAriaLabel',
|
||||
'{{value0}}, link to {{value1}}',
|
||||
{ value0: String(node.attrs.label ?? ''), value1: href }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.editor.richMarkdownHtmlSuperscriptLink.unavailableAriaLabel',
|
||||
'{{value0}}, citation link unavailable',
|
||||
{ value0: String(node.attrs.label ?? '') }
|
||||
)
|
||||
)
|
||||
if (actionable) {
|
||||
label.setAttribute('role', 'link')
|
||||
} else {
|
||||
label.removeAttribute('role')
|
||||
}
|
||||
label.toggleAttribute('data-actionable', actionable)
|
||||
}
|
||||
updateActionability()
|
||||
const unsubscribe = context.subscribe(updateActionability)
|
||||
return { dom, destroy: unsubscribe }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseStructuredPayload(value: string): HtmlSuperscriptLinkSource | null {
|
||||
let candidate: unknown
|
||||
try {
|
||||
candidate = JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!isCitationShape(candidate)) {
|
||||
return null
|
||||
}
|
||||
const parsed = parseHtmlSuperscriptLinkSource(candidate.source)
|
||||
return parsed && sameCitation(parsed, candidate) ? parsed : null
|
||||
}
|
||||
|
||||
function validateClipboardElement(element: HTMLElement): false | Record<string, unknown> {
|
||||
if (
|
||||
element.getAttribute(MARKER_ATTRIBUTE) !== CLIPBOARD_VERSION ||
|
||||
!hasOnlyAttributes(element, [MARKER_ATTRIBUTE, SOURCE_ATTRIBUTE, 'data-pm-slice'])
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const source = element.getAttribute(SOURCE_ATTRIBUTE)
|
||||
if (
|
||||
!source ||
|
||||
source.length > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT ||
|
||||
clipboardEncoder.encode(source).byteLength > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const parsed = parseHtmlSuperscriptLinkSource(source)
|
||||
const anchor = element.firstElementChild
|
||||
if (
|
||||
!parsed ||
|
||||
element.childNodes.length !== 1 ||
|
||||
element.children.length !== 1 ||
|
||||
!anchor ||
|
||||
element.firstChild !== anchor ||
|
||||
anchor.tagName !== 'A' ||
|
||||
!hasOnlyAttributes(anchor, ['href', 'title']) ||
|
||||
anchor.childNodes.length !== 1 ||
|
||||
anchor.firstChild?.nodeType !== window.Node.TEXT_NODE ||
|
||||
anchor.textContent !== parsed.label ||
|
||||
anchor.getAttribute('title') !== parsed.title ||
|
||||
anchor.getAttribute('href') !== projectMarkdownHrefForClipboard(parsed.href)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function hasOnlyAttributes(element: Element, allowed: string[]): boolean {
|
||||
const allowedSet = new Set(allowed)
|
||||
return Array.from(element.attributes).every((attribute) => allowedSet.has(attribute.name))
|
||||
}
|
||||
|
||||
function isCitationShape(value: unknown): value is HtmlSuperscriptLinkSource {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const candidate = value as Record<string, unknown>
|
||||
return (
|
||||
Object.keys(candidate).length === 4 &&
|
||||
typeof candidate.source === 'string' &&
|
||||
typeof candidate.href === 'string' &&
|
||||
typeof candidate.label === 'string' &&
|
||||
(typeof candidate.title === 'string' || candidate.title === null)
|
||||
)
|
||||
}
|
||||
|
||||
function sameCitation(left: HtmlSuperscriptLinkSource, right: HtmlSuperscriptLinkSource): boolean {
|
||||
return (
|
||||
left.source === right.source &&
|
||||
left.href === right.href &&
|
||||
left.label === right.label &&
|
||||
left.title === right.title
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler'
|
||||
|
||||
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
|
||||
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
|
||||
|
||||
function createEditor(content: object): Editor {
|
||||
return new Editor({
|
||||
@@ -67,6 +67,18 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext
|
||||
typedEmptyOrderedListMarkerRef: { current: typedMarker },
|
||||
flushPendingSerialization: vi.fn(),
|
||||
openSearchRef: { current: vi.fn() },
|
||||
linkBubbleOwnerId: 'test-owner',
|
||||
htmlSuperscriptLinkContext: {
|
||||
getSnapshot: () => ({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' as const },
|
||||
version: 0
|
||||
}),
|
||||
subscribe: () => () => {},
|
||||
update: () => {}
|
||||
},
|
||||
setIsEditingLink: vi.fn(),
|
||||
setLinkBubble: vi.fn(),
|
||||
setSelectedCommandIndex: vi.fn(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
|
||||
import { editorShortcutMatches } from './editor-shortcuts'
|
||||
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands'
|
||||
import {
|
||||
runSlashCommand,
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
exitTrailingEmptyOrderedListItem
|
||||
} from './rich-markdown-list-continuation'
|
||||
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
|
||||
import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
|
||||
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
|
||||
|
||||
export type KeyHandlerContext = {
|
||||
isMac: boolean
|
||||
@@ -44,6 +47,9 @@ export type KeyHandlerContext = {
|
||||
setSelectedDocLinkIndex: Dispatch<SetStateAction<number>>
|
||||
setSlashMenu: Dispatch<SetStateAction<SlashMenuState | null>>
|
||||
setDocLinkMenu: (menu: DocLinkMenuState | null) => void
|
||||
openSelectedHtmlSuperscriptLink?: () => boolean
|
||||
linkBubbleOwnerId: string
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
}
|
||||
|
||||
function isComposingMarkdownInput(event: KeyboardEvent, editor: Editor | null): boolean {
|
||||
@@ -100,6 +106,16 @@ export function createRichMarkdownKeyHandler(
|
||||
): (_view: unknown, event: KeyboardEvent) => boolean {
|
||||
return (_view, event) => {
|
||||
const mod = ctx.isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
if (
|
||||
handleRichMarkdownCitationKey({
|
||||
editor: ctx.editorRef.current,
|
||||
event,
|
||||
linkBubbleOwnerId: ctx.linkBubbleOwnerId,
|
||||
onOpen: ctx.openSelectedHtmlSuperscriptLink
|
||||
})
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
isMarkdownPreviewFindShortcut(
|
||||
event,
|
||||
@@ -131,29 +147,18 @@ export function createRichMarkdownKeyHandler(
|
||||
return true
|
||||
}
|
||||
|
||||
// Link: Cmd/Ctrl+K — insert or edit a hyperlink.
|
||||
if (mod && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
const ed = ctx.editorRef.current
|
||||
if (!ed) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (ctx.isEditingLinkRef.current) {
|
||||
ctx.setIsEditingLink(false)
|
||||
if (!ed.isActive('link')) {
|
||||
ctx.setLinkBubble(null)
|
||||
}
|
||||
ed.commands.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
const pos = getLinkBubblePosition(ed, ctx.rootRef.current)
|
||||
if (pos) {
|
||||
const href = ed.isActive('link') ? (ed.getAttributes('link').href as string) || '' : ''
|
||||
ctx.setLinkBubble({ href, ...pos })
|
||||
ctx.setIsEditingLink(true)
|
||||
}
|
||||
if (
|
||||
handleRichMarkdownLinkShortcut({
|
||||
editor: ctx.editorRef.current,
|
||||
event,
|
||||
htmlSuperscriptLinkContext: ctx.htmlSuperscriptLinkContext,
|
||||
isEditing: ctx.isEditingLinkRef.current,
|
||||
isMac: ctx.isMac,
|
||||
root: ctx.rootRef.current,
|
||||
setEditing: ctx.setIsEditingLink,
|
||||
setLinkBubble: ctx.setLinkBubble
|
||||
})
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ type RichMarkdownLargeTextPasteOptions = {
|
||||
measureYieldAfterCodeUnits?: number
|
||||
yieldToEventLoop?: () => Promise<void>
|
||||
canContinue?: (editor: Editor) => boolean
|
||||
plainTextOverride?: string
|
||||
htmlTextOverride?: string
|
||||
}
|
||||
|
||||
export type RichMarkdownLargeTextPasteResult =
|
||||
@@ -188,8 +190,8 @@ export function handleRichMarkdownLargeTextPaste(
|
||||
return false
|
||||
}
|
||||
|
||||
const text = readPlainText(event)
|
||||
const html = readHtmlText(event)
|
||||
const text = options.plainTextOverride ?? readPlainText(event)
|
||||
const html = options.htmlTextOverride ?? readHtmlText(event)
|
||||
const directMaxBytes = options.directMaxBytes ?? RICH_MARKDOWN_PASTE_DIRECT_MAX_BYTES
|
||||
const maxBytes = options.maxBytes ?? RICH_MARKDOWN_PASTE_MAX_BYTES
|
||||
const ownershipMeasurement = measureTextControlPasteByteLength(text, {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { toastError, toastSuccess } = vi.hoisted(() => ({
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: toastSuccess, error: toastError } }))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
|
||||
import { copyRichMarkdownLink } from './rich-markdown-link-clipboard'
|
||||
|
||||
describe('copyRichMarkdownLink', () => {
|
||||
const writeClipboardText = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(window as unknown as { api: unknown }).api = { ui: { writeClipboardText } }
|
||||
})
|
||||
|
||||
it('reports success only after the clipboard write resolves', async () => {
|
||||
let resolveWrite: (() => void) | undefined
|
||||
writeClipboardText.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveWrite = resolve
|
||||
})
|
||||
)
|
||||
const copy = copyRichMarkdownLink('https://example.com')
|
||||
expect(toastSuccess).not.toHaveBeenCalled()
|
||||
resolveWrite?.()
|
||||
await copy
|
||||
expect(toastSuccess).toHaveBeenCalledWith('Copied link')
|
||||
})
|
||||
|
||||
it('reports a rejected clipboard write without throwing', async () => {
|
||||
writeClipboardText.mockRejectedValue(new Error('clipboard unavailable'))
|
||||
await expect(copyRichMarkdownLink('https://example.com')).resolves.toBeUndefined()
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to copy link')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export async function copyRichMarkdownLink(href: string): Promise<void> {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(href)
|
||||
toast.success(
|
||||
translate('auto.components.editor.richMarkdownLinkClipboard.copiedLink', 'Copied link')
|
||||
)
|
||||
} catch {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.editor.richMarkdownLinkClipboard.copyLinkFailed',
|
||||
'Failed to copy link'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
|
||||
|
||||
const TEST_KEY = '0123456789abcdef0123456789abcdef'
|
||||
|
||||
function createEditor(content: string): {
|
||||
editor: Editor
|
||||
context: ReturnType<typeof createRichMarkdownHtmlSuperscriptLinkContext>
|
||||
root: HTMLDivElement
|
||||
} {
|
||||
const codec = createRichMarkdownEditorCodec(TEST_KEY)
|
||||
const context = createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const editor = new Editor({
|
||||
element: root,
|
||||
extensions: createRichMarkdownExtensions({
|
||||
codec,
|
||||
htmlSuperscriptLinks: true,
|
||||
htmlSuperscriptLinkContext: context
|
||||
}),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
|
||||
htmlSuperscriptLinks: true
|
||||
}),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
return { editor, context, root }
|
||||
}
|
||||
|
||||
function keyEvent(): KeyboardEvent {
|
||||
return {
|
||||
key: 'k',
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as KeyboardEvent
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('handleRichMarkdownLinkShortcut', () => {
|
||||
it('opens the html-superscript action bubble for a citation NodeSelection', () => {
|
||||
const source = '<sup><a href="https://example.com">1</a></sup>'
|
||||
const { editor, context, root } = createEditor(`See note ${source}.`)
|
||||
try {
|
||||
let citationPos = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'richMarkdownHtmlSuperscriptLink' && citationPos < 0) {
|
||||
citationPos = pos
|
||||
}
|
||||
})
|
||||
expect(citationPos).toBeGreaterThanOrEqual(0)
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, citationPos))
|
||||
)
|
||||
|
||||
const setLinkBubble = vi.fn()
|
||||
const setEditing = vi.fn()
|
||||
|
||||
const handled = handleRichMarkdownLinkShortcut({
|
||||
editor,
|
||||
event: keyEvent(),
|
||||
htmlSuperscriptLinkContext: context,
|
||||
isEditing: false,
|
||||
isMac: true,
|
||||
root,
|
||||
setEditing,
|
||||
setLinkBubble
|
||||
})
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(setEditing).toHaveBeenCalledWith(false)
|
||||
expect(setLinkBubble).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'html-superscript',
|
||||
href: 'https://example.com',
|
||||
label: '1'
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('opens an editable markdown bubble when there is no link selection', () => {
|
||||
const { editor, context, root } = createEditor('plain text')
|
||||
try {
|
||||
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1)))
|
||||
const setLinkBubble = vi.fn()
|
||||
const setEditing = vi.fn()
|
||||
|
||||
handleRichMarkdownLinkShortcut({
|
||||
editor,
|
||||
event: keyEvent(),
|
||||
htmlSuperscriptLinkContext: context,
|
||||
isEditing: false,
|
||||
isMac: true,
|
||||
root,
|
||||
setEditing,
|
||||
setLinkBubble
|
||||
})
|
||||
|
||||
expect(setEditing).toHaveBeenCalledWith(true)
|
||||
expect(setLinkBubble).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'markdown',
|
||||
href: ''
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { getLinkBubblePosition } from './RichMarkdownLinkBubble'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
import {
|
||||
createEditableMarkdownLinkBubble,
|
||||
getRichMarkdownSelectionLinkBubble
|
||||
} from './rich-markdown-selected-link-actions'
|
||||
|
||||
export function handleRichMarkdownLinkShortcut({
|
||||
editor,
|
||||
event,
|
||||
htmlSuperscriptLinkContext,
|
||||
isEditing,
|
||||
isMac,
|
||||
root,
|
||||
setEditing,
|
||||
setLinkBubble
|
||||
}: {
|
||||
editor: Editor | null
|
||||
event: KeyboardEvent
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
isEditing: boolean
|
||||
isMac: boolean
|
||||
root: HTMLElement | null
|
||||
setEditing: (editing: boolean) => void
|
||||
setLinkBubble: (bubble: LinkBubbleState | null) => void
|
||||
}): boolean {
|
||||
const modifier = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
if (!modifier || event.key.toLowerCase() !== 'k') {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
if (!editor) {
|
||||
return true
|
||||
}
|
||||
if (isEditing) {
|
||||
setEditing(false)
|
||||
if (!editor.isActive('link')) {
|
||||
setLinkBubble(null)
|
||||
}
|
||||
editor.commands.focus()
|
||||
return true
|
||||
}
|
||||
// Why: NodeSelection on an HTML citation still has a bubble position, but
|
||||
// markdown setLink/unsetLink cannot edit that atom — open the citation
|
||||
// action bubble instead of the markdown edit field.
|
||||
const selectionBubble = getRichMarkdownSelectionLinkBubble(
|
||||
editor,
|
||||
root,
|
||||
htmlSuperscriptLinkContext
|
||||
)
|
||||
if (selectionBubble) {
|
||||
setLinkBubble(selectionBubble)
|
||||
setEditing(selectionBubble.kind === 'markdown')
|
||||
return true
|
||||
}
|
||||
const position = getLinkBubblePosition(editor, root)
|
||||
if (position) {
|
||||
setLinkBubble(createEditableMarkdownLinkBubble('', position))
|
||||
setEditing(true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import {
|
||||
collapseEmptyListContinuationParagraph,
|
||||
commitEmptyOrderedListMarkerAsText,
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
isSingleEmptyTopLevelOrderedList
|
||||
} from './rich-markdown-list-continuation'
|
||||
|
||||
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
|
||||
|
||||
function createEditor(content: object): Editor {
|
||||
// Why: each Editor needs its own marked registry; sharing one module-scoped
|
||||
// extension accumulates tokenizer state across tests.
|
||||
return new Editor({
|
||||
element: null,
|
||||
extensions,
|
||||
extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()],
|
||||
content
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { resetLocalImageSrcStateForTests } from './useLocalImageSrc'
|
||||
import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context'
|
||||
|
||||
@@ -40,7 +41,7 @@ describe('rich markdown local images', () => {
|
||||
document.body.appendChild(host)
|
||||
const editor = new Editor({
|
||||
element: host,
|
||||
extensions: createRichMarkdownExtensions(),
|
||||
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
|
||||
content: '',
|
||||
contentType: 'markdown'
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
|
||||
|
||||
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
|
||||
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import type { Slice } from '@tiptap/pm/model'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { handleRichMarkdownImagePaste } from './rich-markdown-paste-image'
|
||||
import { handleRichMarkdownLargeTextPaste } from './rich-markdown-large-text-paste'
|
||||
import { handleRichMarkdownTerminalPathPaste } from './rich-markdown-terminal-path-paste'
|
||||
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
|
||||
import { getRichMarkdownVisibleText } from './rich-markdown-visible-text-map'
|
||||
|
||||
export type RichMarkdownPasteHandlerArgs = {
|
||||
editor: Editor | null
|
||||
@@ -9,6 +13,8 @@ export type RichMarkdownPasteHandlerArgs = {
|
||||
filePath: string
|
||||
worktreeId: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
slice?: Slice
|
||||
view?: EditorView
|
||||
}
|
||||
|
||||
export function handleRichMarkdownPaste({
|
||||
@@ -16,7 +22,9 @@ export function handleRichMarkdownPaste({
|
||||
event,
|
||||
filePath,
|
||||
worktreeId,
|
||||
runtimeEnvironmentId
|
||||
runtimeEnvironmentId,
|
||||
slice,
|
||||
view
|
||||
}: RichMarkdownPasteHandlerArgs): boolean {
|
||||
if (
|
||||
handleRichMarkdownImagePaste({
|
||||
@@ -30,6 +38,36 @@ export function handleRichMarkdownPaste({
|
||||
return true
|
||||
}
|
||||
|
||||
const sourceOwningStatus = slice ? inspectRichMarkdownSourceOwningSlice(slice) : null
|
||||
if (sourceOwningStatus?.containsSourceOwningNode && slice && view) {
|
||||
if (sourceOwningStatus.canPreserve) {
|
||||
// Why: dispatch directly so the transaction can carry paste/uiEvent
|
||||
// metadata that editor.commands.insertContent would omit.
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.replaceSelection(slice)
|
||||
.setMeta('paste', true)
|
||||
.setMeta('uiEvent', 'paste')
|
||||
.scrollIntoView()
|
||||
)
|
||||
return true
|
||||
}
|
||||
const visibleText = getRichMarkdownVisibleText(slice.content)
|
||||
if (
|
||||
handleRichMarkdownLargeTextPaste(editor, event, {
|
||||
plainTextOverride: visibleText,
|
||||
htmlTextOverride: ''
|
||||
})
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (visibleText && editor) {
|
||||
event.preventDefault()
|
||||
editor.commands.insertContent(visibleText)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (handleRichMarkdownTerminalPathPaste(editor, event)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from './rich-markdown-range-bounds'
|
||||
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
|
||||
import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges'
|
||||
import { getRichMarkdownSelectionVisibleText } from './rich-markdown-visible-text-map'
|
||||
import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
|
||||
export { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
|
||||
|
||||
const RICH_MARKDOWN_ANNOTATION_BUTTON_SIZE_PX = 24
|
||||
const RICH_MARKDOWN_ANNOTATION_EDGE_PADDING_PX = 8
|
||||
@@ -42,27 +45,6 @@ export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & {
|
||||
buttonLeft: number
|
||||
}
|
||||
|
||||
export function countRichMarkdownReviewMarkdownLines(value: string): number {
|
||||
if (value.length === 0) {
|
||||
return 1
|
||||
}
|
||||
let lineCount = 1
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const charCode = value.charCodeAt(index)
|
||||
if (charCode === 13) {
|
||||
lineCount += 1
|
||||
if (value.charCodeAt(index + 1) === 10) {
|
||||
index += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (charCode === 10) {
|
||||
lineCount += 1
|
||||
}
|
||||
}
|
||||
return lineCount
|
||||
}
|
||||
|
||||
function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string {
|
||||
return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd()
|
||||
}
|
||||
@@ -293,7 +275,7 @@ export function getRichMarkdownAnnotationTarget(
|
||||
if (!rect) {
|
||||
return null
|
||||
}
|
||||
const selectedText = window.getSelection()?.toString().trim() ?? ''
|
||||
const selectedText = getRichMarkdownSelectionVisibleText(editor.state)
|
||||
if (!selectedText) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function countRichMarkdownReviewMarkdownLines(value: string): number {
|
||||
if (value.length === 0) {
|
||||
return 1
|
||||
}
|
||||
let lineCount = 1
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const charCode = value.charCodeAt(index)
|
||||
if (charCode === 13) {
|
||||
lineCount += 1
|
||||
if (value.charCodeAt(index + 1) === 10) {
|
||||
index += 1
|
||||
}
|
||||
} else if (charCode === 10) {
|
||||
lineCount += 1
|
||||
}
|
||||
}
|
||||
return lineCount
|
||||
}
|
||||
@@ -40,13 +40,13 @@ function buildSelectedText(wordCount: number): string {
|
||||
}
|
||||
|
||||
describe('findRichMarkdownSelectedTextRanges', () => {
|
||||
it('matches selected text across normalized whitespace and text nodes', () => {
|
||||
it('matches selected text across adjacent text nodes without inventing whitespace', () => {
|
||||
const editor = editorWithTextNodes([
|
||||
{ isText: true, text: 'alpha' },
|
||||
{ isText: true, text: 'beta' }
|
||||
])
|
||||
|
||||
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'alpha\n beta' })).toEqual([
|
||||
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'alphabeta' })).toEqual([
|
||||
{ from: 0, to: 9 }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight'
|
||||
import { forEachRichMarkdownVisibleTextSegment } from './rich-markdown-visible-text-map'
|
||||
|
||||
type TextPosition = { from: number; to: number } | null
|
||||
|
||||
type NormalizedTextChar = {
|
||||
value: string
|
||||
pos: number | null
|
||||
pos: TextPosition
|
||||
}
|
||||
|
||||
type MatchState = {
|
||||
readonly needle: string
|
||||
readonly prefixTable: number[]
|
||||
recentPositions: (number | null)[]
|
||||
recentPositions: TextPosition[]
|
||||
recentPositionWriteIndex: number
|
||||
matchLength: number
|
||||
positions: (number | null)[] | null
|
||||
positions: TextPosition[] | null
|
||||
}
|
||||
|
||||
type NormalizationState = {
|
||||
previousWasWhitespace: boolean
|
||||
emittedAnyText: boolean
|
||||
}
|
||||
|
||||
export function findRichMarkdownSelectedTextRanges({
|
||||
@@ -45,31 +47,32 @@ export function findRichMarkdownSelectedTextRanges({
|
||||
positions: null
|
||||
}
|
||||
const normalizationState: NormalizationState = {
|
||||
previousWasWhitespace: false,
|
||||
emittedAnyText: false
|
||||
previousWasWhitespace: false
|
||||
}
|
||||
|
||||
// Why: review text can come from large pasted selections; stream the editor
|
||||
// text instead of building per-character haystack/needle arrays.
|
||||
editor.state.doc.nodesBetween(from ?? 0, to ?? editor.state.doc.content.size, (node, pos) => {
|
||||
if (matchState.positions || !node.isText) {
|
||||
return
|
||||
forEachRichMarkdownVisibleTextSegment(
|
||||
editor.state.doc,
|
||||
from ?? 0,
|
||||
to ?? editor.state.doc.content.size,
|
||||
(segment) => {
|
||||
for (let index = 0; index < segment.text.length && !matchState.positions; index += 1) {
|
||||
processRawTextChar(
|
||||
{
|
||||
value: segment.text.charAt(index),
|
||||
pos:
|
||||
segment.kind === 'separator'
|
||||
? null
|
||||
: segment.kind === 'read-only-atom'
|
||||
? { from: segment.from, to: segment.to }
|
||||
: { from: segment.from + index, to: segment.from + index + 1 }
|
||||
},
|
||||
normalizationState,
|
||||
matchState
|
||||
)
|
||||
}
|
||||
return !matchState.positions
|
||||
}
|
||||
const nodeText = node.text
|
||||
if (!nodeText) {
|
||||
return
|
||||
}
|
||||
if (normalizationState.emittedAnyText) {
|
||||
processRawTextChar({ value: ' ', pos: null }, normalizationState, matchState)
|
||||
}
|
||||
for (let index = 0; index < nodeText.length && !matchState.positions; index += 1) {
|
||||
processRawTextChar(
|
||||
{ value: nodeText.charAt(index), pos: pos + index },
|
||||
normalizationState,
|
||||
matchState
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return matchState.positions ? positionsToRanges(matchState.positions) : []
|
||||
}
|
||||
@@ -101,7 +104,6 @@ function processRawTextChar(
|
||||
if (isRichMarkdownWhitespace(code)) {
|
||||
if (!normalizationState.previousWasWhitespace) {
|
||||
processNormalizedTextChar({ value: ' ', pos: char.pos }, matchState)
|
||||
normalizationState.emittedAnyText = true
|
||||
}
|
||||
normalizationState.previousWasWhitespace = true
|
||||
return
|
||||
@@ -109,7 +111,6 @@ function processRawTextChar(
|
||||
|
||||
processNormalizedTextChar(char, matchState)
|
||||
normalizationState.previousWasWhitespace = false
|
||||
normalizationState.emittedAnyText = true
|
||||
}
|
||||
|
||||
function processNormalizedTextChar(char: NormalizedTextChar, matchState: MatchState): void {
|
||||
@@ -128,7 +129,7 @@ function processNormalizedTextChar(char: NormalizedTextChar, matchState: MatchSt
|
||||
}
|
||||
}
|
||||
|
||||
function recordRecentPosition(pos: number | null, matchState: MatchState): void {
|
||||
function recordRecentPosition(pos: TextPosition, matchState: MatchState): void {
|
||||
if (matchState.recentPositions.length < matchState.needle.length) {
|
||||
matchState.recentPositions.push(pos)
|
||||
matchState.recentPositionWriteIndex =
|
||||
@@ -141,8 +142,8 @@ function recordRecentPosition(pos: number | null, matchState: MatchState): void
|
||||
(matchState.recentPositionWriteIndex + 1) % matchState.needle.length
|
||||
}
|
||||
|
||||
function readRecentPositions(matchState: MatchState): (number | null)[] {
|
||||
const positions: (number | null)[] = []
|
||||
function readRecentPositions(matchState: MatchState): TextPosition[] {
|
||||
const positions: TextPosition[] = []
|
||||
for (let index = 0; index < matchState.needle.length; index += 1) {
|
||||
const bufferIndex = (matchState.recentPositionWriteIndex + index) % matchState.needle.length
|
||||
positions.push(matchState.recentPositions[bufferIndex] ?? null)
|
||||
@@ -168,26 +169,26 @@ function buildPrefixTable(value: string): number[] {
|
||||
return table
|
||||
}
|
||||
|
||||
function positionsToRanges(positions: (number | null)[]): RichMarkdownAnnotationHighlightRange[] {
|
||||
function positionsToRanges(positions: TextPosition[]): RichMarkdownAnnotationHighlightRange[] {
|
||||
const ranges: RichMarkdownAnnotationHighlightRange[] = []
|
||||
let rangeFrom: number | null = null
|
||||
let rangeTo: number | null = null
|
||||
for (const pos of positions) {
|
||||
if (pos === null) {
|
||||
for (const position of positions) {
|
||||
if (position === null) {
|
||||
continue
|
||||
}
|
||||
if (rangeFrom === null || rangeTo === null) {
|
||||
rangeFrom = pos
|
||||
rangeTo = pos + 1
|
||||
rangeFrom = position.from
|
||||
rangeTo = position.to
|
||||
continue
|
||||
}
|
||||
if (pos === rangeTo) {
|
||||
rangeTo += 1
|
||||
if (position.from <= rangeTo) {
|
||||
rangeTo = Math.max(rangeTo, position.to)
|
||||
continue
|
||||
}
|
||||
ranges.push({ from: rangeFrom, to: rangeTo })
|
||||
rangeFrom = pos
|
||||
rangeTo = pos + 1
|
||||
rangeFrom = position.from
|
||||
rangeTo = position.to
|
||||
}
|
||||
if (rangeFrom !== null && rangeTo !== null) {
|
||||
ranges.push({ from: rangeFrom, to: rangeTo })
|
||||
|
||||
@@ -6,7 +6,13 @@ import { findRichMarkdownSearchMatches } from './rich-markdown-search'
|
||||
|
||||
function docFromText(text: string, pos = 0): ProseMirrorNode {
|
||||
return {
|
||||
descendants(callback: (node: { isText: boolean; text?: string }, pos: number) => void) {
|
||||
nodeSize: pos + text.length + 2,
|
||||
content: { size: pos + text.length },
|
||||
nodesBetween(
|
||||
_from: number,
|
||||
_to: number,
|
||||
callback: (node: { isText: boolean; text?: string }, pos: number) => void
|
||||
) {
|
||||
callback({ isText: true, text }, pos)
|
||||
}
|
||||
} as unknown as ProseMirrorNode
|
||||
|
||||
@@ -6,12 +6,20 @@ import {
|
||||
isMarkdownPreviewSearchQueryTooLarge,
|
||||
type TextMatchOptions
|
||||
} from './markdown-preview-search'
|
||||
import {
|
||||
createRichMarkdownVisibleTextMap,
|
||||
type RichMarkdownVisibleTextSegment
|
||||
} from './rich-markdown-visible-text-map'
|
||||
|
||||
export type RichMarkdownSearchMatch = {
|
||||
from: number
|
||||
to: number
|
||||
touchesReadOnlyAtom?: boolean
|
||||
decorationRanges?: { from: number; to: number; kind: 'inline' | 'node' }[]
|
||||
}
|
||||
|
||||
export type RichMarkdownSearchStats = { segmentVisits: number }
|
||||
|
||||
type RichMarkdownSearchState = {
|
||||
activeIndex: number
|
||||
decorations: DecorationSet
|
||||
@@ -31,7 +39,8 @@ export const richMarkdownSearchPluginKey = new PluginKey<RichMarkdownSearchState
|
||||
export function findRichMarkdownSearchMatches(
|
||||
doc: ProseMirrorNode,
|
||||
query: string,
|
||||
options?: TextMatchOptions
|
||||
options?: TextMatchOptions,
|
||||
stats?: RichMarkdownSearchStats
|
||||
): RichMarkdownSearchMatch[] {
|
||||
if (!query) {
|
||||
return []
|
||||
@@ -41,28 +50,80 @@ export function findRichMarkdownSearchMatches(
|
||||
}
|
||||
|
||||
const matches: RichMarkdownSearchMatch[] = []
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) {
|
||||
return
|
||||
const visibleMap = createRichMarkdownVisibleTextMap(doc)
|
||||
const ranges = findTextMatchRanges(visibleMap.text, query, options)
|
||||
let segmentIndex = 0
|
||||
for (const range of ranges) {
|
||||
while (
|
||||
segmentIndex < visibleMap.segments.length &&
|
||||
visibleMap.segments[segmentIndex]!.visibleTo <= range.start
|
||||
) {
|
||||
if (stats) {
|
||||
stats.segmentVisits += 1
|
||||
}
|
||||
segmentIndex += 1
|
||||
}
|
||||
|
||||
const text = node.text ?? ''
|
||||
if (!text.trim()) {
|
||||
return
|
||||
const firstSegmentIndex = segmentIndex
|
||||
let lastSegmentIndex = firstSegmentIndex - 1
|
||||
let touchesReadOnlyAtom = false
|
||||
let segmentsAreContiguous = true
|
||||
let touchesSeparator = false
|
||||
while (
|
||||
lastSegmentIndex + 1 < visibleMap.segments.length &&
|
||||
visibleMap.segments[lastSegmentIndex + 1]!.visibleFrom < range.end
|
||||
) {
|
||||
lastSegmentIndex += 1
|
||||
if (stats) {
|
||||
stats.segmentVisits += 1
|
||||
}
|
||||
touchesReadOnlyAtom ||= visibleMap.segments[lastSegmentIndex]!.kind === 'read-only-atom'
|
||||
touchesSeparator ||= visibleMap.segments[lastSegmentIndex]!.kind === 'separator'
|
||||
const previous = visibleMap.segments[lastSegmentIndex - 1]
|
||||
const current = visibleMap.segments[lastSegmentIndex]!
|
||||
if (lastSegmentIndex > firstSegmentIndex && previous?.to !== current.from) {
|
||||
segmentsAreContiguous = false
|
||||
}
|
||||
}
|
||||
|
||||
const ranges = findTextMatchRanges(text, query, options)
|
||||
for (const range of ranges) {
|
||||
matches.push({
|
||||
from: pos + range.start,
|
||||
to: pos + range.end
|
||||
})
|
||||
const first = visibleMap.segments[firstSegmentIndex]
|
||||
const last = visibleMap.segments[lastSegmentIndex]
|
||||
if (!first || !last || !segmentsAreContiguous || touchesSeparator) {
|
||||
continue
|
||||
}
|
||||
})
|
||||
const from = mapSegmentStart(first, range.start)
|
||||
const to = mapSegmentEnd(last, range.end)
|
||||
matches.push(
|
||||
touchesReadOnlyAtom
|
||||
? {
|
||||
from,
|
||||
to,
|
||||
touchesReadOnlyAtom: true,
|
||||
decorationRanges: visibleMap.segments
|
||||
.slice(firstSegmentIndex, lastSegmentIndex + 1)
|
||||
.map((segment) => ({
|
||||
from: mapSegmentStart(segment, range.start),
|
||||
to: mapSegmentEnd(segment, range.end),
|
||||
kind: segment.kind === 'text' ? ('inline' as const) : ('node' as const)
|
||||
}))
|
||||
}
|
||||
: { from, to }
|
||||
)
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
function mapSegmentStart(segment: RichMarkdownVisibleTextSegment, visibleFrom: number): number {
|
||||
return segment.kind === 'text'
|
||||
? segment.from + Math.max(0, visibleFrom - segment.visibleFrom)
|
||||
: segment.from
|
||||
}
|
||||
|
||||
function mapSegmentEnd(segment: RichMarkdownVisibleTextSegment, visibleTo: number): number {
|
||||
return segment.kind === 'text'
|
||||
? segment.from + Math.min(segment.text.length, visibleTo - segment.visibleFrom)
|
||||
: segment.to
|
||||
}
|
||||
|
||||
export function createRichMarkdownSearchPlugin(): Plugin<RichMarkdownSearchState> {
|
||||
return new Plugin<RichMarkdownSearchState>({
|
||||
key: richMarkdownSearchPluginKey,
|
||||
@@ -126,11 +187,18 @@ function buildSearchDecorationsFromMatches(
|
||||
return DecorationSet.empty
|
||||
}
|
||||
|
||||
const decorations = matches.map((match, index) =>
|
||||
Decoration.inline(match.from, match.to, {
|
||||
class: 'rich-markdown-search-match',
|
||||
'data-active': index === activeIndex ? 'true' : undefined
|
||||
})
|
||||
const decorations = matches.flatMap((match, index) =>
|
||||
(match.decorationRanges ?? [{ from: match.from, to: match.to, kind: 'inline' as const }]).map(
|
||||
(range) => {
|
||||
const attrs = {
|
||||
class: 'rich-markdown-search-match',
|
||||
'data-active': index === activeIndex ? 'true' : undefined
|
||||
}
|
||||
return range.kind === 'node'
|
||||
? Decoration.node(range.from, range.to, attrs)
|
||||
: Decoration.inline(range.from, range.to, attrs)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { NodeSelection } from '@tiptap/pm/state'
|
||||
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
|
||||
import type { ActivateMarkdownLink } from './rich-markdown-editor-click-routing'
|
||||
import {
|
||||
classifyHtmlSuperscriptLinkAction,
|
||||
type RichMarkdownHtmlSuperscriptLinkContext
|
||||
} from './rich-markdown-html-superscript-link-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function createEditableMarkdownLinkBubble(
|
||||
href: string,
|
||||
position: Pick<LinkBubbleState, 'left' | 'top'>
|
||||
): LinkBubbleState {
|
||||
return {
|
||||
kind: 'markdown',
|
||||
href,
|
||||
openEnabled: Boolean(href),
|
||||
copyEnabled: Boolean(href),
|
||||
...position
|
||||
}
|
||||
}
|
||||
|
||||
export function getRichMarkdownSelectionLinkBubble(
|
||||
editor: Editor,
|
||||
root: HTMLElement | null,
|
||||
context: RichMarkdownHtmlSuperscriptLinkContext
|
||||
): LinkBubbleState | null {
|
||||
const position = getLinkBubblePosition(editor, root)
|
||||
if (!position) {
|
||||
return null
|
||||
}
|
||||
if (editor.isActive('link')) {
|
||||
return createBubble(
|
||||
'markdown',
|
||||
String(editor.getAttributes('link').href ?? ''),
|
||||
position,
|
||||
context
|
||||
)
|
||||
}
|
||||
const selection = editor.state.selection
|
||||
if (
|
||||
!(selection instanceof NodeSelection) ||
|
||||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return createBubble(
|
||||
'html-superscript',
|
||||
String(selection.node.attrs.href ?? ''),
|
||||
position,
|
||||
context,
|
||||
String(selection.node.attrs.label ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
export function getSelectedHtmlSuperscriptLinkStatus(
|
||||
editor: Editor | null,
|
||||
context: RichMarkdownHtmlSuperscriptLinkContext
|
||||
): { href: string; label: string; openEnabled: boolean } | null {
|
||||
const selection = editor?.state.selection
|
||||
if (
|
||||
!editor ||
|
||||
!(selection instanceof NodeSelection) ||
|
||||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const href = String(selection.node.attrs.href ?? '')
|
||||
return {
|
||||
href,
|
||||
label: String(selection.node.attrs.label ?? ''),
|
||||
openEnabled: classifyHtmlSuperscriptLinkAction(href, context.getSnapshot())
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSelectedHtmlSuperscriptLinkStatus(
|
||||
status: NonNullable<ReturnType<typeof getSelectedHtmlSuperscriptLinkStatus>>
|
||||
): string {
|
||||
const label =
|
||||
status.label ||
|
||||
translate('auto.components.editor.RichMarkdownEditor.citationFallbackLabel', 'Citation')
|
||||
if (status.openEnabled) {
|
||||
return translate(
|
||||
'auto.components.editor.RichMarkdownEditor.citationLinkAvailable',
|
||||
'{{value0}}, link to {{value1}}. Press Enter to open or Tab for link actions.',
|
||||
{ value0: label, value1: status.href }
|
||||
)
|
||||
}
|
||||
const actionHint = status.href
|
||||
? translate(
|
||||
'auto.components.editor.RichMarkdownEditor.tabForCitationActions',
|
||||
'Tab for available actions.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.editor.RichMarkdownEditor.noCitationActions',
|
||||
'No link actions are available.'
|
||||
)
|
||||
return translate(
|
||||
'auto.components.editor.RichMarkdownEditor.citationLinkUnavailable',
|
||||
'{{value0}}, citation link unavailable. {{value1}}',
|
||||
{ value0: label, value1: actionHint }
|
||||
)
|
||||
}
|
||||
|
||||
export function openSelectedHtmlSuperscriptLink({
|
||||
activateMarkdownLink,
|
||||
context,
|
||||
editor,
|
||||
root,
|
||||
runtimeEnvironmentId
|
||||
}: {
|
||||
activateMarkdownLink: ActivateMarkdownLink
|
||||
context: RichMarkdownHtmlSuperscriptLinkContext
|
||||
editor: Editor | null
|
||||
root: HTMLElement | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
}): boolean {
|
||||
const selection = editor?.state.selection
|
||||
if (
|
||||
!editor ||
|
||||
!(selection instanceof NodeSelection) ||
|
||||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const href = String(selection.node.attrs.href ?? '')
|
||||
const snapshot = context.getSnapshot()
|
||||
if (!classifyHtmlSuperscriptLinkAction(href, snapshot)) {
|
||||
return true
|
||||
}
|
||||
if (href.startsWith('#')) {
|
||||
scrollToAnchorInEditor(root, href.slice(1))
|
||||
return true
|
||||
}
|
||||
void activateMarkdownLink(href, {
|
||||
sourceFilePath: snapshot.sourceFilePath,
|
||||
worktreeId: snapshot.worktreeId,
|
||||
worktreeRoot: snapshot.worktreeRoot,
|
||||
runtimeEnvironmentId,
|
||||
sourceOwner: snapshot.sourceOwner
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function createBubble(
|
||||
kind: LinkBubbleState['kind'],
|
||||
href: string,
|
||||
position: Pick<LinkBubbleState, 'left' | 'top'>,
|
||||
context: RichMarkdownHtmlSuperscriptLinkContext,
|
||||
label?: string
|
||||
): LinkBubbleState {
|
||||
return {
|
||||
kind,
|
||||
href,
|
||||
label,
|
||||
openEnabled: classifyHtmlSuperscriptLinkAction(href, context.getSnapshot()),
|
||||
copyEnabled: Boolean(href),
|
||||
...position
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function showRichMarkdownSourceOwningCutLimitError(): void {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.editor.richMarkdownSourceOwningCutFeedback.selectLessContent',
|
||||
'Select less content or use code mode to cut preserved HTML citations.'
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Slice } from '@tiptap/pm/model'
|
||||
import {
|
||||
getRichMarkdownLeafVisibleText,
|
||||
isRichMarkdownVisibleBlockStart
|
||||
} from './rich-markdown-visible-text-map'
|
||||
|
||||
export const RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT = 256 * 1024
|
||||
export const RICH_MARKDOWN_SOURCE_OWNING_NODE_LIMIT = 256
|
||||
|
||||
export type RichMarkdownSourceOwningSliceStatus = {
|
||||
containsSourceOwningNode: boolean
|
||||
canPreserve: boolean
|
||||
}
|
||||
|
||||
export function inspectRichMarkdownSourceOwningSlice(
|
||||
slice: Slice
|
||||
): RichMarkdownSourceOwningSliceStatus {
|
||||
let sourceBytes = 0
|
||||
let visibleBytes = 0
|
||||
let nodeCount = 0
|
||||
let containsSourceOwningNode = false
|
||||
let canPreserve = true
|
||||
let sawVisibleBlock = false
|
||||
|
||||
slice.content.descendants((node, pos, parent, index) => {
|
||||
const startsVisibleBlock = isRichMarkdownVisibleBlockStart(node)
|
||||
if (canPreserve && startsVisibleBlock) {
|
||||
if (sawVisibleBlock) {
|
||||
const separator = addUtf8BytesWithinLimit(
|
||||
visibleBytes,
|
||||
'\n',
|
||||
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
|
||||
)
|
||||
visibleBytes = separator.byteLength
|
||||
canPreserve = !separator.exceeded
|
||||
}
|
||||
sawVisibleBlock = true
|
||||
}
|
||||
if (node.type.name === 'richMarkdownHtmlSuperscriptLink') {
|
||||
containsSourceOwningNode = true
|
||||
if (!canPreserve) {
|
||||
return false
|
||||
}
|
||||
nodeCount += 1
|
||||
const sourceMeasurement = addUtf8BytesWithinLimit(
|
||||
sourceBytes,
|
||||
String(node.attrs.source ?? ''),
|
||||
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
|
||||
)
|
||||
sourceBytes = sourceMeasurement.byteLength
|
||||
canPreserve =
|
||||
!sourceMeasurement.exceeded && nodeCount <= RICH_MARKDOWN_SOURCE_OWNING_NODE_LIMIT
|
||||
if (!canPreserve) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (!canPreserve) {
|
||||
// Keep walking only far enough to detect a later source-owning atom.
|
||||
return true
|
||||
}
|
||||
const visible = node.isText
|
||||
? (node.text ?? '')
|
||||
: node.isLeaf
|
||||
? getRichMarkdownLeafVisibleText(node, pos, parent, index)
|
||||
: ''
|
||||
if (visible) {
|
||||
const measurement = addUtf8BytesWithinLimit(
|
||||
visibleBytes,
|
||||
visible,
|
||||
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
|
||||
)
|
||||
visibleBytes = measurement.byteLength
|
||||
canPreserve = !measurement.exceeded
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return { containsSourceOwningNode, canPreserve }
|
||||
}
|
||||
|
||||
function addUtf8BytesWithinLimit(
|
||||
current: number,
|
||||
value: string,
|
||||
limit: number
|
||||
): { byteLength: number; exceeded: boolean } {
|
||||
const remaining = limit - current
|
||||
if (value.length > remaining) {
|
||||
return { byteLength: limit + 1, exceeded: true }
|
||||
}
|
||||
let byteLength = current
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codePoint = value.codePointAt(index) ?? 0
|
||||
byteLength += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4
|
||||
if (byteLength > limit) {
|
||||
return { byteLength, exceeded: true }
|
||||
}
|
||||
if (codePoint > 0xffff) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return { byteLength, exceeded: false }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { marked } from 'marked'
|
||||
import { createTiptapMarkedFacade } from './tiptap-marked-facade'
|
||||
|
||||
export type RichMarkdownSourceKind =
|
||||
| 'literal'
|
||||
| 'inline-html'
|
||||
| 'block-html'
|
||||
| 'document-link'
|
||||
| 'html-superscript-link'
|
||||
|
||||
const TRANSPORT_PREFIX = '[[ORCA_RICH_MD:'
|
||||
const TRANSPORT_SUFFIX = ']]'
|
||||
const KEY_PATTERN = /^[a-f0-9]{32}$/
|
||||
const TRANSPORT_BODY_PATTERN =
|
||||
/^ORCA_RICH_MD:[a-f0-9]{32}:(?:literal|inline-html|block-html|document-link|html-superscript-link):/
|
||||
const LEGACY_PREFIXES = ['ORCA_RAW_HTML_INLINE:', 'ORCA_RAW_HTML_BLOCK:', 'ORCA_DOC_LINK:'] as const
|
||||
|
||||
export type RichMarkdownSourceTransport = {
|
||||
readonly key: string
|
||||
readonly authoredPrefix: string
|
||||
create: (kind: RichMarkdownSourceKind, value: string) => string
|
||||
match: (source: string, kind: RichMarkdownSourceKind) => { raw: string; value: string } | null
|
||||
startFor: (kind: RichMarkdownSourceKind) => string
|
||||
}
|
||||
|
||||
export function isLegacyRichMarkdownTransportBody(value: string): boolean {
|
||||
return LEGACY_PREFIXES.some((prefix) => value.startsWith(prefix))
|
||||
}
|
||||
|
||||
export function isReservedRichMarkdownTransportBody(value: string): boolean {
|
||||
// Why: a foreign editor's valid keyed envelope is authored text here, not
|
||||
// a document link whose target happens to resemble transport metadata.
|
||||
return isLegacyRichMarkdownTransportBody(value) || TRANSPORT_BODY_PATTERN.test(value)
|
||||
}
|
||||
|
||||
export type RichMarkdownEditorCodec = {
|
||||
transport: RichMarkdownSourceTransport
|
||||
marked: typeof marked
|
||||
}
|
||||
|
||||
export function createRichMarkdownEditorCodec(key = createCodecKey()): RichMarkdownEditorCodec {
|
||||
return {
|
||||
transport: createRichMarkdownSourceTransport(key),
|
||||
marked: createTiptapMarkedFacade()
|
||||
}
|
||||
}
|
||||
|
||||
export function createRichMarkdownSourceTransport(key: string): RichMarkdownSourceTransport {
|
||||
if (!KEY_PATTERN.test(key)) {
|
||||
throw new Error('Rich Markdown transport keys must be 128-bit lowercase hex values')
|
||||
}
|
||||
const authoredPrefix = `${TRANSPORT_PREFIX}${key}:`
|
||||
const startFor = (kind: RichMarkdownSourceKind): string => `${authoredPrefix}${kind}:`
|
||||
|
||||
return {
|
||||
key,
|
||||
authoredPrefix,
|
||||
startFor,
|
||||
create: (kind, value) => `${startFor(kind)}${encodeURIComponent(value)}${TRANSPORT_SUFFIX}`,
|
||||
match: (source, kind) => {
|
||||
const prefix = startFor(kind)
|
||||
if (!source.startsWith(prefix)) {
|
||||
return null
|
||||
}
|
||||
const endIndex = source.indexOf(TRANSPORT_SUFFIX, prefix.length)
|
||||
if (endIndex === -1) {
|
||||
return null
|
||||
}
|
||||
const raw = source.slice(0, endIndex + TRANSPORT_SUFFIX.length)
|
||||
try {
|
||||
return {
|
||||
raw,
|
||||
value: decodeURIComponent(source.slice(prefix.length, endIndex))
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createCodecKey(): string {
|
||||
const bytes = new Uint8Array(16)
|
||||
globalThis.crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
@@ -3,20 +3,20 @@ import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
|
||||
import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler'
|
||||
|
||||
const extensions = [
|
||||
StarterKit,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Markdown.configure({ markedOptions: { gfm: true } })
|
||||
]
|
||||
|
||||
function createEditor(content: object): Editor {
|
||||
// Why: each Editor needs its own marked registry; sharing one module-scoped
|
||||
// extension accumulates tokenizer state across tests.
|
||||
return new Editor({
|
||||
element: null,
|
||||
extensions,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
createIsolatedMarkdownExtensionForTests()
|
||||
],
|
||||
content
|
||||
})
|
||||
}
|
||||
@@ -74,6 +74,18 @@ function createContext(editor: Editor): KeyHandlerContext {
|
||||
typedEmptyOrderedListMarkerRef: { current: false },
|
||||
flushPendingSerialization: vi.fn(),
|
||||
openSearchRef: { current: vi.fn() },
|
||||
linkBubbleOwnerId: 'test-owner',
|
||||
htmlSuperscriptLinkContext: {
|
||||
getSnapshot: () => ({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' as const },
|
||||
version: 0
|
||||
}),
|
||||
subscribe: () => () => {},
|
||||
update: () => {}
|
||||
},
|
||||
setIsEditingLink: vi.fn(),
|
||||
setLinkBubble: vi.fn(),
|
||||
setSelectedCommandIndex: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Fragment, Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
|
||||
type RichMarkdownVisibleTextContainer = ProseMirrorNode | Fragment
|
||||
|
||||
export type RichMarkdownVisibleTextSegment = {
|
||||
kind: 'text' | 'read-only-atom' | 'separator'
|
||||
text: string
|
||||
from: number
|
||||
to: number
|
||||
visibleFrom: number
|
||||
visibleTo: number
|
||||
}
|
||||
|
||||
export type RichMarkdownVisibleTextMap = {
|
||||
text: string
|
||||
segments: RichMarkdownVisibleTextSegment[]
|
||||
}
|
||||
|
||||
export function createRichMarkdownVisibleTextMap(
|
||||
doc: RichMarkdownVisibleTextContainer,
|
||||
from = 0,
|
||||
to = getContainerContentSize(doc)
|
||||
): RichMarkdownVisibleTextMap {
|
||||
const segments: RichMarkdownVisibleTextSegment[] = []
|
||||
let text = ''
|
||||
forEachRichMarkdownVisibleTextSegment(doc, from, to, (segment) => {
|
||||
const visibleFrom = text.length
|
||||
text += segment.text
|
||||
segments.push({ ...segment, visibleFrom, visibleTo: text.length })
|
||||
return true
|
||||
})
|
||||
return { text, segments }
|
||||
}
|
||||
|
||||
export function getRichMarkdownVisibleText(
|
||||
doc: RichMarkdownVisibleTextContainer,
|
||||
from = 0,
|
||||
to = getContainerContentSize(doc)
|
||||
): string {
|
||||
return createRichMarkdownVisibleTextMap(doc, from, to).text
|
||||
}
|
||||
|
||||
export function getRichMarkdownSelectionVisibleText(state: {
|
||||
doc: ProseMirrorNode
|
||||
selection: { from: number; to: number }
|
||||
}): string {
|
||||
return getRichMarkdownVisibleText(state.doc, state.selection.from, state.selection.to).trim()
|
||||
}
|
||||
|
||||
/** Why: shared block-boundary heuristic for visible-text maps and slice byte limits. */
|
||||
export function isRichMarkdownVisibleBlockStart(node: ProseMirrorNode): boolean {
|
||||
return node.isTextblock || (node.isBlock && node.isLeaf)
|
||||
}
|
||||
|
||||
export function forEachRichMarkdownVisibleTextSegment(
|
||||
doc: RichMarkdownVisibleTextContainer,
|
||||
from: number,
|
||||
to: number,
|
||||
visit: (segment: Omit<RichMarkdownVisibleTextSegment, 'visibleFrom' | 'visibleTo'>) => boolean
|
||||
): void {
|
||||
let stopped = false
|
||||
let sawVisibleBlock = false
|
||||
const inspect = (
|
||||
node: ProseMirrorNode,
|
||||
pos: number,
|
||||
parent: ProseMirrorNode | null = null,
|
||||
index = 0
|
||||
) => {
|
||||
if (stopped) {
|
||||
return false
|
||||
}
|
||||
const startsVisibleBlock = isRichMarkdownVisibleBlockStart(node)
|
||||
if (startsVisibleBlock) {
|
||||
if (sawVisibleBlock) {
|
||||
stopped = !visit({ kind: 'separator', text: '\n', from: pos, to: pos })
|
||||
if (stopped) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
sawVisibleBlock = true
|
||||
if (node.isTextblock) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
let visible = ''
|
||||
let segmentFrom = pos
|
||||
let segmentTo = pos + node.nodeSize
|
||||
let kind: RichMarkdownVisibleTextSegment['kind'] = 'text'
|
||||
if (node.isText) {
|
||||
const source = node.text ?? ''
|
||||
const startOffset = Math.max(0, from - pos)
|
||||
const endOffset = Math.min(source.length, to - pos)
|
||||
if (endOffset <= startOffset) {
|
||||
return
|
||||
}
|
||||
visible = source.slice(startOffset, endOffset)
|
||||
segmentFrom = pos + startOffset
|
||||
segmentTo = pos + endOffset
|
||||
} else if (node.isLeaf) {
|
||||
visible = getRichMarkdownLeafVisibleText(node, pos, parent, index)
|
||||
kind = node.isAtom ? 'read-only-atom' : 'text'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
stopped = !visit({
|
||||
kind,
|
||||
text: visible,
|
||||
from: segmentFrom,
|
||||
to: segmentTo
|
||||
})
|
||||
return !stopped
|
||||
}
|
||||
doc.nodesBetween(from, to, inspect)
|
||||
}
|
||||
|
||||
export function getRichMarkdownLeafVisibleText(
|
||||
node: ProseMirrorNode,
|
||||
pos: number,
|
||||
parent: ProseMirrorNode | null,
|
||||
index: number
|
||||
): string {
|
||||
return (
|
||||
node.type.spec.toText?.({ node, pos, parent, index }) ?? node.type.spec.leafText?.(node) ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function getContainerContentSize(container: RichMarkdownVisibleTextContainer): number {
|
||||
return 'nodeSize' in container ? container.content.size : container.size
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DOMSerializer } from '@tiptap/pm/model'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { writeRichMarkdownSliceToClipboard } from './rich-markdown-clipboard-write'
|
||||
import { createRichMarkdownVisibleTextMap } from './rich-markdown-visible-text-map'
|
||||
|
||||
/**
|
||||
* Why: a paragraph that word-wraps across multiple screen lines should be cut
|
||||
@@ -85,14 +86,15 @@ export function cutVisualLine(
|
||||
}
|
||||
event.preventDefault()
|
||||
|
||||
const lineText = view.state.doc.textBetween(lineRange.from, lineRange.to, '')
|
||||
const lineText = createRichMarkdownVisibleTextMap(
|
||||
view.state.doc,
|
||||
lineRange.from,
|
||||
lineRange.to
|
||||
).text
|
||||
const slice = view.state.doc.slice(lineRange.from, lineRange.to)
|
||||
const serializer = DOMSerializer.fromSchema(view.state.schema)
|
||||
const fragment = serializer.serializeFragment(slice.content)
|
||||
const div = document.createElement('div')
|
||||
div.appendChild(fragment)
|
||||
clipboardEvent.clipboardData.setData('text/html', div.innerHTML)
|
||||
clipboardEvent.clipboardData.setData('text/plain', lineText)
|
||||
if (!writeRichMarkdownSliceToClipboard(clipboardEvent.clipboardData, view, slice, lineText)) {
|
||||
return true
|
||||
}
|
||||
|
||||
let tr = view.state.tr.delete(lineRange.from, lineRange.to)
|
||||
const clampedPos = Math.max(0, Math.min(lineRange.from, tr.doc.content.size))
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Hooks,
|
||||
Lexer,
|
||||
Marked,
|
||||
Parser,
|
||||
Renderer,
|
||||
TextRenderer,
|
||||
Tokenizer,
|
||||
getDefaults,
|
||||
type MarkedOptions,
|
||||
type Token,
|
||||
type TokensList,
|
||||
marked
|
||||
} from 'marked'
|
||||
|
||||
export function createTiptapMarkedFacade(): typeof marked {
|
||||
const registry = new Marked()
|
||||
|
||||
// Why: Tiptap 3.22.5 registers on the injected instance but parses with
|
||||
// `new instance.Lexer()`, so the constructor must retain the private registry.
|
||||
class RegistryLexer extends Lexer {
|
||||
constructor(options?: MarkedOptions) {
|
||||
super({
|
||||
...registry.defaults,
|
||||
...options,
|
||||
extensions: registry.defaults.extensions
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const parser = (tokens: Token[], options?: MarkedOptions) => registry.parser(tokens, options)
|
||||
const lexer = (src: string, options?: MarkedOptions): TokensList =>
|
||||
new RegistryLexer(options).lex(src)
|
||||
const facade = new Proxy(marked, {
|
||||
apply: (_target, _thisArg, args) => Reflect.apply(registry.parse, registry, args),
|
||||
get: (target, property, receiver) => {
|
||||
switch (property) {
|
||||
case 'defaults':
|
||||
return registry.defaults
|
||||
case 'getDefaults':
|
||||
return getDefaults
|
||||
case 'Lexer':
|
||||
return RegistryLexer
|
||||
case 'Parser':
|
||||
return Parser
|
||||
case 'Renderer':
|
||||
return Renderer
|
||||
case 'TextRenderer':
|
||||
return TextRenderer
|
||||
case 'Tokenizer':
|
||||
return Tokenizer
|
||||
case 'Hooks':
|
||||
return Hooks
|
||||
case 'parse':
|
||||
return facade
|
||||
case 'parseInline':
|
||||
return registry.parseInline
|
||||
case 'parser':
|
||||
return parser
|
||||
case 'lexer':
|
||||
return lexer
|
||||
case 'walkTokens':
|
||||
return registry.walkTokens.bind(registry)
|
||||
case 'use':
|
||||
return (...extensions: Parameters<typeof registry.use>) => {
|
||||
registry.use(...extensions)
|
||||
return facade
|
||||
}
|
||||
case 'setOptions':
|
||||
case 'options':
|
||||
return (options: MarkedOptions) => {
|
||||
registry.setOptions(options)
|
||||
return facade
|
||||
}
|
||||
default:
|
||||
return Reflect.get(target, property, receiver)
|
||||
}
|
||||
}
|
||||
}) satisfies typeof marked
|
||||
|
||||
return facade
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, useRef, useState } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
|
||||
const activateMarkdownLinkMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (
|
||||
selector: (state: { activateMarkdownLink: typeof activateMarkdownLinkMock }) => unknown
|
||||
) => selector({ activateMarkdownLink: activateMarkdownLinkMock })
|
||||
}))
|
||||
|
||||
import { useLinkBubble } from './useLinkBubble'
|
||||
|
||||
describe('useLinkBubble owner hydration', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
activateMarkdownLinkMock.mockReset()
|
||||
})
|
||||
|
||||
it.each<HttpLinkSourceOwner>([{ kind: 'local' }, { kind: 'ssh', connectionId: 'ssh-1' }])(
|
||||
'refreshes an open Markdown bubble when ownership becomes $kind',
|
||||
(sourceOwner) => {
|
||||
const context = createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'unknown' }
|
||||
})
|
||||
|
||||
function Harness(): React.JSX.Element {
|
||||
const rootRef = useRef<HTMLElement>(null)
|
||||
const [bubble, setBubble] = useState<LinkBubbleState | null>({
|
||||
kind: 'markdown',
|
||||
href: 'https://example.com',
|
||||
openEnabled: false,
|
||||
copyEnabled: true,
|
||||
left: 0,
|
||||
top: 0
|
||||
})
|
||||
const { handleLinkOpen } = useLinkBubble(null, rootRef, bubble, setBubble, () => {}, {
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
htmlSuperscriptLinkContext: context
|
||||
})
|
||||
return (
|
||||
<button data-enabled={String(bubble?.openEnabled)} onClick={handleLinkOpen}>
|
||||
{bubble?.href}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => root.render(<Harness />))
|
||||
const button = container.querySelector('button')
|
||||
if (!button) {
|
||||
throw new Error('Expected link action')
|
||||
}
|
||||
expect(button.dataset.enabled).toBe('false')
|
||||
act(() => button.click())
|
||||
expect(activateMarkdownLinkMock).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
context.update({
|
||||
sourceFilePath: '/repo/README.md',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner
|
||||
})
|
||||
})
|
||||
expect(button.dataset.enabled).toBe('true')
|
||||
expect(button.textContent).toBe('https://example.com')
|
||||
|
||||
act(() => button.click())
|
||||
expect(activateMarkdownLinkMock).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
expect.objectContaining({ sourceOwner })
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect, useSyncExternalStore } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { getLinkBubblePosition } from './RichMarkdownLinkBubble'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import { useAppStore } from '@/store'
|
||||
import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
|
||||
import {
|
||||
classifyHtmlSuperscriptLinkAction,
|
||||
type RichMarkdownHtmlSuperscriptLinkContext
|
||||
} from './rich-markdown-html-superscript-link-context'
|
||||
import { copyRichMarkdownLink } from './rich-markdown-link-clipboard'
|
||||
|
||||
/**
|
||||
* Extracts link-editing action handlers from the editor component to reduce
|
||||
@@ -21,14 +26,30 @@ export function useLinkBubble(
|
||||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
}
|
||||
): {
|
||||
handleLinkSave: (href: string) => void
|
||||
handleLinkRemove: () => void
|
||||
handleLinkEditCancel: () => void
|
||||
handleLinkOpen: () => void
|
||||
handleLinkCopy: () => void
|
||||
toggleLinkFromToolbar: () => void
|
||||
} {
|
||||
const citationContextSnapshot = useSyncExternalStore(
|
||||
linkContext.htmlSuperscriptLinkContext.subscribe,
|
||||
linkContext.htmlSuperscriptLinkContext.getSnapshot,
|
||||
linkContext.htmlSuperscriptLinkContext.getSnapshot
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!linkBubble) {
|
||||
return
|
||||
}
|
||||
const openEnabled = classifyHtmlSuperscriptLinkAction(linkBubble.href, citationContextSnapshot)
|
||||
if (openEnabled !== linkBubble.openEnabled) {
|
||||
setLinkBubble({ ...linkBubble, openEnabled })
|
||||
}
|
||||
}, [citationContextSnapshot, linkBubble, setLinkBubble])
|
||||
const startLinkEdit = useCallback(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
@@ -38,7 +59,13 @@ export function useLinkBubble(
|
||||
const href = editor.isActive('link')
|
||||
? (editor.getAttributes('link').href as string) || ''
|
||||
: ''
|
||||
setLinkBubble({ href, ...pos })
|
||||
setLinkBubble({
|
||||
kind: 'markdown',
|
||||
href,
|
||||
openEnabled: Boolean(href),
|
||||
copyEnabled: Boolean(href),
|
||||
...pos
|
||||
})
|
||||
setIsEditingLink(true)
|
||||
}
|
||||
}, [editor, rootRef, setLinkBubble, setIsEditingLink])
|
||||
@@ -99,7 +126,11 @@ export function useLinkBubble(
|
||||
const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink)
|
||||
|
||||
const handleLinkOpen = useCallback(() => {
|
||||
if (!linkBubble?.href) {
|
||||
if (
|
||||
!linkBubble?.href ||
|
||||
!linkBubble.openEnabled ||
|
||||
!classifyHtmlSuperscriptLinkAction(linkBubble.href, citationContextSnapshot)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (linkBubble.href.startsWith('#')) {
|
||||
@@ -110,11 +141,14 @@ export function useLinkBubble(
|
||||
sourceFilePath: linkContext.sourceFilePath,
|
||||
worktreeId: linkContext.worktreeId,
|
||||
worktreeRoot: linkContext.worktreeRoot,
|
||||
runtimeEnvironmentId: linkContext.runtimeEnvironmentId
|
||||
runtimeEnvironmentId: linkContext.runtimeEnvironmentId,
|
||||
sourceOwner: citationContextSnapshot.sourceOwner
|
||||
})
|
||||
}, [
|
||||
activateMarkdownLink,
|
||||
citationContextSnapshot,
|
||||
linkBubble?.href,
|
||||
linkBubble?.openEnabled,
|
||||
linkContext.sourceFilePath,
|
||||
linkContext.worktreeId,
|
||||
linkContext.worktreeRoot,
|
||||
@@ -122,6 +156,13 @@ export function useLinkBubble(
|
||||
rootRef
|
||||
])
|
||||
|
||||
const handleLinkCopy = useCallback(() => {
|
||||
if (!linkBubble?.href || !linkBubble.copyEnabled) {
|
||||
return
|
||||
}
|
||||
void copyRichMarkdownLink(linkBubble.href)
|
||||
}, [linkBubble?.copyEnabled, linkBubble?.href])
|
||||
|
||||
const toggleLinkFromToolbar = useCallback(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
@@ -139,6 +180,7 @@ export function useLinkBubble(
|
||||
handleLinkRemove,
|
||||
handleLinkEditCancel,
|
||||
handleLinkOpen,
|
||||
handleLinkCopy,
|
||||
toggleLinkFromToolbar
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,21 @@ import {
|
||||
type EditorConfigParams
|
||||
} from './rich-markdown-editor-config'
|
||||
|
||||
const richMarkdownExtensions = createRichMarkdownExtensions({ includePlaceholder: true })
|
||||
|
||||
export function useRichMarkdownEditorInstance(params: EditorConfigParams): Editor | null {
|
||||
const extensions = useMemo(
|
||||
() =>
|
||||
createRichMarkdownExtensions({
|
||||
codec: params.codec,
|
||||
includePlaceholder: true,
|
||||
htmlSuperscriptLinks: true,
|
||||
htmlSuperscriptLinkContext: params.htmlSuperscriptLinkContext
|
||||
}),
|
||||
[params.codec, params.htmlSuperscriptLinkContext]
|
||||
)
|
||||
const editor = useEditor(
|
||||
useMemo(
|
||||
() => ({
|
||||
extensions: richMarkdownExtensions,
|
||||
extensions,
|
||||
...createRichMarkdownEditorConfig(params)
|
||||
}),
|
||||
// Dependencies are the same as the params object keys
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
setRichMarkdownImageResolverContext,
|
||||
type RichMarkdownImageResolverSettings
|
||||
} from './rich-markdown-image-context'
|
||||
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
|
||||
type RichMarkdownProgrammaticSyncOptions = {
|
||||
codec: RichMarkdownEditorCodec
|
||||
content: string
|
||||
docLinkMenuSetter: Dispatch<SetStateAction<DocLinkMenuState | null>>
|
||||
editor: Editor | null
|
||||
@@ -36,6 +38,7 @@ type RichMarkdownEditorStorage = {
|
||||
}
|
||||
|
||||
export function useRichMarkdownProgrammaticSync({
|
||||
codec,
|
||||
content,
|
||||
docLinkMenuSetter,
|
||||
editor,
|
||||
@@ -103,7 +106,7 @@ export function useRichMarkdownProgrammaticSync({
|
||||
}
|
||||
isApplyingProgrammaticUpdateRef.current = true
|
||||
try {
|
||||
applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef)
|
||||
applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef, codec)
|
||||
} finally {
|
||||
isApplyingProgrammaticUpdateRef.current = false
|
||||
}
|
||||
@@ -111,6 +114,7 @@ export function useRichMarkdownProgrammaticSync({
|
||||
syncDocLinkMenu(editor, rootRef.current, docLinkMenuSetter)
|
||||
}, [
|
||||
content,
|
||||
codec,
|
||||
docLinkMenuSetter,
|
||||
editor,
|
||||
fileId,
|
||||
@@ -124,15 +128,19 @@ export function useRichMarkdownProgrammaticSync({
|
||||
function applyExternalRichMarkdownContent(
|
||||
editor: Editor,
|
||||
content: string,
|
||||
lastCommittedMarkdownRef: MutableRefObject<string>
|
||||
lastCommittedMarkdownRef: MutableRefObject<string>,
|
||||
codec: RichMarkdownEditorCodec
|
||||
): void {
|
||||
try {
|
||||
const hadFocus = editor.isFocused
|
||||
const { from: prevFrom, to: prevTo } = editor.state.selection
|
||||
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(content), {
|
||||
contentType: 'markdown',
|
||||
emitUpdate: false
|
||||
})
|
||||
editor.commands.setContent(
|
||||
encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }),
|
||||
{
|
||||
contentType: 'markdown',
|
||||
emitUpdate: false
|
||||
}
|
||||
)
|
||||
// Why: normalizeEmptyListItems avoids splitting hard-wrapped paragraphs from
|
||||
// external content, matching onCreate's single-paragraph reflow behavior.
|
||||
normalizeEmptyListItems(editor)
|
||||
|
||||
@@ -84,6 +84,11 @@ export function useRichMarkdownSearch({
|
||||
})
|
||||
}, [editor, isSearchOpen, matchCase, searchQuery, wholeWord])
|
||||
|
||||
// Why: mirror the guard used by replaceCurrentMatch/replaceAllMatches so the
|
||||
// disabled state never disagrees with what a click will actually do during the
|
||||
// debounce window when live matches diverge from the highlight set.
|
||||
const replaceDisabled = getLiveMatches().some((match) => match.touchesReadOnlyAtom)
|
||||
|
||||
// Clamp the user-controlled index to the valid range on every render.
|
||||
// No state update needed — this is a pure derivation.
|
||||
const activeMatchIndex =
|
||||
@@ -160,7 +165,7 @@ export function useRichMarkdownSearch({
|
||||
const liveActiveMatchIndex =
|
||||
activeMatchIndex >= 0 && activeMatchIndex < liveMatches.length ? activeMatchIndex : 0
|
||||
const match = liveMatches[liveActiveMatchIndex]
|
||||
if (!match) {
|
||||
if (!match || liveMatches.some((candidate) => candidate.touchesReadOnlyAtom)) {
|
||||
return
|
||||
}
|
||||
// Why: removing the active match shifts the next match into the same index,
|
||||
@@ -173,7 +178,10 @@ export function useRichMarkdownSearch({
|
||||
return
|
||||
}
|
||||
const liveMatches = getLiveMatches()
|
||||
if (liveMatches.length === 0) {
|
||||
if (
|
||||
liveMatches.length === 0 ||
|
||||
liveMatches.some((candidate) => candidate.touchesReadOnlyAtom)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const tr = editor.state.tr
|
||||
@@ -344,6 +352,7 @@ export function useRichMarkdownSearch({
|
||||
matchCase,
|
||||
matchCount,
|
||||
replaceQuery,
|
||||
replaceDisabled,
|
||||
searchQuery,
|
||||
searchInputRef,
|
||||
wholeWord
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution'
|
||||
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
export function resolveRichMarkdownWorktreeRoot(
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>,
|
||||
worktreeId: string
|
||||
): string | null {
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
return workspaceScope?.type === 'folder'
|
||||
? (state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId)
|
||||
?.folderPath ?? null)
|
||||
: (getIndexedWorktreeById(state.worktreesByRepo, worktreeId)?.path ?? null)
|
||||
}
|
||||
|
||||
export function useRichMarkdownSuperscriptLinkSetup({
|
||||
filePath,
|
||||
runtimeEnvironmentId,
|
||||
worktreeId
|
||||
}: {
|
||||
filePath: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
worktreeId: string
|
||||
}) {
|
||||
const worktreeRoot = useAppStore((state) => resolveRichMarkdownWorktreeRoot(state, worktreeId))
|
||||
const runtimeId = runtimeEnvironmentId?.trim()
|
||||
const connectionIdSelector = useMemo(() => {
|
||||
return createConnectionIdForFileSelector(worktreeId, filePath, { skip: Boolean(runtimeId) })
|
||||
}, [filePath, runtimeId, worktreeId])
|
||||
const connectionId = useAppStore(connectionIdSelector)
|
||||
const sourceOwner = useMemo<HttpLinkSourceOwner>(() => {
|
||||
if (runtimeId) {
|
||||
return { kind: 'runtime', runtimeEnvironmentId: runtimeId }
|
||||
}
|
||||
if (connectionId === undefined) {
|
||||
return { kind: 'unknown' }
|
||||
}
|
||||
return connectionId === null ? { kind: 'local' } : { kind: 'ssh', connectionId }
|
||||
}, [connectionId, runtimeId])
|
||||
const [codec] = useState(createRichMarkdownEditorCodec)
|
||||
const [context] = useState(() =>
|
||||
createRichMarkdownHtmlSuperscriptLinkContext({
|
||||
sourceFilePath: filePath,
|
||||
worktreeId,
|
||||
worktreeRoot,
|
||||
sourceOwner
|
||||
})
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
context.update({ sourceFilePath: filePath, worktreeId, worktreeRoot, sourceOwner })
|
||||
}, [context, filePath, sourceOwner, worktreeId, worktreeRoot])
|
||||
return { codec, htmlSuperscriptLinkContext: context, worktreeRoot }
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
type LinkBubbleState
|
||||
} from '@/components/editor/RichMarkdownLinkBubble'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html'
|
||||
import { createRichMarkdownEditorCodec } from '@/components/editor/rich-markdown-source-transport'
|
||||
import { createEditableMarkdownLinkBubble } from '@/components/editor/rich-markdown-selected-link-actions'
|
||||
import { copyRichMarkdownLink } from '@/components/editor/rich-markdown-link-clipboard'
|
||||
import { normalizeSoftBreaks } from '@/components/editor/rich-markdown-normalize'
|
||||
import { GitHubMarkdownComposerPreviewPane } from '@/components/github/github-markdown-composer-preview-pane'
|
||||
import {
|
||||
@@ -71,6 +74,7 @@ export function GitHubMarkdownComposer({
|
||||
const [linkBubble, setLinkBubble] = useState<LinkBubbleState | null>(null)
|
||||
const [isEditingLink, setIsEditingLink] = useState(false)
|
||||
const isTabbed = layout === 'tabbed'
|
||||
const codec = useMemo(() => createRichMarkdownEditorCodec(), [])
|
||||
|
||||
const {
|
||||
imageUrl,
|
||||
@@ -89,13 +93,13 @@ export function GitHubMarkdownComposer({
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
...createRichMarkdownExtensions(),
|
||||
...createRichMarkdownExtensions({ codec }),
|
||||
Placeholder.configure({
|
||||
includeChildren: true,
|
||||
placeholder
|
||||
})
|
||||
],
|
||||
[placeholder]
|
||||
[codec, placeholder]
|
||||
)
|
||||
|
||||
const openLinkEditor = useCallback(() => {
|
||||
@@ -109,7 +113,7 @@ export function GitHubMarkdownComposer({
|
||||
return
|
||||
}
|
||||
const href = editor.isActive('link') ? String(editor.getAttributes('link').href ?? '') : ''
|
||||
setLinkBubble({ href, ...position })
|
||||
setLinkBubble(createEditableMarkdownLinkBubble(href, position))
|
||||
setIsEditingLink(true)
|
||||
}, [])
|
||||
|
||||
@@ -117,7 +121,7 @@ export function GitHubMarkdownComposer({
|
||||
immediatelyRender: false,
|
||||
extensions,
|
||||
editable: !disabled,
|
||||
content: encodeRawMarkdownHtmlForRichEditor(value),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(value, codec),
|
||||
contentType: 'markdown',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
@@ -177,10 +181,12 @@ export function GitHubMarkdownComposer({
|
||||
if (nextEditor.isActive('link')) {
|
||||
const position = getLinkBubblePosition(nextEditor, rootRef.current)
|
||||
if (position) {
|
||||
setLinkBubble({
|
||||
href: String(nextEditor.getAttributes('link').href ?? ''),
|
||||
...position
|
||||
})
|
||||
setLinkBubble(
|
||||
createEditableMarkdownLinkBubble(
|
||||
String(nextEditor.getAttributes('link').href ?? ''),
|
||||
position
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -224,7 +230,7 @@ export function GitHubMarkdownComposer({
|
||||
}
|
||||
applyingExternalValueRef.current = true
|
||||
try {
|
||||
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), {
|
||||
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), {
|
||||
contentType: 'markdown',
|
||||
emitUpdate: false
|
||||
})
|
||||
@@ -233,7 +239,7 @@ export function GitHubMarkdownComposer({
|
||||
} finally {
|
||||
applyingExternalValueRef.current = false
|
||||
}
|
||||
}, [editor, value])
|
||||
}, [codec, editor, value])
|
||||
|
||||
const handleLinkSave = useCallback((href: string) => {
|
||||
const editor = editorRef.current
|
||||
@@ -403,6 +409,7 @@ export function GitHubMarkdownComposer({
|
||||
editorRef.current?.commands.focus()
|
||||
}}
|
||||
onOpen={handleLinkOpen}
|
||||
onCopy={() => void copyRichMarkdownLink(linkBubble.href)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -11605,7 +11605,8 @@
|
||||
"1c99b726e0": "Remove link",
|
||||
"cdfe166f6f": "Edit link",
|
||||
"bfc813e909": "Open link",
|
||||
"7b0b945fdc": "Paste or type a link…"
|
||||
"7b0b945fdc": "Paste or type a link…",
|
||||
"copyLink": "Copy link"
|
||||
},
|
||||
"RichMarkdownReviewNoteLayer": {
|
||||
"f3ef92952b": "This note",
|
||||
@@ -11632,7 +11633,8 @@
|
||||
"68d090241d": "Match whole word",
|
||||
"fd97c7e585": "Replace",
|
||||
"44682b4159": "Replace in rich markdown editor",
|
||||
"c2884f5e95": "Replace all"
|
||||
"c2884f5e95": "Replace all",
|
||||
"preservedRichContentReadOnly": "Preserved rich content is read-only in rich mode."
|
||||
},
|
||||
"RichMarkdownSlashMenu": {
|
||||
"82c6816ff8": "No blocks found",
|
||||
@@ -11849,6 +11851,24 @@
|
||||
"3fa2b8d417": "Reload from Disk",
|
||||
"a95d02c644": "Keep My Edits",
|
||||
"2c8f1e07b9": "Loading comparison..."
|
||||
},
|
||||
"RichMarkdownEditor": {
|
||||
"citationLinkAvailable": "{{value0}}, link to {{value1}}. Press Enter to open or Tab for link actions.",
|
||||
"citationFallbackLabel": "Citation",
|
||||
"citationLinkUnavailable": "{{value0}}, citation link unavailable. {{value1}}",
|
||||
"tabForCitationActions": "Tab for available actions.",
|
||||
"noCitationActions": "No link actions are available."
|
||||
},
|
||||
"richMarkdownHtmlSuperscriptLink": {
|
||||
"availableAriaLabel": "{{value0}}, link to {{value1}}",
|
||||
"unavailableAriaLabel": "{{value0}}, citation link unavailable"
|
||||
},
|
||||
"richMarkdownLinkClipboard": {
|
||||
"copiedLink": "Copied link",
|
||||
"copyLinkFailed": "Failed to copy link"
|
||||
},
|
||||
"richMarkdownSourceOwningCutFeedback": {
|
||||
"selectLessContent": "Select less content or use code mode to cut preserved HTML citations."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
||||
@@ -11605,7 +11605,8 @@
|
||||
"1c99b726e0": "Quitar enlace",
|
||||
"cdfe166f6f": "Editar enlace",
|
||||
"bfc813e909": "Abrir enlace",
|
||||
"7b0b945fdc": "Pega o escribe un enlace…"
|
||||
"7b0b945fdc": "Pega o escribe un enlace…",
|
||||
"copyLink": "Copiar enlace"
|
||||
},
|
||||
"RichMarkdownReviewNoteLayer": {
|
||||
"f3ef92952b": "Esta nota",
|
||||
@@ -11632,7 +11633,8 @@
|
||||
"68d090241d": "Coincidir con toda la palabra",
|
||||
"fd97c7e585": "Reemplazar",
|
||||
"44682b4159": "Reemplazar en el editor de markdown enriquecido",
|
||||
"c2884f5e95": "Reemplazar todo"
|
||||
"c2884f5e95": "Reemplazar todo",
|
||||
"preservedRichContentReadOnly": "El contenido enriquecido conservado es de solo lectura en el modo enriquecido."
|
||||
},
|
||||
"RichMarkdownSlashMenu": {
|
||||
"82c6816ff8": "No se encontraron bloques",
|
||||
@@ -11849,6 +11851,24 @@
|
||||
"3fa2b8d417": "Recargar desde disco",
|
||||
"a95d02c644": "Mantener mis ediciones",
|
||||
"2c8f1e07b9": "Cargando comparación..."
|
||||
},
|
||||
"RichMarkdownEditor": {
|
||||
"citationLinkAvailable": "{{value0}}, enlace a {{value1}}. Pulsa Intro para abrirlo o Tab para las acciones del enlace.",
|
||||
"citationFallbackLabel": "Cita",
|
||||
"citationLinkUnavailable": "{{value0}}, enlace de cita no disponible. {{value1}}",
|
||||
"tabForCitationActions": "Pulsa Tab para ver las acciones disponibles.",
|
||||
"noCitationActions": "No hay acciones de enlace disponibles."
|
||||
},
|
||||
"richMarkdownHtmlSuperscriptLink": {
|
||||
"availableAriaLabel": "{{value0}}, enlace a {{value1}}",
|
||||
"unavailableAriaLabel": "{{value0}}, enlace de cita no disponible"
|
||||
},
|
||||
"richMarkdownLinkClipboard": {
|
||||
"copiedLink": "Enlace copiado",
|
||||
"copyLinkFailed": "No se pudo copiar el enlace"
|
||||
},
|
||||
"richMarkdownSourceOwningCutFeedback": {
|
||||
"selectLessContent": "Selecciona menos contenido o usa el modo de código para cortar citas HTML conservadas."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
||||
@@ -11605,7 +11605,8 @@
|
||||
"1c99b726e0": "リンクを削除する",
|
||||
"cdfe166f6f": "リンクを編集",
|
||||
"bfc813e909": "リンクを開く",
|
||||
"7b0b945fdc": "リンクを貼り付けるか入力してください…"
|
||||
"7b0b945fdc": "リンクを貼り付けるか入力してください…",
|
||||
"copyLink": "リンクをコピー"
|
||||
},
|
||||
"RichMarkdownReviewNoteLayer": {
|
||||
"f3ef92952b": "このメモ",
|
||||
@@ -11632,7 +11633,8 @@
|
||||
"68d090241d": "単語全体に一致",
|
||||
"fd97c7e585": "置換",
|
||||
"44682b4159": "リッチ markdown エディタで置換",
|
||||
"c2884f5e95": "すべて置換"
|
||||
"c2884f5e95": "すべて置換",
|
||||
"preservedRichContentReadOnly": "保持されたリッチコンテンツはリッチモードでは読み取り専用です。"
|
||||
},
|
||||
"RichMarkdownSlashMenu": {
|
||||
"82c6816ff8": "ブロックが見つかりませんでした",
|
||||
@@ -11849,6 +11851,24 @@
|
||||
"3fa2b8d417": "ディスクから再読み込み",
|
||||
"a95d02c644": "編集内容を保持",
|
||||
"2c8f1e07b9": "比較を読み込み中..."
|
||||
},
|
||||
"RichMarkdownEditor": {
|
||||
"citationLinkAvailable": "{{value0}}、{{value1}}へのリンク。Enterキーで開くか、Tabキーでリンク操作に移動します。",
|
||||
"citationFallbackLabel": "引用",
|
||||
"citationLinkUnavailable": "{{value0}}、引用リンクは利用できません。{{value1}}",
|
||||
"tabForCitationActions": "Tabキーで利用可能な操作に移動します。",
|
||||
"noCitationActions": "利用できるリンク操作はありません。"
|
||||
},
|
||||
"richMarkdownHtmlSuperscriptLink": {
|
||||
"availableAriaLabel": "{{value0}}、{{value1}}へのリンク",
|
||||
"unavailableAriaLabel": "{{value0}}、引用リンクは利用できません"
|
||||
},
|
||||
"richMarkdownLinkClipboard": {
|
||||
"copiedLink": "リンクをコピーしました",
|
||||
"copyLinkFailed": "リンクをコピーできませんでした"
|
||||
},
|
||||
"richMarkdownSourceOwningCutFeedback": {
|
||||
"selectLessContent": "保持されたHTML引用を切り取るには、選択範囲を小さくするかコードモードを使用してください。"
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
||||
@@ -11605,7 +11605,8 @@
|
||||
"1c99b726e0": "링크 삭제",
|
||||
"cdfe166f6f": "링크 수정",
|
||||
"bfc813e909": "링크 열기",
|
||||
"7b0b945fdc": "링크를 붙여넣거나 입력하세요…"
|
||||
"7b0b945fdc": "링크를 붙여넣거나 입력하세요…",
|
||||
"copyLink": "링크 복사"
|
||||
},
|
||||
"RichMarkdownReviewNoteLayer": {
|
||||
"f3ef92952b": "이 메모",
|
||||
@@ -11632,7 +11633,8 @@
|
||||
"68d090241d": "전체 단어 일치",
|
||||
"fd97c7e585": "바꾸기",
|
||||
"44682b4159": "리치 markdown 편집기에서 바꾸기",
|
||||
"c2884f5e95": "모두 바꾸기"
|
||||
"c2884f5e95": "모두 바꾸기",
|
||||
"preservedRichContentReadOnly": "보존된 리치 콘텐츠는 리치 모드에서 읽기 전용입니다."
|
||||
},
|
||||
"RichMarkdownSlashMenu": {
|
||||
"82c6816ff8": "블록을 찾을 수 없습니다.",
|
||||
@@ -11849,6 +11851,24 @@
|
||||
"3fa2b8d417": "디스크에서 다시 로드",
|
||||
"a95d02c644": "편집 내용 유지",
|
||||
"2c8f1e07b9": "비교 로딩 중..."
|
||||
},
|
||||
"RichMarkdownEditor": {
|
||||
"citationLinkAvailable": "{{value0}}, {{value1}} 링크. Enter 키로 열거나 Tab 키로 링크 작업으로 이동하세요.",
|
||||
"citationFallbackLabel": "인용",
|
||||
"citationLinkUnavailable": "{{value0}}, 인용 링크를 사용할 수 없습니다. {{value1}}",
|
||||
"tabForCitationActions": "Tab 키로 사용 가능한 작업으로 이동하세요.",
|
||||
"noCitationActions": "사용 가능한 링크 작업이 없습니다."
|
||||
},
|
||||
"richMarkdownHtmlSuperscriptLink": {
|
||||
"availableAriaLabel": "{{value0}}, {{value1}} 링크",
|
||||
"unavailableAriaLabel": "{{value0}}, 인용 링크를 사용할 수 없음"
|
||||
},
|
||||
"richMarkdownLinkClipboard": {
|
||||
"copiedLink": "링크를 복사했습니다",
|
||||
"copyLinkFailed": "링크를 복사하지 못했습니다"
|
||||
},
|
||||
"richMarkdownSourceOwningCutFeedback": {
|
||||
"selectLessContent": "보존된 HTML 인용을 잘라내려면 더 적은 콘텐츠를 선택하거나 코드 모드를 사용하세요."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
||||
@@ -11605,7 +11605,8 @@
|
||||
"1c99b726e0": "删除链接",
|
||||
"cdfe166f6f": "编辑链接",
|
||||
"bfc813e909": "打开链接",
|
||||
"7b0b945fdc": "粘贴或输入链接..."
|
||||
"7b0b945fdc": "粘贴或输入链接...",
|
||||
"copyLink": "复制链接"
|
||||
},
|
||||
"RichMarkdownReviewNoteLayer": {
|
||||
"f3ef92952b": "此注",
|
||||
@@ -11632,7 +11633,8 @@
|
||||
"68d090241d": "匹配整个单词",
|
||||
"fd97c7e585": "替换",
|
||||
"44682b4159": "在 Markdown 编辑器中替换",
|
||||
"c2884f5e95": "全部替换"
|
||||
"c2884f5e95": "全部替换",
|
||||
"preservedRichContentReadOnly": "保留的富文本内容在富文本模式下为只读。"
|
||||
},
|
||||
"RichMarkdownSlashMenu": {
|
||||
"82c6816ff8": "没有找到块",
|
||||
@@ -11849,6 +11851,24 @@
|
||||
"3fa2b8d417": "从磁盘重新加载",
|
||||
"a95d02c644": "保留我的编辑",
|
||||
"2c8f1e07b9": "正在加载比较..."
|
||||
},
|
||||
"RichMarkdownEditor": {
|
||||
"citationLinkAvailable": "{{value0}},链接到 {{value1}}。按 Enter 打开,或按 Tab 移至链接操作。",
|
||||
"citationFallbackLabel": "引用",
|
||||
"citationLinkUnavailable": "{{value0}},引用链接不可用。{{value1}}",
|
||||
"tabForCitationActions": "按 Tab 移至可用操作。",
|
||||
"noCitationActions": "没有可用的链接操作。"
|
||||
},
|
||||
"richMarkdownHtmlSuperscriptLink": {
|
||||
"availableAriaLabel": "{{value0}},链接到 {{value1}}",
|
||||
"unavailableAriaLabel": "{{value0}},引用链接不可用"
|
||||
},
|
||||
"richMarkdownLinkClipboard": {
|
||||
"copiedLink": "已复制链接",
|
||||
"copyLinkFailed": "复制链接失败"
|
||||
},
|
||||
"richMarkdownSourceOwningCutFeedback": {
|
||||
"selectLessContent": "请选择较少的内容,或使用代码模式剪切保留的 HTML 引用。"
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from './connection-context'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { createConnectionIdForFileSelector } from './connection-owner-resolution'
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
|
||||
@@ -581,4 +582,24 @@ describe('getConnectionIdFromState', () => {
|
||||
|
||||
expect(getConnectionIdFromState(state, null)).toBeNull()
|
||||
})
|
||||
|
||||
it('recomputes a retained file-owner selector when ownership slices hydrate', () => {
|
||||
const selector = createConnectionIdForFileSelector(
|
||||
'repo-ssh::/home/neil/repo-feature',
|
||||
'/home/neil/repo-feature/README.md'
|
||||
)
|
||||
const unresolved: ConnectionContextState = {
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
repos: [],
|
||||
worktreesByRepo: {}
|
||||
}
|
||||
expect(selector(unresolved)).toBeUndefined()
|
||||
|
||||
const hydrated: ConnectionContextState = {
|
||||
...unresolved,
|
||||
repos: [makeRepo({ id: 'repo-ssh', connectionId: 'ssh-hydrated' })]
|
||||
}
|
||||
expect(selector(hydrated)).toBe('ssh-hydrated')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../../shared/cross-platform-path'
|
||||
import {
|
||||
getFolderWorkspaceCandidateRepos,
|
||||
getFolderWorkspaceConnectionId
|
||||
} from './folder-workspace-connection'
|
||||
getConnectionIdForFileFromState,
|
||||
getConnectionIdFromState
|
||||
} from './connection-owner-resolution'
|
||||
|
||||
export { getConnectionIdFromState } from './connection-owner-resolution'
|
||||
|
||||
/**
|
||||
* Resolve the SSH connectionId for a worktree. Returns null for local repos,
|
||||
@@ -22,33 +16,6 @@ export function getConnectionId(worktreeId: string | null): string | null | unde
|
||||
return getConnectionIdFromState(useAppStore.getState(), worktreeId)
|
||||
}
|
||||
|
||||
export function getConnectionIdFromState(
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
|
||||
worktreeId: string | null
|
||||
): string | null | undefined {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
return null
|
||||
}
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type === 'folder') {
|
||||
return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)
|
||||
}
|
||||
// Why: retained Zustand selectors call this on unrelated writes; reuse the
|
||||
// immutable-slice indexes instead of flattening every worktree each time.
|
||||
const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId)
|
||||
// Why: SSH worktrees can be restored from session IDs before relay discovery
|
||||
// repopulates worktreesByRepo. The composite ID still carries the repo ID.
|
||||
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const repo = getIndexedRepoMap(state.repos).get(repoId)
|
||||
if (!repo) {
|
||||
return undefined
|
||||
}
|
||||
return repo.connectionId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* True when we can determine the owning host (local vs. a specific SSH target)
|
||||
* for a worktree. False means the backing repo has not landed in the store yet
|
||||
@@ -75,41 +42,5 @@ export function getConnectionIdForFile(
|
||||
worktreeId: string | null,
|
||||
filePath: string
|
||||
): string | null | undefined {
|
||||
const connectionId = getConnectionId(worktreeId)
|
||||
if (connectionId !== undefined || !worktreeId) {
|
||||
return connectionId
|
||||
}
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type !== 'folder') {
|
||||
return undefined
|
||||
}
|
||||
// Why: mixed local/SSH folder workspaces cannot pick one owner globally, but
|
||||
// a concrete file path can still belong unambiguously to a child repo.
|
||||
const state = useAppStore.getState()
|
||||
const candidateRepos = getFolderWorkspaceCandidateRepos(
|
||||
state,
|
||||
parsedWorkspaceKey.folderWorkspaceId
|
||||
)
|
||||
return resolveConnectionIdForRepoPath(candidateRepos, filePath)
|
||||
}
|
||||
|
||||
function resolveConnectionIdForRepoPath(
|
||||
repos: readonly { path: string; connectionId?: string | null }[],
|
||||
filePath: string
|
||||
): string | null | undefined {
|
||||
const matchingRepos = repos
|
||||
.filter((repo) => isPathInsideOrEqual(repo.path, filePath))
|
||||
.map((repo) => ({ repo, normalizedPath: normalizeRuntimePathForComparison(repo.path) }))
|
||||
.sort((a, b) => b.normalizedPath.length - a.normalizedPath.length)
|
||||
const longestPathLength = matchingRepos[0]?.normalizedPath.length
|
||||
if (!longestPathLength) {
|
||||
return undefined
|
||||
}
|
||||
// Why: containment normalizes separators/trailing slashes; ambiguity checks
|
||||
// need the same representation or equal repo roots can be hidden.
|
||||
const bestMatches = matchingRepos.filter(
|
||||
(candidate) => candidate.normalizedPath.length === longestPathLength
|
||||
)
|
||||
const connectionIds = new Set(bestMatches.map(({ repo }) => repo.connectionId ?? null))
|
||||
return connectionIds.size === 1 ? ([...connectionIds][0] ?? null) : undefined
|
||||
return getConnectionIdForFileFromState(useAppStore.getState(), worktreeId, filePath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../../shared/cross-platform-path'
|
||||
import {
|
||||
getFolderWorkspaceCandidateRepos,
|
||||
getFolderWorkspaceConnectionId
|
||||
} from './folder-workspace-connection'
|
||||
|
||||
type ConnectionOwnerState = Pick<
|
||||
AppState,
|
||||
'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'
|
||||
>
|
||||
|
||||
export function createConnectionIdForFileSelector(
|
||||
worktreeId: string | null,
|
||||
filePath: string,
|
||||
{ skip = false }: { skip?: boolean } = {}
|
||||
): (state: ConnectionOwnerState) => string | null | undefined {
|
||||
let previousSlices: ConnectionOwnerState | null = null
|
||||
let previousResult: string | null | undefined
|
||||
return (state) => {
|
||||
if (skip) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
previousSlices?.folderWorkspaces === state.folderWorkspaces &&
|
||||
previousSlices.projectGroups === state.projectGroups &&
|
||||
previousSlices.repos === state.repos &&
|
||||
previousSlices.worktreesByRepo === state.worktreesByRepo
|
||||
) {
|
||||
return previousResult
|
||||
}
|
||||
previousSlices = {
|
||||
folderWorkspaces: state.folderWorkspaces,
|
||||
projectGroups: state.projectGroups,
|
||||
repos: state.repos,
|
||||
worktreesByRepo: state.worktreesByRepo
|
||||
}
|
||||
previousResult = getConnectionIdForFileFromState(state, worktreeId, filePath)
|
||||
return previousResult
|
||||
}
|
||||
}
|
||||
|
||||
export function getConnectionIdFromState(
|
||||
state: ConnectionOwnerState,
|
||||
worktreeId: string | null
|
||||
): string | null | undefined {
|
||||
if (!worktreeId || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
return null
|
||||
}
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type === 'folder') {
|
||||
return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)
|
||||
}
|
||||
// Why: owner resolution runs from retained Zustand selectors, so unrelated
|
||||
// store writes must not flatten every worktree or scan every repository.
|
||||
const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId)
|
||||
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const repo = getIndexedRepoMap(state.repos).get(repoId)
|
||||
return repo ? (repo.connectionId ?? null) : undefined
|
||||
}
|
||||
|
||||
export function getConnectionIdForFileFromState(
|
||||
state: ConnectionOwnerState,
|
||||
worktreeId: string | null,
|
||||
filePath: string
|
||||
): string | null | undefined {
|
||||
const connectionId = getConnectionIdFromState(state, worktreeId)
|
||||
if (connectionId !== undefined || !worktreeId) {
|
||||
return connectionId
|
||||
}
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type !== 'folder') {
|
||||
return undefined
|
||||
}
|
||||
const candidateRepos = getFolderWorkspaceCandidateRepos(
|
||||
state,
|
||||
parsedWorkspaceKey.folderWorkspaceId
|
||||
)
|
||||
const matchingRepos = candidateRepos
|
||||
.filter((repo) => isPathInsideOrEqual(repo.path, filePath))
|
||||
.map((repo) => ({ repo, normalizedPath: normalizeRuntimePathForComparison(repo.path) }))
|
||||
.sort((left, right) => right.normalizedPath.length - left.normalizedPath.length)
|
||||
const longestPathLength = matchingRepos[0]?.normalizedPath.length
|
||||
if (!longestPathLength) {
|
||||
return undefined
|
||||
}
|
||||
const connectionIds = new Set(
|
||||
matchingRepos
|
||||
.filter((candidate) => candidate.normalizedPath.length === longestPathLength)
|
||||
.map(({ repo }) => repo.connectionId ?? null)
|
||||
)
|
||||
return connectionIds.size === 1 ? ([...connectionIds][0] ?? null) : undefined
|
||||
}
|
||||
@@ -32,12 +32,14 @@ const storeState = {
|
||||
allWorktrees: vi.fn(
|
||||
() => [] as { id: string; projectId?: string; repoId?: string; displayName?: string }[]
|
||||
),
|
||||
workspacePortScan: null as { result: WorkspacePortScanResult } | null
|
||||
workspacePortScan: null as { result: WorkspacePortScanResult } | null,
|
||||
workspacePortScansByKey: {} as Record<string, WorkspacePortScanResult>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storeState.settings = undefined
|
||||
storeState.workspacePortScansByKey = {}
|
||||
registerHttpLinkStoreAccessor(() => storeState)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
@@ -110,6 +112,106 @@ describe('openHttpLink', () => {
|
||||
expect(setActiveWorktreeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors an explicit local document owner despite an unrelated active runtime', () => {
|
||||
storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-other' }
|
||||
|
||||
openHttpLink('https://example.com/', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
|
||||
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
|
||||
activate: true
|
||||
})
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes explicit runtime and SSH document owners to the exact system URL', () => {
|
||||
storeState.settings = { openLinksInApp: true, localhostWorktreeLabelsEnabled: true }
|
||||
|
||||
openHttpLink('http://localhost:5180/runtime', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' }
|
||||
})
|
||||
openHttpLink('http://localhost:5180/ssh', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }
|
||||
})
|
||||
|
||||
expect(openUrlMock).toHaveBeenNthCalledWith(1, 'http://localhost:5180/runtime')
|
||||
expect(openUrlMock).toHaveBeenNthCalledWith(2, 'http://localhost:5180/ssh')
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(registerLocalhostLabelMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('labels explicit local links from the local scan instead of a merged remote port', async () => {
|
||||
storeState.settings = {
|
||||
openLinksInApp: true,
|
||||
activeRuntimeEnvironmentId: 'env-other',
|
||||
localhostWorktreeLabelsEnabled: true
|
||||
}
|
||||
storeState.repos = [
|
||||
{ id: 'repo-local', displayName: 'Local' },
|
||||
{ id: 'repo-remote', displayName: 'Remote' }
|
||||
]
|
||||
storeState.worktreesByRepo = {
|
||||
'repo-local': [{ id: 'wt-local', projectId: 'repo-local' }],
|
||||
'repo-remote': [{ id: 'wt-remote', projectId: 'repo-remote' }]
|
||||
}
|
||||
const port = (repoId: string, worktreeId: string, path: string) => ({
|
||||
id: `tcp:5180:${worktreeId}`,
|
||||
kind: 'workspace' as const,
|
||||
port: 5180,
|
||||
protocol: 'http' as const,
|
||||
bindHost: '127.0.0.1',
|
||||
connectHost: 'localhost',
|
||||
owner: {
|
||||
repoId,
|
||||
worktreeId,
|
||||
displayName: worktreeId,
|
||||
path,
|
||||
confidence: 'cwd' as const
|
||||
}
|
||||
})
|
||||
storeState.workspacePortScan = {
|
||||
result: {
|
||||
platform: 'darwin',
|
||||
scannedAt: 2,
|
||||
ports: [port('repo-remote', 'wt-remote', '/remote')]
|
||||
}
|
||||
}
|
||||
storeState.workspacePortScansByKey = {
|
||||
'local:all': {
|
||||
platform: 'darwin',
|
||||
scannedAt: 1,
|
||||
ports: [port('repo-local', 'wt-local', '/local')]
|
||||
}
|
||||
}
|
||||
registerLocalhostLabelMock.mockResolvedValue({ url: 'http://wt-local.orca.localhost:60016/' })
|
||||
|
||||
openHttpLink('http://localhost:5180/', {
|
||||
worktreeId: 'wt-local',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(registerLocalhostLabelMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ repoId: 'repo-local', worktreeId: 'wt-local' })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps unresolved document ownership non-actionable', () => {
|
||||
storeState.settings = { openLinksInApp: true }
|
||||
|
||||
openHttpLink('https://example.com/', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'unknown' }
|
||||
})
|
||||
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes to the system browser when no worktree id is provided', () => {
|
||||
storeState.settings = { openLinksInApp: true }
|
||||
|
||||
|
||||
@@ -9,8 +9,15 @@ import type { WorkspacePort, WorkspacePortScanResult } from '../../../shared/wor
|
||||
export type OpenHttpLinkOptions = {
|
||||
worktreeId?: string | null
|
||||
forceSystemBrowser?: boolean
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
}
|
||||
|
||||
export type HttpLinkSourceOwner =
|
||||
| { kind: 'local' }
|
||||
| { kind: 'runtime'; runtimeEnvironmentId: string }
|
||||
| { kind: 'ssh'; connectionId: string }
|
||||
| { kind: 'unknown' }
|
||||
|
||||
type StoreAccessor = () => {
|
||||
settings?: Partial<
|
||||
Pick<
|
||||
@@ -25,6 +32,7 @@ type StoreAccessor = () => {
|
||||
worktreesByRepo?: Record<string, LocalhostLinkWorktree[]>
|
||||
allWorktrees?: () => LocalhostLinkWorktree[]
|
||||
workspacePortScan?: { result: WorkspacePortScanResult } | null
|
||||
workspacePortScansByKey?: Record<string, WorkspacePortScanResult>
|
||||
}
|
||||
|
||||
type LocalhostLinkRepo = {
|
||||
@@ -56,11 +64,15 @@ export function registerHttpLinkStoreAccessor(fn: StoreAccessor): void {
|
||||
// branch). Shift+Cmd/Ctrl is the escape hatch: callers pass forceSystemBrowser
|
||||
// to bypass the setting entirely.
|
||||
export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void {
|
||||
const { worktreeId, forceSystemBrowser } = opts
|
||||
const { worktreeId, forceSystemBrowser, sourceOwner } = opts
|
||||
if (sourceOwner?.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
const state = storeAccessor?.()
|
||||
const remoteRuntimeActive = Boolean(state?.settings?.activeRuntimeEnvironmentId?.trim())
|
||||
const sourceIsLocal = sourceOwner ? sourceOwner.kind === 'local' : !remoteRuntimeActive
|
||||
const routeToOrca =
|
||||
!remoteRuntimeActive &&
|
||||
sourceIsLocal &&
|
||||
!forceSystemBrowser &&
|
||||
Boolean(worktreeId) &&
|
||||
state?.settings?.openLinksInApp === true
|
||||
@@ -75,7 +87,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
|
||||
// to the global activeWorktreeId deselects the real repo workspace.
|
||||
state.setActiveWorktree(worktreeId)
|
||||
}
|
||||
const localhostRoute = localhostLabelRouteForTerminalLink(url, state)
|
||||
const localhostRoute = localhostLabelRouteForHttpLink(url, state, sourceOwner)
|
||||
if (!localhostRoute) {
|
||||
state.createBrowserTab(worktreeId, url, { activate: true })
|
||||
return
|
||||
@@ -86,7 +98,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
|
||||
return
|
||||
}
|
||||
|
||||
const localhostRoute = state ? localhostLabelRouteForTerminalLink(url, state) : null
|
||||
const localhostRoute = state ? localhostLabelRouteForHttpLink(url, state, sourceOwner) : null
|
||||
if (!localhostRoute) {
|
||||
void window.api.shell.openUrl(url)
|
||||
return
|
||||
@@ -96,6 +108,24 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
|
||||
})
|
||||
}
|
||||
|
||||
function localhostLabelRouteForHttpLink(
|
||||
url: string,
|
||||
state: ReturnType<StoreAccessor>,
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
): LocalhostWorktreeLabelRoute | null {
|
||||
if (sourceOwner && sourceOwner.kind !== 'local') {
|
||||
return null
|
||||
}
|
||||
if (!sourceOwner && state.settings?.activeRuntimeEnvironmentId?.trim()) {
|
||||
return null
|
||||
}
|
||||
const sourceScan =
|
||||
sourceOwner?.kind === 'local'
|
||||
? (state.workspacePortScansByKey?.['local:all'] ?? null)
|
||||
: undefined
|
||||
return localhostLabelRouteForTerminalLink(url, state, sourceOwner?.kind === 'local', sourceScan)
|
||||
}
|
||||
|
||||
export async function resolveLocalhostHttpLinkDisplayUrl(url: string): Promise<string | null> {
|
||||
const state = storeAccessor?.()
|
||||
if (!state) {
|
||||
@@ -128,11 +158,13 @@ async function openLabeledLocalhostLink(
|
||||
|
||||
function localhostLabelRouteForTerminalLink(
|
||||
rawUrl: string,
|
||||
state: ReturnType<StoreAccessor>
|
||||
state: ReturnType<StoreAccessor>,
|
||||
ignoreActiveRuntime = false,
|
||||
sourceScan?: WorkspacePortScanResult | null
|
||||
): LocalhostWorktreeLabelRoute | null {
|
||||
if (
|
||||
state.settings?.localhostWorktreeLabelsEnabled !== true ||
|
||||
state.settings?.activeRuntimeEnvironmentId?.trim()
|
||||
(!ignoreActiveRuntime && state.settings?.activeRuntimeEnvironmentId?.trim())
|
||||
) {
|
||||
return null
|
||||
}
|
||||
@@ -142,7 +174,8 @@ function localhostLabelRouteForTerminalLink(
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
const port = findWorkspacePortByNumber(state.workspacePortScan?.result, Number(parsed.port))
|
||||
const scan = sourceScan === undefined ? state.workspacePortScan?.result : sourceScan
|
||||
const port = findWorkspacePortByNumber(scan, Number(parsed.port))
|
||||
if (!port) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ function createEditorStore(): StoreApi<AppState> {
|
||||
browserTabsByWorktree: {},
|
||||
activeBrowserTabId: null,
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
repos: [{ id: 'repo-1', path: '/repo' }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
|
||||
})) as unknown as StoreApi<AppState>
|
||||
@@ -62,6 +66,10 @@ function createEditorTabsStore(): StoreApi<AppState> {
|
||||
browserTabsByWorktree: {},
|
||||
activeBrowserTabId: null,
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
repos: [{ id: 'repo-1', path: '/repo' }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
...createTabsSlice(...(args as Parameters<typeof createTabsSlice>)),
|
||||
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
|
||||
@@ -4003,6 +4011,47 @@ describe('createEditorSlice activateMarkdownLink', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects ambiguous same-path owner fallback and honors an explicit source owner', async () => {
|
||||
const store = createEditorStore()
|
||||
store.getState().openFile({
|
||||
filePath: '/repo/docs/note.md',
|
||||
relativePath: 'docs/note.md',
|
||||
worktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: 'env-source',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
store.getState().openFile(
|
||||
{
|
||||
filePath: '/repo/docs/note.md',
|
||||
relativePath: 'docs/note.md',
|
||||
worktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: null,
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
},
|
||||
{ suppressActiveRuntimeFallback: true }
|
||||
)
|
||||
|
||||
await store.getState().activateMarkdownLink('https://example.com', {
|
||||
sourceFilePath: '/repo/docs/note.md',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeRoot: '/repo'
|
||||
})
|
||||
expect(openHttpLinkMock).not.toHaveBeenCalled()
|
||||
|
||||
await store.getState().activateMarkdownLink('https://example.com', {
|
||||
sourceFilePath: '/repo/docs/note.md',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
})
|
||||
|
||||
it('stats SSH markdown links through the source worktree connection before opening', async () => {
|
||||
const store = createEditorStore()
|
||||
pathExistsMock.mockResolvedValue(true)
|
||||
@@ -4268,11 +4317,38 @@ describe('createEditorSlice activateMarkdownLink', () => {
|
||||
worktreeId: 'wt-1',
|
||||
worktreeRoot: '/repo'
|
||||
})
|
||||
expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', { worktreeId: 'wt-1' })
|
||||
expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
expect(store.getState().openFiles).toEqual([])
|
||||
})
|
||||
|
||||
it('does not rescan legacy owner state when the source owner is explicit', async () => {
|
||||
const store = createEditorStore()
|
||||
for (const key of ['openFiles', 'repos', 'worktreesByRepo', 'folderWorkspaces'] as const) {
|
||||
Object.defineProperty(store.getState(), key, {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
throw new Error(`explicit owner must not read ${key}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await store.getState().activateMarkdownLink('https://example.com', {
|
||||
sourceFilePath: '/repo/docs/note.md',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeRoot: '/repo',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
|
||||
expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
})
|
||||
|
||||
it('opens in-worktree file links in Orca', async () => {
|
||||
const store = createEditorStore()
|
||||
await store.getState().activateMarkdownLink('./image.png', {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
getCheckRunDetailsTabLabel,
|
||||
type OpenCheckRunDetailsState
|
||||
} from '@/components/editor/check-run-details-tab'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resolution'
|
||||
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import type {
|
||||
@@ -500,6 +501,7 @@ export type EditorSlice = {
|
||||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
}
|
||||
) => Promise<void>
|
||||
openMarkdownPreview: (
|
||||
@@ -1335,13 +1337,6 @@ function shouldDeleteUntouchedUntitledFile(file: OpenFile | undefined, hasDraft:
|
||||
)
|
||||
}
|
||||
|
||||
function getWorktreeConnectionId(state: AppState, worktreeId: string): string | undefined {
|
||||
const worktree = findWorktreeById(state.worktreesByRepo ?? {}, worktreeId)
|
||||
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const repo = (state.repos ?? []).find((candidate) => candidate.id === repoId)
|
||||
return repo?.connectionId ?? undefined
|
||||
}
|
||||
|
||||
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (set, get) => ({
|
||||
editorDrafts: {},
|
||||
setEditorDraft: (fileId, content) =>
|
||||
@@ -4170,16 +4165,48 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
|
||||
activateMarkdownLink: async (rawHref, ctx) => {
|
||||
const initialState = get()
|
||||
let inferredRuntimeEnvironmentId: string | null | undefined
|
||||
if (!ctx.sourceOwner && ctx.runtimeEnvironmentId === undefined) {
|
||||
const inferredRuntimeOwners = new Set(
|
||||
initialState.openFiles
|
||||
.filter(
|
||||
(file) => file.filePath === ctx.sourceFilePath && file.worktreeId === ctx.worktreeId
|
||||
)
|
||||
.map((file) => file.runtimeEnvironmentId?.trim() || null)
|
||||
)
|
||||
if (inferredRuntimeOwners.size > 1) {
|
||||
return
|
||||
}
|
||||
inferredRuntimeEnvironmentId =
|
||||
inferredRuntimeOwners.size === 1 ? [...inferredRuntimeOwners][0] : undefined
|
||||
}
|
||||
const sourceRuntimeEnvironmentId =
|
||||
ctx.runtimeEnvironmentId !== undefined
|
||||
? ctx.runtimeEnvironmentId
|
||||
: initialState.openFiles.find((file) => file.filePath === ctx.sourceFilePath)
|
||||
?.runtimeEnvironmentId
|
||||
const sourceSettings = settingsForRuntimeOwner(
|
||||
initialState.settings,
|
||||
sourceRuntimeEnvironmentId
|
||||
)
|
||||
const sourceConnectionId = getWorktreeConnectionId(initialState, ctx.worktreeId)
|
||||
ctx.sourceOwner?.kind === 'runtime'
|
||||
? ctx.sourceOwner.runtimeEnvironmentId
|
||||
: ctx.sourceOwner
|
||||
? null
|
||||
: ctx.runtimeEnvironmentId !== undefined
|
||||
? ctx.runtimeEnvironmentId
|
||||
: inferredRuntimeEnvironmentId
|
||||
const runtimeOwnerId = sourceRuntimeEnvironmentId?.trim() || null
|
||||
const sourceSettings = settingsForRuntimeOwner(initialState.settings, runtimeOwnerId)
|
||||
const resolvedConnectionId =
|
||||
ctx.sourceOwner || runtimeOwnerId
|
||||
? undefined
|
||||
: getConnectionIdForFileFromState(initialState, ctx.worktreeId, ctx.sourceFilePath)
|
||||
const sourceOwner: HttpLinkSourceOwner =
|
||||
ctx.sourceOwner ??
|
||||
(runtimeOwnerId
|
||||
? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId }
|
||||
: resolvedConnectionId === undefined
|
||||
? { kind: 'unknown' }
|
||||
: resolvedConnectionId === null
|
||||
? { kind: 'local' }
|
||||
: { kind: 'ssh', connectionId: resolvedConnectionId })
|
||||
if (sourceOwner.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
const sourceConnectionId = sourceOwner.kind === 'ssh' ? sourceOwner.connectionId : undefined
|
||||
const fileContext = {
|
||||
settings: sourceSettings,
|
||||
worktreeId: ctx.worktreeId,
|
||||
@@ -4194,7 +4221,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
return
|
||||
}
|
||||
if (target.kind === 'external') {
|
||||
openHttpLink(target.url, { worktreeId: ctx.worktreeId })
|
||||
openHttpLink(target.url, { worktreeId: ctx.worktreeId, sourceOwner })
|
||||
return
|
||||
}
|
||||
if (target.kind === 'file') {
|
||||
|
||||
Reference in New Issue
Block a user