mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
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.
This commit is contained in:
@@ -177,6 +177,7 @@
|
||||
"lint-staged": "^16.4.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"oxfmt": "^0.52.0",
|
||||
|
||||
Generated
+8
@@ -263,6 +263,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(react@19.2.5)
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.6
|
||||
mermaid:
|
||||
specifier: ^11.15.0
|
||||
version: 11.15.0
|
||||
@@ -3457,6 +3460,7 @@ packages:
|
||||
|
||||
boolean@3.2.0:
|
||||
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
brace-expansion@1.1.14:
|
||||
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
|
||||
@@ -4446,6 +4450,7 @@ packages:
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
global-agent@3.0.0:
|
||||
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
|
||||
@@ -4642,6 +4647,7 @@ packages:
|
||||
|
||||
inflight@1.0.6:
|
||||
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
||||
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
@@ -4967,6 +4973,7 @@ packages:
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
@@ -5330,6 +5337,7 @@ packages:
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch@3.3.2:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
|
||||
@@ -1390,9 +1390,6 @@ export default function MarkdownPreview({
|
||||
if (target.protocol !== 'file:') {
|
||||
return
|
||||
}
|
||||
if (sourceOwner.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
|
||||
const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot)
|
||||
const classifiedFileTarget =
|
||||
@@ -1406,11 +1403,19 @@ 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
|
||||
}
|
||||
|
||||
if (sourceOwner.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
|
||||
const targetWorktree = findMarkdownPreviewTargetWorktree(
|
||||
worktreesByRepo,
|
||||
absolutePath,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
@@ -248,10 +248,13 @@ export default function RichMarkdownEditor({
|
||||
setSlashMenu: menu.setSlashMenu,
|
||||
setDocLinkMenu: menu.setDocLinkMenu
|
||||
})
|
||||
const selectedCitationStatus = getSelectedHtmlSuperscriptLinkStatus(
|
||||
// Why: useEditor defaults shouldRerenderOnTransaction to false, so selection-only
|
||||
// citation NodeSelections would leave aria status stale without useEditorState.
|
||||
const selectedCitationStatus = useEditorState({
|
||||
editor,
|
||||
htmlSuperscriptLinkContext
|
||||
)
|
||||
selector: (snapshot) =>
|
||||
getSelectedHtmlSuperscriptLinkStatus(snapshot.editor, htmlSuperscriptLinkContext)
|
||||
})
|
||||
useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled)
|
||||
|
||||
// Why: use useLayoutEffect (synchronous cleanup) so the pending serialization
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 ?? ''
|
||||
@@ -10,6 +12,8 @@ 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>`
|
||||
|
||||
@@ -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,9 +1,9 @@
|
||||
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 { serializeRichMarkdownSliceForClipboard } from './rich-markdown-clipboard-serialization'
|
||||
import { showRichMarkdownSourceOwningCutLimitError } from './rich-markdown-source-owning-cut-feedback'
|
||||
|
||||
function deleteBlockAndRestoreSelection(view: EditorView, from: number, to: number): void {
|
||||
@@ -97,19 +97,7 @@ export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent):
|
||||
event.preventDefault()
|
||||
|
||||
const slice = view.state.doc.slice($from.before(cutDepth), $from.after(cutDepth))
|
||||
const status = inspectRichMarkdownSourceOwningSlice(slice)
|
||||
if (status.containsSourceOwningNode && !status.canPreserve) {
|
||||
showRichMarkdownSourceOwningCutLimitError()
|
||||
return true
|
||||
}
|
||||
const serialized = serializeRichMarkdownSliceForClipboard(view, slice)
|
||||
event.clipboardData.setData('text/html', serialized.html)
|
||||
event.clipboardData.setData('text/plain', text)
|
||||
if (
|
||||
typeof event.clipboardData.getData === 'function' &&
|
||||
(event.clipboardData.getData('text/html') !== serialized.html ||
|
||||
event.clipboardData.getData('text/plain') !== text)
|
||||
) {
|
||||
if (!writeRichMarkdownSliceToClipboard(event.clipboardData, view, slice, text)) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-ext
|
||||
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.
|
||||
*/
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
||||
flushPendingSerialization,
|
||||
openSearchRef,
|
||||
linkBubbleOwnerId: codec.transport.key,
|
||||
htmlSuperscriptLinkContext,
|
||||
setIsEditingLink,
|
||||
setLinkBubble,
|
||||
setSelectedCommandIndex,
|
||||
|
||||
@@ -68,6 +68,17 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext
|
||||
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(),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} 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 = {
|
||||
@@ -48,6 +49,7 @@ export type KeyHandlerContext = {
|
||||
setDocLinkMenu: (menu: DocLinkMenuState | null) => void
|
||||
openSelectedHtmlSuperscriptLink?: () => boolean
|
||||
linkBubbleOwnerId: string
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
}
|
||||
|
||||
function isComposingMarkdownInput(event: KeyboardEvent, editor: Editor | null): boolean {
|
||||
@@ -149,6 +151,7 @@ export function createRichMarkdownKeyHandler(
|
||||
handleRichMarkdownLinkShortcut({
|
||||
editor: ctx.editorRef.current,
|
||||
event,
|
||||
htmlSuperscriptLinkContext: ctx.htmlSuperscriptLinkContext,
|
||||
isEditing: ctx.isEditingLinkRef.current,
|
||||
isMac: ctx.isMac,
|
||||
root: ctx.rootRef.current,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
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,
|
||||
@@ -12,6 +19,7 @@ export function handleRichMarkdownLinkShortcut({
|
||||
}: {
|
||||
editor: Editor | null
|
||||
event: KeyboardEvent
|
||||
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
|
||||
isEditing: boolean
|
||||
isMac: boolean
|
||||
root: HTMLElement | null
|
||||
@@ -34,16 +42,22 @@ export function handleRichMarkdownLinkShortcut({
|
||||
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) {
|
||||
const href = editor.isActive('link') ? String(editor.getAttributes('link').href ?? '') : ''
|
||||
setLinkBubble({
|
||||
kind: 'markdown',
|
||||
href,
|
||||
openEnabled: Boolean(href),
|
||||
copyEnabled: Boolean(href),
|
||||
...position
|
||||
})
|
||||
setLinkBubble(createEditableMarkdownLinkBubble('', position))
|
||||
setEditing(true)
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
isSingleEmptyTopLevelOrderedList
|
||||
} from './rich-markdown-list-continuation'
|
||||
|
||||
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ export function handleRichMarkdownPaste({
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Slice } from '@tiptap/pm/model'
|
||||
import { getRichMarkdownLeafVisibleText } from './rich-markdown-visible-text-map'
|
||||
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
|
||||
@@ -20,7 +23,7 @@ export function inspectRichMarkdownSourceOwningSlice(
|
||||
let sawVisibleBlock = false
|
||||
|
||||
slice.content.descendants((node, pos, parent, index) => {
|
||||
const startsVisibleBlock = node.isTextblock || (node.isBlock && node.isLeaf)
|
||||
const startsVisibleBlock = isRichMarkdownVisibleBlockStart(node)
|
||||
if (canPreserve && startsVisibleBlock) {
|
||||
if (sawVisibleBlock) {
|
||||
const separator = addUtf8BytesWithinLimit(
|
||||
|
||||
@@ -6,17 +6,17 @@ import TaskItem from '@tiptap/extension-task-item'
|
||||
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 }),
|
||||
createIsolatedMarkdownExtensionForTests()
|
||||
]
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -75,6 +75,17 @@ function createContext(editor: Editor): KeyHandlerContext {
|
||||
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(),
|
||||
|
||||
@@ -47,6 +47,11 @@ export function getRichMarkdownSelectionVisibleText(state: {
|
||||
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,
|
||||
@@ -64,7 +69,7 @@ export function forEachRichMarkdownVisibleTextSegment(
|
||||
if (stopped) {
|
||||
return false
|
||||
}
|
||||
const startsVisibleBlock = node.isTextblock || (node.isBlock && node.isLeaf)
|
||||
const startsVisibleBlock = isRichMarkdownVisibleBlockStart(node)
|
||||
if (startsVisibleBlock) {
|
||||
if (sawVisibleBlock) {
|
||||
stopped = !visit({ kind: 'separator', text: '\n', from: pos, to: pos })
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
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'
|
||||
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
|
||||
import { serializeRichMarkdownSliceForClipboard } from './rich-markdown-clipboard-serialization'
|
||||
import { showRichMarkdownSourceOwningCutLimitError } from './rich-markdown-source-owning-cut-feedback'
|
||||
|
||||
/**
|
||||
* Why: a paragraph that word-wraps across multiple screen lines should be cut
|
||||
@@ -94,19 +92,7 @@ export function cutVisualLine(
|
||||
lineRange.to
|
||||
).text
|
||||
const slice = view.state.doc.slice(lineRange.from, lineRange.to)
|
||||
const status = inspectRichMarkdownSourceOwningSlice(slice)
|
||||
if (status.containsSourceOwningNode && !status.canPreserve) {
|
||||
showRichMarkdownSourceOwningCutLimitError()
|
||||
return true
|
||||
}
|
||||
const serialized = serializeRichMarkdownSliceForClipboard(view, slice)
|
||||
clipboardEvent.clipboardData.setData('text/html', serialized.html)
|
||||
clipboardEvent.clipboardData.setData('text/plain', lineText)
|
||||
if (
|
||||
typeof clipboardEvent.clipboardData.getData === 'function' &&
|
||||
(clipboardEvent.clipboardData.getData('text/html') !== serialized.html ||
|
||||
clipboardEvent.clipboardData.getData('text/plain') !== lineText)
|
||||
) {
|
||||
if (!writeRichMarkdownSliceToClipboard(clipboardEvent.clipboardData, view, slice, lineText)) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ export function useRichMarkdownSearch({
|
||||
}, [editor, isSearchOpen, searchRequestQuery, searchRevision, matchCase, wholeWord])
|
||||
|
||||
const matchCount = matches.length
|
||||
const replaceDisabled = matches.some((match) => match.touchesReadOnlyAtom)
|
||||
|
||||
const getLiveMatches = useCallback(() => {
|
||||
if (
|
||||
@@ -85,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 =
|
||||
|
||||
Reference in New Issue
Block a user