-
Notifications
You must be signed in to change notification settings - Fork 6
ENG-1477: Convert a tldraw arrow to a DG relation #1082
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
08c8fb2
0a799cc
42cce45
dc41755
5015301
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,6 @@ | ||
| import React, { useCallback, useEffect, useMemo, useRef } from "react"; | ||
| import { TLShapeId, useEditor, DefaultColorThemePalette } from "tldraw"; | ||
| import { getRelationColor } from "~/components/canvas/DiscourseRelationShape/DiscourseRelationUtil"; | ||
| import { | ||
| checkConnectionType, | ||
| getAllRelations, | ||
| isDiscourseNodeShape, | ||
| } from "~/components/canvas/canvasUtils"; | ||
| import { TLShapeId, useEditor } from "tldraw"; | ||
| import { getValidRelationTypesBetween } from "./relationCreation"; | ||
|
|
||
| type RelationTypeDropdownProps = { | ||
| sourceId: TLShapeId; | ||
|
|
@@ -25,49 +20,10 @@ export const RelationTypeDropdown = ({ | |
| const editor = useEditor(); | ||
| const dropdownRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| // Get valid relation types based on source/target node types | ||
| const validRelationTypes = useMemo(() => { | ||
| const startNode = editor.getShape(sourceId); | ||
| const endNode = editor.getShape(targetId); | ||
| if (!startNode || !endNode) return []; | ||
|
|
||
| const startNodeType = startNode.type; | ||
| const endNodeType = endNode.type; | ||
|
|
||
| // Verify both are discourse nodes | ||
| if ( | ||
| !isDiscourseNodeShape(editor, startNode) || | ||
| !isDiscourseNodeShape(editor, endNode) | ||
| ) | ||
| return []; | ||
|
|
||
| const colorPalette = DefaultColorThemePalette.lightMode; | ||
| const validTypes: { id: string; label: string; color: string }[] = []; | ||
| const allRelations = getAllRelations(); | ||
| const seenLabels = new Set<string>(); | ||
|
|
||
| for (const relation of allRelations) { | ||
| const { isDirect: isForward, isReverse } = checkConnectionType( | ||
| relation, | ||
| startNodeType, | ||
| endNodeType, | ||
| ); | ||
|
|
||
| if (!isForward && !isReverse) continue; | ||
|
|
||
| const label = | ||
| isReverse && relation.complement ? relation.complement : relation.label; | ||
|
|
||
| if (!seenLabels.has(label)) { | ||
| seenLabels.add(label); | ||
| const tldrawColor = getRelationColor(relation.label); | ||
| const hexColor = colorPalette[tldrawColor]?.solid ?? "#333"; | ||
| validTypes.push({ id: relation.id, label, color: hexColor }); | ||
| } | ||
| } | ||
|
|
||
| return validTypes; | ||
| }, [editor, sourceId, targetId]); | ||
| const validRelationTypes = useMemo( | ||
| () => getValidRelationTypesBetween(editor, sourceId, targetId), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This keeps the dropdown's relation filtering behavior the same, but reuses the same valid-relation lookup that the new arrow context menu needs. |
||
| [editor, sourceId, targetId], | ||
| ); | ||
|
|
||
| // Handle click outside | ||
| useEffect(() => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| import { | ||
| DefaultColorThemePalette, | ||
| Editor, | ||
| TLShapeId, | ||
| createShapeId, | ||
| } from "tldraw"; | ||
| import { | ||
| BaseDiscourseRelationUtil, | ||
| DiscourseRelationShape, | ||
| getRelationColor, | ||
| } from "~/components/canvas/DiscourseRelationShape/DiscourseRelationUtil"; | ||
| import { createOrUpdateArrowBinding } from "~/components/canvas/DiscourseRelationShape/helpers"; | ||
| import { | ||
| checkConnectionType, | ||
| getAllRelations, | ||
| isDiscourseNodeShape, | ||
| } from "~/components/canvas/canvasUtils"; | ||
| import type { DiscourseRelation } from "~/utils/getDiscourseRelations"; | ||
|
|
||
| type RelationTypeOption = { id: string; label: string; color: string }; | ||
|
|
||
| type DirectionalRelation = Pick< | ||
| DiscourseRelation, | ||
| "label" | "complement" | "source" | "destination" | ||
| >; | ||
|
|
||
| export const getDirectionalRelationLabel = ({ | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The directional label/complement rule is shared because it is the same question in the dropdown, default creation, and arrow conversion paths. This keeps label selection single-source without merging the different shape-creation lifecycles. |
||
| relation, | ||
| sourceNodeType, | ||
| targetNodeType, | ||
| }: { | ||
| relation: DirectionalRelation; | ||
| sourceNodeType: string; | ||
| targetNodeType: string; | ||
| }): string => { | ||
| const { isReverse } = checkConnectionType( | ||
| relation, | ||
| sourceNodeType, | ||
| targetNodeType, | ||
| ); | ||
| return isReverse && relation.complement | ||
| ? relation.complement | ||
| : relation.label; | ||
| }; | ||
|
|
||
| export const persistRelationArrow = async ({ | ||
| editor, | ||
| arrow, | ||
| targetId, | ||
| }: { | ||
| editor: Editor; | ||
| arrow: DiscourseRelationShape; | ||
| targetId: TLShapeId; | ||
| }): Promise<void> => { | ||
| const util = editor.getShapeUtil(arrow); | ||
| if ( | ||
| util instanceof BaseDiscourseRelationUtil && | ||
| "handleCreateRelationsInRoam" in util | ||
| ) { | ||
| type UtilWithRoamPersistence = BaseDiscourseRelationUtil & { | ||
| handleCreateRelationsInRoam: (args: { | ||
| arrow: DiscourseRelationShape; | ||
| targetId: TLShapeId; | ||
| }) => Promise<void>; | ||
| }; | ||
| await (util as UtilWithRoamPersistence).handleCreateRelationsInRoam({ | ||
| arrow, | ||
| targetId, | ||
| }); | ||
| } | ||
| }; | ||
|
|
||
| export const getValidRelationTypesBetween = ( | ||
| editor: Editor, | ||
| startId: TLShapeId, | ||
| endId: TLShapeId, | ||
| ): RelationTypeOption[] => { | ||
| const startNode = editor.getShape(startId); | ||
| const endNode = editor.getShape(endId); | ||
| if (!startNode || !endNode) return []; | ||
| if ( | ||
| !isDiscourseNodeShape(editor, startNode) || | ||
| !isDiscourseNodeShape(editor, endNode) | ||
| ) | ||
| return []; | ||
|
|
||
| const colorPalette = DefaultColorThemePalette.lightMode; | ||
| const validTypes: RelationTypeOption[] = []; | ||
| const seenLabels = new Set<string>(); | ||
|
|
||
| for (const relation of getAllRelations()) { | ||
| const { isDirect, isReverse } = checkConnectionType( | ||
| relation, | ||
| startNode.type, | ||
| endNode.type, | ||
| ); | ||
| if (!isDirect && !isReverse) continue; | ||
|
|
||
| const label = getDirectionalRelationLabel({ | ||
| relation, | ||
| sourceNodeType: startNode.type, | ||
| targetNodeType: endNode.type, | ||
| }); | ||
| if (seenLabels.has(label)) continue; | ||
| seenLabels.add(label); | ||
|
|
||
| const hexColor = | ||
| colorPalette[getRelationColor(relation.label)]?.solid ?? "#333"; | ||
| validTypes.push({ id: relation.id, label, color: hexColor }); | ||
| } | ||
|
|
||
| return validTypes; | ||
| }; | ||
|
|
||
| export const createDefaultRelationBetweenNodes = async ({ | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is intentionally the default/fresh relation creation path. It keeps the drag-handle behavior centered and default-styled; existing-arrow conversion lives separately because that flow has a different lifecycle and rollback contract. |
||
| editor, | ||
| relationId, | ||
| sourceId, | ||
| targetId, | ||
| }: { | ||
| editor: Editor; | ||
| relationId: string; | ||
| sourceId: TLShapeId; | ||
| targetId: TLShapeId; | ||
| }): Promise<TLShapeId | null> => { | ||
| const selectedRelation = getAllRelations().find((r) => r.id === relationId); | ||
| if (!selectedRelation) return null; | ||
|
|
||
| const sourceNode = editor.getShape(sourceId); | ||
| const targetNode = editor.getShape(targetId); | ||
| if (!sourceNode || !targetNode) return null; | ||
| const label = getDirectionalRelationLabel({ | ||
| relation: selectedRelation, | ||
| sourceNodeType: sourceNode.type, | ||
| targetNodeType: targetNode.type, | ||
| }); | ||
|
|
||
| const sourceBounds = editor.getShapePageBounds(sourceId); | ||
| if (!sourceBounds) return null; | ||
|
|
||
| const arrowId = createShapeId(); | ||
| editor.createShape<DiscourseRelationShape>({ | ||
| id: arrowId, | ||
| type: relationId, | ||
| x: sourceBounds.midX, | ||
| y: sourceBounds.midY, | ||
| props: { | ||
| color: getRelationColor(selectedRelation.label), | ||
| text: label, | ||
| dash: "draw", | ||
| size: "m", | ||
| fill: "none", | ||
| bend: 0, | ||
| start: { x: 0, y: 0 }, | ||
| end: { x: 0, y: 0 }, | ||
| arrowheadStart: "none", | ||
| arrowheadEnd: "arrow", | ||
| labelPosition: 0.5, | ||
| font: "draw", | ||
| scale: 1, | ||
| }, | ||
| }); | ||
|
|
||
| const newArrow = editor.getShape<DiscourseRelationShape>(arrowId); | ||
| if (!newArrow) return null; | ||
|
|
||
| createOrUpdateArrowBinding(editor, newArrow, sourceId, { | ||
| terminal: "start", | ||
| normalizedAnchor: { x: 0.5, y: 0.5 }, | ||
| isPrecise: false, | ||
| isExact: false, | ||
| }); | ||
| createOrUpdateArrowBinding(editor, newArrow, targetId, { | ||
| terminal: "end", | ||
| normalizedAnchor: { x: 0.5, y: 0.5 }, | ||
| isPrecise: false, | ||
| isExact: false, | ||
| }); | ||
|
|
||
| editor.select(arrowId); | ||
|
|
||
| await persistRelationArrow({ editor, arrow: newArrow, targetId }); | ||
|
|
||
| // handleCreateRelationsInRoam deletes the new arrow if it rejects the | ||
| // conversion, so a surviving shape means the relation was persisted. | ||
| return editor.getShape(arrowId) ? arrowId : null; | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This replaces the old inline drag-handle relation creation block with the same default-create path in a named helper. The drag flow still creates a fresh relation arrow with default geometry and center bindings; conversion does not use this helper because it has to preserve an existing arrow's geometry.