diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index c74e459a06f..378c9b68799 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -52,6 +52,12 @@ export const {ServiceName}Block: BlockConfig = { // Auth mode authMode: AuthMode.OAuth, // or AuthMode.ApiKey + // Card summary sentences — see "Canvas Sentences" below + canvasPresentation: { + defaultTitle: '{Default Operation}', + sentences: { byOperation: { /* one per operation dropdown option id */ } }, + }, + subBlocks: [ // Define all UI fields here ], @@ -945,6 +951,35 @@ Derive templates from the service's real use cases. Each prompt should name a co - **Ground every skill in operations the block actually exposes** — cross-check each skill's steps against `tools.access`. Never describe an action the integration cannot perform. - **Derive skills from real, popular use cases found online — never invent them.** Web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles) and only add a skill you can source as something people genuinely do with the service. Do not hallucinate skills. +## Canvas Sentences + +Every block declares a one-line prose summary that replaces its card's field rows: + +``` +Slack ← header (already names the block) +Posts ⟨Ship it 🚀⟩ to ⟨#eng⟩ ← the sentence; ⟨…⟩ are live value chips +``` + +Write one `byOperation` entry per operation dropdown option (or a single `default` +when the block has no operation dropdown). + +**The full authoring contract — voice, structure, and the two mistakes that break +cards silently — is `apps/sim/blocks/AGENTS.md` → "Canvas sentences". Read it +before writing any.** The two failures worth repeating here, because both are +invisible at runtime: + +1. A clause naming only one member of a `canonicalParamId` pair drops the sentence + for every advanced-mode user. List all members: + `field: ['channelSelector', 'manualChannel']`. +2. A clause referencing a subblock whose `condition` excludes that operation can + never render. + +Validate before finishing: + +```bash +bun run apps/sim/scripts/check-canvas-sentences.ts --block={service} +``` + ## Generated artifacts Adding a block on its own needs no **tool metadata** regeneration — a block references existing @@ -963,7 +998,6 @@ bun run integration-catalog:check The catalog check independently derives deployment metadata from the executable block registry and compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated diff and keep only intentional changes. - ## Checklist Before Finishing - [ ] `integrationType` is set to the correct `IntegrationType` enum value @@ -991,6 +1025,7 @@ diff and keep only intentional changes. - [ ] Exported `{Service}BlockMeta` with at least 7 templates - [ ] `url` set on `{Service}BlockMeta` to the external service's verified homepage (omit only for first-party blocks with no external service) - [ ] `skills` added to `{Service}BlockMeta`, each grounded in `tools.access` and sourced from a real online use case (not invented) +- [ ] `canvasPresentation.sentences` covers every operation, and `bun run apps/sim/scripts/check-canvas-sentences.ts --block={service}` passes with 100% coverage ## Final Validation (Required) diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index 57df2b4dd1b..1f9a8554af8 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -51,6 +51,12 @@ export const {ServiceName}Block: BlockConfig = { // Auth mode authMode: AuthMode.OAuth, // or AuthMode.ApiKey + // Card summary sentences — see "Canvas Sentences" below + canvasPresentation: { + defaultTitle: '{Default Operation}', + sentences: { byOperation: { /* one per operation dropdown option id */ } }, + }, + subBlocks: [ // Define all UI fields here ], @@ -944,6 +950,35 @@ Derive templates from the service's real use cases. Each prompt should name a co - **Ground every skill in operations the block actually exposes** — cross-check each skill's steps against `tools.access`. Never describe an action the integration cannot perform. - **Derive skills from real, popular use cases found online — never invent them.** Web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles) and only add a skill you can source as something people genuinely do with the service. Do not hallucinate skills. +## Canvas Sentences + +Every block declares a one-line prose summary that replaces its card's field rows: + +``` +Slack ← header (already names the block) +Posts ⟨Ship it 🚀⟩ to ⟨#eng⟩ ← the sentence; ⟨…⟩ are live value chips +``` + +Write one `byOperation` entry per operation dropdown option (or a single `default` +when the block has no operation dropdown). + +**The full authoring contract — voice, structure, and the two mistakes that break +cards silently — is `apps/sim/blocks/AGENTS.md` → "Canvas sentences". Read it +before writing any.** The two failures worth repeating here, because both are +invisible at runtime: + +1. A clause naming only one member of a `canonicalParamId` pair drops the sentence + for every advanced-mode user. List all members: + `field: ['channelSelector', 'manualChannel']`. +2. A clause referencing a subblock whose `condition` excludes that operation can + never render. + +Validate before finishing: + +```bash +bun run apps/sim/scripts/check-canvas-sentences.ts --block={service} +``` + ## Generated artifacts Adding a block on its own needs no **tool metadata** regeneration — a block references existing @@ -962,7 +997,6 @@ bun run integration-catalog:check The catalog check independently derives deployment metadata from the executable block registry and compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated diff and keep only intentional changes. - ## Checklist Before Finishing - [ ] `integrationType` is set to the correct `IntegrationType` enum value @@ -990,6 +1024,7 @@ diff and keep only intentional changes. - [ ] Exported `{Service}BlockMeta` with at least 7 templates - [ ] `url` set on `{Service}BlockMeta` to the external service's verified homepage (omit only for first-party blocks with no external service) - [ ] `skills` added to `{Service}BlockMeta`, each grounded in `tools.access` and sourced from a real online use case (not invented) +- [ ] `canvasPresentation.sentences` covers every operation, and `bun run apps/sim/scripts/check-canvas-sentences.ts --block={service}` passes with 100% coverage ## Final Validation (Required) diff --git a/.cursor/commands/add-block.md b/.cursor/commands/add-block.md index 1ac378de897..d73e9efbac7 100644 --- a/.cursor/commands/add-block.md +++ b/.cursor/commands/add-block.md @@ -46,6 +46,12 @@ export const {ServiceName}Block: BlockConfig = { // Auth mode authMode: AuthMode.OAuth, // or AuthMode.ApiKey + // Card summary sentences — see "Canvas Sentences" below + canvasPresentation: { + defaultTitle: '{Default Operation}', + sentences: { byOperation: { /* one per operation dropdown option id */ } }, + }, + subBlocks: [ // Define all UI fields here ], @@ -939,6 +945,35 @@ Derive templates from the service's real use cases. Each prompt should name a co - **Ground every skill in operations the block actually exposes** — cross-check each skill's steps against `tools.access`. Never describe an action the integration cannot perform. - **Derive skills from real, popular use cases found online — never invent them.** Web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles) and only add a skill you can source as something people genuinely do with the service. Do not hallucinate skills. +## Canvas Sentences + +Every block declares a one-line prose summary that replaces its card's field rows: + +``` +Slack ← header (already names the block) +Posts ⟨Ship it 🚀⟩ to ⟨#eng⟩ ← the sentence; ⟨…⟩ are live value chips +``` + +Write one `byOperation` entry per operation dropdown option (or a single `default` +when the block has no operation dropdown). + +**The full authoring contract — voice, structure, and the two mistakes that break +cards silently — is `apps/sim/blocks/AGENTS.md` → "Canvas sentences". Read it +before writing any.** The two failures worth repeating here, because both are +invisible at runtime: + +1. A clause naming only one member of a `canonicalParamId` pair drops the sentence + for every advanced-mode user. List all members: + `field: ['channelSelector', 'manualChannel']`. +2. A clause referencing a subblock whose `condition` excludes that operation can + never render. + +Validate before finishing: + +```bash +bun run apps/sim/scripts/check-canvas-sentences.ts --block={service} +``` + ## Generated artifacts Adding a block on its own needs no **tool metadata** regeneration — a block references existing @@ -957,7 +992,6 @@ bun run integration-catalog:check The catalog check independently derives deployment metadata from the executable block registry and compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated diff and keep only intentional changes. - ## Checklist Before Finishing - [ ] `integrationType` is set to the correct `IntegrationType` enum value @@ -985,6 +1019,7 @@ diff and keep only intentional changes. - [ ] Exported `{Service}BlockMeta` with at least 7 templates - [ ] `url` set on `{Service}BlockMeta` to the external service's verified homepage (omit only for first-party blocks with no external service) - [ ] `skills` added to `{Service}BlockMeta`, each grounded in `tools.access` and sourced from a real online use case (not invented) +- [ ] `canvasPresentation.sentences` covers every operation, and `bun run apps/sim/scripts/check-canvas-sentences.ts --block={service}` passes with 100% coverage ## Final Validation (Required) diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index cec1c8c0ba4..aea7a58ae2e 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -32,6 +32,9 @@ import { isKnownWorkflowTriggerBlock, isWorkflowAnnotationOnlyBlockType, isWorkflowBlockProtected, + normalizeWorkflowEdgeSourceHandle, + normalizeWorkflowEdgeTargetHandle, + withPersistedErrorEnabled, } from '@sim/workflow-types/workflow' import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' @@ -57,13 +60,21 @@ function toEdgeHandles(edge: PersistedEdgeRecord) { } interface EdgeAddCandidate { - id?: string + id: string source: string target: string sourceHandle?: string | null targetHandle?: string | null } +function canonicalizeEdgeAddCandidate(edge: EdgeAddCandidate): EdgeAddCandidate { + return { + ...edge, + sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle), + } +} + interface FilterEdgesForPersistResult { safeEdges: T[] droppedCounts: Record @@ -283,8 +294,8 @@ async function insertAutoConnectEdge( workflowId, sourceBlockId: autoConnectEdge.source, targetBlockId: autoConnectEdge.target, - sourceHandle: autoConnectEdge.sourceHandle || null, - targetHandle: autoConnectEdge.targetHandle || null, + sourceHandle: normalizeWorkflowEdgeSourceHandle(autoConnectEdge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(autoConnectEdge.targetHandle), }) logger.debug( `Added auto-connect edge ${autoConnectEdge.id}: ${autoConnectEdge.source} -> ${autoConnectEdge.target}` @@ -736,6 +747,33 @@ async function handleBlockOperationTx( break } + case BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED: { + if (!payload.id || payload.errorEnabled === undefined) { + throw new Error('Missing required fields for update error enabled operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + data: sql`jsonb_set( + coalesce(${workflowBlocks.data}, '{}'::jsonb), + '{errorEnabled}', + ${JSON.stringify(payload.errorEnabled)}::jsonb, + true + )`, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug(`Updated block error output: ${payload.id} -> ${payload.errorEnabled}`) + break + } + case BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE: { if (!payload.id || !payload.canonicalId || !payload.canonicalMode) { throw new Error('Missing required fields for update canonical mode operation') @@ -922,7 +960,10 @@ async function handleBlocksOperationTx( name: block.name as string, positionX: (block.position as { x: number; y: number }).x, positionY: (block.position as { x: number; y: number }).y, - data: (block.data as Record) || {}, + data: withPersistedErrorEnabled( + block.data as Record | undefined, + block.errorEnabled as boolean | undefined + ), subBlocks: mergedSubBlocks, outputs: (block.outputs as Record) || {}, enabled: (block.enabled as boolean) ?? true, @@ -1024,8 +1065,8 @@ async function handleBlocksOperationTx( // blocksById lookup (a plain `tx.select` from `workflowBlocks`) also // sees the blocks this same batch just inserted — reads observe a // transaction's own prior writes. - const candidates: EdgeAddCandidate[] = (edges as Array>).map( - (e) => ({ + const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => + canonicalizeEdgeAddCandidate({ id: e.id as string, source: e.source as string, target: e.target as string, @@ -1051,8 +1092,8 @@ async function handleBlocksOperationTx( workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle), })) await tx @@ -1509,18 +1550,17 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str throw new Error('Missing required fields for add edge operation') } + const candidate = canonicalizeEdgeAddCandidate({ + id: payload.id, + source: payload.source, + target: payload.target, + sourceHandle: payload.sourceHandle ?? null, + targetHandle: payload.targetHandle ?? null, + }) const { safeEdges, droppedCounts, droppedDuplicates } = await filterEdgesForPersist( tx, workflowId, - [ - { - id: payload.id, - source: payload.source, - target: payload.target, - sourceHandle: payload.sourceHandle ?? null, - targetHandle: payload.targetHandle ?? null, - }, - ] + [candidate] ) if (safeEdges.length === 0) { @@ -1534,13 +1574,14 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str break } + const [safeEdge] = safeEdges await tx.insert(workflowEdges).values({ - id: payload.id, + id: safeEdge.id, workflowId, - sourceBlockId: payload.source, - targetBlockId: payload.target, - sourceHandle: payload.sourceHandle || null, - targetHandle: payload.targetHandle || null, + sourceBlockId: safeEdge.source, + targetBlockId: safeEdge.target, + sourceHandle: normalizeWorkflowEdgeSourceHandle(safeEdge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(safeEdge.targetHandle), }) logger.debug(`Added edge ${payload.id}: ${payload.source} -> ${payload.target}`) @@ -1755,13 +1796,15 @@ async function handleEdgesOperationTx( logger.info(`Batch adding ${edges.length} edges to workflow ${workflowId}`) - const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => ({ - id: e.id as string, - source: e.source as string, - target: e.target as string, - sourceHandle: (e.sourceHandle as string | null) ?? null, - targetHandle: (e.targetHandle as string | null) ?? null, - })) + const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => + canonicalizeEdgeAddCandidate({ + id: e.id as string, + source: e.source as string, + target: e.target as string, + sourceHandle: (e.sourceHandle as string | null) ?? null, + targetHandle: (e.targetHandle as string | null) ?? null, + }) + ) const { safeEdges, droppedCounts, droppedDuplicates, droppedCyclic } = await filterEdgesForPersist(tx, workflowId, candidates) @@ -1784,8 +1827,8 @@ async function handleEdgesOperationTx( workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle), })) await tx @@ -2136,7 +2179,7 @@ async function handleWorkflowOperationTx( name: block.name, positionX: block.position.x, positionY: block.position.y, - data: block.data || {}, + data: withPersistedErrorEnabled(block.data, block.errorEnabled), subBlocks: block.subBlocks || {}, outputs: block.outputs || {}, enabled: block.enabled ?? true, @@ -2152,16 +2195,34 @@ async function handleWorkflowOperationTx( // Insert all edges from the new state if (edges && edges.length > 0) { - const edgeValues = edges.map((edge: any) => ({ + const canonicalEdges = (edges as Array>).map((edge) => + canonicalizeEdgeAddCandidate({ + id: edge.id as string, + source: edge.source as string, + target: edge.target as string, + sourceHandle: (edge.sourceHandle as string | null) ?? null, + targetHandle: (edge.targetHandle as string | null) ?? null, + }) + ) + const uniqueEdges = filterUniqueWorkflowEdges(canonicalEdges, []) + const edgeValues = uniqueEdges.map((edge) => ({ id: edge.id, workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: edge.sourceHandle ?? null, + targetHandle: edge.targetHandle ?? null, })) - await tx.insert(workflowEdges).values(edgeValues) + if (uniqueEdges.length < edges.length) { + logger.info(`Dropped ${edges.length - uniqueEdges.length} duplicate edge(s)`, { + operation: WORKFLOW_OPERATIONS.REPLACE_STATE, + }) + } + + if (edgeValues.length > 0) { + await tx.insert(workflowEdges).values(edgeValues) + } } // Insert all loops from the new state diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index 69892a590a6..9992b8583ec 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -29,6 +29,7 @@ const WRITE_OPERATIONS: string[] = [ BLOCK_OPERATIONS.TOGGLE_ENABLED, BLOCK_OPERATIONS.UPDATE_PARENT, BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, + BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED, BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE, BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES, BLOCK_OPERATIONS.TOGGLE_HANDLES, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/chrome-scope.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/chrome-scope.test.ts new file mode 100644 index 00000000000..6548abe1cbc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/chrome-scope.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment jsdom + * + * The editor's node chrome must be reachable from every surface, including a bare one. + * + * `.rich-markdown-prose` is the document type scale and ink, and a host that paints its own surface + * (the canvas Note card) drops it — so a chrome rule authored under that class silently vanishes + * there. That is how a note's checklists shipped rendering as a native checkbox beside a disc + * bullet, with the label wrapped onto the next line: every `ul[data-type="taskList"]` rule lived on + * the prose class, and the generic `ul` styling was all that was left. + * + * Reads the real, shipped stylesheet rather than a copy. + */ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' + +const EDITOR_CSS_PATH = path.join(__dirname, 'rich-markdown-editor.css') + +/** + * Selector fragments that identify a ProseMirror node or interaction the editor owns and no host + * re-declares — as opposed to typography, which a bare host supplies itself. + */ +const CHROME_MARKERS = [ + 'taskList', + 'ProseMirror-selectednode', + 'ProseMirror-gapcursor', + 'rich-leaf-in-selection', + 'column-resize-handle', + 'selectedCell', + 'code-editor-theme', + 'raw-markdown-', +] as const + +let selectors: string[] = [] + +beforeAll(() => { + const style = document.createElement('style') + style.textContent = readFileSync(EDITOR_CSS_PATH, 'utf-8') + document.head.appendChild(style) + if (!style.sheet) throw new Error('rich-markdown-editor.css did not parse') + selectors = Array.from(style.sheet.cssRules) + .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule) + .map((rule) => rule.selectorText) +}) + +describe('rich markdown chrome scoping', () => { + it.each(CHROME_MARKERS)('scopes every %s rule to the shared node class', (marker) => { + const matching = selectors.filter((selector) => selector.includes(marker)) + expect(matching.length).toBeGreaterThan(0) + for (const selector of matching) { + expect(selector).toContain('.rich-markdown-nodes') + expect(selector).not.toContain('.rich-markdown-prose') + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts new file mode 100644 index 00000000000..bc00c7b827f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts @@ -0,0 +1,138 @@ +/** + * @vitest-environment jsdom + * + * Dragging an image to reposition it inside a document must MOVE it, not import it again. + * + * The browser enriches a dragged `` with an image `File`, and TipTap's image node view runs its + * own `dragstart` that bypasses ProseMirror's serialization — so the drop looks exactly like an + * external image drop, and the upload path stored a fresh copy of the image on every nudge while the + * original never moved. + */ +import { type Node as PMNode, Schema } from '@tiptap/pm/model' +import { EditorState, NodeSelection } from '@tiptap/pm/state' +import type { EditorView } from '@tiptap/pm/view' +import { describe, expect, it, vi } from 'vitest' +import { moveDraggedImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move' + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'text*' }, + image: { group: 'block', attrs: { src: {} }, draggable: true }, + text: {}, + }, +}) + +const SRC = '/api/workspaces/ws-1/files/inline?fileId=f1' + +function imageHtml(src: string): string { + return `` +} + +/** A doc of `paragraph, image, paragraph` with the image node-selected, and a view around it. */ +function selectedImageView(): { view: EditorView; dispatched: { state: () => EditorState } } { + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [schema.text('before')]), + schema.node('image', { src: SRC }), + schema.node('paragraph', null, [schema.text('after')]), + ]) + let state = EditorState.create({ doc }) + state = state.apply(state.tr.setSelection(NodeSelection.create(state.doc, 8))) + + const view = { + get state() { + return state + }, + posAtCoords: () => ({ pos: 1, inside: 0 }), + dispatch: vi.fn((tr) => { + state = state.apply(tr) + }), + } as unknown as EditorView + + return { view, dispatched: { state: () => state } } +} + +function dropEvent(): DragEvent { + return { clientX: 10, clientY: 10, preventDefault: vi.fn() } as unknown as DragEvent +} + +function imageCount(doc: PMNode): number { + let count = 0 + doc.descendants((node) => { + if (node.type.name === 'image') count += 1 + }) + return count +} + +describe('moveDraggedImageNode', () => { + it('moves the dragged image instead of leaving it to be re-uploaded', () => { + const { view, dispatched } = selectedImageView() + const event = dropEvent() + + expect(moveDraggedImageNode(view, event, { images: [], html: imageHtml(SRC) })).toBe(true) + expect(event.preventDefault).toHaveBeenCalled() + expect(imageCount(dispatched.state().doc)).toBe(1) + /* Moved ahead of the paragraph it started after. */ + expect(dispatched.state().doc.firstChild?.type.name).toBe('image') + }) + + it('still claims the drop when the browser also attached the image as a file', () => { + const { view } = selectedImageView() + const file = new File([''], 'shot.png', { type: 'image/png' }) + + expect(moveDraggedImageNode(view, dropEvent(), { images: [file], html: imageHtml(SRC) })).toBe( + true + ) + }) + + it('resolves the rendered src through the host before comparing', () => { + const { view } = selectedImageView() + const rendered = '/api/files/public/tok/inline?fileId=f1' + + expect( + moveDraggedImageNode(view, dropEvent(), { + images: [], + html: imageHtml(rendered), + resolveSrc: () => rendered, + }) + ).toBe(true) + }) + + it('leaves a genuinely new image to the upload path', () => { + const { view } = selectedImageView() + const file = new File([''], 'other.png', { type: 'image/png' }) + + expect( + moveDraggedImageNode(view, dropEvent(), { + images: [file], + html: imageHtml('https://elsewhere.test/other.png'), + }) + ).toBe(false) + }) + + it('leaves a multi-file drop to the upload path', () => { + const { view } = selectedImageView() + const files = [ + new File([''], 'a.png', { type: 'image/png' }), + new File([''], 'b.png', { type: 'image/png' }), + ] + + expect(moveDraggedImageNode(view, dropEvent(), { images: files, html: imageHtml(SRC) })).toBe( + false + ) + }) + + it('ignores a drop with no image selected', () => { + const doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text('hi')])]) + const state = EditorState.create({ doc }) + const view = { + state, + posAtCoords: () => ({ pos: 1 }), + dispatch: vi.fn(), + } as unknown as EditorView + + expect(moveDraggedImageNode(view, dropEvent(), { images: [], html: imageHtml(SRC) })).toBe( + false + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.ts new file mode 100644 index 00000000000..904f2cd5ed0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.ts @@ -0,0 +1,66 @@ +import { Fragment, Slice } from '@tiptap/pm/model' +import { NodeSelection } from '@tiptap/pm/state' +import { dropPoint } from '@tiptap/pm/transform' +import type { EditorView } from '@tiptap/pm/view' +import { htmlReferencesSrc } from './image-paste' + +interface MoveDraggedImageOptions { + /** Image files on the drop, from `extractImageFiles`. */ + images: File[] + /** The drop's `text/html`, which the browser enriches with the dragged node's rendered src. */ + html: string + /** + * Maps a node's stored `src` to the URL actually rendered into ``, when the host rewrites + * it (the file editor's display-layer resolve). Omit where stored and rendered are the same. + */ + resolveSrc?: (src: string | undefined) => string | undefined +} + +/** + * Repositions an image the user dragged from inside this editor, returning true when it consumed + * the drop. + * + * TipTap's image node view runs its own `dragstart`, which bypasses ProseMirror's clipboard + * serialization — no PM `text/html`, and `view.dragging` never set — so neither ProseMirror's default + * move nor the `slice` argument to `handleDrop` sees anything. What survives is a NodeSelection on the + * dragged image plus the browser's native enrichment, whose html carries the absolute rendered URL of + * exactly that node. That pair is the signal, and without acting on it the drop falls through to the + * upload path and stores a second copy of an image the document already has. + * + * The move is the same shape as ProseMirror's own: compute the drop point against the pre-delete doc, + * delete the source, then map the insert position through that delete. A null `dropPoint` (no valid + * insertion point) is a handled no-op — the node stays put, still selected — rather than a raw-position + * fallback, which `tr.insert` can throw on. + * + * The gate accepts at most one file rather than exactly one: some drag transports carry the html + * alone. A genuinely external drop can never reference the currently selected node's own rendered src. + */ +export function moveDraggedImageNode( + view: EditorView, + event: DragEvent, + { images, html, resolveSrc }: MoveDraggedImageOptions +): boolean { + const { selection } = view.state + if (images.length > 1) return false + if (!(selection instanceof NodeSelection) || selection.node.type.name !== 'image') return false + + const src = selection.node.attrs.src + const rendered = typeof src === 'string' ? (resolveSrc?.(src) ?? src) : undefined + if (!htmlReferencesSrc(html, rendered)) return false + + event.preventDefault() + const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }) + if (!coords) return true + + const node = selection.node + const tr = view.state.tr + const insertPos = dropPoint(view.state.doc, coords.pos, new Slice(Fragment.from(node), 0, 0)) + if (insertPos === null) return true + + tr.delete(selection.from, selection.to) + const mapped = tr.mapping.map(insertPos) + tr.insert(mapped, node) + tr.setSelection(NodeSelection.create(tr.doc, mapped)) + view.dispatch(tr.scrollIntoView()) + return true +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 1af8faa0bd0..b148b2904f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -1,3 +1,18 @@ +/* + * Two classes, both on the ProseMirror root, splitting what a host may own from what it may not. + * + * `.rich-markdown-nodes` is the editor's node chrome: task-list layout and checkboxes, selection + * rings, the gap cursor, image and divider treatment, the highlight mark, table resize affordances, + * collaborative carets. It is the same on every surface and no host re-declares it. + * + * `.rich-markdown-prose` is the document type scale and ink — sizes, weights, colours, block rhythm. + * A host that paints its own surface (the canvas Note card, which has its own fill and text colour) + * takes `surface='bare'`, drops this class, and supplies its own typography instead. + * + * Keep new rules on the right side of that line. A chrome rule that lands in `prose` silently + * disappears on every bare surface: that is how a note's checklists ended up rendering as a native + * checkbox next to a disc bullet, with the label wrapped onto the following line. + */ .rich-markdown-prose { flex: 1 1 auto; outline: none; @@ -10,7 +25,7 @@ overflow-wrap: anywhere; } -.rich-markdown-prose img { +.rich-markdown-nodes img { max-width: 100%; height: auto; border-radius: 8px; @@ -19,7 +34,7 @@ /* One consistent ring for every node that can only be selected as a whole (divider, image, code block, table) — so a mouse click and a keyboard NodeSelection look identical. */ -.rich-markdown-prose .ProseMirror-selectednode { +.rich-markdown-nodes .ProseMirror-selectednode { outline: 2px solid var(--brand-secondary); outline-offset: 2px; border-radius: 4px; @@ -27,11 +42,11 @@ /* An image is its own framed element; ring the image itself so the indicator hugs the picture rather than the node's bounding box, which can be far wider/taller than a small image. */ -.rich-markdown-prose .ProseMirror-selectednode:has(img) { +.rich-markdown-nodes .ProseMirror-selectednode:has(img) { outline: none; } -.rich-markdown-prose .ProseMirror-selectednode img { +.rich-markdown-nodes .ProseMirror-selectednode img { outline: 2px solid var(--brand-secondary); outline-offset: 2px; } @@ -39,8 +54,8 @@ /* The inline mention chip isn't a block leaf, so it skips the heavy outline ring above and just uses the same native text-selection highlight as the surrounding prose. ProseMirror puts `selectednode` on the node-view wrapper, so match it via `:has` (the chip span itself when it's the selected node). */ -.rich-markdown-prose .mention-chip.ProseMirror-selectednode, -.rich-markdown-prose .ProseMirror-selectednode:has(.mention-chip) { +.rich-markdown-nodes .mention-chip.ProseMirror-selectednode, +.rich-markdown-nodes .ProseMirror-selectednode:has(.mention-chip) { outline: none; } @@ -128,7 +143,7 @@ /* Render the gap cursor (e.g. above a leading divider) as a normal vertical caret rather than ProseMirror's default short horizontal bar, which reads as a stray underscore. */ -.rich-markdown-prose .ProseMirror-gapcursor::after { +.rich-markdown-nodes .ProseMirror-gapcursor::after { top: 0; width: 2px; height: 1.25em; @@ -140,7 +155,7 @@ empty space the view gives no hint about. Hide it there (matching Linear): the gap cursor stays functional — typing still inserts a block between them — it just isn't drawn. Leading/trailing gaps keep theirs. The keymap plugin sets `data-gap-between-leaves` when both neighbours are leaves. */ -.rich-markdown-prose[data-gap-between-leaves] .ProseMirror-gapcursor::after { +.rich-markdown-nodes[data-gap-between-leaves] .ProseMirror-gapcursor::after { display: none; } @@ -157,7 +172,7 @@ list-style: decimal; } -.rich-markdown-prose li > p { +.rich-markdown-nodes li > p { margin: 0; } @@ -165,38 +180,44 @@ color: var(--text-primary); } -.rich-markdown-prose ul[data-type="taskList"] { +/* A checklist is not a bulleted list, so this has to outrank whatever the host set on `ul` — hence + the doubled-up root selector. Both classes are always on the ProseMirror root; the extra class + buys the specificity to beat a host's own `ul` rule, which would otherwise tie and be decided by + stylesheet order. Losing that tie puts a disc bullet beside every checkbox. */ +.ProseMirror.rich-markdown-nodes ul[data-type="taskList"] { list-style: none; padding-left: 0; } -.rich-markdown-prose ul[data-type="taskList"] li { +.rich-markdown-nodes ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5em; } -/* One line tall with the box centered, so it aligns with the item's first line. */ -.rich-markdown-prose ul[data-type="taskList"] li > label { +/* One line tall with the box centered, so it aligns with the item's first line. The height has to + match whatever line-height the host set — the default is the prose 25px at 15px font, and a host + on a different scale (the canvas Note) overrides the variable rather than this rule. */ +.rich-markdown-nodes ul[data-type="taskList"] li > label { display: flex; align-items: center; - height: 1.6667em; /* = the prose 25px line-height at 15px font */ + height: var(--rich-markdown-line-height, 1.6667em); flex-shrink: 0; user-select: none; } -.rich-markdown-prose ul[data-type="taskList"] li > div { +.rich-markdown-nodes ul[data-type="taskList"] li > div { flex: 1 1 auto; min-width: 0; } /* TaskItem nests content as li > div > p, which the `li > p` reset misses, leaving UA margins. */ -.rich-markdown-prose ul[data-type="taskList"] li > div > p { +.rich-markdown-nodes ul[data-type="taskList"] li > div > p { margin: 0; } /* Match the design-system Checkbox (emcn) rather than the platform-native control. */ -.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"] { +.rich-markdown-nodes ul[data-type="taskList"] input[type="checkbox"] { appearance: none; -webkit-appearance: none; display: inline-grid; @@ -210,12 +231,12 @@ cursor: pointer; } -.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"]:checked { +.rich-markdown-nodes ul[data-type="taskList"] input[type="checkbox"]:checked { background-color: var(--text-primary); border-color: var(--text-primary); } -.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"]:checked::after { +.rich-markdown-nodes ul[data-type="taskList"] input[type="checkbox"]:checked::after { content: ""; width: 10px; height: 10px; @@ -275,15 +296,15 @@ only wrap when the line-wrap toggle sets data-wrap, breaking long unbroken tokens too. The `overflow-wrap`/`word-break` resets undo the editor-wide `overflow-wrap: anywhere`, which would otherwise still break a long unbroken token even under `white-space: pre`. */ -.rich-markdown-prose pre.code-editor-theme, -.rich-markdown-prose pre.code-editor-theme code { +.rich-markdown-nodes pre.code-editor-theme, +.rich-markdown-nodes pre.code-editor-theme code { white-space: pre; overflow-wrap: normal; word-break: normal; } -.rich-markdown-prose pre.code-editor-theme[data-wrap="true"], -.rich-markdown-prose pre.code-editor-theme[data-wrap="true"] code { +.rich-markdown-nodes pre.code-editor-theme[data-wrap="true"], +.rich-markdown-nodes pre.code-editor-theme[data-wrap="true"] code { white-space: pre-wrap; overflow-wrap: anywhere; } @@ -304,8 +325,8 @@ comments, footnotes) — held verbatim and re-emitted byte-for-byte on save (./raw-markdown-snippet.ts). Same neutral surface as `code`/`pre` below (no color tint — a tint reads as a warning/error state, which isn't the signal here); the hover "Raw HTML"/"Footnote" badge is what conveys "not interpreted". */ -.rich-markdown-prose .raw-markdown-block, -.rich-markdown-prose .raw-markdown-inline { +.rich-markdown-nodes .raw-markdown-block, +.rich-markdown-nodes .raw-markdown-inline { font-family: var(--font-martian-mono, ui-monospace, monospace); font-size: 0.875em; color: var(--text-muted); @@ -314,22 +335,22 @@ overflow-wrap: anywhere; } -.dark .rich-markdown-prose .raw-markdown-block, -.dark .rich-markdown-prose .raw-markdown-inline { +.dark .rich-markdown-nodes .raw-markdown-block, +.dark .rich-markdown-nodes .raw-markdown-inline { background: var(--code-bg); } -.rich-markdown-prose .raw-markdown-block { +.rich-markdown-nodes .raw-markdown-block { border-radius: 8px; padding: 0.75rem 1rem; } -.rich-markdown-prose .raw-markdown-inline { +.rich-markdown-nodes .raw-markdown-inline { border-radius: 4px; padding: 0.0625rem 0.3rem; } -.rich-markdown-prose hr { +.rich-markdown-nodes hr { border: none; border-top: var(--border-width) solid var(--border); margin: 1.5em 0; @@ -339,17 +360,17 @@ highlight skips leaf nodes (they hold no text), so the keymap's decoration paints the band itself. A divider is a void
(no pseudo-elements), so a box-shadow spreads the selection band around its hairline without shifting layout; an image rings instead. */ -.rich-markdown-prose hr.rich-leaf-in-selection { +.rich-markdown-nodes hr.rich-leaf-in-selection { box-shadow: 0 0 0 0.4em var(--selection-bg); border-radius: 1px; } -.dark .rich-markdown-prose hr.rich-leaf-in-selection { +.dark .rich-markdown-nodes hr.rich-leaf-in-selection { box-shadow: 0 0 0 0.4em var(--selection-dark); } -.rich-markdown-prose .rich-leaf-in-selection:has(img) img, -.rich-markdown-prose img.rich-leaf-in-selection { +.rich-markdown-nodes .rich-leaf-in-selection:has(img) img, +.rich-markdown-nodes img.rich-leaf-in-selection { outline: 2px solid var(--selection-bg); outline-offset: 2px; border-radius: 4px; @@ -357,21 +378,25 @@ /* Borders, padding, typography, and header fill come from document-table.css — the chrome shared with the CSV/XLSX previews. Only the editor-specific bits live here: `table-layout: fixed` is - required by prosemirror-tables' column-resizing plugin, the block margin is prose rhythm, and - `overflow: visible` lets a collaborator's caret name label escape the table box instead of being - clipped (fixed layout means columns can't exceed the table width, so nothing else needs clipping). */ -.rich-markdown-prose table { + required by prosemirror-tables' column-resizing plugin, and `overflow: visible` lets a + collaborator's caret name label escape the table box instead of being clipped (fixed layout means + columns can't exceed the table width, so nothing else needs clipping). */ +.rich-markdown-nodes table { table-layout: fixed; - margin: 1rem 0; overflow: visible; } -.rich-markdown-prose th > p, -.rich-markdown-prose td > p { +/* The block margin is prose rhythm, so it stays with the type scale. */ +.rich-markdown-prose table { + margin: 1rem 0; +} + +.rich-markdown-nodes th > p, +.rich-markdown-nodes td > p { margin: 0; } -.rich-markdown-prose .selectedCell::after { +.rich-markdown-nodes .selectedCell::after { content: ""; position: absolute; inset: 0; @@ -380,7 +405,7 @@ pointer-events: none; } -.rich-markdown-prose .column-resize-handle { +.rich-markdown-nodes .column-resize-handle { position: absolute; right: -2px; top: 0; @@ -395,7 +420,7 @@ * while the pointer is over a column boundary; without this rule the handle shows but the cursor * never changes to the resize affordance. */ -.rich-markdown-prose.resize-cursor { +.rich-markdown-nodes.resize-cursor { cursor: col-resize; } @@ -414,7 +439,7 @@ * horizontal padding is cancelled by an equal negative margin so the tint bleeds slightly past the * text without ever shifting the text (or any following text) as the highlight is applied/removed. */ -.rich-markdown-prose mark { +.rich-markdown-nodes mark { background-color: rgba(255, 212, 0, 0.4); color: inherit; border-radius: 2px; @@ -452,7 +477,7 @@ * caret (and its label) above table cell backgrounds so a caret inside a table * cell is not hidden behind adjacent cells. */ -.rich-markdown-prose .collaboration-carets__caret { +.rich-markdown-nodes .collaboration-carets__caret { /* Zero inline footprint: a positioned anchor with NO width/border/margin, so inserting or moving this * inline widget never reflows the surrounding text. The visible bar, dormant cap (::before), name label, * and hover slop (::after) are all positioned relative to this anchor and are out of the inline flow. */ @@ -463,7 +488,7 @@ /* The visible caret bar — absolutely positioned so it draws over the text without occupying inline * width. `left: -1px` centers the 2px bar on the cursor position; top/bottom span the line box height. */ -.rich-markdown-prose .collaboration-carets__bar { +.rich-markdown-nodes .collaboration-carets__bar { position: absolute; top: -0.1em; bottom: -0.1em; @@ -478,7 +503,7 @@ * tables/canvas tags) so the whole presence system reads as one language. It signals * "someone's here — hover for who", and fades out when the full name tag takes over * (peer active or on hover). */ -.rich-markdown-prose .collaboration-carets__caret::before { +.rich-markdown-nodes .collaboration-carets__caret::before { content: ""; position: absolute; /* Seat the cap's square bottom-left corner on the pole's top-left, flush like a flag on its pole. @@ -493,8 +518,8 @@ transition: opacity 0.2s ease; } -.rich-markdown-prose .collaboration-carets__caret--active::before, -.rich-markdown-prose .collaboration-carets__caret:hover::before { +.rich-markdown-nodes .collaboration-carets__caret--active::before, +.rich-markdown-nodes .collaboration-carets__caret:hover::before { opacity: 0; } @@ -504,7 +529,7 @@ * height and over the cap — nothing visible changes, only the pointer area. `pointer-events: * auto` is required so it catches the hover; kept narrow so it barely intrudes on selecting * text next to a remote caret. */ -.rich-markdown-prose .collaboration-carets__caret::after { +.rich-markdown-nodes .collaboration-carets__caret::after { content: ""; position: absolute; top: -4px; @@ -519,7 +544,7 @@ * one system. `--surface-1` is the base surface token (readable on every assigned * identity color in both themes), not a hardcoded value. Hidden by default; the * show/fade is driven by the `--active` class (see caret-presence.ts) and hover. */ -.rich-markdown-prose .collaboration-carets__label { +.rich-markdown-nodes .collaboration-carets__label { position: absolute; top: -1.4em; left: -1px; @@ -541,14 +566,14 @@ transition: opacity 0.2s ease; } -.rich-markdown-prose .collaboration-carets__caret--active .collaboration-carets__label, -.rich-markdown-prose .collaboration-carets__caret:hover .collaboration-carets__label { +.rich-markdown-nodes .collaboration-carets__caret--active .collaboration-carets__label, +.rich-markdown-nodes .collaboration-carets__caret:hover .collaboration-carets__label { opacity: 1; } /* Near the editor's right edge the label is flipped to the caret's left so it never * runs off (JS toggles `--flip` after measuring); mirror the tag's notch corner. */ -.rich-markdown-prose .collaboration-carets__caret--flip .collaboration-carets__label { +.rich-markdown-nodes .collaboration-carets__caret--flip .collaboration-carets__label { left: auto; right: -1px; border-radius: 2px 2px 0 2px; @@ -556,7 +581,7 @@ /* Remote text selection: a rounded translucent tint of the collaborator's identity * color (the alpha fill is set inline by selectionRender). */ -.rich-markdown-prose .collaboration-carets__selection { +.rich-markdown-nodes .collaboration-carets__selection { border-radius: 2px; pointer-events: none; } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index f00c89f7a0c..4bc6285eedb 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -5,9 +5,6 @@ import { cn, toast } from '@sim/emcn' import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc' import type { Extensions, JSONContent } from '@tiptap/core' import { isChangeOrigin } from '@tiptap/extension-collaboration' -import { Fragment, Slice } from '@tiptap/pm/model' -import { NodeSelection } from '@tiptap/pm/state' -import { dropPoint } from '@tiptap/pm/transform' import type { Editor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react' import { useRouter } from 'next/navigation' @@ -42,11 +39,11 @@ import { nextCollabReadiness } from './collaboration/readiness' import { useFileDocCollaboration } from './collaboration/use-file-doc-collaboration' import { createMarkdownEditorExtensions } from './editor-extensions' import { findHeadingPos } from './heading-anchors' +import { moveDraggedImageNode } from './image-drag-move' import { extractImageFiles, extractImgSrcs, findHostedImageAttrs, - htmlReferencesSrc, shouldSkipFileUpload, } from './image-paste' import { @@ -114,7 +111,7 @@ function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) { immediatelyRender: true, shouldRerenderOnTransaction: false, content, - editorProps: { attributes: { class: 'rich-markdown-prose' } }, + editorProps: { attributes: { class: 'rich-markdown-nodes rich-markdown-prose' } }, }) return } @@ -540,7 +537,10 @@ export function LoadedRichMarkdownEditor({ shouldRerenderOnTransaction: false, content: initialContent, editorProps: { - attributes: { class: 'rich-markdown-prose', 'data-owned-shortcuts': 'Mod+K' }, + attributes: { + class: 'rich-markdown-nodes rich-markdown-prose', + 'data-owned-shortcuts': 'Mod+K', + }, handleKeyDown: (_view, event) => { const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's' if (!isSaveShortcut) return false @@ -615,24 +615,11 @@ export function LoadedRichMarkdownEditor({ * the browser doesn't navigate away from the editor; internal text drags carry no files and fall * through to the default behavior. * - * Drag-REORDER of an image node is the deceptive case. TipTap's node-view dragstart bypasses - * ProseMirror's own drag serialization entirely — no PM `text/html`, no `view.dragging` — but it - * DOES NodeSelect the dragged image; what the drop carries instead is the browser's native - * enrichment for a dragged ``: an image `File` plus `text/html` whose src is the ABSOLUTE - * rendered URL of that exact node. So when the drop's html points at the currently-selected image - * node ({@link htmlReferencesSrc}), this drop IS that node being moved, and the move must be - * performed here: uploading would duplicate it (the original never moves), and falling through to - * ProseMirror is no better — with `view.dragging` unset its default drop PARSES the html into a - * copy (persisting the display-layer src, which share/export tracking don't recognize) and never - * deletes the original. The gate accepts at most one file (not exactly one): some drag transports - * (e.g. CDP-driven input) carry the html alone, and a genuinely external drop can never reference - * the currently-selected node's own resolved src. - * - * The move itself is the same shape as ProseMirror's own: compute the drop point on the - * pre-delete doc, delete the source, map the insert position through that delete. A null - * `dropPoint` (no valid insertion point) is a handled no-op — the node stays put, still - * selected — never a raw-position fallback, which `tr.insert` could throw on (PM's own null - * fallback is only safe because it uses the forgiving `replaceRangeWith`). + * Drag-REORDER of an image node is the deceptive case, and {@link moveDraggedImageNode} owns it — + * uploading would duplicate the image (the original never moves), and falling through to + * ProseMirror is no better, since with `view.dragging` unset its default drop PARSES the html into + * a copy (persisting the display-layer src, which share/export tracking don't recognize) and never + * deletes the original. * * PM-serialized drags (a text selection spanning an image, dragged from a textblock) still reach * the `shouldSkipFileUpload` bail below: PM set `view.dragging` for those itself, so its default @@ -642,29 +629,13 @@ export function LoadedRichMarkdownEditor({ if (!view.editable) return false const images = extractImageFiles(event.dataTransfer) const html = event.dataTransfer?.getData('text/html') ?? '' - const { selection } = view.state if ( - images.length <= 1 && - selection instanceof NodeSelection && - selection.node.type.name === 'image' && - htmlReferencesSrc(html, resolveImageSrcRef.current(selection.node.attrs.src)) + moveDraggedImageNode(view, event, { + images, + html, + resolveSrc: resolveImageSrcRef.current, + }) ) { - event.preventDefault() - const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }) - if (!coords) return true - const node = selection.node - const tr = view.state.tr - const insertPos = dropPoint( - view.state.doc, - coords.pos, - new Slice(Fragment.from(node), 0, 0) - ) - if (insertPos === null) return true - tr.delete(selection.from, selection.to) - const mapped = tr.mapping.map(insertPos) - tr.insert(mapped, node) - tr.setSelection(NodeSelection.create(tr.doc, mapped)) - view.dispatch(tr.scrollIntoView()) return true } if (shouldSkipFileUpload(images, html, (src) => extractEmbeddedFileRef(src) !== null)) { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index f52206c3bce..559afc129ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -5,6 +5,8 @@ import { ChipTextarea, chipFieldSurfaceClass, cn } from '@sim/emcn' import type { JSONContent } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' import { createMarkdownEditorExtensions } from './editor-extensions' +import { moveDraggedImageNode } from './image-drag-move' +import { extractImageFiles, isInlineRouteSrc, shouldSkipFileUpload } from './image-paste' import { applyFrontmatter, postProcessSerializedMarkdown, @@ -20,6 +22,17 @@ import '@sim/emcn/components/code/code.css' import '../document-table.css' import './rich-markdown-editor.css' +/** + * Sends the formatting toolbar back to its body portal instead of anchoring it inside the editor's + * container. + * + * A field or a full page gives the toolbar a bounded pane to sit in and scroll with, and being + * clipped at that pane's edge is the point. A bare host has no such pane — the canvas Note card is + * 320px wide and clips its own overflow, so an in-card toolbar would be cropped to a stub. A null + * container is how {@link EditorBubbleMenu} spells "portal to the body". + */ +const BODY_PORTAL: React.RefObject = { current: null } + interface RichMarkdownFieldProps { /** Current markdown value. Seeds the editor once on mount; external changes only apply while {@link isStreaming}. */ value: string @@ -31,6 +44,13 @@ interface RichMarkdownFieldProps { /** True while `value` is being pushed in externally (AI generation) — the editor turns read-only and mirrors each update. */ isStreaming?: boolean autoFocus?: boolean + /** + * Viewport point to place the caret at on mount, instead of the document end. + * For hosts whose read view sits under a click-to-edit overlay: the overlay + * consumes the click that opens editing, so without the point the caret can + * only land at the end and the user's aim is lost. + */ + autoFocusAt?: { clientX: number; clientY: number } | null /** Min height of the editor box in px. */ minHeight?: number /** @@ -46,12 +66,45 @@ interface RichMarkdownFieldProps { workspaceId?: string /** Force the `@` tag-insertion menu off even with a workspace set (existing tags still render). */ disableTagging?: boolean + /** + * Uploads a pasted or dropped image and returns its hosted URL, or null on + * failure. Providing this enables image paste/drop; without it, file drops + * are swallowed so the browser cannot navigate to the dropped file. The + * expected implementation is the workspace-file pipeline — the same + * presigned-S3 upload and `/api/workspaces/{id}/files/inline` URL shape the + * file editor persists — so every embedded image stays behind workspace + * auth and inside the existing storage lifecycle. + */ + uploadImage?: (file: File) => Promise<{ url: string; alt: string } | null> /** * Intercepts a plain-text paste before the editor handles it. Return `true` to consume the paste * (e.g. a full document the host destructures elsewhere); `false` to fall through to normal * markdown paste. */ onPasteText?: (text: string) => boolean + /** + * Chrome around the editor. + * + * `'field'` is the bordered chip-field box every form surface uses. `'bare'` + * drops the box, its padding and its default height so a host that already + * owns a surface — the canvas Note card, which paints its own fill and text + * colour — renders the editor inline instead of nesting a second field + * inside it. Both surfaces get the same editor and the same floating menus. + */ + surface?: 'field' | 'bare' + /** + * Typography for a `'bare'` surface, where the host owns the type scale and + * colour. Ignored by `'field'`, which keeps the shared prose styling. + */ + proseClassName?: string + /** + * Classes for the contenteditable root itself on a `'bare'` surface. Anything + * that must beat a rule targeting that element directly belongs here rather + * than in {@link proseClassName} — a global base rule pins `caret-color` on + * `[contenteditable="true"]`, and a declaration on the element always beats a + * value inherited from an ancestor, however specific that ancestor's is. + */ + editorClassName?: string } /** @@ -66,14 +119,22 @@ function LoadedRichMarkdownField({ disabled = false, isStreaming = false, autoFocus = false, - minHeight = 140, + autoFocusAt = null, + minHeight, maxHeight, error = false, workspaceId, disableTagging, onPasteText, + uploadImage, + surface = 'field', + proseClassName, + editorClassName, }: RichMarkdownFieldProps) { const containerRef = useRef(null) + const isBare = surface === 'bare' + /* A field keeps its 140px floor; a bare surface is sized by its host. */ + const boxMinHeight = minHeight ?? (isBare ? undefined : 140) /** * Frontmatter is held out-of-band and re-attached on serialize, exactly like the file editor. Split @@ -87,6 +148,51 @@ function LoadedRichMarkdownField({ onChangeRef.current = onChange const onPasteTextRef = useRef(onPasteText) onPasteTextRef.current = onPasteText + const uploadImageRef = useRef(uploadImage) + uploadImageRef.current = uploadImage + const autoFocusAtRef = useRef(autoFocusAt) + const editorInstanceRef = useRef>(null) + + /** + * The `/Image` slash command opens this hidden picker; `pendingImagePosRef` holds the caret + * position captured when the command ran, so the upload inserts where `/Image` was typed. + */ + const imageInputRef = useRef(null) + const pendingImagePosRef = useRef(null) + + /** + * Sequential upload-then-insert, mirroring the file editor's own image flow: + * each image inserts at the evolving position so a multi-image paste lands in + * order, and a failed upload skips its insert without aborting the rest. The + * upload mutation owns user feedback. + */ + const insertImagesRef = useRef<(images: File[], at: number) => Promise>(() => + Promise.resolve() + ) + insertImagesRef.current = async (images, at) => { + const upload = uploadImageRef.current + const owner = editorInstanceRef.current + if (!upload || !owner) return + let position = at + for (const image of images) { + const result = await upload(image).catch(() => null) + /* Bail if the editor unmounted (note closed) while the upload ran. */ + if (!result || editorInstanceRef.current !== owner || owner.isDestroyed) continue + const safePosition = Math.min(position, owner.state.doc.content.size) + try { + owner + .chain() + .insertContentAt(safePosition, { + type: 'image', + attrs: { src: result.url, alt: result.alt }, + }) + .run() + position = owner.state.selection.to + } catch { + position = owner.state.doc.content.size + } + } + } /** * The original value verbatim, plus its canonical serialization. The editor only ever emits canonical @@ -104,17 +210,38 @@ function LoadedRichMarkdownField({ extensions, editable: !disabled && !isStreaming, enablePasteRules: false, - autofocus: autoFocus ? 'end' : false, + autofocus: autoFocusAt ? false : autoFocus ? 'end' : false, immediatelyRender: false, shouldRerenderOnTransaction: false, content: initialContent, editorProps: { attributes: { - class: 'rich-markdown-prose rich-markdown-field-prose', + /* + * `rich-markdown-nodes` carries the editor's node chrome and is not the + * host's to own, so both surfaces take it. The prose classes pin the + * field's ink and type ramp — `--text-primary` at 15px/25px, then + * 14px/22px from the field layer — which a bare host supplies itself + * (the Note card matches its rendered view via `proseClassName`), so on + * that surface they would recolor the text and shift its metrics the + * moment editing opens. + */ + class: cn( + 'rich-markdown-nodes', + isBare ? editorClassName : 'rich-markdown-prose rich-markdown-field-prose' + ), // Claim ⌘K so the bubble-menu link editor wins over the global search palette. 'data-owned-shortcuts': 'Mod+K', }, - handlePaste: (_view, event) => { + handlePaste: (view, event) => { + const images = uploadImageRef.current ? extractImageFiles(event.clipboardData) : [] + /* Copying an image already in the document puts its file on the clipboard too. Let the + html through instead of uploading a second copy of something already hosted. */ + const clipboardHtml = event.clipboardData?.getData('text/html') ?? '' + if (images.length > 0 && !shouldSkipFileUpload(images, clipboardHtml, isInlineRouteSrc)) { + event.preventDefault() + void insertImagesRef.current(images, view.state.selection.from) + return true + } const handler = onPasteTextRef.current if (!handler) return false const text = event.clipboardData?.getData('text/plain') @@ -122,17 +249,37 @@ function LoadedRichMarkdownField({ return handler(text) }, /** - * The field has no image upload; swallow any file drop so the browser doesn't navigate to the - * dropped file and tear down the modal. Internal text drags carry no files and fall through. + * Mirrors the file editor's order: reposition an image dragged from inside the document, then + * bail on a same-page copy of an already-hosted one so ProseMirror inserts it from the html + * instead of uploading a duplicate, then upload anything genuinely new. Any remaining file drop + * is swallowed so the browser doesn't navigate to it and tear down the host; internal text drags + * carry no files and fall through. */ - handleDrop: (_view, event) => { + handleDrop: (view, event) => { + const html = event.dataTransfer?.getData('text/html') ?? '' + const images = uploadImageRef.current ? extractImageFiles(event.dataTransfer) : [] + if (moveDraggedImageNode(view, event, { images, html })) return true + if (shouldSkipFileUpload(images, html, isInlineRouteSrc)) return false if (event.dataTransfer?.files.length) { event.preventDefault() + if (images.length > 0) { + const dropPos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos + void insertImagesRef.current(images, dropPos ?? view.state.selection.from) + } return true } return false }, }, + /* Resolved after creation, not via `autofocus`: mapping a point to a + document position needs the editor's DOM laid out. */ + onCreate: ({ editor }) => { + editorInstanceRef.current = editor + const point = autoFocusAtRef.current + if (!point) return + const resolved = editor.view.posAtCoords({ left: point.clientX, top: point.clientY }) + editor.commands.focus(resolved ? resolved.pos : 'end') + }, onUpdate: ({ editor }) => { const md = postProcessSerializedMarkdown(editor.getMarkdown()) lastSyncedBodyRef.current = md @@ -176,6 +323,22 @@ function LoadedRichMarkdownField({ if (editor.isEditable !== !disabled) editor.setEditable(!disabled) }, [editor, value, isStreaming, disabled]) + /** + * Wires the `/Image` slash command to the hidden picker, but only for a host that gave us an + * uploader — the command hides itself when this storage slot stays null, which is what keeps it + * out of the modal field editors that have nowhere to put an image. + */ + useEffect(() => { + if (!editor || !uploadImage) return + editor.storage.slashCommand.insertImage = (at: number) => { + pendingImagePosRef.current = at + imageInputRef.current?.click() + } + return () => { + editor.storage.slashCommand.insertImage = null + } + }, [editor, uploadImage]) + useEditorMentions(editor, workspaceId, { disableTagging }) return ( @@ -184,25 +347,59 @@ function LoadedRichMarkdownField({ className={cn( // `relative` makes this the positioning context for the bubble menu, which is // appended here and absolutely positioned so it tracks the selection through scroll. - 'relative flex flex-col px-3 py-2', + 'relative flex flex-col', // Only a capped box scrolls itself. Uncapped, the box grows and the page // scrolls — making it a scroll container anyway would clip the bubble // menu against an edge that never moves. maxHeight !== undefined && 'overflow-y-auto', - chipFieldSurfaceClass, - error && 'border-[var(--text-error)]', - !disabled && !isStreaming && 'cursor-text', - // Match the chip fields' disabled chrome (dimmed, not copyable) while - // keeping the container scrollable; streaming stays full-strength. - disabled && !isStreaming && 'select-none opacity-50' + !isBare && [ + 'px-3 py-2', + chipFieldSurfaceClass, + error && 'border-[var(--text-error)]', + !disabled && !isStreaming && 'cursor-text', + // Match the chip fields' disabled chrome (dimmed, not copyable) while + // keeping the container scrollable; streaming stays full-strength. + disabled && !isStreaming && 'select-none opacity-50', + ], + isBare && 'min-h-full w-full' )} - style={{ minHeight, maxHeight }} + style={{ minHeight: boxMinHeight, maxHeight }} > - {editor && } + {editor && ( + + )} {editor && } + {uploadImage && ( + { + const input = event.currentTarget + const images = Array.from(input.files ?? []).filter((file) => + file.type.startsWith('image/') + ) + const at = + pendingImagePosRef.current ?? editorInstanceRef.current?.state.selection.from ?? 0 + pendingImagePosRef.current = null + input.value = '' + if (images.length > 0) void insertImagesRef.current(images, at) + }} + /> + )} ) @@ -220,14 +417,18 @@ function RawMarkdownField({ placeholder, disabled = false, isStreaming = false, - minHeight = 140, + minHeight, maxHeight, error = false, onPasteText, + surface = 'field', + proseClassName, }: RichMarkdownFieldProps) { // Disabled-look without the `disabled` attribute — a disabled textarea is // inert to wheel/scrollbar, but locked content must stay scrollable. const lockedView = disabled && !isStreaming + const isBare = surface === 'bare' + const boxMinHeight = minHeight ?? (isBare ? undefined : 140) /** * Uncapped, the textarea grows with its content so it matches the WYSIWYG @@ -244,7 +445,7 @@ function RawMarkdownField({ const measure = () => { el.style.height = 'auto' - el.style.height = `${Math.max(el.scrollHeight, minHeight)}px` + el.style.height = `${Math.max(el.scrollHeight, boxMinHeight ?? 0)}px` } measure() @@ -254,24 +455,51 @@ function RawMarkdownField({ const observer = new ResizeObserver(measure) observer.observe(el) return () => observer.disconnect() - }, [autoGrow, value, minHeight]) + }, [autoGrow, value, boxMinHeight]) + + const handlePaste = (event: React.ClipboardEvent) => { + const text = event.clipboardData.getData('text/plain') + if (text && onPasteText?.(text)) event.preventDefault() + } + + /* A bare host paints its own surface, so the raw fallback is a plain + textarea inheriting the host's colour — a chip field nested inside a Note + card would draw a second, conflicting surface. */ + if (isBare) { + return ( +