diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 81d5946ff..87e80ea23 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,18 +12,24 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + version: 10.15.1 + run_install: false + - name: Setup Node.js environment uses: actions/setup-node@v4 with: node-version: 20 - cache: "npm" + cache: "pnpm" - name: Install Dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: Check TypeScript Types run: npx turbo check-types # TODO: Add future linting, testing, style checks, etc. # name: Lint - # run: npm run lint + # run: pnpm run lint diff --git a/.github/workflows/database-deploy.yaml b/.github/workflows/database-deploy.yaml index e9f7faa2b..25a26a429 100644 --- a/.github/workflows/database-deploy.yaml +++ b/.github/workflows/database-deploy.yaml @@ -12,12 +12,25 @@ jobs: SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID_PROD }} SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD_PROD }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.1 + run_install: false + + - uses: actions/setup-node@v4 with: node-version: "20" - - run: npm ci + cache: "pnpm" + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + - uses: supabase/setup-cli@v1 with: version: latest + - run: npx turbo deploy -F @repo/database diff --git a/.github/workflows/roam-main.yaml b/.github/workflows/roam-main.yaml index 7f76c23ee..724bf7a2a 100644 --- a/.github/workflows/roam-main.yaml +++ b/.github/workflows/roam-main.yaml @@ -2,14 +2,19 @@ name: Main - Roam To Blob Storage on: workflow_dispatch: push: - branches: main + branches: + - main paths: - "apps/roam/**" - "packages/tailwind-config/**" + - "packages/utils/**" + - "packages/database/**" - "packages/ui/**" env: BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} jobs: deploy: @@ -18,14 +23,20 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.1 + run_install: false + - name: Setup Node.js environment uses: actions/setup-node@v4 with: node-version: 20 - cache: "npm" + cache: "pnpm" - name: Install Dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: Deploy run: npx turbo run deploy --filter=roam diff --git a/.github/workflows/roam-pr.yaml b/.github/workflows/roam-pr.yaml index 364fe108f..a27bb6630 100644 --- a/.github/workflows/roam-pr.yaml +++ b/.github/workflows/roam-pr.yaml @@ -7,6 +7,8 @@ on: paths: - "apps/roam/**" - "packages/tailwind-config/**" + - "packages/utils/**" + - "packages/database/**" - "packages/ui/**" env: BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} @@ -22,14 +24,20 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.1 + run_install: false + - name: Setup Node.js environment uses: actions/setup-node@v4 with: node-version: 20 - cache: "npm" + cache: "pnpm" - name: Install Dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: Deploy run: npx turbo run deploy --filter=roam diff --git a/.github/workflows/roam-release.yaml b/.github/workflows/roam-release.yaml index 5920e6fba..f60ee004f 100644 --- a/.github/workflows/roam-release.yaml +++ b/.github/workflows/roam-release.yaml @@ -17,14 +17,20 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.1 + run_install: false + - name: Setup Node.js environment uses: actions/setup-node@v4 with: node-version: 20 - cache: "npm" + cache: "pnpm" - name: Install Dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: Update Roam Depot Extension run: npx turbo run publish --filter=roam diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 000000000..525f86d9d --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,11 @@ +{ + "lsp": { + "eslint": { + "settings": { + "experimental": { + "useFlatConfig": true + } + } + } + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71f5ea616..09093a233 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,3 +21,62 @@ Here’s how to contribute: 4. Write tests that validate your change and/or fix. 5. Push your branch and open a pull request. πŸš€ + +## Adding Documentation to the Website + +The Discourse Graphs website hosts documentation for plugins and general information. Here's how to add or edit documentation: + +### Blog Posts + +Blog posts are located in `/apps/website/app/(home)/blog/posts/` + +1. **Create your post file**: Copy `EXAMPLE.md` as a starting template and rename it to your desired URL slug (e.g., `my-new-post.md`) + +2. **Required metadata**: Every blog post must start with YAML frontmatter (reference `EXAMPLE.md` for the exact format): + + ```yaml + --- + title: "Your Post Title" + date: "YYYY-MM-DD" + author: "Author's name" + published: true # Set to true to make the post visible + --- + ``` + +3. **Content**: Write your content below the frontmatter using standard Markdown + +### Plugin Documentation + +Plugin documentation is organized in `/apps/website/app/(docs)/docs/` with separate folders: + +- `/obsidian/pages/` - Obsidian plugin documentation +- `/roam/pages/` - Roam Research extension documentation +- `/sharedPages/` - Documentation shared between platforms + +1. **Create your documentation file**: Add a new `.md` file in the appropriate platform's `pages/` folder +2. **Use standard Markdown**: No special frontmatter is required for documentation files +3. **Update navigation**: You may need to update the corresponding `navigation.ts` file to include your new page in the sidebar + +### Documentation Images + +All documentation images should be placed in `/apps/website/public/docs/[platform]/` following this structure: + +- **Platform-specific images**: `/public/docs/[platform]/` (e.g., `/public/docs/roam/`, `/public/docs/obsidian/`) +- **General documentation images**: `/public/docs/` + +When referencing images in your documentation, use relative paths from the public folder: + +```markdown +![Alt text](/docs/roam/my-image.png) +``` + +### Running the Website Locally + +To preview your changes locally: + +1. **Environment setup**: Copy `/apps/website/.env.example` to `/apps/website/.env` and configure any necessary environment variables +2. **Install dependencies**: Run `npm install` from the project root +3. **Start development server**: Run `npm run dev` or `npx turbo dev` to start the website locally +4. **View your changes**: Navigate to `http://localhost:3000` to see your documentation + +The website uses Next.js with the App Router, so changes to Markdown files should be reflected automatically during development. diff --git a/README.md b/README.md index f5977764c..78cfc6ee1 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ git clone https://github.com/DiscourseGraphs/discourse-graph.git ```bash cd discourse-graph -npm install +npm install -g pnpm@10 +pnpm install ``` 3. Run all applications in development mode: diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index 7628f59dc..683dd5deb 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -9,7 +9,7 @@ "build": "tsx scripts/build.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", - "publish": "tsx scripts/publish-obsidian.ts", + "publish": "tsx scripts/publish.ts --version 0.1.0", "check-types": "tsc --noEmit --skipLibCheck" }, "keywords": [], @@ -17,21 +17,32 @@ "license": "Apache-2.0", "devDependencies": { "@octokit/core": "^6.1.2", + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", "@types/node": "^20", + "@types/react": "catalog:obsidian", + "@types/react-dom": "catalog:obsidian", "autoprefixer": "^10.4.21", "builtin-modules": "3.3.0", "dotenv": "^16.4.5", "esbuild": "0.17.3", + "eslint": "catalog:", "obsidian": "^1.7.2", "postcss": "^8.5.3", "tailwindcss": "^3.4.17", "tslib": "2.5.1", "tsx": "^4.19.2", - "typescript": "5.5.4" + "typescript": "5.5.4", + "zod": "^3.24.1" }, "dependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0", - "tailwindcss-animate": "^1.0.7" + "date-fns": "^4.1.0", + "tldraw": "^3.14.2", + "nanoid": "^4.0.2", + "react": "catalog:obsidian", + "react-dom": "catalog:obsidian", + "date-fns": "^4.1.0", + "tailwindcss-animate": "^1.0.7", + "tldraw": "3.14.2" } } diff --git a/apps/obsidian/scripts/README.md b/apps/obsidian/scripts/README.md index d95218e38..43719e56b 100644 --- a/apps/obsidian/scripts/README.md +++ b/apps/obsidian/scripts/README.md @@ -9,7 +9,7 @@ This directory contains a script for publishing the Discourse Graph Obsidian plu ## Prerequisites -1. **Node.js and npm** - Ensure you have Node.js 18+ installed +1. **Node.js and pnpm** - Ensure you have Node.js 20+ installed, with pnpm@10 installed globally (`npm install -g pnpm`) 2. **Git** - For repository operations 3. **GitHub Token** - For authentication (see Authentication section) @@ -42,7 +42,7 @@ The script automatically determines release type based on version format and alw ### BRAT Version Priority BRAT uses alphabetical ordering for pre-release identifiers. This is why we enforce: -- **Internal**: `alpha-*` (comes first alphabetically) +- **Internal**: `alpha-*` (comes first alphabetically) - **External**: `beta-*` (comes after alpha, gets higher priority) This ensures that external beta releases always take precedence over internal alpha releases for BRAT auto-updates. @@ -53,7 +53,7 @@ The script automatically determines the release type based on version format: **πŸ§ͺ Pre-release** (automatic detection) - **Triggers**: Any version with `alpha` or `beta` suffixes -- **GitHub**: Marked as "Pre-release" +- **GitHub**: Marked as "Pre-release" - **Main Branch**: Not updated (keeps stable code in main) - **Use Case**: Testing, beta versions, internal releases @@ -122,7 +122,7 @@ tsx scripts/publish.ts --version 0.1.0-alpha-canvas-feature --release-name "Canv tsx scripts/publish.ts --version 0.1.0-canvas-feature ``` -### Beta Release for Public Testing +### Beta Release for Public Testing ```bash # βœ… Correct format with beta prefix - creates pre-release automatically tsx scripts/publish.ts --version 1.0.0-beta.1 --release-name "Beta: New Graph View" @@ -137,10 +137,10 @@ tsx scripts/publish.ts --version 1.0.0-test.1 tsx scripts/publish.ts --version 1.0.0 ``` -### Using npm script from obsidian directory +### Using pnpm script from obsidian directory ```bash cd apps/obsidian -npm run publish -- --version 1.0.0-beta.1 +pnpm run publish -- --version 1.0.0-beta.1 ``` ## What the Script Does @@ -148,7 +148,7 @@ npm run publish -- --version 1.0.0-beta.1 ### For All Releases: 1. **Validates input** - Checks version format and arguments 2. **Detects release type** - Internal vs External based on version format -3. **Builds the plugin** - Runs `npm run build` to create distribution files +3. **Builds the plugin** - Runs `pnpm run build` to create distribution files 4. **Copies source files** - Copies plugin source (excluding build artifacts) 5. **Creates GitHub release** - Always creates a release with: - Custom or default release name @@ -184,7 +184,7 @@ Publishes to: `DiscourseGraphs/discourse-graph-obsidian` ### Repository State by Release Type: **Internal Release**: Repository unchanged, GitHub pre-release created -**External Pre-release**: Repository unchanged, GitHub pre-release created +**External Pre-release**: Repository unchanged, GitHub pre-release created **External Stable**: Repository main branch updated + GitHub stable release created ## Troubleshooting @@ -195,8 +195,8 @@ Publishes to: `DiscourseGraphs/discourse-graph-obsidian` ```bash # ❌ Wrong tsx scripts/publish.ts - - # βœ… Correct + + # βœ… Correct tsx scripts/publish.ts --version 1.0.0 ``` @@ -204,7 +204,7 @@ Publishes to: `DiscourseGraphs/discourse-graph-obsidian` ```bash # ❌ Wrong tsx scripts/publish.ts --version "beta-1" - + # βœ… Correct tsx scripts/publish.ts --version 1.0.0-beta.1 ``` @@ -219,7 +219,7 @@ Publishes to: `DiscourseGraphs/discourse-graph-obsidian` - Verify repository exists and is accessible 5. **"Required build files missing"** - - Run `npm run build` manually to check for build errors + - Run `pnpm run build` manually to check for build errors - Ensure TypeScript compiles without errors 6. **BRAT picking wrong version** @@ -247,10 +247,10 @@ This is why the naming convention is critical for ensuring the right version get ### Key Functions: - `isExternalRelease()` - Determines if version is external (stable/beta) or internal (alpha) -- `updateMainBranch()` - Uses GitHub API to create proper commits +- `updateMainBranch()` - Uses GitHub API to create proper commits - `createGithubRelease()` - Creates releases with assets and automatic pre-release detection - `updateManifest()` - Updates version in manifest.json ### Security: - Uses GitHub API instead of git commands for better security -- Never commits tokens to repository \ No newline at end of file +- Never commits tokens to repository diff --git a/apps/obsidian/scripts/publish.ts b/apps/obsidian/scripts/publish.ts index 3acc8b5b4..781e29e7c 100644 --- a/apps/obsidian/scripts/publish.ts +++ b/apps/obsidian/scripts/publish.ts @@ -258,7 +258,7 @@ const copyDirectory = ({ const buildPlugin = async (dir: string): Promise => { log("Building plugin..."); - await execCommand("npm run build", { cwd: dir }); + await execCommand("pnpm run build", { cwd: dir }); const buildDir = path.join(dir, "dist"); const missingFiles = REQUIRED_BUILD_FILES.filter( diff --git a/apps/obsidian/src/components/BulkIdentifyDiscourseNodesModal.tsx b/apps/obsidian/src/components/BulkIdentifyDiscourseNodesModal.tsx index 73cb4c63e..1959b2670 100644 --- a/apps/obsidian/src/components/BulkIdentifyDiscourseNodesModal.tsx +++ b/apps/obsidian/src/components/BulkIdentifyDiscourseNodesModal.tsx @@ -5,6 +5,7 @@ import type DiscourseGraphPlugin from "../index"; import { BulkImportCandidate, BulkImportPattern } from "~/types"; import { QueryEngine } from "~/services/QueryEngine"; import { TFile } from "obsidian"; +import { getNodeTypeById } from "~/utils/utils"; type BulkImportModalProps = { plugin: DiscourseGraphPlugin; @@ -216,9 +217,7 @@ const BulkImportContent = ({ plugin, onClose }: BulkImportModalProps) => {
{patterns.map((pattern, index) => { - const nodeType = plugin.settings.nodeTypes.find( - (n) => n.id === pattern.nodeTypeId, - ); + const nodeType = getNodeTypeById(plugin, pattern.nodeTypeId); return (
{ return
Not a discourse node (no nodeTypeId)
; } - const nodeType = plugin.settings.nodeTypes.find( - (type) => type.id === frontmatter.nodeTypeId, - ); + const nodeType = getNodeTypeById(plugin, frontmatter.nodeTypeId); if (!nodeType) { return
Unknown node type: {frontmatter.nodeTypeId}
; diff --git a/apps/obsidian/src/components/GeneralSettings.tsx b/apps/obsidian/src/components/GeneralSettings.tsx index e44344ae1..36e14c859 100644 --- a/apps/obsidian/src/components/GeneralSettings.tsx +++ b/apps/obsidian/src/components/GeneralSettings.tsx @@ -69,6 +69,11 @@ const GeneralSettings = () => { const [nodesFolderPath, setNodesFolderPath] = useState( plugin.settings.nodesFolderPath, ); + const [canvasFolderPath, setCanvasFolderPath] = useState( + plugin.settings.canvasFolderPath, + ); + const [canvasAttachmentsFolderPath, setCanvasAttachmentsFolderPath] = + useState(plugin.settings.canvasAttachmentsFolderPath); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const handleToggleChange = (newValue: boolean) => { @@ -81,9 +86,24 @@ const GeneralSettings = () => { setHasUnsavedChanges(true); }, []); + const handleCanvasFolderPathChange = useCallback((newValue: string) => { + setCanvasFolderPath(newValue); + setHasUnsavedChanges(true); + }, []); + + const handleCanvasAttachmentsFolderPathChange = useCallback( + (newValue: string) => { + setCanvasAttachmentsFolderPath(newValue); + setHasUnsavedChanges(true); + }, + [], + ); + const handleSave = async () => { plugin.settings.showIdsInFrontmatter = showIdsInFrontmatter; plugin.settings.nodesFolderPath = nodesFolderPath; + plugin.settings.canvasFolderPath = canvasFolderPath; + plugin.settings.canvasAttachmentsFolderPath = canvasAttachmentsFolderPath; await plugin.saveSettings(); new Notice("General settings saved"); setHasUnsavedChanges(false); @@ -126,6 +146,42 @@ const GeneralSettings = () => {
+
+
+
Canvas folder path
+
+ Folder where new Discourse Graph canvases will be created. Default: + "Discourse Canvas". +
+
+
+ +
+
+ +
+
+
+ Canvas attachments folder path +
+
+ Folder where attachments for canvases are stored. Default: + "attachments". +
+
+
+ +
+
+
@@ -476,7 +480,7 @@ const NodeTypeSettings = () => { >
el && setIcon(el, "arrow-left")} + ref={(el) => (el && setIcon(el, "arrow-left")) || undefined} />

Edit Node Type

diff --git a/apps/obsidian/src/components/RelationshipSection.tsx b/apps/obsidian/src/components/RelationshipSection.tsx index bb71bc8a3..7c29408e1 100644 --- a/apps/obsidian/src/components/RelationshipSection.tsx +++ b/apps/obsidian/src/components/RelationshipSection.tsx @@ -5,6 +5,7 @@ import SearchBar from "./SearchBar"; import { DiscourseNode } from "~/types"; import DropdownSelect from "./DropdownSelect"; import { usePlugin } from "./PluginContext"; +import { getNodeTypeById } from "~/utils/utils"; type RelationTypeOption = { id: string; @@ -60,12 +61,7 @@ const AddRelationship = ({ activeFile }: RelationshipSectionProps) => { ); const compatibleNodeTypes = compatibleNodeTypeIds - .map((id) => { - const nodeType = plugin.settings.nodeTypes.find( - (type) => type.id === id, - ); - return nodeType; - }) + .map((id) => getNodeTypeById(plugin, id)) .filter(Boolean) as DiscourseNode[]; setCompatibleNodeTypes(compatibleNodeTypes); @@ -152,12 +148,12 @@ const AddRelationship = ({ activeFile }: RelationshipSectionProps) => { const nodeTypeIdsToSearch = compatibleNodeTypes.map((type) => type.id); const results = - await queryEngineRef.current?.searchCompatibleNodeByTitle( + await queryEngineRef.current.searchCompatibleNodeByTitle({ query, - nodeTypeIdsToSearch, + compatibleNodeTypeIds: nodeTypeIdsToSearch, activeFile, selectedRelationType, - ); + }); if (results.length === 0 && query.length >= 2) { setSearchError( diff --git a/apps/obsidian/src/components/RelationshipSettings.tsx b/apps/obsidian/src/components/RelationshipSettings.tsx index 7dc1bca7b..86f2a0a2b 100644 --- a/apps/obsidian/src/components/RelationshipSettings.tsx +++ b/apps/obsidian/src/components/RelationshipSettings.tsx @@ -1,12 +1,9 @@ import { useState } from "react"; -import { - DiscourseRelation, - DiscourseNode, - DiscourseRelationType, -} from "~/types"; +import { DiscourseRelation, DiscourseRelationType } from "~/types"; import { Notice } from "obsidian"; import { usePlugin } from "./PluginContext"; import { ConfirmationModal } from "./ConfirmationModal"; +import { getNodeTypeById } from "~/utils/utils"; const RelationshipSettings = () => { const plugin = usePlugin(); @@ -15,10 +12,6 @@ const RelationshipSettings = () => { >(() => plugin.settings.discourseRelations ?? []); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const findNodeById = (id: string): DiscourseNode | undefined => { - return plugin.settings.nodeTypes.find((node) => node.id === id); - }; - const findRelationTypeById = ( id: string, ): DiscourseRelationType | undefined => { @@ -72,8 +65,8 @@ const RelationshipSettings = () => { relation.destinationId && relation.relationshipTypeId ) { - const sourceNode = findNodeById(relation.sourceId); - const targetNode = findNodeById(relation.destinationId); + const sourceNode = getNodeTypeById(plugin, relation.sourceId); + const targetNode = getNodeTypeById(plugin, relation.destinationId); const relationType = findRelationTypeById(relation.relationshipTypeId); if (sourceNode && targetNode && relationType) { @@ -202,7 +195,7 @@ const RelationshipSettings = () => {
- {findNodeById(relation.sourceId)?.name || + {getNodeTypeById(plugin, relation.sourceId)?.name || "Unknown Node"}
@@ -224,8 +217,8 @@ const RelationshipSettings = () => {
- {findNodeById(relation.destinationId)?.name || - "Unknown Node"} + {getNodeTypeById(plugin, relation.destinationId) + ?.name || "Unknown Node"}
diff --git a/apps/obsidian/src/components/RelationshipTypeSettings.tsx b/apps/obsidian/src/components/RelationshipTypeSettings.tsx index e8e473795..24a20ecf3 100644 --- a/apps/obsidian/src/components/RelationshipTypeSettings.tsx +++ b/apps/obsidian/src/components/RelationshipTypeSettings.tsx @@ -12,15 +12,20 @@ const RelationshipTypeSettings = () => { ); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const handleRelationTypeChange = async ( + const handleRelationTypeChange = ( index: number, field: keyof DiscourseRelationType, value: string, - ): Promise => { + ): void => { const updatedRelationTypes = [...relationTypes]; if (!updatedRelationTypes[index]) { const newId = generateUid("rel"); - updatedRelationTypes[index] = { id: newId, label: "", complement: "" }; + updatedRelationTypes[index] = { + id: newId, + label: "", + complement: "", + color: "#000000", + }; } updatedRelationTypes[index][field] = value; @@ -37,6 +42,7 @@ const RelationshipTypeSettings = () => { id: newId, label: "", complement: "", + color: "#000000", }, ]; setRelationTypes(updatedRelationTypes); @@ -47,6 +53,7 @@ const RelationshipTypeSettings = () => { const relationType = relationTypes[index] || { label: "Unnamed", complement: "", + color: "#000000", }; const modal = new ConfirmationModal(plugin.app, { title: "Delete Relation Type", @@ -77,7 +84,7 @@ const RelationshipTypeSettings = () => { const handleSave = async (): Promise => { for (const relType of relationTypes) { - if (!relType.id || !relType.label || !relType.complement) { + if (!relType.id || !relType.label || !relType.complement || !relType.color) { new Notice("All fields are required for relation types."); return; } @@ -125,6 +132,15 @@ const RelationshipTypeSettings = () => { } className="flex-1" /> + + handleRelationTypeChange(index, "color", e.target.value) + } + className="w-12 h-8 rounded border" + title="Relation color" + /> + ); +}; + +type NodeTypeSummary = { + id: string; + name: string; + color?: string; +}; + +type NodeTypeButtonProps = { + nodeType: NodeTypeSummary; + handlers: { + handlePointerDown: (e: React.PointerEvent) => void; + handlePointerUp: (e: React.PointerEvent) => void; + }; + didDragRef: React.MutableRefObject; + onClickNoDrag: () => void; +}; + +const RelationTypeButton = ({ + relationType, + onClick, +}: { + relationType: { id: string; label: string; color: string }; + onClick: () => void; +}) => { + return ( + + ); +}; diff --git a/apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx b/apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx new file mode 100644 index 000000000..98e0a97c4 --- /dev/null +++ b/apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx @@ -0,0 +1,89 @@ +import { useCallback, useState } from "react"; +import { TFile } from "obsidian"; +import { createShapeId, Editor } from "tldraw"; +import DiscourseGraphPlugin from "~/index"; +import { QueryEngine } from "~/services/QueryEngine"; +import SearchBar from "~/components/SearchBar"; +import { addWikilinkBlockrefForFile } from "./stores/assetStore"; +import { getFrontmatterForFile } from "./shapes/discourseNodeShapeUtils"; + +export const ExistingNodeSearch = ({ + plugin, + canvasFile, + getEditor, + nodeTypeId, +}: { + plugin: DiscourseGraphPlugin; + canvasFile: TFile; + getEditor: () => Editor | null; + nodeTypeId?: string; +}) => { + const [engine] = useState(() => new QueryEngine(plugin.app)); + + const search = useCallback( + async (query: string) => { + return await engine.searchDiscourseNodesByTitle(query, nodeTypeId); + }, + [engine, nodeTypeId], + ); + + const getItemText = useCallback((file: TFile) => file.basename, []); + + const renderItem = useCallback((file: TFile, el: HTMLElement) => { + const wrapper = el.createEl("div", { + cls: "file-suggestion", + attr: { style: "display:flex; align-items:center; gap:8px;" }, + }); + wrapper.createEl("div", { text: "πŸ“„" }); + wrapper.createEl("div", { text: file.basename }); + }, []); + + const handleSelect = useCallback( + (file: TFile | null) => { + const editor = getEditor(); + if (!file || !editor) return; + void (async () => { + const pagePoint = editor.getViewportScreenCenter(); + try { + const src = await addWikilinkBlockrefForFile({ + app: plugin.app, + canvasFile, + linkedFile: file, + }); + const id = createShapeId(); + editor.createShape({ + id, + type: "discourse-node", + x: pagePoint.x - Math.random() * 100, + y: pagePoint.y - Math.random() * 100, + props: { + w: 200, + h: 100, + src, + title: file.basename, + nodeTypeId: getFrontmatterForFile(plugin.app, file)?.nodeTypeId, + }, + }); + editor.markHistoryStoppingPoint("add existing discourse node"); + editor.setSelectedShapes([id]); + } catch (error) { + console.error("Error in handleSelect:", error); + } + })(); + }, + [canvasFile, getEditor, plugin.app], + ); + + return ( +
+ + onSelect={handleSelect} + placeholder="Node search" + getItemText={getItemText} + renderItem={renderItem} + asyncSearch={search} + className="!bg-[var(--color-panel)] !text-[var(--color-text)]" + /> +
+ ); +}; diff --git a/apps/obsidian/src/components/canvas/TldrawView.tsx b/apps/obsidian/src/components/canvas/TldrawView.tsx new file mode 100644 index 000000000..51b4b1e82 --- /dev/null +++ b/apps/obsidian/src/components/canvas/TldrawView.tsx @@ -0,0 +1,247 @@ +import { TextFileView, TFile, WorkspaceLeaf } from "obsidian"; +import { VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; +import { Root, createRoot } from "react-dom/client"; +import { TldrawPreviewComponent } from "./TldrawViewComponent"; +import { TLStore } from "tldraw"; +import React from "react"; +import DiscourseGraphPlugin from "~/index"; +import { processInitialData, TLData } from "~/components/canvas/utils/tldraw"; +import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore"; +import { PluginProvider } from "../PluginContext"; + +export class TldrawView extends TextFileView { + plugin: DiscourseGraphPlugin; + private reactRoot?: Root; + private store: TLStore | null = null; + private assetStore: ObsidianTLAssetStore | null = null; + private onUnloadCallbacks: (() => void)[] = []; + + constructor(leaf: WorkspaceLeaf, plugin: DiscourseGraphPlugin) { + super(leaf); + this.plugin = plugin; + this.navigation = true; + } + + getViewType(): string { + return VIEW_TYPE_TLDRAW_DG_PREVIEW; + } + + getDisplayText(): string { + return this.file?.basename ?? "Discourse Graph Canvas Preview"; + } + + getViewData(): string { + return this.data; + } + + setViewData(data: string, _clear: boolean): void { + this.data = data; + } + + clear(): void { + this.data = ""; + } + + protected get tldrawContainer() { + return this.containerEl.children[1]; + } + + override onload(): void { + super.onload(); + this.contentEl.addClass("tldraw-view-content"); + this.addAction("file-text", "View as markdown", () => + this.leaf.setViewState({ + type: "markdown", + state: this.leaf.view.getState(), + }), + ); + } + + async onOpen() { + const container = this.tldrawContainer; + if (!container) return; + + container.empty(); + } + + async onLoadFile(file: TFile): Promise { + await super.onLoadFile(file); + + const fileData = await this.app.vault.read(file); + + const assetStore = new ObsidianTLAssetStore( + `tldraw-${encodeURIComponent(file.path)}`, + { + app: this.app, + file, + plugin: this.plugin, + }, + ); + const store = this.createStore(fileData, assetStore); + + if (!store) { + console.warn("No tldraw data found in file"); + return; + } + + this.assetStore = assetStore; + await this.setStore(store); + } + + private createStore( + fileData: string, + assetStore: ObsidianTLAssetStore, + ): TLStore | undefined { + try { + const match = fileData.match( + /```json !!!_START_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!([\s\S]*?)!!!_END_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!\n```/, + ); + + if (!match?.[1]) { + console.warn("No tldraw data found in file"); + return; + } + + const data = JSON.parse(match[1]) as TLData; + if (!data.raw) { + console.warn("Invalid tldraw data format - missing raw field"); + return; + } + + if (!this.file) { + console.warn("TldrawView not initialized: missing file"); + return; + } + + const { store } = processInitialData(data, assetStore, { + app: this.app, + canvasFile: this.file, + plugin: this.plugin, + }); + + return store; + } catch (e) { + console.error("Failed to create store from file data", e); + return; + } + } + + private assertInitialized(): void { + if (!this.file) throw new Error("TldrawView not initialized: missing file"); + if (!this.assetStore) + throw new Error("TldrawView not initialized: missing assetStore"); + if (!this.store) + throw new Error("TldrawView not initialized: missing store"); + } + + private createReactRoot(entryPoint: Element, store: TLStore) { + const root = createRoot(entryPoint); + if (!this.file) throw new Error("TldrawView not initialized: missing file"); + if (!this.assetStore) + throw new Error("TldrawView not initialized: missing assetStore"); + if (!this.store) + throw new Error("TldrawView not initialized: missing store"); + + if (!this.assetStore) { + console.warn("Asset store is not set"); + return; + } + + root.render( + + + + + , + ); + return root; + } + + protected async setStore(store: TLStore) { + if (this.store) { + try { + this.store.dispose(); + } catch (e) { + console.error("Failed to dispose old store", e); + } + } + + this.store = store; + if (this.tldrawContainer) { + await this.refreshView(); + } + } + + private async refreshView() { + if (!this.store) return; + + if (this.reactRoot) { + try { + const container = this.tldrawContainer; + if (container?.hasChildNodes()) { + this.reactRoot.unmount(); + } + } catch (e) { + console.error("Failed to unmount React root", e); + } + this.reactRoot = undefined; + } + + const container = this.tldrawContainer; + if (container) { + this.reactRoot = this.createReactRoot(container, this.store); + await new Promise((resolve) => setTimeout(resolve, 0)); // Wait for React to render + } + } + + registerOnUnloadFile(callback: () => void) { + this.onUnloadCallbacks.push(callback); + } + + async onUnloadFile(file: TFile): Promise { + const callbacks = [...this.onUnloadCallbacks]; + this.onUnloadCallbacks = []; + callbacks.forEach((cb) => cb()); + + if (this.assetStore) { + this.assetStore.dispose(); + this.assetStore = null; + } + + return super.onUnloadFile(file); + } + + async onClose() { + await super.onClose(); + + if (this.reactRoot) { + try { + const container = this.tldrawContainer; + if (container?.hasChildNodes()) { + this.reactRoot.unmount(); + } + } catch (e) { + console.error("Failed to unmount React root", e); + } + this.reactRoot = undefined; + } + + if (this.store) { + try { + this.store.dispose(); + } catch (e) { + console.error("Failed to dispose store", e); + } + this.store = null; + } + + if (this.assetStore) { + this.assetStore.dispose(); + this.assetStore = null; + } + } +} \ No newline at end of file diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx new file mode 100644 index 000000000..3e08499aa --- /dev/null +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -0,0 +1,380 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + defaultShapeUtils, + DefaultStylePanel, + DefaultToolbar, + DefaultToolbarContent, + ErrorBoundary, + Tldraw, + TldrawUiMenuItem, + TLStore, + Editor, + useIsToolSelected, + useTools, + defaultBindingUtils, + TLPointerEventInfo, +} from "tldraw"; +import "tldraw/tldraw.css"; +import { + getTLDataTemplate, + createRawTldrawFile, + getUpdatedMdContent, + TLData, + processInitialData, +} from "~/components/canvas/utils/tldraw"; +import { + DEFAULT_SAVE_DELAY, + TLDATA_DELIMITER_END, + TLDATA_DELIMITER_START, + WHITE_LOGO_SVG, +} from "~/constants"; +import { TFile } from "obsidian"; +import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore"; +import { createDiscourseNodeUtil } from "~/components/canvas/shapes/DiscourseNodeShape"; +import { DiscourseNodeTool } from "./DiscourseNodeTool"; +import { DiscourseToolPanel } from "./DiscourseToolPanel"; +import { usePlugin } from "~/components/PluginContext"; +import { createDiscourseRelationUtil } from "~/components/canvas/shapes/DiscourseRelationShape"; +import { DiscourseRelationTool } from "./DiscourseRelationTool"; +import { + DiscourseRelationBindingUtil, + BaseRelationBindingUtil, +} from "~/components/canvas/shapes/DiscourseRelationBinding"; +import ToastListener from "./ToastListener"; +import { RelationsOverlay } from "./overlays/RelationOverlay"; +import { DiscourseNodeShape } from "~/components/canvas/shapes/DiscourseNodeShape"; +import { + extractBlockRefId, + resolveLinkedTFileByBlockRef, +} from "~/components/canvas/stores/assetStore"; +import { showToast } from "~/components/canvas/utils/toastUtils"; + +type TldrawPreviewProps = { + store: TLStore; + file: TFile; + assetStore: ObsidianTLAssetStore; +}; + +// No longer needed - using tldraw's event system instead + +export const TldrawPreviewComponent = ({ + store, + file, + assetStore, +}: TldrawPreviewProps) => { + const containerRef = useRef(null); + const [currentStore, setCurrentStore] = useState(store); + const [isReady, setIsReady] = useState(false); + const isCreatingRelationRef = useRef(false); + const lastShiftClickRef = useRef(0); + const SHIFT_CLICK_DEBOUNCE_MS = 300; // Prevent double clicks within 300ms + const saveTimeoutRef = useRef(null); + const lastSavedDataRef = useRef(""); + const editorRef = useRef(null); + const plugin = usePlugin(); + + const customShapeUtils = [ + ...defaultShapeUtils, + createDiscourseNodeUtil({ + app: plugin.app, + canvasFile: file, + plugin, + }), + createDiscourseRelationUtil({ + app: plugin.app, + canvasFile: file, + plugin, + }), + ]; + + const customTools = [DiscourseNodeTool, DiscourseRelationTool]; + + const iconUrl = `data:image/svg+xml;utf8,${encodeURIComponent(WHITE_LOGO_SVG)}`; + + useEffect(() => { + const timer = setTimeout(() => { + setIsReady(true); + }, 250); + return () => clearTimeout(timer); + }, []); + + const saveChanges = useCallback(async () => { + const newData = getTLDataTemplate({ + pluginVersion: plugin.manifest.version, + tldrawFile: createRawTldrawFile(currentStore), + uuid: window.crypto.randomUUID(), + }); + const stringifiedData = JSON.stringify(newData, null, "\t"); + + if (stringifiedData === lastSavedDataRef.current) { + return; + } + + const currentContent = await plugin.app.vault.read(file); + if (!currentContent) { + console.error("Could not read file content"); + return; + } + + const updatedString = getUpdatedMdContent(currentContent, stringifiedData); + if (updatedString === currentContent) { + return; + } + + try { + await plugin.app.vault.modify(file, updatedString); + + const verifyContent = await plugin.app.vault.read(file); + const verifyMatch = verifyContent.match( + new RegExp( + `${TLDATA_DELIMITER_START}\\s*([\\s\\S]*?)\\s*${TLDATA_DELIMITER_END}`, + ), + ); + + if (!verifyMatch || verifyMatch[1]?.trim() !== stringifiedData.trim()) { + throw new Error("Failed to verify saved TLDraw data"); + } + + lastSavedDataRef.current = stringifiedData; + } catch (error) { + console.error("Error saving/verifying TLDraw data:", error); + // Reload the editor state from file since save failed + const fileContent = await plugin.app.vault.read(file); + const match = fileContent.match( + new RegExp( + `${TLDATA_DELIMITER_START}([\\s\\S]*?)${TLDATA_DELIMITER_END}`, + ), + ); + if (match?.[1]) { + const data = JSON.parse(match[1]) as TLData; + const { store: newStore } = processInitialData(data, assetStore, { + app: plugin.app, + canvasFile: file, + plugin, + }); + setCurrentStore(newStore); + } + } + }, [file, plugin, currentStore, assetStore]); + + useEffect(() => { + const unsubscribe = currentStore.listen( + () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + saveTimeoutRef.current = setTimeout( + () => void saveChanges(), + DEFAULT_SAVE_DELAY, + ); + }, + { source: "user", scope: "document" }, + ); + + return () => { + unsubscribe(); + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, [currentStore, saveChanges]); + + const handleMount = (editor: Editor) => { + editorRef.current = editor; + + editor.on("event", (event) => { + const e = event as TLPointerEventInfo; + if (e.type === "pointer" && e.name === "pointer_down") { + const currentTool = editor.getCurrentTool(); + const currentToolId = currentTool.id; + + if (currentToolId === "discourse-relation") { + isCreatingRelationRef.current = true; + } + } + + if (e.type === "pointer" && e.name === "pointer_up") { + if (isCreatingRelationRef.current) { + BaseRelationBindingUtil.checkAndReifyRelation(editor); + isCreatingRelationRef.current = false; + } + + if (e.shiftKey) { + const now = Date.now(); + + // Debounce to prevent double opening + if (now - lastShiftClickRef.current < SHIFT_CLICK_DEBOUNCE_MS) { + return; + } + lastShiftClickRef.current = now; + + const shape = editor.getShapeAtPoint( + editor.inputs.currentPagePoint, + ) as DiscourseNodeShape; + + if (!shape || shape.type !== "discourse-node") return; + + const selectedShapes = editor.getSelectedShapes(); + const selectedDiscourseNodes = selectedShapes.filter( + (s) => s.type === "discourse-node", + ); + + if (selectedDiscourseNodes.length > 1) { + return; + } + + const blockRefId = extractBlockRefId(shape.props.src ?? undefined); + if (!blockRefId) { + showToast({ + severity: "warning", + title: "Cannot open node", + description: "No valid block reference found", + }); + return; + } + + const canvasFileCache = plugin.app.metadataCache.getFileCache(file); + if (!canvasFileCache) { + showToast({ + severity: "error", + title: "Error", + description: "Could not read canvas file", + }); + return; + } + + void resolveLinkedTFileByBlockRef({ + app: plugin.app, + canvasFile: file, + blockRefId, + canvasFileCache, + }) + .then((linkedFile) => { + if (!linkedFile) { + showToast({ + severity: "warning", + title: "Cannot open node", + description: "Linked file not found", + }); + return; + } + + void plugin.app.workspace.openLinkText( + linkedFile.path, + file.path, + true, + ); + + editor.selectNone(); + }) + .catch((error) => { + console.error("Error opening linked file:", error); + showToast({ + severity: "error", + title: "Error", + description: "Failed to open linked file", + }); + }); + } + } + }); + }; + + return ( +
+ {isReady ? ( + ( +
Error in Tldraw component: {JSON.stringify(error)}
+ )} + > + { + tools["discourse-node"] = { + id: "discourse-node", + label: "Discourse Node", + readonlyOk: false, + icon: "discourseNodeIcon", + onSelect: () => { + editor.setCurrentTool("discourse-node"); + }, + }; + tools["discourse-relation"] = { + id: "discourse-relation", + label: "Discourse Relation", + readonlyOk: false, + icon: "tool-arrow", + onSelect: () => { + editor.setCurrentTool("discourse-relation"); + }, + }; + return tools; + }, + }} + components={{ + StylePanel: () => { + const tools = useTools(); + const isDiscourseNodeSelected = useIsToolSelected( + tools["discourse-node"], + ); + const isDiscourseRelationSelected = useIsToolSelected( + tools["discourse-relation"], + ); + + if (!isDiscourseNodeSelected && !isDiscourseRelationSelected) { + return ; + } + + return ; + }, + OnTheCanvas: () => , + Toolbar: (props) => { + const tools = useTools(); + const isDiscourseNodeSelected = useIsToolSelected( + tools["discourse-node"], + ); + return ( + + { + if (editorRef.current) { + editorRef.current.setCurrentTool("discourse-node"); + } + }} + isSelected={isDiscourseNodeSelected} + /> + + + ); + }, + InFrontOfTheCanvas: () => ( + + ), + }} + /> +
+ ) : ( +
Loading Tldraw...
+ )} +
+ ); +}; diff --git a/apps/obsidian/src/components/canvas/ToastListener.tsx b/apps/obsidian/src/components/canvas/ToastListener.tsx new file mode 100644 index 000000000..fd4b85e46 --- /dev/null +++ b/apps/obsidian/src/components/canvas/ToastListener.tsx @@ -0,0 +1,49 @@ +import { useEffect } from "react"; +import { useToasts, TLUiToast } from "tldraw"; + +export const dispatchToastEvent = (toast: TLUiToast) => { + document.dispatchEvent( + new CustomEvent("show-toast", { detail: toast }), + ); +}; + +const ToastListener = () => { + // this warning comes from the useToasts hook + // eslint-disable-next-line @typescript-eslint/unbound-method + const { addToast } = useToasts(); + + useEffect(() => { + const handleToastEvent = ((event: CustomEvent) => { + const { + id, + icon, + title, + description, + actions, + keepOpen, + closeLabel, + severity, + } = event.detail; + addToast({ + id, + icon, + title, + description, + actions, + keepOpen, + closeLabel, + severity, + }); + }) as EventListener; + + document.addEventListener("show-toast", handleToastEvent); + + return () => { + document.removeEventListener("show-toast", handleToastEvent); + }; + }, [addToast]); + + return null; +}; + +export default ToastListener; diff --git a/apps/obsidian/src/components/canvas/overlays/RelationOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/RelationOverlay.tsx new file mode 100644 index 000000000..048e46b91 --- /dev/null +++ b/apps/obsidian/src/components/canvas/overlays/RelationOverlay.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import { TFile } from "obsidian"; +import { useEditor, useValue } from "tldraw"; +import DiscourseGraphPlugin from "~/index"; +import { DiscourseNodeShape } from "~/components/canvas/shapes/DiscourseNodeShape"; +import { RelationsPanel } from "~/components/canvas/overlays/RelationPanel"; + +type RelationsOverlayProps = { + plugin: DiscourseGraphPlugin; + file: TFile; +}; + +export const RelationsOverlay = ({ plugin, file }: RelationsOverlayProps) => { + const editor = useEditor(); + const [isOpen, setIsOpen] = useState(false); + + // Currently selected discourse-node shape (first one found) + const selectedNode = useValue( + "selectedDiscourseNode", + () => { + const shape = editor.getOnlySelectedShape(); + if (shape && shape.type === "discourse-node") { + return shape as DiscourseNodeShape; + } + return null; + }, + [editor], + ); + + // Close the panel if selection is cleared or not a discourse-node + useEffect(() => { + if (!selectedNode) setIsOpen(false); + }, [selectedNode]); + + // Compute viewport position for the floating button (center-top of selection) + const buttonPosition = useValue<{ left: number; top: number } | null>( + "relationsButtonPosition", + () => { + if (!selectedNode) return null; + const bounds = editor.getSelectionRotatedPageBounds(); + if (!bounds) return null; + + const topLeft = editor.pageToViewport({ x: bounds.minX, y: bounds.minY }); + const topRight = editor.pageToViewport({ + x: bounds.maxX, + y: bounds.minY, + }); + const width = topRight.x - topLeft.x; + const left = topLeft.x + width / 2; + const top = topLeft.y - 8; // a bit above the shape + return { left, top }; + }, + [editor, selectedNode?.id], + ); + + const handleOpen = () => setIsOpen(true); + const handleClose = () => setIsOpen(false); + + const showButton = !!selectedNode && !!buttonPosition && !isOpen; + + return ( +
+ {showButton && ( + + )} + + {isOpen && selectedNode && ( +
e.stopPropagation()} + onMouseUp={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + +
+ )} +
+ ); +}; diff --git a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx new file mode 100644 index 000000000..7ab946fa7 --- /dev/null +++ b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx @@ -0,0 +1,544 @@ +import { useEffect, useMemo, useState } from "react"; +import type { TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import { DiscourseNodeShape } from "~/components/canvas/shapes/DiscourseNodeShape"; +import { + ensureBlockRefForFile, + resolveLinkedFileFromSrc, + extractBlockRefId, +} from "~/components/canvas/stores/assetStore"; +import { TLShapeId, createShapeId, useEditor } from "tldraw"; +import { DiscourseRelationShape } from "~/components/canvas/shapes/DiscourseRelationShape"; +import { + createOrUpdateArrowBinding, + getArrowBindings, +} from "~/components/canvas/utils/relationUtils"; +import { getFrontmatterForFile } from "~/components/canvas/shapes/discourseNodeShapeUtils"; +import { getRelationTypeById } from "~/utils/utils"; +import { showToast } from "~/components/canvas/utils/toastUtils"; + +type GroupedRelation = { + key: string; + label: string; + isSource: boolean; + relationTypeId: string; + linkedFiles: TFile[]; +}; + +type RelationFileItemProps = { + file: TFile; + group: GroupedRelation; + checkExistingRelation: ( + targetFile: TFile, + relationTypeId: string, + ) => Promise; + handleCreateRelationTo: ( + targetFile: TFile, + relationTypeId: string, + isSource: boolean, + ) => Promise; + handleDeleteRelation: ( + targetFile: TFile, + relationTypeId: string, + ) => Promise; +}; + +export type RelationsPanelProps = { + plugin: DiscourseGraphPlugin; + canvasFile: TFile; + nodeShape: DiscourseNodeShape; + onClose: () => void; +}; + +const RelationFileItem = ({ + file, + group, + checkExistingRelation, + handleCreateRelationTo, + handleDeleteRelation, +}: RelationFileItemProps) => { + const [hasExistingRelation, setHasExistingRelation] = useState< + boolean | null + >(null); + const [isLoading, setIsLoading] = useState(false); + + // Check if relation exists when component mounts + useEffect(() => { + const checkRelation = async () => { + try { + const existingRelation = await checkExistingRelation( + file, + group.relationTypeId, + ); + setHasExistingRelation(!!existingRelation); + } catch (e) { + console.error("Failed to check existing relation", e); + setHasExistingRelation(false); + } + }; + void checkRelation(); + }, [file, group.relationTypeId, checkExistingRelation]); + + const handleButtonClick = async (e: React.MouseEvent) => { + e.preventDefault(); + if (isLoading) return; + + setIsLoading(true); + try { + if (hasExistingRelation) { + await handleDeleteRelation(file, group.relationTypeId); + setHasExistingRelation(false); + } else { + await handleCreateRelationTo( + file, + group.relationTypeId, + group.isSource, + ); + setHasExistingRelation(true); + } + } catch (e) { + showToast({ + severity: "error", + title: "Failed to Handle Relation Action", + description: "Could not handle relation action", + }); + } finally { + setIsLoading(false); + } + }; + + const getButtonProps = () => { + if (hasExistingRelation === null) { + return { + className: + "ml-2 rounded bg-gray-300 px-2 py-0.5 text-xs text-white cursor-not-allowed", + title: "Checking relation status...", + disabled: true, + children: "?", + }; + } + + if (hasExistingRelation) { + return { + className: + "ml-2 rounded bg-red-500 px-2 py-0.5 text-xs text-white hover:bg-red-600 disabled:bg-red-300", + title: "Remove this relation from canvas", + disabled: isLoading, + children: "βˆ’", + }; + } + + return { + className: + "ml-2 rounded bg-blue-500 px-2 py-0.5 text-xs text-white hover:bg-blue-600 disabled:bg-blue-300", + title: "Add this relation to canvas", + disabled: isLoading, + children: "+", + }; + }; + + const buttonProps = getButtonProps(); + + return ( +
  • + + {file.basename} + +
  • + ); +}; + +export const RelationsPanel = ({ + plugin, + canvasFile, + nodeShape, + onClose, +}: RelationsPanelProps) => { + const editor = useEditor(); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Resolve the file from the shape's src + useEffect(() => { + const load = async () => { + setLoading(true); + setError(null); + try { + const src = nodeShape.props.src ?? undefined; + if (!src) { + setGroups([]); + setError("This node is not linked to a file."); + return; + } + const file = await resolveLinkedFileFromSrc({ + app: plugin.app, + canvasFile, + src, + }); + if (!file) { + setGroups([]); + setError("Linked file not found."); + return; + } + const g = computeRelations(plugin, file); + setGroups(g); + } catch (e) { + showToast({ + severity: "error", + title: "Failed to Load Relations", + description: "Could not load relations", + }); + setError("Failed to load relations."); + } finally { + setLoading(false); + } + }; + void load(); + }, [plugin, canvasFile, nodeShape.id, nodeShape.props.src, editor]); + + const headerTitle = useMemo(() => { + return nodeShape.props.title || "Selected node"; + }, [nodeShape.props.title]); + + const ensureNodeShapeForFile = async ( + file: TFile, + ): Promise => { + // Try to find an existing node shape that points to this file via block ref + const blockRef = await ensureBlockRefForFile({ + app: plugin.app, + canvasFile, + targetFile: file, + }); + const shapes = editor.getCurrentPageShapes(); + const existing = shapes.find((s) => { + if (s.type !== "discourse-node") return false; + const src = (s as DiscourseNodeShape).props.src ?? ""; + return extractBlockRefId(src) === blockRef; + }) as DiscourseNodeShape | undefined; + + if (existing) return existing; + + // Create a new node shape near the selected node + const newId = createShapeId(); + const src = `asset:obsidian.blockref.${blockRef}`; + const x = nodeShape.x + nodeShape.props.w + 80; + const y = nodeShape.y; + + const nodeTypeId = getFrontmatterForFile(plugin.app, file) + ?.nodeTypeId as string; + + const created: DiscourseNodeShape = { + id: newId, + typeName: "shape", + type: "discourse-node", + x, + y, + rotation: 0, + index: editor.getHighestIndexForParent(editor.getCurrentPageId()), + parentId: editor.getCurrentPageId(), + isLocked: false, + opacity: 1, + meta: {}, + props: { + w: 200, + h: 100, + src, + title: file.basename, + nodeTypeId: nodeTypeId, + }, + }; + + editor.createShape(created); + return created; + }; + + // Check if a relation already exists between the selected node and a target file + const checkExistingRelation = async ( + targetFile: TFile, + relationTypeId: string, + ): Promise => { + try { + // Get all shapes on the canvas + const allShapes = editor.getCurrentPageShapes(); + + // Find the target node shape that corresponds to the file + const targetBlockRef = await ensureBlockRefForFile({ + app: plugin.app, + canvasFile, + targetFile, + }); + const targetNodeShape = allShapes.find((shape) => { + if (shape.type !== "discourse-node") return false; + const src = (shape as DiscourseNodeShape).props.src ?? ""; + return extractBlockRefId(src) === targetBlockRef; + }) as DiscourseNodeShape | undefined; + + if (!targetNodeShape) return null; + + // Find relation shapes that connect the selected node and target node + const relationShapes = allShapes.filter( + (shape) => + shape.type === "discourse-relation" && + (shape as DiscourseRelationShape).props.relationTypeId === + relationTypeId, + ) as DiscourseRelationShape[]; + + for (const relationShape of relationShapes) { + const bindings = getArrowBindings(editor, relationShape); + + // Check if this relation connects our two nodes in ANY direction + // The relation could exist as either: + // 1. selectedNode -> targetNode (forward direction) + // 2. targetNode -> selectedNode (reverse direction) + const isConnectedForward = + bindings.start?.toId === nodeShape.id && + bindings.end?.toId === targetNodeShape.id; + + const isConnectedReverse = + bindings.start?.toId === targetNodeShape.id && + bindings.end?.toId === nodeShape.id; + + if (isConnectedForward || isConnectedReverse) { + return relationShape; + } + } + + return null; + } catch (e) { + console.error("Failed to check existing relation", e); + return null; + } + }; + + const handleDeleteRelationShape = async ( + targetFile: TFile, + relationTypeId: string, + ) => { + try { + const existingRelation = await checkExistingRelation( + targetFile, + relationTypeId, + ); + if (existingRelation) { + editor.deleteShapes([existingRelation.id]); + } + } catch (e) { + showToast({ + severity: "error", + title: "Failed to Delete Relation", + description: "Could not delete relation", + }); + console.error("Failed to delete relation", e); + } + }; + + const handleCreateRelationTo = async ( + targetFile: TFile, + relationTypeId: string, + isSource: boolean, + ) => { + try { + const targetNode = await ensureNodeShapeForFile(targetFile); + const relationType = getRelationTypeById(plugin, relationTypeId); + const relationLabel = relationType?.label ?? ""; + + const id: TLShapeId = createShapeId(); + + // Determine source and destination nodes + const sourceNode = isSource ? nodeShape : targetNode; + const destNode = isSource ? targetNode : nodeShape; + + // Calculate connection points on the edges of the nodes + const sourcePoint = { + x: sourceNode.x + sourceNode.props.w, + y: sourceNode.y + sourceNode.props.h / 2, + }; + + // Position the relation shape at the source point + const shape: DiscourseRelationShape = { + id, + typeName: "shape", + type: "discourse-relation", + x: sourcePoint.x, + y: sourcePoint.y, + rotation: 0, + index: editor.getHighestIndexForParent(editor.getCurrentPageId()), + parentId: editor.getCurrentPageId(), + isLocked: false, + opacity: 1, + meta: {}, + props: { + // Use defaults from DiscourseRelationUtil.getDefaultProps() + dash: "draw", + size: "m", + fill: "none", + color: "black", + labelColor: "black", + bend: 0, + // Will be updated by bindings + start: { x: 0, y: 0 }, + end: { x: 100, y: 0 }, + arrowheadStart: "none", + arrowheadEnd: "arrow", + text: relationLabel, + labelPosition: 0.5, + font: "draw", + scale: 1, + kind: "arc", + elbowMidPoint: 0, + relationTypeId, + }, + }; + + editor.createShape(shape); + + // Create bindings using the proper utility function + // This follows the same pattern as DiscourseRelationTool and onHandleDrag + createOrUpdateArrowBinding(editor, shape, sourceNode.id, { + terminal: "start", + normalizedAnchor: { x: 1, y: 0.5 }, // Right edge of source node + isPrecise: false, + isExact: false, + snap: "none", + }); + + createOrUpdateArrowBinding(editor, shape, destNode.id, { + terminal: "end", + normalizedAnchor: { x: 0, y: 0.5 }, // Left edge of dest node + isPrecise: false, + isExact: false, + snap: "none", + }); + } catch (e) { + console.error("Failed to create relation to file", e); + showToast({ + severity: "error", + title: "Failed to Create Relation", + description: "Could not create relation to file", + }); + } + }; + + return ( +
    +
    +

    Relations

    + +
    + +
    +
    {headerTitle}
    +
    + + {loading ? ( +
    Loading relations...
    + ) : error ? ( +
    {error}
    + ) : groups.length === 0 ? ( +
    No relations found.
    + ) : ( +
      + {groups.map((group) => ( +
    • +
      + + {group.isSource ? "β†’" : "←"} + + {group.label} +
      + {group.linkedFiles.length === 0 ? ( +
      None
      + ) : ( +
        + {group.linkedFiles.map((f) => { + return ( + + ); + })} +
      + )} +
    • + ))} +
    + )} +
    + ); +}; + +const computeRelations = ( + plugin: DiscourseGraphPlugin, + file: TFile, +): GroupedRelation[] => { + const fileCache = plugin.app.metadataCache.getFileCache(file); + if (!fileCache?.frontmatter) return []; + + const activeNodeTypeId = fileCache.frontmatter.nodeTypeId as string; + if (!activeNodeTypeId) return []; + + const result = new Map(); + + for (const relationType of plugin.settings.relationTypes) { + const frontmatterLinks = fileCache.frontmatter[relationType.id] as unknown; + if (!frontmatterLinks) continue; + + const links = Array.isArray(frontmatterLinks) + ? (frontmatterLinks as unknown[]) + : [frontmatterLinks]; + + const relation = plugin.settings.discourseRelations.find( + (rel) => + (rel.sourceId === activeNodeTypeId || + rel.destinationId === activeNodeTypeId) && + rel.relationshipTypeId === relationType.id, + ); + if (!relation) continue; + + const isSource = relation.sourceId === activeNodeTypeId; + const label = isSource ? relationType.label : relationType.complement; + const key = `${relationType.id}-${isSource}`; + + if (!result.has(key)) { + result.set(key, { + key, + label, + isSource, + relationTypeId: relationType.id, + linkedFiles: [], + }); + } + + for (const link of links) { + const match = String(link).match(/\[\[(.*?)\]\]/); + if (!match) continue; + const linkedFileName = match[1] ?? ""; + const linked = plugin.app.metadataCache.getFirstLinkpathDest( + linkedFileName, + file.path, + ); + if (!linked) continue; + + const group = result.get(key); + if (group && !group.linkedFiles.some((f) => f.path === linked.path)) { + group.linkedFiles.push(linked); + } + } + } + + return Array.from(result.values()); +}; + diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx new file mode 100644 index 000000000..5682b33a8 --- /dev/null +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx @@ -0,0 +1,195 @@ +import { + BaseBoxShapeUtil, + HTMLContainer, + T, + TLBaseShape, + useEditor, +} from "tldraw"; +import type { App, TFile } from "obsidian"; +import { memo, createElement, useEffect } from "react"; +import DiscourseGraphPlugin from "~/index"; +import { + getFrontmatterForFile, + FrontmatterRecord, +} from "./discourseNodeShapeUtils"; +import { resolveLinkedFileFromSrc } from "~/components/canvas/stores/assetStore"; +import { getNodeTypeById } from "~/utils/utils"; + +export type DiscourseNodeShape = TLBaseShape< + "discourse-node", + { + w: number; + h: number; + // asset-style source: asset:obsidian.blockref. + src: string | null; + // Cached display data + title: string; + nodeTypeId: string; + } +>; + +export type DiscourseNodeUtilOptions = { + app: App; + plugin: DiscourseGraphPlugin; + canvasFile: TFile; +}; + +export class DiscourseNodeUtil extends BaseBoxShapeUtil { + static type = "discourse-node" as const; + declare options: DiscourseNodeUtilOptions; + + static props = { + w: T.number, + h: T.number, + src: T.string.nullable(), + title: T.string.optional(), + nodeTypeId: T.string.nullable().optional(), + }; + + getDefaultProps(): DiscourseNodeShape["props"] { + return { + w: 200, + h: 100, + src: null, + title: "", + nodeTypeId: "", + }; + } + + component(shape: DiscourseNodeShape) { + return ( + + {createElement(discourseNodeContent, { + shape, + app: this.options.app, + canvasFile: this.options.canvasFile, + plugin: this.options.plugin, + })} + + ); + } + + indicator(shape: DiscourseNodeShape) { + return ; + } + + getFile = async ( + shape: DiscourseNodeShape, + ctx: { app: App; canvasFile: TFile }, + ): Promise => { + const app = ctx?.app ?? this.options.app; + const canvasFile = ctx?.canvasFile ?? this.options.canvasFile; + return resolveLinkedFileFromSrc({ + app, + canvasFile, + src: shape.props.src ?? undefined, + }); + }; + + getFrontmatter = async ( + shape: DiscourseNodeShape, + ctx: { app: App; canvasFile: TFile }, + ): Promise => { + const app = ctx?.app ?? this.options.app; + const file = await this.getFile(shape, ctx); + if (!file) return null; + return getFrontmatterForFile(app, file); + }; + + getRelations = async ( + shape: DiscourseNodeShape, + ctx: { app: App; canvasFile: TFile }, + ): Promise => { + const frontmatter = await this.getFrontmatter(shape, ctx); + if (!frontmatter) return []; + // TODO: derive relations from frontmatter + return []; + }; +} + +const discourseNodeContent = memo( + ({ + shape, + app, + canvasFile, + plugin, + }: { + shape: DiscourseNodeShape; + app: App; + canvasFile: TFile; + plugin: DiscourseGraphPlugin; + }) => { + const editor = useEditor(); + const { src, title, nodeTypeId } = shape.props; + const nodeType = getNodeTypeById(plugin, nodeTypeId); + + useEffect(() => { + const loadNodeData = async () => { + if (!src) { + editor.updateShape({ + id: shape.id, + type: "discourse-node", + props: { + ...shape.props, + title: "(no source)", + }, + }); + return; + } + + try { + const linkedFile = await resolveLinkedFileFromSrc({ + app, + canvasFile, + src, + }); + + if (!linkedFile) { + return; + } + + if (linkedFile.basename !== shape.props.title) { + editor.updateShape({ + id: shape.id, + type: "discourse-node", + props: { + ...shape.props, + title: linkedFile.basename, + }, + }); + } + } catch (error) { + console.error("Error loading node data", error); + return; + } + }; + + void loadNodeData(); + + return () => { + return; + }; + }, [src, shape.id, shape.props, editor, app, canvasFile, plugin]); + + return ( +
    +

    {title || "..."}

    +

    {nodeType?.name || ""}

    +
    + ); + }, +); + +discourseNodeContent.displayName = "DiscourseNodeContent"; + +export const createDiscourseNodeUtil = (options: DiscourseNodeUtilOptions) => { + const configuredUtil = class extends DiscourseNodeUtil { + options = options; + }; + return configuredUtil; +}; diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx new file mode 100644 index 000000000..747354226 --- /dev/null +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx @@ -0,0 +1,434 @@ +/* Note: Most functions here are copied and modified from tldraw Arrow binding util 3.14.2 +https://github.com/tldraw/tldraw/blob/main/packages/tldraw/src/lib/bindings/arrow/ArrowBindingUtil.ts + */ +import { + TLBaseBinding, + TLArrowBindingProps, + BindingUtil, + arrowBindingProps, + BindingOnCreateOptions, + BindingOnChangeOptions, + BindingOnShapeChangeOptions, + BindingOnShapeIsolateOptions, + Editor, + TLArcInfo, + TLArrowPoint, + VecLike, + IndexKey, + TLParentId, + TLShape, + TLShapeId, + getIndexAbove, + getIndexBetween, + TLShapePartial, + Vec, + approximately, + BindingOnShapeDeleteOptions, +} from "tldraw"; +import { + DiscourseRelationShape, + DiscourseRelationUtil, +} from "./DiscourseRelationShape"; +import { + assert, + getArrowBindings, + getArrowInfo, + removeArrowBinding, +} from "~/components/canvas/utils/relationUtils"; + +export type RelationBindings = { + start: RelationBinding | undefined; + end: RelationBinding | undefined; +}; + +export type RelationInfo = + | { + bindings: RelationBindings; + isStraight: false; + start: TLArrowPoint; + end: TLArrowPoint; + middle: VecLike; + handleArc: TLArcInfo; + bodyArc: TLArcInfo; + isValid: boolean; + } + | { + bindings: RelationBindings; + isStraight: true; + start: TLArrowPoint; + end: TLArrowPoint; + middle: VecLike; + isValid: boolean; + length: number; + }; + +export type RelationBinding = TLBaseBinding; +export class BaseRelationBindingUtil extends BindingUtil { + static override props = arrowBindingProps; + private static reifiedArrows = new Set(); + + override getDefaultProps(): Partial { + return { + isPrecise: false, + isExact: false, + normalizedAnchor: { x: 0.5, y: 0.5 }, + snap: "center", + }; + } + + // when the binding itself changes + override onAfterCreate({ + binding, + }: BindingOnCreateOptions): void { + const arrow = this.editor.getShape( + binding.fromId, + ) as DiscourseRelationShape; + arrowDidUpdate(this.editor, arrow); + } + + // when the binding itself changes + override onAfterChange({ + bindingAfter, + }: BindingOnChangeOptions): void { + arrowDidUpdate( + this.editor, + this.editor.getShape(bindingAfter.fromId) as DiscourseRelationShape, + ); + } + + // when the arrow itself changes + override onAfterChangeFromShape({ + shapeAfter, + }: BindingOnShapeChangeOptions): void { + arrowDidUpdate(this.editor, shapeAfter as DiscourseRelationShape); + } + + // when the shape an arrow is bound to changes + override onAfterChangeToShape({ + binding, + }: BindingOnShapeChangeOptions): void { + reparentArrow(this.editor, binding.fromId); + } + + // when the arrow is isolated we need to update it's x,y positions + override onBeforeIsolateFromShape({ + binding, + }: BindingOnShapeIsolateOptions): void { + const arrow = this.editor.getShape(binding.fromId); + if (!arrow) return; + // this.editor.deleteShape(arrow.id); // we don't want to keep the arrow + // Clean up tracking when arrow becomes unbound + BaseRelationBindingUtil.reifiedArrows.delete(arrow.id); + + updateArrowTerminal({ + editor: this.editor, + arrow, + terminal: binding.props.terminal, + }); + } + + override onBeforeDeleteToShape({ + binding, + }: BindingOnShapeDeleteOptions): void { + const arrow = this.editor.getShape(binding.fromId); + // if toShape is deleted, delete the arrow + // we don't want any unbound arrows hanging around + if (arrow) { + BaseRelationBindingUtil.reifiedArrows.delete(arrow.id); + this.editor.deleteShape(arrow.id); + } + } + + /** + * Check selected relation shapes for completed bindings + * Called from mouseup event handler + */ + static checkAndReifyRelation(editor: Editor): void { + const selectedShapes = editor.getSelectedShapes(); + const relationShapes = selectedShapes.filter( + (shape) => shape.type === "discourse-relation", + ) as DiscourseRelationShape[]; + + relationShapes.forEach((arrow) => { + const bindings = getArrowBindings(editor, arrow); + if ( + bindings.start && + bindings.end && + !BaseRelationBindingUtil.reifiedArrows.has(arrow.id) + ) { + BaseRelationBindingUtil.reifiedArrows.add(arrow.id); + const util = editor.getShapeUtil(arrow); + if (util instanceof DiscourseRelationUtil) { + util.reifyRelationInFrontmatter(arrow, bindings).catch((error) => { + console.error("Failed to reify relation in frontmatter:", error); + // Remove from reified set on error so it can be retried + BaseRelationBindingUtil.reifiedArrows.delete(arrow.id); + }); + } + } + }); + } +} + +// Obsidian uses a single relation shape type: 'discourse-relation'. +// Provide a binding util for that single type so bindings work for all relations. +export class DiscourseRelationBindingUtil extends BaseRelationBindingUtil { + static override type = "discourse-relation" as const; +} + +// eslint-disable-next-line preferArrows/prefer-arrow-functions +function arrowDidUpdate(editor: Editor, arrow: DiscourseRelationShape) { + const bindings = getArrowBindings(editor, arrow); + // if the shape is an arrow and its bound shape is on another page + // or was deleted, unbind it + for (const handle of ["start", "end"] as const) { + const binding = bindings[handle]; + if (!binding) continue; + const boundShape = editor.getShape(binding.toId); + const isShapeInSamePageAsArrow = + editor.getAncestorPageId(arrow) === editor.getAncestorPageId(boundShape); + if (!boundShape || !isShapeInSamePageAsArrow) { + // console.log("deleted arrow"); + // editor.deleteShape(arrow.id); // we don't want to keep the arrow + updateArrowTerminal({ editor, arrow, terminal: handle, unbind: true }); + } + } + + // always check the arrow parents + reparentArrow(editor, arrow.id); +} + +// eslint-disable-next-line preferArrows/prefer-arrow-functions +function reparentArrow(editor: Editor, arrowId: TLShapeId) { + const arrow = editor.getShape(arrowId); + if (!arrow) return; + const bindings = getArrowBindings(editor, arrow); + const { start, end } = bindings; + const startShape = start ? editor.getShape(start.toId) : undefined; + const endShape = end ? editor.getShape(end.toId) : undefined; + + const parentPageId = editor.getAncestorPageId(arrow); + if (!parentPageId) return; + + let nextParentId: TLParentId; + if (startShape && endShape) { + // if arrow has two bindings, always parent arrow to closest common ancestor of the bindings + nextParentId = + editor.findCommonAncestor([startShape, endShape]) ?? parentPageId; + } else if (startShape || endShape) { + const bindingParentId = (startShape || endShape)?.parentId; + // If the arrow and the shape that it is bound to have the same parent, then keep that parent + if (bindingParentId && bindingParentId === arrow.parentId) { + nextParentId = arrow.parentId; + } else { + // if arrow has one binding, keep arrow on its own page + nextParentId = parentPageId; + } + } else { + return; + } + + if (nextParentId && nextParentId !== arrow.parentId) { + editor.reparentShapes([arrowId], nextParentId); + } + + const reparentedArrow = editor.getShape(arrowId); + if (!reparentedArrow) throw Error("no reparented arrow"); + + const startSibling = getShapeNearestSibling( + editor, + reparentedArrow, + startShape, + ); + const endSibling = getShapeNearestSibling(editor, reparentedArrow, endShape); + + let highestSibling: TLShape | undefined; + + if (startSibling && endSibling) { + highestSibling = + startSibling.index > endSibling.index ? startSibling : endSibling; + } else if (startSibling && !endSibling) { + highestSibling = startSibling; + } else if (endSibling && !startSibling) { + highestSibling = endSibling; + } else { + return; + } + + let finalIndex: IndexKey; + + const higherSiblings = editor + .getSortedChildIdsForParent(highestSibling.parentId) + .map((id) => editor.getShape(id)!) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + .filter((sibling) => sibling.index > highestSibling!.index); + + if (higherSiblings.length) { + // there are siblings above the highest bound sibling, we need to + // insert between them. + + // if the next sibling is also a bound arrow though, we can end up + // all fighting for the same indexes. so lets find the next + // non-arrow sibling... + const nextHighestNonArrowSibling = higherSiblings.find( + (sibling) => sibling.type !== arrow.type, + ); + + if ( + // ...then, if we're above the last shape we want to be above... + reparentedArrow.index > highestSibling.index && + // ...but below the next non-arrow sibling... + (!nextHighestNonArrowSibling || + reparentedArrow.index < nextHighestNonArrowSibling.index) + ) { + // ...then we're already in the right place. no need to update! + return; + } + + // otherwise, we need to find the index between the highest sibling + // we want to be above, and the next highest sibling we want to be + // below: + finalIndex = getIndexBetween( + highestSibling.index, + higherSiblings[0]?.index, + ); + } else { + // if there are no siblings above us, we can just get the next index: + finalIndex = getIndexAbove(highestSibling.index); + } + + if (finalIndex !== reparentedArrow.index) { + editor.updateShapes([ + { id: arrowId, type: arrow.type, index: finalIndex }, + ]); + } +} + +// eslint-disable-next-line preferArrows/prefer-arrow-functions +function getShapeNearestSibling( + editor: Editor, + siblingShape: TLShape, + targetShape: TLShape | undefined, +): TLShape | undefined { + if (!targetShape) { + return undefined; + } + if (targetShape.parentId === siblingShape.parentId) { + return targetShape; + } + + const ancestor = editor.findShapeAncestor( + targetShape, + (ancestor) => ancestor.parentId === siblingShape.parentId, + ); + + return ancestor; +} + +// eslint-disable-next-line preferArrows/prefer-arrow-functions +export function updateArrowTerminal({ + editor, + arrow, + terminal, + unbind = false, + useHandle = false, +}: { + editor: Editor; + arrow: DiscourseRelationShape; + terminal: "start" | "end"; + unbind?: boolean; + useHandle?: boolean; +}) { + const info = getArrowInfo(editor, arrow); + if (!info) { + throw new Error("expected arrow info"); + } + + const startPoint = useHandle ? info.start.handle : info.start.point; + const endPoint = useHandle ? info.end.handle : info.end.point; + const point = terminal === "start" ? startPoint : endPoint; + + const update = { + id: arrow.id, + type: arrow.type, + props: { [terminal]: { x: point.x, y: point.y }, bend: arrow.props.bend }, + } satisfies TLShapePartial; + + // fix up the bend: + if (!info.isStraight) { + // find the new start/end points of the resulting arrow + const newStart = terminal === "start" ? startPoint : info.start.handle; + const newEnd = terminal === "end" ? endPoint : info.end.handle; + const newMidPoint = Vec.Med(newStart, newEnd); + + // intersect a line segment perpendicular to the new arrow with the old arrow arc to + // find the new mid-point + const lineSegment = Vec.Sub(newStart, newEnd) + .per() + .uni() + .mul(info.handleArc.radius * 2 * Math.sign(arrow.props.bend)); + + // find the intersections with the old arrow arc: + const intersections = intersectLineSegmentCircle( + info.handleArc.center, + Vec.Add(newMidPoint, lineSegment), + info.handleArc.center, + info.handleArc.radius, + ); + + assert(intersections?.length === 1); + const bend = + Vec.Dist(newMidPoint, intersections[0]!) * Math.sign(arrow.props.bend); + // use `approximately` to avoid endless update loops + if (!approximately(bend, update.props.bend)) { + update.props.bend = bend; + } + } + + editor.updateShape(update); + if (unbind) { + removeArrowBinding(editor, arrow, terminal); + } +} + +// eslint-disable-next-line preferArrows/prefer-arrow-functions, max-params +function intersectLineSegmentCircle( + a1: VecLike, + a2: VecLike, + c: VecLike, + r: number, +) { + const a = (a2.x - a1.x) * (a2.x - a1.x) + (a2.y - a1.y) * (a2.y - a1.y); + const b = 2 * ((a2.x - a1.x) * (a1.x - c.x) + (a2.y - a1.y) * (a1.y - c.y)); + const cc = + c.x * c.x + + c.y * c.y + + a1.x * a1.x + + a1.y * a1.y - + 2 * (c.x * a1.x + c.y * a1.y) - + r * r; + const deter = b * b - 4 * a * cc; + + if (deter < 0) return null; // outside + if (deter === 0) return null; // tangent + + const e = Math.sqrt(deter); + const u1 = (-b + e) / (2 * a); + const u2 = (-b - e) / (2 * a); + + if ((u1 < 0 || u1 > 1) && (u2 < 0 || u2 > 1)) { + return null; // outside or inside + // if ((u1 < 0 && u2 < 0) || (u1 > 1 && u2 > 1)) { + // return null // outside + // } else return null // inside' + } + + const result: VecLike[] = []; + + if (0 <= u1 && u1 <= 1) result.push(Vec.Lrp(a1, a2, u1)); + if (0 <= u2 && u2 <= 1) result.push(Vec.Lrp(a1, a2, u2)); + + if (result.length === 0) return null; // no intersection + + return result; +} \ No newline at end of file diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx new file mode 100644 index 000000000..a161a0d85 --- /dev/null +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx @@ -0,0 +1,1175 @@ +/* Note: Lots of functions are copied and modified from tldraw Arrow shape 3.14.2 +https://github.com/tldraw/tldraw/tree/main/packages/tldraw/src/lib/shapes/arrow +*/ +import { + ShapeUtil, + TLBaseShape, + arrowShapeProps, + RecordPropsType, + T, + Geometry2d, + Edge2d, + Vec, + Group2d, + Rectangle2d, + Arc2d, + SVGContainer, + TLShapeUtilCanBindOpts, + TLHandle, + TLArrowBindingProps, + TLShapePartial, + TLHandleDragInfo, + Box, + TLShapeUtilCanBeLaidOutOpts, + WeakCache, + TLResizeInfo, + toDomPrecision, + useIsEditing, + getDefaultColorTheme, + SvgExportContext, + TLShapeUtilCanvasSvgDef, + TEXT_PROPS, + TextLabel, +} from "tldraw"; +import { type App, type TFile } from "obsidian"; +import DiscourseGraphPlugin from "~/index"; +import { + ARROW_HANDLES, + ArrowheadCrossDef, + ArrowheadDotDef, + ArrowSvg, + createOrUpdateArrowBinding, + getArrowBindings, + getArrowheadPathForType, + getArrowInfo, + getArrowLabelFontSize, + getArrowLabelPosition, + getArrowTerminalsInArrowSpace, + getFillDefForCanvas, + getFillDefForExport, + getFontDefForExport, + getSolidCurvedArrowPath, + getSolidStraightArrowPath, + mapObjectMapValues, + removeArrowBinding, + shapeAtTranslationStart, + STROKE_SIZES, + SvgTextLabel, + updateArrowTerminal, +} from "~/components/canvas/utils/relationUtils"; +import { RelationBindings } from "./DiscourseRelationBinding"; +import { DiscourseNodeShape, DiscourseNodeUtil } from "./DiscourseNodeShape"; +import { addRelationToFrontmatter } from "~/components/canvas/utils/frontmatterUtils"; +import { showToast } from "~/components/canvas/utils/toastUtils"; + +export enum ArrowHandles { + start = "start", + middle = "middle", + end = "end", +} + +// Use arrow shape props directly +export type DiscourseRelationShapeProps = RecordPropsType< + typeof arrowShapeProps +> & { + relationTypeId: string; +}; + +export type DiscourseRelationShape = TLBaseShape< + "discourse-relation", + DiscourseRelationShapeProps +>; + +export type DiscourseRelationUtilOptions = { + app: App; + plugin: DiscourseGraphPlugin; + canvasFile: TFile; +}; + +export class DiscourseRelationUtil extends ShapeUtil { + static override type = "discourse-relation" as const; + static props = { + ...arrowShapeProps, + relationTypeId: T.string, + }; + + declare options: DiscourseRelationUtilOptions; + + // Utility flags + override canEdit = () => true; + override canSnap = () => false; + override hideResizeHandles = () => true; + override hideRotateHandle = () => true; + override hideSelectionBoundsBg = () => true; + override hideSelectionBoundsFg = () => true; + + override canBind({ + toShapeType, + }: TLShapeUtilCanBindOpts): boolean { + return toShapeType === "discourse-node"; + } + + override canBeLaidOut( + shape: DiscourseRelationShape, + info: TLShapeUtilCanBeLaidOutOpts, + ) { + if (info.type === "flip") { + // If we don't have this then the flip will be non-idempotent; that is, the flip will be multipotent, varipotent, or perhaps even omni-potent... and we can't have that + const bindings = getArrowBindings(this.editor, shape); + const { start, end } = bindings; + const { shapes = [] } = info; + if (start && !shapes.find((s) => s.id === start.toId)) return false; + if (end && !shapes.find((s) => s.id === end.toId)) return false; + } + return true; + } + + getDefaultProps(): DiscourseRelationShape["props"] { + return { + dash: "draw", + size: "m", + fill: "none", + color: "black", + labelColor: "black", + bend: 0, + start: { x: 0, y: 0 }, + end: { x: 100, y: 0 }, + arrowheadStart: "none", + arrowheadEnd: "arrow", + text: "", + labelPosition: 0.5, + font: "draw", + scale: 1, + kind: "arc", + elbowMidPoint: 0, + relationTypeId: "", + }; + } + + getGeometry(shape: DiscourseRelationShape): Geometry2d { + const info = getArrowInfo(this.editor, shape)!; + + const debugGeom: Geometry2d[] = []; + + const bodyGeom = info.isStraight + ? new Edge2d({ + start: Vec.From(info.start.point), + end: Vec.From(info.end.point), + }) + : new Arc2d({ + center: Vec.From(info.bodyArc.center), + start: Vec.From(info.start.point), + end: Vec.From(info.end.point), + sweepFlag: info.bodyArc.sweepFlag, + largeArcFlag: info.bodyArc.largeArcFlag, + }); + + let labelGeom; + if (shape.props.text.trim()) { + const labelPosition = getArrowLabelPosition(this.editor, shape); + debugGeom.push(...labelPosition.debugGeom); + labelGeom = new Rectangle2d({ + x: labelPosition.box.x, + y: labelPosition.box.y, + width: labelPosition.box.w, + height: labelPosition.box.h, + isFilled: true, + isLabel: true, + }); + } + + return new Group2d({ + children: [ + ...(labelGeom ? [bodyGeom, labelGeom] : [bodyGeom]), + ...debugGeom, + ], + }); + } + + override onHandleDrag( + shape: DiscourseRelationShape, + info: TLHandleDragInfo, + ) { + const handleId = info.handle.id as ArrowHandles; + const bindings = getArrowBindings(this.editor, shape); + + if (handleId === ArrowHandles.middle) { + // Bending the arrow... + const { start, end } = getArrowTerminalsInArrowSpace( + this.editor, + shape, + bindings, + ); + + const delta = Vec.Sub(end, start); + const v = Vec.Per(delta); + + const med = Vec.Med(end, start); + const A = Vec.Sub(med, v); + const B = Vec.Add(med, v); + + const point = Vec.NearestPointOnLineSegment(A, B, info.handle, false); + let bend = Vec.Dist(point, med); + if (Vec.Clockwise(point, end, med)) bend *= -1; + return { id: shape.id, type: shape.type, props: { bend } }; + } + + // Start or end, pointing the arrow... + + const update: TLShapePartial = { + id: shape.id, + type: shape.type, + props: {}, + }; + + const currentBinding = bindings[handleId]; + + const otherHandleId = + handleId === ArrowHandles.start ? ArrowHandles.end : ArrowHandles.start; + const otherBinding = bindings[otherHandleId]; + + if (this.editor.inputs.ctrlKey) { + // todo: maybe double check that this isn't equal to the other handle too? + // Skip binding + removeArrowBinding(this.editor, shape, handleId); + + update.props![handleId] = { x: info.handle.x, y: info.handle.y }; + return update; + } + + const point = this.editor + .getShapePageTransform(shape.id) + .applyToPoint(info.handle); + + const target = this.editor.getShapeAtPoint(point, { + hitInside: true, + hitFrameInside: true, + margin: 0, + filter: (targetShape) => { + return ( + !targetShape.isLocked && + this.editor.canBindShapes({ + fromShape: shape, + toShape: targetShape, + binding: shape.type, + }) + ); + }, + }); + + if ( + !target || + // TODO - this is a hack/fix + // the shape is targeting itself on initial drag + // find out why + target.id === shape.id + ) { + // TODO re-implement this on pointer up + // if ( + // currentBinding && + // otherBinding && + // currentBinding.toId !== otherBinding.toId + // ) { + // this.cancelAndWarn("Cannot remove handle."); + // return update; + // } + + // todo: maybe double check that this isn't equal to the other handle too? + removeArrowBinding(this.editor, shape, handleId); + update.props![handleId] = { x: info.handle.x, y: info.handle.y }; + return update; + } + + // we've got a target! the handle is being dragged over a shape, bind to it + + const targetGeometry = this.editor.getShapeGeometry(target); + const targetBounds = Box.ZeroFix(targetGeometry.bounds); + const pageTransform = this.editor.getShapePageTransform(update.id); + const pointInPageSpace = pageTransform.applyToPoint(info.handle); + const pointInTargetSpace = this.editor.getPointInShapeSpace( + target, + pointInPageSpace, + ); + + let precise = info.isPrecise; + + if (!precise) { + // If we're switching to a new bound shape, then precise only if moving slowly + if ( + !currentBinding || + (currentBinding && target.id !== currentBinding.toId) + ) { + precise = this.editor.inputs.pointerVelocity.len() < 0.5; + } + } + + if (!precise) { + if (!targetGeometry.isClosed) { + precise = true; + } + + // Double check that we're not going to be doing an imprecise snap on + // the same shape twice, as this would result in a zero length line + if ( + otherBinding && + target.id === otherBinding.toId && + otherBinding.props.isPrecise + ) { + precise = true; + } + } + + const normalizedAnchor = { + x: (pointInTargetSpace.x - targetBounds.minX) / targetBounds.width, + y: (pointInTargetSpace.y - targetBounds.minY) / targetBounds.height, + }; + + if (precise) { + // Turn off precision if we're within a certain distance to the center of the shape. + // Funky math but we want the snap distance to be 4 at the minimum and either + // 16 or 15% of the smaller dimension of the target shape, whichever is smaller + if ( + Vec.Dist(pointInTargetSpace, targetBounds.center) < + Math.max( + 4, + Math.min( + Math.min(targetBounds.width, targetBounds.height) * 0.15, + 16, + ), + ) / + this.editor.getZoomLevel() + ) { + normalizedAnchor.x = 0.5; + normalizedAnchor.y = 0.5; + } + } + + // Validate target node type compatibility before creating binding + // Only validate when we're actually connecting to a different target node + if ( + target.type === "discourse-node" && + otherBinding && + target.id !== otherBinding.toId && // Only validate when connecting to a different node + (!currentBinding || target.id !== currentBinding.toId) // Only validate when changing targets + ) { + const sourceNodeId = otherBinding.toId; + const sourceNode = this.editor.getShape(sourceNodeId); + const targetNodeTypeId = (target as { props?: { nodeTypeId?: string } }) + .props?.nodeTypeId; + const sourceNodeTypeId = ( + sourceNode as { props?: { nodeTypeId?: string } } | null + )?.props?.nodeTypeId; + + if (sourceNodeTypeId && targetNodeTypeId && shape.props.relationTypeId) { + const isValidConnection = this.isValidNodeConnection( + sourceNodeTypeId, + targetNodeTypeId, + shape.props.relationTypeId, + ); + + if (!isValidConnection) { + const sourceNodeType = this.options.plugin.settings.nodeTypes.find( + (nt) => nt.id === sourceNodeTypeId, + ); + const targetNodeType = this.options.plugin.settings.nodeTypes.find( + (nt) => nt.id === targetNodeTypeId, + ); + const relationType = this.options.plugin.settings.relationTypes.find( + (rt) => rt.id === shape.props.relationTypeId, + ); + + // Show error toast and delete the entire relation shape + const errorMessage = `Cannot connect "${sourceNodeType?.name}" to "${targetNodeType?.name}" with "${relationType?.label}" relation`; + showToast({ + severity: "error", + title: "Invalid Connection", + description: errorMessage, + }); + + // Remove binding and return without creating connection + removeArrowBinding(this.editor, shape, handleId); + update.props![handleId] = { x: info.handle.x, y: info.handle.y }; + this.editor.deleteShapes([shape.id]); + return update; + } + } + } + + const b: TLArrowBindingProps = { + terminal: handleId, + normalizedAnchor, + isPrecise: precise, + isExact: this.editor.inputs.altKey, + snap: "none", + }; + + createOrUpdateArrowBinding(this.editor, shape, target.id, b); + + this.editor.setHintingShapes([target.id]); + + const newBindings = getArrowBindings(this.editor, shape); + + // Check if both ends are bound and update text based on direction + if (newBindings.start && newBindings.end) { + this.updateRelationTextForDirection(shape, newBindings); + } + if ( + newBindings.start && + newBindings.end && + newBindings.start.toId === newBindings.end.toId + ) { + if ( + Vec.Equals( + newBindings.start.props.normalizedAnchor, + newBindings.end.props.normalizedAnchor, + ) + ) { + createOrUpdateArrowBinding(this.editor, shape, newBindings.end.toId, { + ...newBindings.end.props, + normalizedAnchor: { + x: newBindings.end.props.normalizedAnchor.x + 0.05, + y: newBindings.end.props.normalizedAnchor.y, + }, + }); + } + } + + return update; + } + + override getHandles(shape: DiscourseRelationShape): TLHandle[] { + const info = getArrowInfo(this.editor, shape)!; + + return [ + { + id: ARROW_HANDLES.START, + type: "vertex", + index: "a0", + x: info.start.handle.x, + y: info.start.handle.y, + }, + { + id: ARROW_HANDLES.MIDDLE, + type: "virtual", + index: "a2", + x: info.middle.x, + y: info.middle.y, + }, + { + id: ARROW_HANDLES.END, + type: "vertex", + index: "a3", + x: info.end.handle.x, + y: info.end.handle.y, + }, + ].filter(Boolean) as TLHandle[]; + } + + override onTranslate( + initialShape: DiscourseRelationShape, + shape: DiscourseRelationShape, + ) { + const atTranslationStart = shapeAtTranslationStart.get(initialShape); + if (!atTranslationStart) return; + + const shapePageTransform = this.editor.getShapePageTransform(shape.id); + const pageDelta = Vec.Sub( + shapePageTransform.applyToPoint(shape), + atTranslationStart.pagePosition, + ); + + for (const terminalBinding of Object.values( + atTranslationStart.terminalBindings, + )) { + if (!terminalBinding) continue; + + const newPagePoint = Vec.Add( + terminalBinding.pagePosition, + Vec.Mul(pageDelta, 0.5), + ); + const newTarget = this.editor.getShapeAtPoint(newPagePoint, { + hitInside: true, + hitFrameInside: true, + margin: 0, + filter: (targetShape) => { + return ( + !targetShape.isLocked && + this.editor.canBindShapes({ + fromShape: shape, + toShape: targetShape, + binding: shape.type, + }) + ); + }, + }); + + if (newTarget?.id === terminalBinding.binding.toId) { + const targetBounds = Box.ZeroFix( + this.editor.getShapeGeometry(newTarget).bounds, + ); + const pointInTargetSpace = this.editor.getPointInShapeSpace( + newTarget, + newPagePoint, + ); + const normalizedAnchor = { + x: (pointInTargetSpace.x - targetBounds.minX) / targetBounds.width, + y: (pointInTargetSpace.y - targetBounds.minY) / targetBounds.height, + }; + createOrUpdateArrowBinding(this.editor, shape, newTarget.id, { + ...terminalBinding.binding.props, + normalizedAnchor, + isPrecise: true, + }); + } else { + removeArrowBinding( + this.editor, + shape, + terminalBinding.binding.props.terminal, + ); + } + } + } + + override onTranslateStart(shape: DiscourseRelationShape) { + const bindings = getArrowBindings(this.editor, shape); + + const terminalsInArrowSpace = getArrowTerminalsInArrowSpace( + this.editor, + shape, + bindings, + ); + const shapePageTransform = this.editor.getShapePageTransform(shape.id); + + // If at least one bound shape is in the selection, do nothing; + // If no bound shapes are in the selection, unbind any bound shapes + + const selectedShapeIds = this.editor.getSelectedShapeIds(); + + if ( + (bindings.start && + (selectedShapeIds.includes(bindings.start.toId) || + this.editor.isAncestorSelected(bindings.start.toId))) || + (bindings.end && + (selectedShapeIds.includes(bindings.end.toId) || + this.editor.isAncestorSelected(bindings.end.toId))) + ) { + return; + } + + // When we start translating shapes, record where their bindings were in page space so we + // can maintain them as we translate the arrow + shapeAtTranslationStart.set(shape, { + pagePosition: shapePageTransform.applyToPoint(shape), + terminalBindings: mapObjectMapValues( + terminalsInArrowSpace, + (terminalName, point) => { + const binding = bindings[terminalName]; + if (!binding) return null; + return { + binding, + shapePosition: point, + pagePosition: shapePageTransform.applyToPoint(point), + }; + }, + ), + }); + + // update arrow terminal bindings eagerly to make sure the arrows unbind nicely when translating + if (bindings.start) { + updateArrowTerminal({ + editor: this.editor, + relation: shape, + terminal: "start", + useHandle: true, + }); + shape = this.editor.getShape(shape.id) as DiscourseRelationShape; + } + if (bindings.end) { + updateArrowTerminal({ + editor: this.editor, + relation: shape, + terminal: "end", + useHandle: true, + }); + } + + for (const handleName of [ + ARROW_HANDLES.START, + ARROW_HANDLES.END, + ] as const) { + const binding = bindings[handleName]; + if (!binding) continue; + + this.editor.updateBinding({ + ...binding, + props: { ...binding.props, isPrecise: true }, + }); + } + + return; + } + + readonly resizeInitialBindings = new WeakCache< + DiscourseRelationShape, + RelationBindings + >(); + + override onResize( + shape: DiscourseRelationShape, + info: TLResizeInfo, + ) { + const { scaleX, scaleY } = info; + + const bindings = this.resizeInitialBindings.get(shape, () => + getArrowBindings(this.editor, shape), + ); + const terminals = getArrowTerminalsInArrowSpace( + this.editor, + shape, + bindings, + ); + + const { start, end } = structuredClone( + shape.props, + ); + let { bend } = shape.props; + + // Rescale start handle if it's not bound to a shape + if (!bindings.start) { + start.x = terminals.start.x * scaleX; + start.y = terminals.start.y * scaleY; + } + + // Rescale end handle if it's not bound to a shape + if (!bindings.end) { + end.x = terminals.end.x * scaleX; + end.y = terminals.end.y * scaleY; + } + + // todo: we should only change the normalized anchor positions + // of the shape's handles if the bound shape is also being resized + + const mx = Math.abs(scaleX); + const my = Math.abs(scaleY); + + const startNormalizedAnchor = bindings?.start + ? Vec.From(bindings.start.props.normalizedAnchor) + : null; + const endNormalizedAnchor = bindings?.end + ? Vec.From(bindings.end.props.normalizedAnchor) + : null; + + if (scaleX < 0 && scaleY >= 0) { + if (bend !== 0) { + bend *= -1; + bend *= Math.max(mx, my); + } + + if (startNormalizedAnchor) { + startNormalizedAnchor.x = 1 - startNormalizedAnchor.x; + } + + if (endNormalizedAnchor) { + endNormalizedAnchor.x = 1 - endNormalizedAnchor.x; + } + } else if (scaleX >= 0 && scaleY < 0) { + if (bend !== 0) { + bend *= -1; + bend *= Math.max(mx, my); + } + + if (startNormalizedAnchor) { + startNormalizedAnchor.y = 1 - startNormalizedAnchor.y; + } + + if (endNormalizedAnchor) { + endNormalizedAnchor.y = 1 - endNormalizedAnchor.y; + } + } else if (scaleX >= 0 && scaleY >= 0) { + if (bend !== 0) { + bend *= Math.max(mx, my); + } + } else if (scaleX < 0 && scaleY < 0) { + if (bend !== 0) { + bend *= Math.max(mx, my); + } + + if (startNormalizedAnchor) { + startNormalizedAnchor.x = 1 - startNormalizedAnchor.x; + startNormalizedAnchor.y = 1 - startNormalizedAnchor.y; + } + + if (endNormalizedAnchor) { + endNormalizedAnchor.x = 1 - endNormalizedAnchor.x; + endNormalizedAnchor.y = 1 - endNormalizedAnchor.y; + } + } + + if (bindings.start && startNormalizedAnchor) { + createOrUpdateArrowBinding(this.editor, shape, bindings.start.toId, { + ...bindings.start.props, + normalizedAnchor: startNormalizedAnchor.toJson(), + }); + } + if (bindings.end && endNormalizedAnchor) { + createOrUpdateArrowBinding(this.editor, shape, bindings.end.toId, { + ...bindings.end.props, + normalizedAnchor: endNormalizedAnchor.toJson(), + }); + } + + const next = { props: { start, end, bend } }; + + return next; + } + + override onDoubleClickHandle( + shape: DiscourseRelationShape, + handle: TLHandle, + ): TLShapePartial | void { + switch (handle.id as ARROW_HANDLES) { + case ARROW_HANDLES.START: { + return { + id: shape.id, + type: shape.type, + props: { + ...shape.props, + arrowheadStart: + shape.props.arrowheadStart === "none" ? "arrow" : "none", + }, + }; + } + case ARROW_HANDLES.END: { + return { + id: shape.id, + type: shape.type, + props: { + ...shape.props, + arrowheadEnd: + shape.props.arrowheadEnd === "none" ? "arrow" : "none", + }, + }; + } + } + } + + component(shape: DiscourseRelationShape) { + // eslint-disable-next-line react-hooks/rules-of-hooks + // const theme = useDefaultColorTheme(); + const onlySelectedShape = this.editor.getOnlySelectedShape(); + const shouldDisplayHandles = + this.editor.isInAny( + "select.idle", + "select.pointing_handle", + "select.dragging_handle", + "select.translating", + "arrow.dragging", + ) && !this.editor.getInstanceState().isReadonly; + + const info = getArrowInfo(this.editor, shape); + if (!info?.isValid) return null; + + const labelPosition = getArrowLabelPosition(this.editor, shape); + const isSelected = shape.id === this.editor.getOnlySelectedShapeId(); + const isEditing = this.editor.getEditingShapeId() === shape.id; + const showArrowLabel = isEditing || shape.props.text; + + return ( + <> + + + + {showArrowLabel && ( + + )} + + ); + } + + indicator(shape: DiscourseRelationShape) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const isEditing = useIsEditing(shape.id); + + const info = getArrowInfo(this.editor, shape); + if (!info) return null; + + const { start, end } = getArrowTerminalsInArrowSpace( + this.editor, + shape, + info?.bindings, + ); + const geometry = this.editor.getShapeGeometry(shape); + const bounds = geometry.bounds; + + const labelGeometry = shape.props.text.trim() + ? (geometry.children[1] as Rectangle2d) + : null; + + if (Vec.Equals(start, end)) return null; + + const strokeWidth = STROKE_SIZES[shape.props.size] * shape.props.scale; + + const as = + info.start.arrowhead && + getArrowheadPathForType(info, "start", strokeWidth); + const ae = + info.end.arrowhead && getArrowheadPathForType(info, "end", strokeWidth); + + const path = info.isStraight + ? getSolidStraightArrowPath(info) + : getSolidCurvedArrowPath(info); + + const includeMask = + (as && info.start.arrowhead !== "arrow") || + (ae && info.end.arrowhead !== "arrow") || + !!labelGeometry; + + const maskId = (shape.id + "_clip").replace(":", "_"); + const labelBounds = labelGeometry + ? labelGeometry.getBounds() + : new Box(0, 0, 0, 0); + if (isEditing && labelGeometry) { + return ( + + ); + } + + return ( + + {includeMask && ( + + + + {labelGeometry && ( + + )} + {as && ( + + )} + {ae && ( + + )} + + + )} + {/* firefox will clip if you provide a maskURL even if there is no mask matching that URL in the DOM */} + + {/* This rect needs to be here if we're creating a mask due to an svg quirk on Chrome */} + {includeMask && ( + + )} + + + + {as && } + {ae && } + {labelGeometry && ( + + )} + + ); + } + + override onEditEnd(shape: DiscourseRelationShape) { + const { + id, + type, + props: { text }, + } = shape; + if (text.trimEnd() !== shape.props.text) { + this.editor.updateShapes([ + { id, type, props: { text: text.trimEnd() } }, + ]); + } + } + + override toSvg(shape: DiscourseRelationShape, ctx: SvgExportContext) { + ctx.addExportDef(getFillDefForExport(shape.props.fill)); + if (shape.props.text) + ctx.addExportDef(getFontDefForExport(shape.props.font)); + const theme = getDefaultColorTheme(ctx); + const scaleFactor = 1 / shape.props.scale; + + return ( + + + + + ); + } + + override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] { + return [ + getFillDefForCanvas(), + { key: `arrow:dot`, component: ArrowheadDotDef }, + { key: `arrow:cross`, component: ArrowheadCrossDef }, + ]; + } + + /** + * Updates the relation text based on the direction of the connection. + * If the relation is pointing in the reverse direction, shows the complement. + */ + updateRelationTextForDirection( + shape: DiscourseRelationShape, + bindings: RelationBindings, + ): void { + const plugin = this.options.plugin; + const relationTypeId = shape.props.relationTypeId; + + if (!relationTypeId || !bindings.start || !bindings.end) return; + + const startNode = this.editor.getShape(bindings.start.toId); + const endNode = this.editor.getShape(bindings.end.toId); + + if (!startNode || !endNode) return; + + const startNodeTypeId = (startNode as { props?: { nodeTypeId?: string } }) + ?.props?.nodeTypeId; + const endNodeTypeId = (endNode as { props?: { nodeTypeId?: string } }) + ?.props?.nodeTypeId; + + if (!startNodeTypeId || !endNodeTypeId) return; + + const relationType = plugin.settings.relationTypes.find( + (rt) => rt.id === relationTypeId, + ); + + if (!relationType) return; + + // Check if this is a direct connection (start -> end) + const isDirectConnection = plugin.settings.discourseRelations.some( + (relation) => + relation.relationshipTypeId === relationTypeId && + relation.sourceId === startNodeTypeId && + relation.destinationId === endNodeTypeId, + ); + + // Check if this is a reverse connection (end -> start, so we need complement) + const isReverseConnection = plugin.settings.discourseRelations.some( + (relation) => + relation.relationshipTypeId === relationTypeId && + relation.sourceId === endNodeTypeId && + relation.destinationId === startNodeTypeId, + ); + + let newText = relationType.label; // Default to main label + + if (isReverseConnection && !isDirectConnection) { + // This is purely a reverse connection, use complement + newText = relationType.complement; + } + + // Update the shape text if it's different + if (shape.props.text !== newText) { + this.editor.updateShapes([ + { + id: shape.id, + type: shape.type, + props: { text: newText }, + }, + ]); + } + } + + /** + * Validates if a connection between source and target node types is allowed + * for the given relation type, checking both directions of the relation. + */ + isValidNodeConnection( + sourceNodeTypeId: string, + targetNodeTypeId: string, + relationTypeId: string, + ): boolean { + const plugin = this.options.plugin; + + // Check direct connection (source -> target) + const directConnection = plugin.settings.discourseRelations.some( + (relation) => + relation.relationshipTypeId === relationTypeId && + relation.sourceId === sourceNodeTypeId && + relation.destinationId === targetNodeTypeId, + ); + + if (directConnection) return true; + + // Check reverse connection (target -> source) + // This handles bidirectional relations where the complement is used + const reverseConnection = plugin.settings.discourseRelations.some( + (relation) => + relation.relationshipTypeId === relationTypeId && + relation.sourceId === targetNodeTypeId && + relation.destinationId === sourceNodeTypeId, + ); + + return reverseConnection; + } + + /** + * Reifies the relation in the frontmatter of both connected files. + * This creates the bidirectional links that make the relation persistent. + */ + async reifyRelationInFrontmatter( + shape: DiscourseRelationShape, + bindings: RelationBindings, + ): Promise { + if (!bindings.start || !bindings.end || !shape.props.relationTypeId) { + return; + } + + try { + const startNode = this.editor.getShape(bindings.start.toId); + const endNode = this.editor.getShape(bindings.end.toId); + + if ( + !startNode || + !endNode || + startNode.type !== "discourse-node" || + endNode.type !== "discourse-node" + ) { + return; + } + + const startNodeUtil = this.editor.getShapeUtil(startNode); + const endNodeUtil = this.editor.getShapeUtil(endNode); + + // Get the files associated with both nodes + const sourceFile = await (startNodeUtil as DiscourseNodeUtil).getFile( + startNode as DiscourseNodeShape, + { + app: this.options.app, + canvasFile: this.options.canvasFile, + }, + ); + const targetFile = await (endNodeUtil as DiscourseNodeUtil).getFile( + endNode as DiscourseNodeShape, + { + app: this.options.app, + canvasFile: this.options.canvasFile, + }, + ); + + if (!sourceFile || !targetFile) { + console.warn("Could not resolve files for relation nodes"); + return; + } + + // Add the bidirectional relation to frontmatter + await addRelationToFrontmatter({ + app: this.options.app, + plugin: this.options.plugin, + sourceFile, + targetFile, + relationTypeId: shape.props.relationTypeId, + }); + + // Show success notice + const relationType = this.options.plugin.settings.relationTypes.find( + (rt) => rt.id === shape.props.relationTypeId, + ); + + if (relationType) { + showToast({ + severity: "success", + title: "Relation Created", + description: `Added ${relationType.label} relation between ${sourceFile.basename} and ${targetFile.basename}`, + }); + } + } catch (error) { + console.error("Failed to reify relation in frontmatter:", error); + showToast({ + severity: "error", + title: "Failed to Save Relation", + description: "Could not save relation to files", + }); + } + } +} + +export const createDiscourseRelationUtil = ( + options: DiscourseRelationUtilOptions, +) => { + const configuredUtil = class extends DiscourseRelationUtil { + options = options; + }; + return configuredUtil; +}; diff --git a/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts b/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts new file mode 100644 index 000000000..b6c0523c3 --- /dev/null +++ b/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts @@ -0,0 +1,25 @@ +import type { App, TFile } from "obsidian"; + +export type FrontmatterRecord = Record; + +export const getFrontmatterForFile = ( + app: App, + file: TFile, +): FrontmatterRecord | null => { + return (app.metadataCache.getFileCache(file)?.frontmatter ?? + null) as FrontmatterRecord | null; +}; + +export const getNodeTypeIdFromFrontmatter = ( + frontmatter: FrontmatterRecord | null, +): string | null => { + if (!frontmatter) return null; + return (frontmatter as { nodeTypeId?: string })?.nodeTypeId ?? null; +}; + +export const getRelationsFromFrontmatter = ( + _frontmatter: FrontmatterRecord | null, +): unknown[] => { + // TODO: derive relations from frontmatter when schema is defined + return []; +}; diff --git a/apps/obsidian/src/components/canvas/stores/assetStore.ts b/apps/obsidian/src/components/canvas/stores/assetStore.ts new file mode 100644 index 000000000..0aea7ad63 --- /dev/null +++ b/apps/obsidian/src/components/canvas/stores/assetStore.ts @@ -0,0 +1,379 @@ +import { App, CachedMetadata, TFile } from "obsidian"; +import { TLAsset, TLAssetStore, TLAssetId, TLAssetContext } from "tldraw"; +import { JsonObject } from "@tldraw/utils"; +import DiscourseGraphPlugin from "~/index"; + +const ASSET_PREFIX = "obsidian.blockref."; +type BlockRefAssetId = `${typeof ASSET_PREFIX}${string}`; +type AssetDataUrl = string; + +type AssetStoreOptions = { + app: App; + file: TFile; + plugin: DiscourseGraphPlugin; +}; + +/** + * Create a wikilink + block reference at the top of the provided canvas markdown file + * that points to the provided linked file, and return an asset-style src that encodes + * the generated block ref id (e.g., `asset:obsidian.blockref.`). + * + * This mirrors how media assets are added/resolved in the ObsidianTLAssetStore, but + * for arbitrarily linked markdown files. Shapes can store the returned `src` and use + * `resolveLinkedFileFromSrc` to obtain the `TFile` later. + */ +export const addWikilinkBlockrefForFile = async ({ + app, + canvasFile, + linkedFile, +}: { + app: App; + canvasFile: TFile; + linkedFile: TFile; +}): Promise => { + const blockRefId = crypto.randomUUID(); + const linkText = app.metadataCache.fileToLinktext( + linkedFile, + canvasFile.path, + ); + const content = `[[${linkText}]]\n^${blockRefId}`; + + await app.vault.process(canvasFile, (data: string) => { + const fileCache = app.metadataCache.getFileCache(canvasFile); + const { start, end } = + fileCache?.frontmatterPosition ?? + ({ + start: { offset: 0 }, + end: { offset: 0 }, + } as { start: { offset: number }; end: { offset: number } }); + + const frontmatter = data.slice(start.offset, end.offset); + const rest = data.slice(end.offset); + return `${frontmatter}\n${content}\n${rest}`; + }); + + return `asset:${ASSET_PREFIX}${blockRefId}`; +}; + +/** + * Extract the block reference id from either an asset src string (e.g., + * `asset:obsidian.blockref.`) or from the internal asset id with the + * `obsidian.blockref.` prefix. Returns null if the input is not a blockref. + */ +export const extractBlockRefId = (assetIdOrSrc?: string): string | null => { + if (!assetIdOrSrc) return null; + // From app-level src: asset:obsidian.blockref. + if (assetIdOrSrc.startsWith("asset:")) { + const raw = assetIdOrSrc.split(":")[1] ?? ""; + if (!raw.startsWith(ASSET_PREFIX)) return null; + return raw.slice(ASSET_PREFIX.length); + } + // From internal asset id: obsidian.blockref. + if (assetIdOrSrc.startsWith(ASSET_PREFIX)) { + return assetIdOrSrc.slice(ASSET_PREFIX.length); + } + return null; +}; + +/** + * Given a block reference id present in the current canvas markdown file, resolve + * the linked Obsidian file referenced by the block (i.e., the file inside the [[link]]). + */ +export const resolveLinkedTFileByBlockRef = async ({ + app, + canvasFile, + blockRefId, + canvasFileCache, +}: { + app: App; + canvasFile: TFile; + blockRefId: string; + canvasFileCache: CachedMetadata; +}): Promise => { + try { + if (!blockRefId) return null; + + if (!canvasFileCache?.blocks?.[blockRefId]) return null; + + const block = canvasFileCache.blocks[blockRefId]; + const fileContent = await app.vault.read(canvasFile); + const blockContent = fileContent.substring( + block.position.start.offset, + block.position.end.offset, + ); + + const match = blockContent.match(/\[\[(.*?)\]\]/); + if (!match?.[1]) return null; + const rawLink = match[1].trim(); + // Drop alias part in [[path|alias]] + const linkPath = rawLink.split("|")[0] ?? rawLink; + return ( + app.metadataCache.getFirstLinkpathDest(linkPath, canvasFile.path) ?? null + ); + } catch (error) { + console.error("Error resolving linked TFile from blockRef:", error); + return null; + } +}; + +/** + * Ensure there is a block reference in the canvas file that links to the given file. + * Return the blockRef id; create it if it doesn't exist yet. + */ +export const ensureBlockRefForFile = async ({ + app, + canvasFile, + targetFile, +}: { + app: App; + canvasFile: TFile; + targetFile: TFile; +}): Promise => { + // First, scan existing blocks to see if any link to the target file + const fileCache = app.metadataCache.getFileCache(canvasFile); + if (!fileCache) return ""; + const blocks = fileCache.blocks ?? {}; + for (const [blockId] of Object.entries(blocks)) { + const linked = await resolveLinkedTFileByBlockRef({ + app, + canvasFile, + blockRefId: blockId, + canvasFileCache: fileCache, + }); + if (linked && linked.path === targetFile.path) { + return blockId; + } + } + + // Create a new block ref at the top that links to the target file + const blockRefId = crypto.randomUUID(); + const linkText = app.metadataCache.fileToLinktext( + targetFile, + canvasFile.path, + ); + const internalLink = `[[${linkText}]]`; + const linkBlock = `${internalLink}\n^${blockRefId}`; + + // Insert right after frontmatter + await app.vault.process(canvasFile, (data: string) => { + const cache = app.metadataCache.getFileCache(canvasFile); + const { start, end } = cache?.frontmatterPosition ?? { + start: { offset: 0 }, + end: { offset: 0 }, + }; + const frontmatter = data.slice(start.offset, end.offset); + const rest = data.slice(end.offset); + return `${frontmatter}\n${linkBlock}\n${rest}`; + }); + + return blockRefId; +}; + +export const resolveLinkedFileFromSrc = async ({ + app, + canvasFile, + src, +}: { + app: App; + canvasFile: TFile; + src?: string; +}): Promise => { + if (!src) return null; + const blockRef = extractBlockRefId(src); + const canvasFileCache = app.metadataCache.getFileCache(canvasFile); + if (!blockRef || !canvasFileCache) return null; + return resolveLinkedTFileByBlockRef({ + app, + canvasFile, + blockRefId: blockRef, + canvasFileCache, + }); +}; + +/** + * Proxy class that handles Obsidian-specific file operations for the TLAssetStore + */ +class ObsidianMarkdownFileTLAssetStoreProxy { + private resolvedAssetDataCache = new Map(); + private app: App; + private file: TFile; + private plugin: DiscourseGraphPlugin; + + /** + * Safely set a cached Blob URL for an asset id, revoking any previous URL to avoid leaks + */ + private setCachedUrl(blockRefAssetId: BlockRefAssetId, url: AssetDataUrl) { + const previousUrl = this.resolvedAssetDataCache.get(blockRefAssetId); + if (previousUrl && previousUrl !== url) { + try { + URL.revokeObjectURL(previousUrl); + } catch (err) { + console.warn("Failed to revoke previous object URL", err); + } + } + this.resolvedAssetDataCache.set(blockRefAssetId, url); + } + + constructor(options: AssetStoreOptions) { + this.app = options.app; + this.file = options.file; + this.plugin = options.plugin; + } + + storeAsset = async ( + _asset: TLAsset, + file: File, + ): Promise => { + const blockRefId = crypto.randomUUID(); + + const objectName = `${blockRefId}-${file.name}`.replace(/\W/g, "-"); + const ext = file.type.split("/").at(1); + const fileName = !ext ? objectName : `${objectName}.${ext}`; + const attachmentFolderPath = + this.plugin.settings.canvasAttachmentsFolderPath ?? "attachments"; + let attachmentFolder = this.app.vault.getFolderByPath(attachmentFolderPath); + if (!attachmentFolder) { + attachmentFolder = + await this.app.vault.createFolder(attachmentFolderPath); + } + const filePath = `${attachmentFolder.path}/${fileName}`; + + const arrayBuffer = await file.arrayBuffer(); + const assetFile = await this.app.vault.createBinary(filePath, arrayBuffer); + + const linkText = this.app.metadataCache.fileToLinktext( + assetFile, + this.file.path, + ); + const internalLink = `[[${linkText}]]`; + const linkBlock = `${internalLink}\n^${blockRefId}`; + + await this.addToTopOfFile(linkBlock); + + const assetDataUri = URL.createObjectURL(file); + const assetId = `${ASSET_PREFIX}${blockRefId}` as BlockRefAssetId; + this.setCachedUrl(assetId, assetDataUri); + + return assetId; + }; + + getCached = async ( + blockRefAssetId: BlockRefAssetId, + ): Promise => { + try { + // Check cache first + const cached = this.resolvedAssetDataCache.get(blockRefAssetId); + if (cached) return cached; + + // Load and cache if needed + const assetData = await this.getAssetData(blockRefAssetId); + if (!assetData) return null; + + const uri = URL.createObjectURL(new Blob([assetData])); + this.setCachedUrl(blockRefAssetId, uri); + return uri; + } catch (error) { + console.error("Error getting cached asset:", error); + return null; + } + }; + + dispose = () => { + for (const url of this.resolvedAssetDataCache.values()) { + URL.revokeObjectURL(url); + } + this.resolvedAssetDataCache.clear(); + }; + + private addToTopOfFile = async (content: string) => { + await this.app.vault.process(this.file, (data: string) => { + const fileCache = this.app.metadataCache.getFileCache(this.file); + const { start, end } = fileCache?.frontmatterPosition ?? { + start: { offset: 0 }, + end: { offset: 0 }, + }; + + const frontmatter = data.slice(start.offset, end.offset); + const rest = data.slice(end.offset); + return `${frontmatter}\n${content}\n${rest}`; + }); + }; + + private getAssetData = async ( + blockRefAssetId: BlockRefAssetId, + ): Promise => { + try { + const blockRef = extractBlockRefId(blockRefAssetId); + const canvasFileCache = this.app.metadataCache.getFileCache(this.file); + if (!blockRef || !canvasFileCache) return null; + + const linkedFile = await resolveLinkedTFileByBlockRef({ + app: this.app, + canvasFile: this.file, + blockRefId: blockRef, + canvasFileCache, + }); + if (!linkedFile) return null; + // TODO: handle other file types too + return await this.app.vault.readBinary(linkedFile); + } catch (error) { + console.error("Error getting asset data:", error); + return null; + } + }; +} + +/** + * TLAssetStore implementation for Obsidian + */ +export class ObsidianTLAssetStore implements Required { + private proxy: ObsidianMarkdownFileTLAssetStoreProxy; + + constructor( + public readonly persistenceKey: string, + options: AssetStoreOptions, + ) { + this.proxy = new ObsidianMarkdownFileTLAssetStoreProxy(options); + } + + upload = async ( + asset: TLAsset, + file: File, + ): Promise<{ src: string; meta?: JsonObject }> => { + try { + const blockRefAssetId = await this.proxy.storeAsset(asset, file); + return { + src: `asset:${blockRefAssetId}`, + }; + } catch (error) { + console.error("Error uploading asset:", error); + throw error; + } + }; + + resolve = async ( + asset: TLAsset, + _ctx: TLAssetContext, + ): Promise => { + try { + const assetSrc = asset.props.src; + if (!assetSrc?.startsWith("asset:")) return assetSrc ?? null; + + const assetId = assetSrc.split(":")[1] as BlockRefAssetId; + if (!assetId) return null; + return await this.proxy.getCached(assetId); + } catch (error) { + console.error("Error resolving asset:", error); + return null; + } + }; + + remove = async (_assetIds: TLAssetId[]): Promise => { + // No-op for now as we don't want to delete files from the vault + // The files will remain in the vault and can be managed by the user + }; + + dispose = () => { + this.proxy.dispose(); + }; +} diff --git a/apps/obsidian/src/components/canvas/utils/frontmatterUtils.ts b/apps/obsidian/src/components/canvas/utils/frontmatterUtils.ts new file mode 100644 index 000000000..fd600baf8 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/frontmatterUtils.ts @@ -0,0 +1,52 @@ +import type { App, FrontMatterCache, TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; + +/** + * Adds bidirectional relation links to the frontmatter of both files. + * This follows the same pattern as RelationshipSection.tsx + */ +export const addRelationToFrontmatter = async ({ + app, + plugin, + sourceFile, + targetFile, + relationTypeId, +}: { + app: App; + plugin: DiscourseGraphPlugin; + sourceFile: TFile; + targetFile: TFile; + relationTypeId: string; +}): Promise => { + const relationType = plugin.settings.relationTypes.find( + (r) => r.id === relationTypeId, + ); + + if (!relationType) { + console.error(`Relation type ${relationTypeId} not found`); + return; + } + + try { + const appendLinkToFrontmatter = async (file: TFile, link: string) => { + await app.fileManager.processFrontMatter(file, (fm: FrontMatterCache) => { + const existingLinks = Array.isArray(fm[relationType.id]) + ? (fm[relationType.id] as string[]) + : []; + + // Check if the link already exists to avoid duplicates + const linkToAdd = `[[${link}]]`; + if (!existingLinks.includes(linkToAdd)) { + fm[relationType.id] = [...existingLinks, linkToAdd]; + } + }); + }; + + // Add bidirectional links + await appendLinkToFrontmatter(sourceFile, targetFile.basename); + await appendLinkToFrontmatter(targetFile, sourceFile.basename); + } catch (error) { + console.error("Failed to add relation to frontmatter:", error); + throw error; + } +}; diff --git a/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts b/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts new file mode 100644 index 000000000..01200e4e1 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts @@ -0,0 +1,74 @@ +import { TFile } from "obsidian"; +import { Editor, createShapeId } from "tldraw"; +import DiscourseGraphPlugin from "~/index"; +import { DiscourseNode } from "~/types"; +import { CreateNodeModal } from "~/components/CreateNodeModal"; +import { createDiscourseNode } from "~/utils/createNode"; +import { addWikilinkBlockrefForFile } from "~/components/canvas/stores/assetStore"; +import { showToast } from "./toastUtils"; + +export type CreateNodeAtArgs = { + plugin: DiscourseGraphPlugin; + canvasFile: TFile; + tldrawEditor: Editor; + position: { x: number; y: number }; + initialNodeType?: DiscourseNode; +}; + +export const openCreateDiscourseNodeAt = (args: CreateNodeAtArgs): void => { + const { plugin, canvasFile, tldrawEditor, position, initialNodeType } = args; + + const modal = new CreateNodeModal(plugin.app, { + nodeTypes: plugin.settings.nodeTypes, + plugin, + initialNodeType, + onNodeCreate: async (selectedNodeType: DiscourseNode, title: string) => { + try { + const createdFile = await createDiscourseNode({ + plugin, + nodeType: selectedNodeType, + text: title, + }); + + if (!createdFile) { + throw new Error("Failed to create discourse node file"); + } + + const src = await addWikilinkBlockrefForFile({ + app: plugin.app, + canvasFile, + linkedFile: createdFile, + }); + + const shapeId = createShapeId(); + tldrawEditor.createShape({ + id: shapeId, + type: "discourse-node", + x: position.x, + y: position.y, + props: { + w: 200, + h: 100, + src: src ?? "", + title: createdFile.basename, + nodeTypeId: selectedNodeType.id, + }, + }); + + tldrawEditor.markHistoryStoppingPoint( + `create discourse node ${selectedNodeType.id}`, + ); + tldrawEditor.setSelectedShapes([shapeId]); + } catch (error) { + console.error("Error creating discourse node:", error); + showToast({ + severity: "error", + title: "Failed to Create Node", + description: `Could not create discourse node: ${error instanceof Error ? error.message : "Unknown error"}`, + }); + } + }, + }); + + modal.open(); +}; diff --git a/apps/obsidian/src/components/canvas/utils/relationUtils.tsx b/apps/obsidian/src/components/canvas/utils/relationUtils.tsx new file mode 100644 index 000000000..4f049c89f --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/relationUtils.tsx @@ -0,0 +1,2647 @@ +/* Note: All the functions are a copied and modified from arrow functions of tldraw 3.14.2 +https://github.com/tldraw/tldraw/tree/main/packages/tldraw/src/lib/shapes/arrow + */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +/* eslint-disable @typescript-eslint/require-await */ +/* eslint-disable react-hooks/rules-of-hooks */ +/* eslint-disable max-params */ +/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ +/* eslint-disable preferArrows/prefer-arrow-functions */ +/* eslint-disable @typescript-eslint/naming-convention */ + +import React, { useRef, useEffect, useState } from "react"; +import { + createComputedCache, + Editor, + TLShapeId, + Vec, + Group2d, + MatModel, + Mat, + intersectLineSegmentPolygon, + intersectLineSegmentPolyline, + VecLike, + useEditor, + useSvgExportContext, + useValue, + TLDefaultColorTheme, + intersectCircleCircle, + PI, + HALF_PI, + Edge2d, + Arc2d, + TEXT_PROPS, + FONT_FAMILIES, + Box, + Geometry2d, + clamp, + getPointOnCircle, + Polygon2d, + Circle2d, + angleDistance, + intersectCirclePolygon, + TLDefaultFillStyle, + SvgExportDef, + DefaultFontStyle, + TLDefaultFontStyle, + TLDefaultHorizontalAlignStyle, + TLDefaultVerticalAlignStyle, + useDefaultColorTheme, + DefaultFontFamilies, + BoxModel, + FileHelpers, + TLShapeUtilCanvasSvgDef, + DefaultColorThemePalette, + track, + getPerfectDashProps, + toDomPrecision, + TLShape, + TLArrowBindingProps, + TLShapePartial, + TLDefaultSizeStyle, + PI2, + TLArcInfo, +} from "tldraw"; +import type { + RelationBindings, + RelationBinding, + RelationInfo, +} from "~/components/canvas/shapes/DiscourseRelationBinding"; +import { + DiscourseRelationShape, + DiscourseRelationUtil, +} from "~/components/canvas/shapes/DiscourseRelationShape"; +import DiscourseGraphPlugin from "~/index"; + +let defaultPixels: { white: string; black: string } | null = null; +let globalRenderIndex = 0; +const WAY_TOO_BIG_ARROW_BEND_FACTOR = 10; +const MIN_ARROW_BEND = 8; +const MIN_ARROW_LENGTH = 10; +const BOUND_ARROW_OFFSET = 10; +const labelSizeCache = new WeakMap(); +const LABEL_TO_ARROW_PADDING = 20; +const ARROW_LABEL_PADDING = 4.25; +const ARROW_LABEL_FONT_SIZES: Record = { + s: 18, + m: 20, + l: 24, + xl: 28, +}; +const TILE_PATTERN_SIZE = 8; +export const STROKE_SIZES: Record = { + s: 2, + m: 3.5, + l: 5, + xl: 10, +}; +export enum ARROW_HANDLES { + START = "start", + MIDDLE = "middle", + END = "end", +} +interface ShapeFillProps { + d: string; + fill: TLDefaultFillStyle; + color: string; + theme?: TLDefaultColorTheme; + scale: number; +} +interface PatternDef { + zoom: number; + url: string; + theme: "light" | "dark"; +} +interface BoundShapeInfo { + shape: T; + didIntersect: boolean; + isExact: boolean; + isClosed: boolean; + transform: Mat; + outline: Vec[]; +} +const arrowInfoCache = createComputedCache( + "relation info", + (editor: Editor, shape: DiscourseRelationShape) => { + const bindings = getArrowBindings(editor, shape); + return getIsArrowStraight(shape) + ? getStraightArrowInfo(editor, shape, bindings) + : getCurvedArrowInfo(editor, shape, bindings); + }, +); +export function getArrowInfo( + editor: Editor, + shape: DiscourseRelationShape | TLShapeId, +) { + const id = typeof shape === "string" ? shape : shape.id; + return arrowInfoCache.get(editor, id); +} +export function getArrowBindings( + editor: Editor, + relation: DiscourseRelationShape, +): RelationBindings { + const bindings = editor.getBindingsFromShape( + relation, + relation.type, // we expect relation.type to = binding.type + ); + return { + start: bindings.find((b) => b.props.terminal === "start"), + end: bindings.find((b) => b.props.terminal === "end"), + }; +} +function getStraightArrowInfo( + editor: Editor, + relation: DiscourseRelationShape, + bindings: RelationBindings, +): RelationInfo { + const { arrowheadStart, arrowheadEnd } = relation.props; + + const terminalsInArrowSpace = getArrowTerminalsInArrowSpace( + editor, + relation, + bindings, + ); + + const a = terminalsInArrowSpace.start.clone(); + const b = terminalsInArrowSpace.end.clone(); + const c = Vec.Med(a, b); + + if (Vec.Equals(a, b)) { + return { + bindings, + isStraight: true, + start: { + handle: a, + point: a, + arrowhead: relation.props.arrowheadStart, + }, + end: { + handle: b, + point: b, + arrowhead: relation.props.arrowheadEnd, + }, + middle: c, + isValid: false, + length: 0, + }; + } + + const uAB = Vec.Sub(b, a).uni(); + + // Update the arrowhead points using intersections with the bound shapes, if any. + + const startShapeInfo = getBoundShapeInfoForTerminal( + editor, + relation, + "start", + ); + const endShapeInfo = getBoundShapeInfoForTerminal(editor, relation, "end"); + + const arrowPageTransform = editor.getShapePageTransform(relation)!; + + // Update the position of the arrowhead's end point + updateArrowheadPointWithBoundShape( + b, // <-- will be mutated + terminalsInArrowSpace.start, + arrowPageTransform, + endShapeInfo, + ); + + // Then update the position of the arrowhead's end point + updateArrowheadPointWithBoundShape( + a, // <-- will be mutated + terminalsInArrowSpace.end, + arrowPageTransform, + startShapeInfo, + ); + + let offsetA = 0; + let offsetB = 0; + let strokeOffsetA = 0; + let strokeOffsetB = 0; + let minLength = MIN_ARROW_LENGTH * relation.props.scale; + + const isSelfIntersection = + startShapeInfo && + endShapeInfo && + startShapeInfo.shape === endShapeInfo.shape; + + const relationship = + startShapeInfo && endShapeInfo + ? getBoundShapeRelationships( + editor, + startShapeInfo.shape.id, + endShapeInfo.shape.id, + ) + : "safe"; + + if ( + relationship === "safe" && + startShapeInfo && + endShapeInfo && + !isSelfIntersection && + !startShapeInfo.isExact && + !endShapeInfo.isExact + ) { + if (endShapeInfo.didIntersect && !startShapeInfo.didIntersect) { + // ...and if only the end shape intersected, then make it + // a short arrow ending at the end shape intersection. + + if (startShapeInfo.isClosed) { + a.setTo( + b + .clone() + .add(uAB.clone().mul(MIN_ARROW_LENGTH * relation.props.scale)), + ); + } + } else if (!endShapeInfo.didIntersect) { + // ...and if only the end shape intersected, or if neither + // shape intersected, then make it a short arrow starting + // at the start shape intersection. + if (endShapeInfo.isClosed) { + b.setTo( + a + .clone() + .sub(uAB.clone().mul(MIN_ARROW_LENGTH * relation.props.scale)), + ); + } + } + } + + const distance = Vec.Sub(b, a); + // Check for divide-by-zero before we call uni() + const u = Vec.Len(distance) ? distance.uni() : Vec.From(distance); + const didFlip = !Vec.Equals(u, uAB); + + // If the arrow is bound non-exact to a start shape and the + // start point has an arrowhead, then offset the start point + if (!isSelfIntersection) { + if ( + relationship !== "start-contains-end" && + startShapeInfo && + arrowheadStart !== "none" && + !startShapeInfo.isExact + ) { + strokeOffsetA = + STROKE_SIZES[relation.props.size] / 2 + + ("size" in startShapeInfo.shape.props + ? STROKE_SIZES[startShapeInfo.shape.props.size] / 2 + : 0); + offsetA = (BOUND_ARROW_OFFSET + strokeOffsetA) * relation.props.scale; + minLength += strokeOffsetA * relation.props.scale; + } + + // If the arrow is bound non-exact to an end shape and the + // end point has an arrowhead offset the end point + if ( + relationship !== "end-contains-start" && + endShapeInfo && + arrowheadEnd !== "none" && + !endShapeInfo.isExact + ) { + strokeOffsetB = + STROKE_SIZES[relation.props.size] / 2 + + ("size" in endShapeInfo.shape.props + ? STROKE_SIZES[endShapeInfo.shape.props.size] / 2 + : 0); + offsetB = (BOUND_ARROW_OFFSET + strokeOffsetB) * relation.props.scale; + minLength += strokeOffsetB * relation.props.scale; + } + } + + // Adjust offsets if the length of the arrow is too small + + const tA = a.clone().add(u.clone().mul(offsetA * (didFlip ? -1 : 1))); + const tB = b.clone().sub(u.clone().mul(offsetB * (didFlip ? -1 : 1))); + + if (Vec.DistMin(tA, tB, minLength)) { + if (offsetA !== 0 && offsetB !== 0) { + // both bound + offset + offsetA *= -1.5; + offsetB *= -1.5; + } else if (offsetA !== 0) { + // start bound + offset + offsetA *= -1; + } else if (offsetB !== 0) { + // end bound + offset + offsetB *= -1; + } else { + // noop, its just a really short arrow + } + } + + a.add(u.clone().mul(offsetA * (didFlip ? -1 : 1))); + b.sub(u.clone().mul(offsetB * (didFlip ? -1 : 1))); + + // If the handles flipped their order, then set the center handle + // to the midpoint of the terminals (rather than the midpoint of the + // arrow body); otherwise, it may not be "between" the other terminals. + if (didFlip) { + if (startShapeInfo && endShapeInfo) { + // If we have two bound shapes...then make the arrow a short arrow from + // the start point towards where the end point should be. + b.setTo( + Vec.Add(a, u.clone().mul(-MIN_ARROW_LENGTH * relation.props.scale)), + ); + } + c.setTo(Vec.Med(terminalsInArrowSpace.start, terminalsInArrowSpace.end)); + } else { + c.setTo(Vec.Med(a, b)); + } + + const length = Vec.Dist(a, b); + + return { + bindings, + isStraight: true, + start: { + handle: terminalsInArrowSpace.start, + point: a, + arrowhead: relation.props.arrowheadStart, + }, + end: { + handle: terminalsInArrowSpace.end, + point: b, + arrowhead: relation.props.arrowheadEnd, + }, + middle: c, + isValid: length > 0, + length, + }; +} +function getBoundShapeInfoForTerminal( + editor: Editor, + relation: DiscourseRelationShape, + terminalName: "start" | "end", +): BoundShapeInfo | undefined { + const binding = editor + .getBindingsFromShape(relation, relation.type) // we expect relation.type to = binding.type + .find((b) => b.props.terminal === terminalName); + if (!binding) return; + + const boundShape = editor.getShape(binding.toId)!; + if (!boundShape) return; + const transform = editor.getShapePageTransform(boundShape)!; + const geometry = editor.getShapeGeometry(boundShape); + + // This is hacky: we're only looking at the first child in the group. Really the arrow should + // consider all items in the group which are marked as snappable as separate polygons with which + // to intersect, in the case of a group that has multiple children which do not overlap; or else + // flatten the geometry into a set of polygons and intersect with that. + const outline = + geometry instanceof Group2d + ? (geometry.children[0]?.vertices ?? []) + : geometry.vertices; + + return { + shape: boundShape, + transform, + isClosed: geometry.isClosed, + isExact: binding.props.isExact, + didIntersect: false, + outline, + }; +} +export function getArrowTerminalsInArrowSpace( + editor: Editor, + shape: DiscourseRelationShape, + bindings: RelationBindings, +) { + const arrowPageTransform = editor.getShapePageTransform(shape)!; + + const boundShapeRelationships = getBoundShapeRelationships( + editor, + bindings.start?.toId, + bindings.end?.toId, + ); + + const start = bindings.start + ? getArrowTerminalInArrowSpace( + editor, + arrowPageTransform, + bindings.start, + boundShapeRelationships === "double-bound" || + boundShapeRelationships === "start-contains-end", + ) + : Vec.From(shape.props.start); + + const end = bindings.end + ? getArrowTerminalInArrowSpace( + editor, + arrowPageTransform, + bindings.end, + boundShapeRelationships === "double-bound" || + boundShapeRelationships === "end-contains-start", + ) + : Vec.From(shape.props.end); + + return { start, end }; +} +function updateArrowheadPointWithBoundShape( + point: Vec, + opposite: Vec, + arrowPageTransform: MatModel, + targetShapeInfo?: BoundShapeInfo, +) { + if (targetShapeInfo === undefined) { + // No bound shape? The arrowhead point will be at the arrow terminal. + return; + } + + if (targetShapeInfo.isExact) { + // Exact type binding? The arrowhead point will be at the arrow terminal. + return; + } + + // From and To in page space + const pageFrom = Mat.applyToPoint(arrowPageTransform, opposite); + const pageTo = Mat.applyToPoint(arrowPageTransform, point); + + // From and To in local space of the target shape + const targetFrom = Mat.applyToPoint( + Mat.Inverse(targetShapeInfo.transform), + pageFrom, + ); + const targetTo = Mat.applyToPoint( + Mat.Inverse(targetShapeInfo.transform), + pageTo, + ); + + const isClosed = targetShapeInfo.isClosed; + const fn = isClosed + ? intersectLineSegmentPolygon + : intersectLineSegmentPolyline; + + const intersection = fn(targetFrom, targetTo, targetShapeInfo.outline); + + let targetInt: VecLike | undefined; + + if (intersection !== null) { + targetInt = + intersection.sort( + (p1, p2) => Vec.Dist2(p1, targetFrom) - Vec.Dist2(p2, targetFrom), + )[0] ?? (isClosed ? undefined : targetTo); + } + + if (targetInt === undefined) { + // No intersection? The arrowhead point will be at the arrow terminal. + return; + } + + const pageInt = Mat.applyToPoint(targetShapeInfo.transform, targetInt); + const arrowInt = Mat.applyToPoint(Mat.Inverse(arrowPageTransform), pageInt); + + point.setTo(arrowInt); + + targetShapeInfo.didIntersect = true; +} +function getArrowTerminalInArrowSpace( + editor: Editor, + arrowPageTransform: Mat, + binding: RelationBinding, + forceImprecise: boolean, +) { + const boundShape = editor.getShape(binding.toId); + + if (!boundShape) { + // this can happen in multiplayer contexts where the shape is being deleted + return new Vec(0, 0); + } else { + // Find the actual local point of the normalized terminal on + // the bound shape and transform it to page space, then transform + // it to arrow space + const { point, size } = editor.getShapeGeometry(boundShape).bounds; + const shapePoint = Vec.Add( + point, + Vec.MulV( + // if the parent is the bound shape, then it's ALWAYS precise + binding.props.isPrecise || forceImprecise + ? binding.props.normalizedAnchor + : { x: 0.5, y: 0.5 }, + size, + ), + ); + const pagePoint = Mat.applyToPoint( + editor.getShapePageTransform(boundShape)!, + shapePoint, + ); + const arrowPoint = Mat.applyToPoint( + Mat.Inverse(arrowPageTransform), + pagePoint, + ); + return arrowPoint; + } +} +function getBoundShapeRelationships( + editor: Editor, + startShapeId?: TLShapeId, + endShapeId?: TLShapeId, +) { + if (!startShapeId || !endShapeId) return "safe"; + if (startShapeId === endShapeId) return "double-bound"; + const startBounds = editor.getShapePageBounds(startShapeId); + const endBounds = editor.getShapePageBounds(endShapeId); + if (startBounds && endBounds) { + if (startBounds.contains(endBounds)) return "start-contains-end"; + if (endBounds.contains(startBounds)) return "end-contains-start"; + } + return "safe"; +} +function PatternFill({ + d, + color, + theme = useDefaultColorTheme(), +}: ShapeFillProps) { + const editor = useEditor(); + const svgExport = useSvgExportContext(); + const zoomLevel = useValue("zoomLevel", () => editor.getZoomLevel(), [ + editor, + ]); + + const teenyTiny = editor.getZoomLevel() <= 0.18; + + return ( + <> + + + + ); +} +function getHashPatternZoomName( + zoom: number, + theme: TLDefaultColorTheme["id"], +) { + const lod = getPatternLodForZoomLevel(zoom); + return `tldraw_hash_pattern_${theme}_${lod}`; +} +function getPatternLodForZoomLevel(zoom: number) { + return Math.ceil(Math.log2(Math.max(1, zoom))); +} +export function getArrowheadPathForType( + info: RelationInfo, + side: "start" | "end", + strokeWidth: number, +): string | undefined { + const type = side === "end" ? info.end.arrowhead : info.start.arrowhead; + if (type === "none") return; + + const points = getArrowPoints(info, side, strokeWidth); + if (!points) return; + + switch (type) { + case "bar": + return getBarHead(points); + case "square": + return getSquareHead(points); + case "diamond": + return getDiamondHead(points); + case "dot": + return getDotHead(points); + case "inverted": + return getInvertedTriangleHead(points); + case "arrow": + return getArrowhead(points); + case "triangle": + return getTriangleHead(points); + } + + return ""; +} +interface RelationArrowPointsInfo { + point: VecLike; + int: VecLike; +} +function getArrowPoints( + info: RelationInfo, + side: "start" | "end", + strokeWidth: number, +): RelationArrowPointsInfo { + const PT = side === "end" ? info.end.point : info.start.point; + const PB = side === "end" ? info.start.point : info.end.point; + + const compareLength = info.isStraight + ? Vec.Dist(PB, PT) + : Math.abs(info.bodyArc.length); // todo: arc length for curved arrows + + const length = Math.max( + Math.min(compareLength / 5, strokeWidth * 3), + strokeWidth, + ); + + let P0: VecLike; + + if (info.isStraight) { + P0 = Vec.Nudge(PT, PB, length); + } else { + const ints = intersectCircleCircle( + PT, + length, + info.handleArc.center, + info.handleArc.radius, + ); + const [i0, i1] = + (ints?.length ?? 0) >= 2 + ? (ints as [VecLike, VecLike]) + : [info.start.point, info.start.point]; + + P0 = + side === "end" + ? info.handleArc.sweepFlag + ? i0 + : i1 + : info.handleArc.sweepFlag + ? i1 + : i0; + } + + if (Vec.IsNaN(P0)) { + P0 = info.start.point; + } + + return { + point: PT, + int: P0, + }; +} +export function getStraightArrowHandlePath( + info: RelationInfo & { isStraight: true }, +) { + return getArrowPath(info.start.handle, info.end.handle); +} +function getArrowPath(start: VecLike, end: VecLike) { + return `M${start.x},${start.y}L${end.x},${end.y}`; +} +export function getSolidStraightArrowPath( + info: RelationInfo & { isStraight: true }, +) { + return getArrowPath(info.start.point, info.end.point); +} +function getArrowhead({ point, int }: RelationArrowPointsInfo) { + const PL = Vec.RotWith(int, point, PI / 6); + const PR = Vec.RotWith(int, point, -PI / 6); + + return `M ${PL.x} ${PL.y} L ${point.x} ${point.y} L ${PR.x} ${PR.y}`; +} +function getTriangleHead({ point, int }: RelationArrowPointsInfo) { + const PL = Vec.RotWith(int, point, PI / 6); + const PR = Vec.RotWith(int, point, -PI / 6); + + return `M ${PL.x} ${PL.y} L ${point.x} ${point.y} L ${PR.x} ${PR.y} Z`; +} +function getInvertedTriangleHead({ point, int }: RelationArrowPointsInfo) { + const d = Vec.Sub(int, point).div(2); + const PL = Vec.Add(point, Vec.Rot(d, HALF_PI)); + const PR = Vec.Sub(point, Vec.Rot(d, HALF_PI)); + + return `M ${PL.x} ${PL.y} L ${int.x} ${int.y} L ${PR.x} ${PR.y} Z`; +} +function getDotHead({ point, int }: RelationArrowPointsInfo) { + const A = Vec.Lrp(point, int, 0.45); + const r = Vec.Dist(A, point); + + return `M ${A.x - r},${A.y} + a ${r},${r} 0 1,0 ${r * 2},0 + a ${r},${r} 0 1,0 -${r * 2},0 `; +} +function getDiamondHead({ point, int }: RelationArrowPointsInfo) { + const PB = Vec.Lrp(point, int, 0.75); + const PL = Vec.RotWith(PB, point, PI / 4); + const PR = Vec.RotWith(PB, point, -PI / 4); + + const PQ = Vec.Lrp(PL, PR, 0.5); + PQ.add(Vec.Sub(PQ, point)); + + return `M ${PQ.x} ${PQ.y} L ${PL.x} ${PL.y} ${point.x} ${point.y} L ${PR.x} ${PR.y} Z`; +} +function getSquareHead({ int, point }: RelationArrowPointsInfo) { + const PB = Vec.Lrp(point, int, 0.85); + const d = Vec.Sub(PB, point).div(2); + const PL1 = Vec.Add(point, Vec.Rot(d, HALF_PI)); + const PR1 = Vec.Sub(point, Vec.Rot(d, HALF_PI)); + const PL2 = Vec.Add(PB, Vec.Rot(d, HALF_PI)); + const PR2 = Vec.Sub(PB, Vec.Rot(d, HALF_PI)); + + return `M ${PL1.x} ${PL1.y} L ${PL2.x} ${PL2.y} L ${PR2.x} ${PR2.y} L ${PR1.x} ${PR1.y} Z`; +} +function getBarHead({ int, point }: RelationArrowPointsInfo) { + const d = Vec.Sub(int, point).div(2); + + const PL = Vec.Add(point, Vec.Rot(d, HALF_PI)); + const PR = Vec.Sub(point, Vec.Rot(d, HALF_PI)); + + return `M ${PL.x} ${PL.y} L ${PR.x} ${PR.y}`; +} +function getLength(editor: Editor, shape: DiscourseRelationShape): number { + const info = getArrowInfo(editor, shape)!; + + return info.isStraight + ? Vec.Dist(info.start.handle, info.end.handle) + : Math.abs(info.handleArc.length); +} +function getArrowLabelSize(editor: Editor, shape: DiscourseRelationShape) { + const cachedSize = labelSizeCache.get(shape); + if (cachedSize) return cachedSize; + + const info = getArrowInfo(editor, shape)!; + let width = 0; + let height = 0; + + const bodyGeom = info.isStraight + ? new Edge2d({ + start: Vec.From(info.start.point), + end: Vec.From(info.end.point), + }) + : new Arc2d({ + center: Vec.Cast(info.handleArc.center), + start: Vec.Cast(info.start.point), + end: Vec.Cast(info.end.point), + sweepFlag: info.bodyArc.sweepFlag, + largeArcFlag: info.bodyArc.largeArcFlag, + }); + + if (shape.props.text.trim()) { + const bodyBounds = bodyGeom.bounds; + + const fontSize = getArrowLabelFontSize(shape); + + const { w, h } = editor.textMeasure.measureText(shape.props.text, { + ...TEXT_PROPS, + fontFamily: FONT_FAMILIES[shape.props.font], + fontSize, + maxWidth: null, + }); + + width = w; + height = h; + + if (bodyBounds.width > bodyBounds.height) { + width = Math.max(Math.min(w, 64), Math.min(bodyBounds.width - 64, w)); + + const { w: squishedWidth, h: squishedHeight } = + editor.textMeasure.measureText(shape.props.text, { + ...TEXT_PROPS, + fontFamily: FONT_FAMILIES[shape.props.font], + fontSize, + maxWidth: width, + }); + + width = squishedWidth; + height = squishedHeight; + } + + if (width > 16 * fontSize) { + width = 16 * fontSize; + + const { w: squishedWidth, h: squishedHeight } = + editor.textMeasure.measureText(shape.props.text, { + ...TEXT_PROPS, + fontFamily: FONT_FAMILIES[shape.props.font], + fontSize, + maxWidth: width, + }); + + width = squishedWidth; + height = squishedHeight; + } + } + + const size = new Vec(width, height).addScalar( + ARROW_LABEL_PADDING * 2 * shape.props.scale, + ); + labelSizeCache.set(shape, size); + return size; +} +function getStraightArrowLabelRange( + editor: Editor, + shape: DiscourseRelationShape, + info: Extract, +): { start: number; end: number } { + const labelSize = getArrowLabelSize(editor, shape); + const labelToArrowPadding = getLabelToArrowPadding(shape); + + // take the start and end points of the arrow, and nudge them in a bit to give some spare space: + const startOffset = Vec.Nudge( + info.start.point, + info.end.point, + labelToArrowPadding, + ); + const endOffset = Vec.Nudge( + info.end.point, + info.start.point, + labelToArrowPadding, + ); + + // assuming we just stick the label in the middle of the shape, where does the arrow intersect the label? + const intersectionPoints = intersectLineSegmentPolygon( + startOffset, + endOffset, + Box.FromCenter(info.middle, labelSize).corners, + ); + if (!intersectionPoints || intersectionPoints.length !== 2) { + return { start: 0.5, end: 0.5 }; + } + + // there should be two intersection points - one near the start, and one near the end + // after the existing guard: + if (!intersectionPoints || intersectionPoints.length !== 2) + return { start: 0.5, end: 0.5 }; + + // replace destructure with a tuple cast + let [startIntersect, endIntersect] = intersectionPoints as [VecLike, VecLike]; + if ( + Vec.Dist2(startIntersect, startOffset) > + Vec.Dist2(endIntersect, startOffset) + ) { + [endIntersect, startIntersect] = intersectionPoints as [VecLike, VecLike]; + } + + // take our nudged start and end points and scooch them in even further to give us the possible + // range for the position of the _center_ of the label + const startConstrained = startOffset.add( + Vec.Sub(info.middle, startIntersect), + ); + const endConstrained = endOffset.add(Vec.Sub(info.middle, endIntersect)); + + // now we can work out the range of possible label positions + const start = Vec.Dist(info.start.point, startConstrained) / info.length; + const end = Vec.Dist(info.start.point, endConstrained) / info.length; + return { start, end }; +} +export function getSolidCurvedArrowPath( + info: RelationInfo & { isStraight: false }, +) { + const { + start, + end, + bodyArc: { radius, largeArcFlag, sweepFlag }, + } = info; + return `M${start.point.x},${start.point.y} A${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end.point.x},${end.point.y}`; +} +export function getArrowLabelPosition( + editor: Editor, + shape: DiscourseRelationShape, +) { + let labelCenter; + const debugGeom: Geometry2d[] = []; + const info = getArrowInfo(editor, shape)!; + + const hasStartBinding = !!info.bindings.start; + const hasEndBinding = !!info.bindings.end; + const hasStartArrowhead = info.start.arrowhead !== "none"; + const hasEndArrowhead = info.end.arrowhead !== "none"; + if (info.isStraight) { + const range = getStraightArrowLabelRange(editor, shape, info); + let clampedPosition = clamp( + shape.props.labelPosition, + hasStartArrowhead || hasStartBinding ? range.start : 0, + hasEndArrowhead || hasEndBinding ? range.end : 1, + ); + // This makes the position snap in the middle. + clampedPosition = + clampedPosition >= 0.48 && clampedPosition <= 0.52 + ? 0.5 + : clampedPosition; + labelCenter = Vec.Lrp(info.start.point, info.end.point, clampedPosition); + } else { + const range = getCurvedArrowLabelRange(editor, shape, info); + if (range.dbg) debugGeom.push(...range.dbg); + let clampedPosition = clamp( + shape.props.labelPosition, + hasStartArrowhead || hasStartBinding ? range.start : 0, + hasEndArrowhead || hasEndBinding ? range.end : 1, + ); + // This makes the position snap in the middle. + clampedPosition = + clampedPosition >= 0.48 && clampedPosition <= 0.52 + ? 0.5 + : clampedPosition; + const labelAngle = interpolateArcAngles( + Vec.Angle(info.bodyArc.center, info.start.point), + Vec.Angle(info.bodyArc.center, info.end.point), + Math.sign(shape.props.bend), + clampedPosition, + ); + labelCenter = getPointOnCircle( + info.bodyArc.center, + info.bodyArc.radius, + labelAngle, + ); + } + + const labelSize = getArrowLabelSize(editor, shape); + + return { box: Box.FromCenter(labelCenter, labelSize), debugGeom }; +} +export function getArrowLabelFontSize(shape: DiscourseRelationShape) { + return ARROW_LABEL_FONT_SIZES[shape.props.size] * shape.props.scale; +} +function getLabelToArrowPadding(shape: DiscourseRelationShape) { + const strokeWidth = STROKE_SIZES[shape.props.size]; + const labelToArrowPadding = + (LABEL_TO_ARROW_PADDING + + (strokeWidth - STROKE_SIZES.s) * 2 + + (strokeWidth === STROKE_SIZES.xl ? 20 : 0)) * + shape.props.scale; + + return labelToArrowPadding; +} +function getCurvedArrowLabelRange( + editor: Editor, + shape: DiscourseRelationShape, + info: Extract, +): { start: number; end: number; dbg?: Geometry2d[] } { + const labelSize = getArrowLabelSize(editor, shape); + const labelToArrowPadding = getLabelToArrowPadding(shape); + const direction = Math.sign(shape.props.bend); + + // take the start and end points of the arrow, and nudge them in a bit to give some spare space: + const labelToArrowPaddingRad = + (labelToArrowPadding / info.handleArc.radius) * direction; + const startOffsetAngle = + Vec.Angle(info.bodyArc.center, info.start.point) - labelToArrowPaddingRad; + const endOffsetAngle = + Vec.Angle(info.bodyArc.center, info.end.point) + labelToArrowPaddingRad; + const startOffset = getPointOnCircle( + info.bodyArc.center, + info.bodyArc.radius, + startOffsetAngle, + ); + const endOffset = getPointOnCircle( + info.bodyArc.center, + info.bodyArc.radius, + endOffsetAngle, + ); + + const dbg: Geometry2d[] = []; + + // unlike the straight arrow, we can't just stick the label in the middle of the shape when + // we're working out the range. this is because as the label moves along the curve, the place + // where the arrow intersects with label changes. instead, we have to stick the label center on + // the `startOffset` (the start-most place where it can go), then find where it intersects with + // the arc. because of the symmetry of the label rectangle, we can move the label to that new + // center and take that as the start-most possible point. + const startIntersections = intersectArcPolygon( + info.bodyArc.center, + info.bodyArc.radius, + startOffsetAngle, + endOffsetAngle, + direction, + Box.FromCenter(startOffset, labelSize).corners, + ); + + dbg.push( + new Polygon2d({ + points: Box.FromCenter(startOffset, labelSize).corners, + debugColor: "lime", + isFilled: false, + ignore: true, + }), + ); + + const endIntersections = intersectArcPolygon( + info.bodyArc.center, + info.bodyArc.radius, + startOffsetAngle, + endOffsetAngle, + direction, + Box.FromCenter(endOffset, labelSize).corners, + ); + + dbg.push( + new Polygon2d({ + points: Box.FromCenter(endOffset, labelSize).corners, + debugColor: "lime", + isFilled: false, + ignore: true, + }), + ); + for (const pt of [ + ...(startIntersections ?? []), + ...(endIntersections ?? []), + startOffset, + endOffset, + ]) { + dbg.push( + new Circle2d({ + x: pt.x - 3, + y: pt.y - 3, + radius: 3, + isFilled: false, + debugColor: "magenta", + ignore: true, + }), + ); + } + + // if we have one or more intersections (we shouldn't have more than two) then the one we need + // is the one furthest from the arrow terminal + const startConstrained = + (startIntersections && furthest(info.start.point, startIntersections)) ?? + info.middle; + const endConstrained = + (endIntersections && furthest(info.end.point, endIntersections)) ?? + info.middle; + + const startAngle = Vec.Angle(info.bodyArc.center, info.start.point); + const endAngle = Vec.Angle(info.bodyArc.center, info.end.point); + const constrainedStartAngle = Vec.Angle( + info.bodyArc.center, + startConstrained, + ); + const constrainedEndAngle = Vec.Angle(info.bodyArc.center, endConstrained); + + // if the arc is small enough that there's no room for the label to move, we constrain it to the middle. + if ( + angleDistance(startAngle, constrainedStartAngle, direction) > + angleDistance(startAngle, constrainedEndAngle, direction) + ) { + return { start: 0.5, end: 0.5, dbg }; + } + + // now we can work out the range of possible label positions + const fullDistance = angleDistance(startAngle, endAngle, direction); + const start = + angleDistance(startAngle, constrainedStartAngle, direction) / fullDistance; + const end = + angleDistance(startAngle, constrainedEndAngle, direction) / fullDistance; + return { start, end, dbg }; +} +function furthest(from: VecLike, candidates: VecLike[]): VecLike | null { + let furthest: VecLike | null = null; + let furthestDist = -Infinity; + + for (const candidate of candidates) { + const dist = Vec.Dist2(from, candidate); + if (dist > furthestDist) { + furthest = candidate; + furthestDist = dist; + } + } + + return furthest; +} +function interpolateArcAngles( + angleStart: number, + angleEnd: number, + direction: number, + t: number, +) { + const dist = angleDistance(angleStart, angleEnd, direction); + return angleStart + dist * t * direction * -1; +} +function intersectArcPolygon( + center: VecLike, + radius: number, + angleStart: number, + angleEnd: number, + direction: number, + polygon: VecLike[], +) { + const intersections = intersectCirclePolygon(center, radius, polygon); + + // filter the circle intersections to just the ones from the arc + const fullArcDistance = angleDistance(angleStart, angleEnd, direction); + return intersections?.filter((pt) => { + const pDistance = angleDistance( + angleStart, + Vec.Angle(center, pt), + direction, + ); + return pDistance >= 0 && pDistance <= fullArcDistance; + }); +} +export function getFillDefForExport(fill: TLDefaultFillStyle): SvgExportDef { + return { + key: `${DefaultFontStyle.id}:${fill}`, + getElement: async () => { + if (fill !== "pattern") return null; + + return ; + }, + }; +} +export const SvgTextLabel = ({ + fontSize, + font, + align, + verticalAlign, + text, + labelColor, + bounds, + padding = 16, + stroke = true, +}: { + fontSize: number; + font: TLDefaultFontStyle; + // fill?: TLDefaultFillStyle + align: TLDefaultHorizontalAlignStyle; + verticalAlign: TLDefaultVerticalAlignStyle; + wrap?: boolean; + text: string; + labelColor: string; + bounds: Box; + padding?: number; + stroke?: boolean; +}) => { + const editor = useEditor(); + const theme = useDefaultColorTheme(); + + const opts = { + fontSize, + fontFamily: DefaultFontFamilies[font], + textAlign: align, + verticalTextAlign: verticalAlign, + width: Math.ceil(bounds.width), + height: Math.ceil(bounds.height), + padding, + lineHeight: TEXT_PROPS.lineHeight, + fontStyle: "normal", + fontWeight: "normal", + overflow: "wrap" as const, + offsetX: 0, + offsetY: 0, + fill: labelColor, + stroke: undefined as string | undefined, + strokeWidth: undefined as number | undefined, + }; + + const spans = editor.textMeasure.measureTextSpans(text, opts); + const offsetX = getLegacyOffsetX(align, padding, spans, bounds.width); + if (offsetX) { + opts.offsetX = offsetX; + } + + opts.offsetX += bounds.x; + opts.offsetY += bounds.y; + + const mainSpans = createTextJsxFromSpans(editor, spans, opts); + + let outlineSpans = null; + if (stroke) { + opts.fill = theme.background; + opts.stroke = theme.background; + opts.strokeWidth = 2; + outlineSpans = createTextJsxFromSpans(editor, spans, opts); + } + + return ( + <> + {outlineSpans} + {mainSpans} + + ); +}; + +function getLegacyOffsetX( + align: TLDefaultHorizontalAlignStyle | string, + padding: number, + spans: { text: string; box: BoxModel }[], + totalWidth: number, +): number | undefined { + if ( + (align === "start-legacy" || align === "end-legacy") && + spans.length !== 0 + ) { + const spansBounds = Box.From(spans[0]?.box ?? new Box(0, 0, 0, 0)); + for (const { box } of spans) { + spansBounds.union(box); + } + if (align === "start-legacy") { + return (totalWidth - 2 * padding - spansBounds.width) / 2; + } else if (align === "end-legacy") { + return -(totalWidth - 2 * padding - spansBounds.width) / 2; + } + } +} +export function createTextJsxFromSpans( + editor: Editor, + spans: { text: string; box: BoxModel }[], + opts: { + fontSize: number; + fontFamily: string; + textAlign: TLDefaultHorizontalAlignStyle; + verticalTextAlign: TLDefaultVerticalAlignStyle; + fontWeight: string; + fontStyle: string; + width: number; + height: number; + stroke?: string; + strokeWidth?: number; + fill?: string; + padding?: number; + offsetX?: number; + offsetY?: number; + }, +) { + const { padding = 0 } = opts; + if (spans.length === 0) return null; + + const bounds = Box.From(spans[0]?.box ?? new Box(0, 0, 0, 0)); + for (const { box } of spans) { + bounds.union(box); + } + + const offsetX = padding + (opts.offsetX ?? 0); + const offsetY = + (opts.offsetY ?? 0) + + opts.fontSize / 2 + + (opts.verticalTextAlign === "start" + ? padding + : opts.verticalTextAlign === "end" + ? opts.height - padding - bounds.height + : (Math.ceil(opts.height) - bounds.height) / 2); + + // Create text span elements for each word + let currentLineTop = null; + const children = []; + for (const { text, box } of spans) { + // if we broke a line, add a line break span. This helps tools like + // figma import our exported svg correctly + const didBreakLine = currentLineTop !== null && box.y > currentLineTop; + if (didBreakLine) { + children.push( + + {"\n"} + , + ); + } + + children.push( + + {correctSpacesToNbsp(text)} + , + ); + + currentLineTop = box.y; + } + function correctSpacesToNbsp(input: string) { + return input.replace(/\s/g, "\xa0"); + } + return ( + + {children} + + ); +} +export function getFontDefForExport( + fontStyle: TLDefaultFontStyle, +): SvgExportDef { + return { + key: `${DefaultFontStyle.id}:${fontStyle}`, + getElement: async () => { + const font = findFont(fontStyle); + if (!font) return null; + + const url: string = (font as any).$$_url; + const fontFaceRule: string = (font as any).$$_fontface; + if (!url || !fontFaceRule) return null; + + const fontFile = await (await fetch(url)).blob(); + const base64FontFile = await FileHelpers.blobToDataUrl(fontFile); + + const newFontFaceRule = fontFaceRule.replace(url, base64FontFile); + return ; + }, + }; +} +function findFont(name: TLDefaultFontStyle): FontFace | null { + const fontFamily = DefaultFontFamilies[name]; + for (const font of document.fonts) { + if (fontFamily.includes(font.family)) { + return font; + } + } + return null; +} +export function getFillDefForCanvas(): TLShapeUtilCanvasSvgDef { + return { + key: `${DefaultFontStyle.id}:pattern`, + component: PatternFillDefForCanvas, + }; +} +function findHtmlLayerParent(element: Element): HTMLElement | null { + if (element.classList.contains("tl-html-layer")) + return element as HTMLElement; + if (element.parentElement) return findHtmlLayerParent(element.parentElement); + return null; +} +const canvasBlob = ( + size: [number, number], + fn: (ctx: CanvasRenderingContext2D) => void, +) => { + const canvas = document.createElement("canvas"); + canvas.width = size[0]; + canvas.height = size[1]; + const ctx = canvas.getContext("2d"); + if (!ctx) return ""; + fn(ctx); + return canvas.toDataURL(); +}; +const generateImage = (dpr: number, currentZoom: number, darkMode: boolean) => { + return new Promise((resolve, reject) => { + const size = TILE_PATTERN_SIZE * currentZoom * dpr; + + const canvasEl = document.createElement("canvas"); + canvasEl.width = size; + canvasEl.height = size; + + const ctx = canvasEl.getContext("2d"); + if (!ctx) return; + + ctx.fillStyle = darkMode + ? DefaultColorThemePalette.darkMode.solid + : DefaultColorThemePalette.lightMode.solid; + ctx.fillRect(0, 0, size, size); + + // This essentially generates an inverse of the pattern we're drawing. + ctx.globalCompositeOperation = "destination-out"; + + ctx.lineCap = "round"; + ctx.lineWidth = 1.25 * currentZoom * dpr; + + const t = 8 / 12; + const s = (v: number) => v * currentZoom * dpr; + + ctx.beginPath(); + ctx.moveTo(s(t * 1), s(t * 3)); + ctx.lineTo(s(t * 3), s(t * 1)); + + ctx.moveTo(s(t * 5), s(t * 7)); + ctx.lineTo(s(t * 7), s(t * 5)); + + ctx.moveTo(s(t * 9), s(t * 11)); + ctx.lineTo(s(t * 11), s(t * 9)); + ctx.stroke(); + + canvasEl.toBlob((blob) => { + if ( + !blob + // || debugFlags.throwToBlob.get() + ) { + reject(); + } else { + resolve(blob); + } + }); + }); +}; +function getDefaultPixels() { + if (!defaultPixels) { + defaultPixels = { + white: canvasBlob([1, 1], (ctx) => { + ctx.fillStyle = "#f8f9fa"; + ctx.fillRect(0, 0, 1, 1); + }), + black: canvasBlob([1, 1], (ctx) => { + ctx.fillStyle = "#212529"; + ctx.fillRect(0, 0, 1, 1); + }), + }; + } + return defaultPixels; +} +function getPatternLodsToGenerate(maxZoom: number) { + const levels = []; + const minLod = 0; + const maxLod = getPatternLodForZoomLevel(maxZoom); + for (let i = minLod; i <= maxLod; i++) { + levels.push(Math.pow(2, i)); + } + return levels; +} +function getDefaultPatterns(maxZoom: number): PatternDef[] { + const defaultPixels = getDefaultPixels(); + return getPatternLodsToGenerate(maxZoom).flatMap((zoom) => [ + { zoom, url: defaultPixels.white, theme: "light" }, + { zoom, url: defaultPixels.black, theme: "dark" }, + ]); +} +function PatternFillDefForCanvas() { + const editor = useEditor(); + const containerRef = useRef(null); + const { defs, isReady } = usePattern(); + + useEffect(() => { + if (isReady && editor.environment.isSafari) { + const htmlLayer = findHtmlLayerParent(containerRef.current!); + if (htmlLayer) { + // Wait for `patternContext` to be picked up + editor.timers.requestAnimationFrame(() => { + htmlLayer.style.display = "none"; + + // Wait for 'display = "none"' to take effect + editor.timers.requestAnimationFrame(() => { + htmlLayer.style.display = ""; + }); + }); + } + } + }, [editor, isReady]); + + return ( + + {defs} + + ); +} +function usePattern() { + const editor = useEditor(); + const dpr = useValue( + "devicePixelRatio", + () => editor.getInstanceState().devicePixelRatio, + [editor], + ); + const maxZoom = useValue( + "maxZoom", + () => Math.ceil(last(editor.getCameraOptions().zoomSteps)!), + [editor], + ); + const [isReady, setIsReady] = useState(false); + const [backgroundUrls, setBackgroundUrls] = useState(() => + getDefaultPatterns(maxZoom), + ); + + useEffect(() => { + if (process.env.NODE_ENV === "test") { + setIsReady(true); + return; + } + + const promise = Promise.all( + getPatternLodsToGenerate(maxZoom).flatMap>((zoom) => [ + generateImage(dpr, zoom, false).then((blob) => ({ + zoom, + theme: "light", + url: URL.createObjectURL(blob), + })), + generateImage(dpr, zoom, true).then((blob) => ({ + zoom, + theme: "dark", + url: URL.createObjectURL(blob), + })), + ]), + ); + + let isCancelled = false; + promise.then((urls) => { + if (isCancelled) return; + setBackgroundUrls(urls); + setIsReady(true); + }); + return () => { + isCancelled = true; + setIsReady(false); + promise.then((patterns) => { + for (const { url } of patterns) { + URL.revokeObjectURL(url); + } + }); + }; + }, [dpr, maxZoom]); + + const defs = ( + <> + {backgroundUrls.map((item) => { + const id = getHashPatternZoomName(item.zoom, item.theme); + return ( + + + + ); + })} + + ); + + return { defs, isReady }; +} +function last(arr: readonly T[]): T | undefined { + return arr[arr.length - 1]; +} +export function ArrowheadDotDef() { + return ( + + + + ); +} +export function ArrowheadCrossDef() { + return ( + + + + + ); +} +function getCurvedArrowHandlePath(info: RelationInfo & { isStraight: false }) { + const { + start, + end, + handleArc: { radius, largeArcFlag, sweepFlag }, + } = info; + return `M${start.handle.x},${start.handle.y} A${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end.handle.x},${end.handle.y}`; +} +function isLegacyAlign(align: TLDefaultHorizontalAlignStyle | string): boolean { + return ( + align === "start-legacy" || + align === "middle-legacy" || + align === "end-legacy" + ); +} +function HashPatternForExport() { + const theme = useDefaultColorTheme(); + const t = 8 / 12; + return ( + <> + + + + + + + + + + + + + ); +} +export function removeArrowBinding( + editor: Editor, + relation: DiscourseRelationShape, + terminal: "start" | "end", +) { + const existing = editor + .getBindingsFromShape(relation, relation.type) // we expect relation.type to = binding.type + .filter((b) => b.props.terminal === terminal); + + editor.deleteBindings(existing); +} +export function createOrUpdateArrowBinding( + editor: Editor, + relation: DiscourseRelationShape, + target: TLShape | TLShapeId, + props: TLArrowBindingProps, + shouldCreateRelation = false, +) { + const arrowId = typeof relation === "string" ? relation : relation.id; + const targetId = typeof target === "string" ? target : target.id; + + const existingMany = editor + .getBindingsFromShape( + arrowId, + relation.type, // we expect relation.type to = binding.type + ) + .filter((b) => b.props.terminal === props.terminal); + + // if we've somehow ended up with too many bindings, delete the extras + if (existingMany.length > 1) { + editor.deleteBindings(existingMany.slice(1)); + } + + const existing = existingMany[0]; + if (existing) { + editor.updateBinding({ + ...existing, + toId: targetId, + props, + }); + } else { + editor.createBinding({ + type: relation.type, + fromId: arrowId, + toId: targetId, + props, + }); + const util = editor.getShapeUtil({ + id: relation.id, + type: relation.type, + }); + if (util instanceof DiscourseRelationUtil && shouldCreateRelation) { + // @ts-expect-error TODO: fix this + util?.handleCreateRelationsInRoam({ + arrow: relation, + targetId, + }); + } + } +} +export const shapeAtTranslationStart = new WeakMap< + DiscourseRelationShape, + { + pagePosition: Vec; + terminalBindings: Record< + "start" | "end", + { + pagePosition: Vec; + shapePosition: Vec; + binding: RelationBinding; + } | null + >; + } +>(); +export function mapObjectMapValues( + object: { readonly [K in Key]: ValueBefore }, + mapper: (key: Key, value: ValueBefore) => ValueAfter, +): { [K in Key]: ValueAfter } { + const result = {} as { [K in Key]: ValueAfter }; + for (const [key, value] of objectMapEntries(object)) { + const newValue = mapper(key, value); + result[key] = newValue; + } + return result; +} +function objectMapEntries(object: { + [K in Key]: Value; +}): Array<[Key, Value]> { + return Object.entries(object) as [Key, Value][]; +} +export function updateArrowTerminal({ + editor, + relation, + terminal, + unbind = false, + useHandle = false, +}: { + editor: Editor; + relation: DiscourseRelationShape; + terminal: "start" | "end"; + unbind?: boolean; + useHandle?: boolean; +}) { + const info = getArrowInfo(editor, relation); + if (!info) { + throw new Error("expected arrow info"); + } + + const startPoint = useHandle ? info.start.handle : info.start.point; + const endPoint = useHandle ? info.end.handle : info.end.point; + const point = terminal === "start" ? startPoint : endPoint; + + const update = { + id: relation.id, + type: relation.type, + props: { + [terminal]: { x: point.x, y: point.y }, + bend: relation.props.bend, + }, + } satisfies TLShapePartial; + + // fix up the bend: + if (!info.isStraight) { + // find the new start/end points of the resulting arrow + const newStart = terminal === "start" ? startPoint : info.start.handle; + const newEnd = terminal === "end" ? endPoint : info.end.handle; + const newMidPoint = Vec.Med(newStart, newEnd); + + // intersect a line segment perpendicular to the new arrow with the old arrow arc to + // find the new mid-point + const lineSegment = Vec.Sub(newStart, newEnd) + .per() + .uni() + .mul(info.handleArc.radius * 2 * Math.sign(relation.props.bend)); + + // find the intersections with the old arrow arc: + const intersections = intersectLineSegmentCircle( + info.handleArc.center, + Vec.Add(newMidPoint, lineSegment), + info.handleArc.center, + info.handleArc.radius, + ); + + if (intersections?.length !== 1) { + throw new Error("expected 1 intersection"); + return; + } + const bend = + Vec.Dist(newMidPoint, intersections[0]!) * Math.sign(relation.props.bend); + // use `approximately` to avoid endless update loops + if (!approximately(bend, update.props.bend)) { + update.props.bend = bend; + } + } + + editor.updateShape(update); + if (unbind) { + removeArrowBinding(editor, relation, terminal); + } +} +function intersectLineSegmentCircle( + a1: VecLike, + a2: VecLike, + c: VecLike, + r: number, +) { + const a = (a2.x - a1.x) * (a2.x - a1.x) + (a2.y - a1.y) * (a2.y - a1.y); + const b = 2 * ((a2.x - a1.x) * (a1.x - c.x) + (a2.y - a1.y) * (a1.y - c.y)); + const cc = + c.x * c.x + + c.y * c.y + + a1.x * a1.x + + a1.y * a1.y - + 2 * (c.x * a1.x + c.y * a1.y) - + r * r; + const deter = b * b - 4 * a * cc; + + if (deter < 0) return null; // outside + if (deter === 0) return null; // tangent + + const e = Math.sqrt(deter); + const u1 = (-b + e) / (2 * a); + const u2 = (-b - e) / (2 * a); + + if ((u1 < 0 || u1 > 1) && (u2 < 0 || u2 > 1)) { + return null; // outside or inside + // if ((u1 < 0 && u2 < 0) || (u1 > 1 && u2 > 1)) { + // return null // outside + // } else return null // inside' + } + + const result: VecLike[] = []; + + if (0 <= u1 && u1 <= 1) result.push(Vec.Lrp(a1, a2, u1)); + if (0 <= u2 && u2 <= 1) result.push(Vec.Lrp(a1, a2, u2)); + + if (result.length === 0) return null; // no intersection + + return result; +} +export const assert: (value: unknown, message?: string) => asserts value = + omitFromStackTrace((value, message) => { + if (!value) { + throw new Error(message || "Assertion Error"); + } + }); +export function omitFromStackTrace, Return>( + fn: (...args: Args) => Return, +): (...args: Args) => Return { + const wrappedFn = (...args: Args) => { + try { + return fn(...args); + } catch (error) { + if (error instanceof Error && Error.captureStackTrace) { + Error.captureStackTrace(error, wrappedFn); + } + throw error; + } + }; + + return wrappedFn; +} +export function approximately(a: number, b: number, precision = 0.000001) { + return Math.abs(a - b) <= precision; +} +export const ArrowSvg = track(function ArrowSvg({ + shape, + shouldDisplayHandles, +}: // color, +{ + shape: DiscourseRelationShape; + shouldDisplayHandles: boolean; + // color: string; +}) { + const editor = useEditor(); + // const theme = useDefaultColorTheme(); + const info = getArrowInfo(editor, shape); + const bounds = Box.ZeroFix(editor.getShapeGeometry(shape).bounds); + const bindings = getArrowBindings(editor, shape); + + const changeIndex = React.useMemo(() => { + return editor.environment.isSafari ? (globalRenderIndex += 1) : 0; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shape]); + + if (!info?.isValid) return null; + + const strokeWidth = STROKE_SIZES[shape.props.size] * shape.props.scale; + + const as = + info.start.arrowhead && getArrowheadPathForType(info, "start", strokeWidth); + const ae = + info.end.arrowhead && getArrowheadPathForType(info, "end", strokeWidth); + + const path = info.isStraight + ? getSolidStraightArrowPath(info) + : getSolidCurvedArrowPath(info); + + let handlePath: null | React.ReactNode = null; + + if (shouldDisplayHandles) { + const sw = 2 / editor.getZoomLevel(); + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + getLength(editor, shape), + sw, + { + end: "skip", + start: "skip", + lengthRatio: 2.5, + }, + ); + + handlePath = + bindings.start || bindings.end ? ( + + ) : null; + } + + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + info.isStraight ? info.length : Math.abs(info.bodyArc.length), + strokeWidth, + { + style: shape.props.dash, + }, + ); + + const labelPosition = getArrowLabelPosition(editor, shape); + + const maskStartArrowhead = !( + info.start.arrowhead === "none" || info.start.arrowhead === "arrow" + ); + const maskEndArrowhead = !( + info.end.arrowhead === "none" || info.end.arrowhead === "arrow" + ); + + // NOTE: I know right setting `changeIndex` hacky-as right! But we need this because otherwise safari loses + // the mask, see + const maskId = (shape.id + "_clip_" + changeIndex).replace(":", "_"); + + return ( + <> + {/* Yep */} + + + + {shape.props.text.trim() && ( + + )} + {as && maskStartArrowhead && ( + + )} + {ae && maskEndArrowhead && ( + + )} + + + + {handlePath} + {/* firefox will clip if you provide a maskURL even if there is no mask matching that URL in the DOM */} + + + + + {as && maskStartArrowhead && shape.props.fill !== "none" && ( + + )} + {ae && maskEndArrowhead && shape.props.fill !== "none" && ( + + )} + {as && } + {ae && } + + + ); +}); +export const ShapeFill = React.memo(function ShapeFill({ + // theme, + d, + color, + fill, + scale, +}: ShapeFillProps) { + switch (fill) { + case "none": { + return null; + } + case "solid": { + return ; + } + case "semi": { + return ; + } + case "fill": { + return ; + } + case "pattern": { + return ( + + ); + } + } +}); +function getIsArrowStraight(shape: DiscourseRelationShape) { + return Math.abs(shape.props.bend) < MIN_ARROW_BEND * shape.props.scale; // snap to +-8px +} +function getCurvedArrowInfo( + editor: Editor, + shape: DiscourseRelationShape, + bindings: RelationBindings, +): RelationInfo { + const { arrowheadEnd, arrowheadStart } = shape.props; + const bend = shape.props.bend; + + if ( + Math.abs(bend) > + Math.abs( + shape.props.bend * (WAY_TOO_BIG_ARROW_BEND_FACTOR * shape.props.scale), + ) + ) { + return getStraightArrowInfo(editor, shape, bindings); + } + + const terminalsInArrowSpace = getArrowTerminalsInArrowSpace( + editor, + shape, + bindings, + ); + + const med = Vec.Med(terminalsInArrowSpace.start, terminalsInArrowSpace.end); // point between start and end + const distance = Vec.Sub( + terminalsInArrowSpace.end, + terminalsInArrowSpace.start, + ); + // Check for divide-by-zero before we call uni() + const u = Vec.Len(distance) ? distance.uni() : Vec.From(distance); // unit vector between start and end + const middle = Vec.Add(med, u.per().mul(-bend)); // middle handle + + const startShapeInfo = getBoundShapeInfoForTerminal(editor, shape, "start"); + const endShapeInfo = getBoundShapeInfoForTerminal(editor, shape, "end"); + + // The positions of the body of the arrow, which may be different + // than the arrow's start / end points if the arrow is bound to shapes + const a = terminalsInArrowSpace.start.clone(); + const b = terminalsInArrowSpace.end.clone(); + const c = middle.clone(); + + if (Vec.Equals(a, b)) { + return { + bindings, + isStraight: true, + start: { + handle: a, + point: a, + arrowhead: shape.props.arrowheadStart, + }, + end: { + handle: b, + point: b, + arrowhead: shape.props.arrowheadEnd, + }, + middle: c, + isValid: false, + length: 0, + }; + } + + const isClockwise = shape.props.bend < 0; + const distFn = isClockwise ? clockwiseAngleDist : counterClockwiseAngleDist; + + const handleArc = getArcInfo(a, b, c); + const handle_aCA = Vec.Angle(handleArc.center, a); + const handle_aCB = Vec.Angle(handleArc.center, b); + const handle_dAB = distFn(handle_aCA, handle_aCB); + + if ( + handleArc.length === 0 || + handleArc.size === 0 || + !isSafeFloat(handleArc.length) || + !isSafeFloat(handleArc.size) + ) { + return getStraightArrowInfo(editor, shape, bindings); + } + + const tempA = a.clone(); + const tempB = b.clone(); + const tempC = c.clone(); + + const arrowPageTransform = editor.getShapePageTransform(shape)!; + + let offsetA = 0; + let offsetB = 0; + + let minLength = MIN_ARROW_LENGTH * shape.props.scale; + + if (startShapeInfo && !startShapeInfo.isExact) { + const startInPageSpace = Mat.applyToPoint(arrowPageTransform, tempA); + const centerInPageSpace = Mat.applyToPoint( + arrowPageTransform, + handleArc.center, + ); + const endInPageSpace = Mat.applyToPoint(arrowPageTransform, tempB); + + const inverseTransform = Mat.Inverse(startShapeInfo.transform); + + const startInStartShapeLocalSpace = Mat.applyToPoint( + inverseTransform, + startInPageSpace, + ); + const centerInStartShapeLocalSpace = Mat.applyToPoint( + inverseTransform, + centerInPageSpace, + ); + const endInStartShapeLocalSpace = Mat.applyToPoint( + inverseTransform, + endInPageSpace, + ); + + const { isClosed } = startShapeInfo; + const fn = isClosed ? intersectCirclePolygon : intersectCirclePolyline; + + let point: VecLike | undefined; + + let intersections = fn( + centerInStartShapeLocalSpace, + handleArc.radius, + startShapeInfo.outline, + ); + + if (intersections) { + const angleToStart = centerInStartShapeLocalSpace.angle( + startInStartShapeLocalSpace, + ); + const angleToEnd = centerInStartShapeLocalSpace.angle( + endInStartShapeLocalSpace, + ); + const dAB = distFn(angleToStart, angleToEnd); + + // Filter out any intersections that aren't in the arc + intersections = intersections.filter( + (pt) => + distFn(angleToStart, centerInStartShapeLocalSpace.angle(pt)) <= dAB, + ); + + const targetDist = dAB * 0.25; + + intersections.sort( + isClosed + ? (p0, p1) => + Math.abs( + distFn(angleToStart, centerInStartShapeLocalSpace.angle(p0)) - + targetDist, + ) < + Math.abs( + distFn(angleToStart, centerInStartShapeLocalSpace.angle(p1)) - + targetDist, + ) + ? -1 + : 1 + : (p0, p1) => + distFn(angleToStart, centerInStartShapeLocalSpace.angle(p0)) < + distFn(angleToStart, centerInStartShapeLocalSpace.angle(p1)) + ? -1 + : 1, + ); + + point = + intersections[0] ?? + (isClosed ? undefined : startInStartShapeLocalSpace); + } else { + point = isClosed ? undefined : startInStartShapeLocalSpace; + } + + if (point) { + tempA.setTo( + editor.getPointInShapeSpace( + shape, + Mat.applyToPoint(startShapeInfo.transform, point), + ), + ); + + startShapeInfo.didIntersect = true; + + if (arrowheadStart !== "none") { + const strokeOffset = + STROKE_SIZES[shape.props.size] / 2 + + ("size" in startShapeInfo.shape.props + ? STROKE_SIZES[startShapeInfo.shape.props.size] / 2 + : 0); + offsetA = (BOUND_ARROW_OFFSET + strokeOffset) * shape.props.scale; + minLength += strokeOffset * shape.props.scale; + } + } + } + + if (endShapeInfo && !endShapeInfo.isExact) { + // get points in shape's coordinates? + const startInPageSpace = Mat.applyToPoint(arrowPageTransform, tempA); + const endInPageSpace = Mat.applyToPoint(arrowPageTransform, tempB); + const centerInPageSpace = Mat.applyToPoint( + arrowPageTransform, + handleArc.center, + ); + + const inverseTransform = Mat.Inverse(endShapeInfo.transform); + + const startInEndShapeLocalSpace = Mat.applyToPoint( + inverseTransform, + startInPageSpace, + ); + const centerInEndShapeLocalSpace = Mat.applyToPoint( + inverseTransform, + centerInPageSpace, + ); + const endInEndShapeLocalSpace = Mat.applyToPoint( + inverseTransform, + endInPageSpace, + ); + + const isClosed = endShapeInfo.isClosed; + const fn = isClosed ? intersectCirclePolygon : intersectCirclePolyline; + + let point: VecLike | undefined; + + let intersections = fn( + centerInEndShapeLocalSpace, + handleArc.radius, + endShapeInfo.outline, + ); + + if (intersections) { + const angleToStart = centerInEndShapeLocalSpace.angle( + startInEndShapeLocalSpace, + ); + const angleToEnd = centerInEndShapeLocalSpace.angle( + endInEndShapeLocalSpace, + ); + const dAB = distFn(angleToStart, angleToEnd); + const targetDist = dAB * 0.75; + + // or simplified... + + intersections = intersections.filter( + (pt) => + distFn(angleToStart, centerInEndShapeLocalSpace.angle(pt)) <= dAB, + ); + + intersections.sort( + isClosed + ? (p0, p1) => + Math.abs( + distFn(angleToStart, centerInEndShapeLocalSpace.angle(p0)) - + targetDist, + ) < + Math.abs( + distFn(angleToStart, centerInEndShapeLocalSpace.angle(p1)) - + targetDist, + ) + ? -1 + : 1 + : (p0, p1) => + distFn(angleToStart, centerInEndShapeLocalSpace.angle(p0)) < + distFn(angleToStart, centerInEndShapeLocalSpace.angle(p1)) + ? -1 + : 1, + ); + + if (intersections[0]) { + point = intersections[0]; + } else { + point = isClosed ? undefined : endInEndShapeLocalSpace; + } + } else { + point = isClosed ? undefined : endInEndShapeLocalSpace; + } + + if (point) { + // Set b to target local point -> page point -> shape local point + tempB.setTo( + editor.getPointInShapeSpace( + shape, + Mat.applyToPoint(endShapeInfo.transform, point), + ), + ); + + endShapeInfo.didIntersect = true; + + if (arrowheadEnd !== "none") { + const strokeOffset = + STROKE_SIZES[shape.props.size] / 2 + + ("size" in endShapeInfo.shape.props + ? STROKE_SIZES[endShapeInfo.shape.props.size] / 2 + : 0); + offsetB = (BOUND_ARROW_OFFSET + strokeOffset) * shape.props.scale; + minLength += strokeOffset * shape.props.scale; + } + } + } + + // Apply arrowhead offsets + + let aCA = Vec.Angle(handleArc.center, tempA); // angle center -> a + let aCB = Vec.Angle(handleArc.center, tempB); // angle center -> b + let dAB = distFn(aCA, aCB); // angle distance between a and b + let lAB = dAB * handleArc.radius; // length of arc between a and b + + // Try the offsets first, then check whether the distance between the points is too small; + // if it is, flip the offsets and expand them. We need to do this using temporary points + // so that we can apply them both in a balanced way. + const tA = tempA.clone(); + const tB = tempB.clone(); + + if (offsetA !== 0) { + tA.setTo(handleArc.center).add( + Vec.FromAngle(aCA + dAB * ((offsetA / lAB) * (isClockwise ? 1 : -1))).mul( + handleArc.radius, + ), + ); + } + + if (offsetB !== 0) { + tB.setTo(handleArc.center).add( + Vec.FromAngle(aCB + dAB * ((offsetB / lAB) * (isClockwise ? -1 : 1))).mul( + handleArc.radius, + ), + ); + } + + if (Vec.DistMin(tA, tB, minLength)) { + if (offsetA !== 0 && offsetB !== 0) { + offsetA *= -1.5; + offsetB *= -1.5; + } else if (offsetA !== 0) { + offsetA *= -2; + } else if (offsetB !== 0) { + offsetB *= -2; + } else { + // noop + } + } + + if (offsetA !== 0) { + tempA + .setTo(handleArc.center) + .add( + Vec.FromAngle( + aCA + dAB * ((offsetA / lAB) * (isClockwise ? 1 : -1)), + ).mul(handleArc.radius), + ); + } + + if (offsetB !== 0) { + tempB + .setTo(handleArc.center) + .add( + Vec.FromAngle( + aCB + dAB * ((offsetB / lAB) * (isClockwise ? -1 : 1)), + ).mul(handleArc.radius), + ); + } + + // Did we miss intersections? This happens when we have overlapping shapes. + if ( + startShapeInfo && + endShapeInfo && + !startShapeInfo.isExact && + !endShapeInfo.isExact + ) { + aCA = Vec.Angle(handleArc.center, tempA); // angle center -> a + aCB = Vec.Angle(handleArc.center, tempB); // angle center -> b + dAB = distFn(aCA, aCB); // angle distance between a and b + lAB = dAB * handleArc.radius; // length of arc between a and b + const relationship = getBoundShapeRelationships( + editor, + startShapeInfo.shape.id, + endShapeInfo.shape.id, + ); + + if (relationship === "double-bound" && lAB < 30) { + tempA.setTo(a); + tempB.setTo(b); + tempC.setTo(c); + } else if (relationship === "safe") { + if (startShapeInfo && !startShapeInfo.didIntersect) { + tempA.setTo(a); + } + + if ( + (endShapeInfo && !endShapeInfo.didIntersect) || + distFn(handle_aCA, aCA) > distFn(handle_aCA, aCB) + ) { + tempB + .setTo(handleArc.center) + .add( + Vec.FromAngle( + aCA + + dAB * + (Math.min(0.9, (MIN_ARROW_LENGTH * shape.props.scale) / lAB) * + (isClockwise ? 1 : -1)), + ).mul(handleArc.radius), + ); + } + } + } + + placeCenterHandle( + handleArc.center, + handleArc.radius, + tempA, + tempB, + tempC, + handle_dAB, + isClockwise, + ); + + if (tempA.equals(tempB)) { + tempA.setTo(tempC.clone().addXY(1, 1)); + tempB.setTo(tempC.clone().subXY(1, 1)); + } + + a.setTo(tempA); + b.setTo(tempB); + c.setTo(tempC); + const bodyArc = getArcInfo(a, b, c); + + return { + bindings, + isStraight: false, + start: { + point: a, + handle: terminalsInArrowSpace.start, + arrowhead: shape.props.arrowheadStart, + }, + end: { + point: b, + handle: terminalsInArrowSpace.end, + arrowhead: shape.props.arrowheadEnd, + }, + middle: c, + handleArc, + bodyArc, + isValid: + bodyArc.length !== 0 && + isFinite(bodyArc.center.x) && + isFinite(bodyArc.center.y), + }; +} +function getArcInfo(a: VecLike, b: VecLike, c: VecLike): TLArcInfo { + // find a circle from the three points + const center = centerOfCircleFromThreePoints(a, b, c); + + const radius = Vec.Dist(center, a); + + // Whether to draw the arc clockwise or counter-clockwise (are the points clockwise?) + const sweepFlag = +Vec.Clockwise(a, c, b); + + // The base angle of the arc in radians + const ab = ((a.y - b.y) ** 2 + (a.x - b.x) ** 2) ** 0.5; + const bc = ((b.y - c.y) ** 2 + (b.x - c.x) ** 2) ** 0.5; + const ca = ((c.y - a.y) ** 2 + (c.x - a.x) ** 2) ** 0.5; + + const theta = Math.acos((bc * bc + ca * ca - ab * ab) / (2 * bc * ca)) * 2; + + // Whether to draw the long arc or short arc + const largeArcFlag = +(PI > theta); + + // The size of the arc to draw in radians + const size = (PI2 - theta) * (sweepFlag ? 1 : -1); + + // The length of the arc to draw in distance units + const length = size * radius; + + return { + center, + radius, + size, + length, + largeArcFlag, + sweepFlag, + }; +} +function clockwiseAngleDist(a0: number, a1: number): number { + a0 = canonicalizeRotation(a0); + a1 = canonicalizeRotation(a1); + if (a0 > a1) { + a1 += PI2; + } + return a1 - a0; +} +function canonicalizeRotation(a: number) { + a = a % PI2; + if (a < 0) { + a = a + PI2; + } else if (a === 0) { + // prevent negative zero + a = 0; + } + return a; +} +function counterClockwiseAngleDist(a0: number, a1: number): number { + return PI2 - clockwiseAngleDist(a0, a1); +} +const isSafeFloat = (n: number) => { + return Math.abs(n) < Number.MAX_SAFE_INTEGER; +}; +function intersectCirclePolyline(c: VecLike, r: number, points: VecLike[]) { + const result: VecLike[] = []; + let a: VecLike | undefined, b: VecLike | undefined, int: VecLike[] | null; + + for (let i = 1, n = points.length; i < n; i++) { + a = points[i - 1]; + b = points[i]; + if (!a || !b) continue; + int = intersectLineSegmentCircle(a, b, c, r); + if (int) result.push(...int); + } + + if (result.length === 0) return null; // no intersection + + return result; +} +function placeCenterHandle( + center: VecLike, + radius: number, + tempA: Vec, + tempB: Vec, + tempC: Vec, + originalArcLength: number, + isClockwise: boolean, +) { + const aCA = Vec.Angle(center, tempA); // angle center -> a + const aCB = Vec.Angle(center, tempB); // angle center -> b + let dAB = clockwiseAngleDist(aCA, aCB); // angle distance between a and b + if (!isClockwise) dAB = PI2 - dAB; + + tempC + .setTo(center) + .add(Vec.FromAngle(aCA + dAB * (0.5 * (isClockwise ? 1 : -1))).mul(radius)); + + if (dAB > originalArcLength) { + tempC.rotWith(center, PI); + const t = tempB.clone(); + tempB.setTo(tempA); + tempA.setTo(t); + } +} +function centerOfCircleFromThreePoints(a: VecLike, b: VecLike, c: VecLike) { + const u = + -2 * (a.x * (b.y - c.y) - a.y * (b.x - c.x) + b.x * c.y - c.x * b.y); + return new Vec( + ((a.x * a.x + a.y * a.y) * (c.y - b.y) + + (b.x * b.x + b.y * b.y) * (a.y - c.y) + + (c.x * c.x + c.y * c.y) * (b.y - a.y)) / + u, + ((a.x * a.x + a.y * a.y) * (b.x - c.x) + + (b.x * b.x + b.y * b.y) * (c.x - a.x) + + (c.x * c.x + c.y * c.y) * (a.x - b.x)) / + u, + ); +} diff --git a/apps/obsidian/src/components/canvas/utils/tldraw.ts b/apps/obsidian/src/components/canvas/utils/tldraw.ts new file mode 100644 index 000000000..349af92ca --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/tldraw.ts @@ -0,0 +1,206 @@ +import { + createTLStore, + defaultBindingUtils, + defaultShapeUtils, + TldrawFile, + TLRecord, + TLStore, +} from "tldraw"; +import { + FRONTMATTER_KEY, + TLDATA_DELIMITER_END, + TLDATA_DELIMITER_START, + TLDRAW_VERSION, +} from "~/constants"; +import DiscourseGraphPlugin from "~/index"; +import { checkAndCreateFolder, getNewUniqueFilepath } from "~/utils/file"; +import { Notice } from "obsidian"; +import { format } from "date-fns"; +import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore"; +import { + DiscourseNodeUtil, + DiscourseNodeUtilOptions, +} from "~/components/canvas/shapes/DiscourseNodeShape"; +import { DiscourseRelationUtil } from "~/components/canvas/shapes/DiscourseRelationShape"; +import { DiscourseRelationBindingUtil } from "~/components/canvas/shapes/DiscourseRelationBinding"; + +export type TldrawPluginMetaData = { + "plugin-version": string; + "tldraw-version": string; + uuid: string; +}; + +export type TldrawRawData = { + tldrawFileFormatVersion: number; + schema: any; + records: any; +}; + +export type TLData = { + meta: TldrawPluginMetaData; + raw: TldrawRawData; +}; + +export const processInitialData = ( + data: TLData, + assetStore: ObsidianTLAssetStore, + ctx: DiscourseNodeUtilOptions, +): { meta: TldrawPluginMetaData; store: TLStore } => { + const customShapeUtils = [ + ...defaultShapeUtils, + DiscourseNodeUtil.configure(ctx), + DiscourseRelationUtil.configure(ctx), + ]; + + const recordsData = Array.isArray(data.raw.records) + ? data.raw.records.reduce( + (acc: Record, record: { id: string } & TLRecord) => { + acc[record.id] = { + ...record, + }; + return acc; + }, + {}, + ) + : data.raw.records; + + let store: TLStore; + if (recordsData) { + store = createTLStore({ + shapeUtils: customShapeUtils, + bindingUtils: [...defaultBindingUtils, DiscourseRelationBindingUtil], + initialData: recordsData, + assets: assetStore, + }); + } else { + store = createTLStore({ + shapeUtils: customShapeUtils, + bindingUtils: [...defaultBindingUtils, DiscourseRelationBindingUtil], + assets: assetStore, + }); + } + + return { + meta: data.meta, + store, + }; +}; + +export const createRawTldrawFile = (store?: TLStore): TldrawFile => { + store ??= createTLStore(); + return { + tldrawFileFormatVersion: 1, + schema: store.schema.serialize(), + records: store.allRecords(), + }; +}; + +export const getTLMetaTemplate = ( + pluginVersion: string, + uuid: string = window.crypto.randomUUID(), +): TldrawPluginMetaData => { + return { + uuid, + "plugin-version": pluginVersion, + "tldraw-version": TLDRAW_VERSION, + }; +}; + +export const getTLDataTemplate = ({ + pluginVersion, + tldrawFile, + uuid, +}: { + pluginVersion: string; + tldrawFile: TldrawFile; + uuid: string; +}): TLData => { + return { + meta: getTLMetaTemplate(pluginVersion, uuid), + raw: tldrawFile, + }; +}; + +export const frontmatterTemplate = (data: string, tags: string[] = []) => { + let str = "---\n"; + str += `${data}\n`; + if (tags.length) { + str += `tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]\n`; + } + str += "---\n"; + return str; +}; + +export const codeBlockTemplate = (data: TLData) => { + let str = "```json" + ` ${TLDATA_DELIMITER_START}`; + str += "\n"; + str += `${JSON.stringify(data, null, "\t")}\n`; + str += `${TLDATA_DELIMITER_END}\n`; + str += "```"; + return str; +}; + +export const tlFileTemplate = (frontmatter: string, codeblock: string) => { + return `${frontmatter}\n\n${codeblock}`; +}; + +export const createEmptyTldrawContent = ( + pluginVersion: string, + tags: string[] = [], +): string => { + const tldrawFile = createRawTldrawFile(); + const tlData = getTLDataTemplate({ + pluginVersion, + tldrawFile, + uuid: window.crypto.randomUUID(), + }); + const frontmatter = frontmatterTemplate(`${FRONTMATTER_KEY}: true`, tags); + const codeblock = codeBlockTemplate(tlData); + return tlFileTemplate(frontmatter, codeblock); +}; + +export const createCanvas = async (plugin: DiscourseGraphPlugin) => { + try { + const filename = `Canvas-${format(new Date(), "yyyy-MM-dd-HHmm")}`; + const folderpath = plugin.settings.canvasFolderPath; + const attachmentsFolder = + plugin.settings.canvasAttachmentsFolderPath; + + await checkAndCreateFolder(folderpath, plugin.app.vault); + await checkAndCreateFolder(attachmentsFolder, plugin.app.vault); + const fname = getNewUniqueFilepath({ + vault: plugin.app.vault, + filename: filename + ".md", + folderpath, + }); + + const content = createEmptyTldrawContent(plugin.manifest.version); + const file = await plugin.app.vault.create(fname, content); + const leaf = plugin.app.workspace.getLeaf(false); + await leaf.openFile(file); + + return file; + } catch (e) { + new Notice(e instanceof Error ? e.message : "Failed to create canvas file"); + console.error(e); + } +}; + +/** + * Get the updated markdown content with the new TLData + * @param currentContent - The current markdown content + * @param stringifiedData - The new TLData stringified + * @returns The updated markdown content + */ +export const getUpdatedMdContent = ( + currentContent: string, + stringifiedData: string, +) => { + const regex = new RegExp( + `${TLDATA_DELIMITER_START}([\\s\\S]*?)${TLDATA_DELIMITER_END}`, + ); + return currentContent.replace( + regex, + `${TLDATA_DELIMITER_START}\n${stringifiedData}\n${TLDATA_DELIMITER_END}`, + ); +}; diff --git a/apps/obsidian/src/components/canvas/utils/toastUtils.ts b/apps/obsidian/src/components/canvas/utils/toastUtils.ts new file mode 100644 index 000000000..06d6fc2e0 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/toastUtils.ts @@ -0,0 +1,21 @@ +import { TLUiToast } from "tldraw"; +import { dispatchToastEvent } from "~/components/canvas/ToastListener"; + +export const showToast = ({ + severity, + title, + description, +}: { + severity: TLUiToast["severity"]; + title: string; + description?: string; +}) => { + const toast: TLUiToast = { + id: `${severity}-${Date.now()}`, + title, + description, + severity, + keepOpen: false, + }; + dispatchToastEvent(toast); +}; \ No newline at end of file diff --git a/apps/obsidian/src/constants.ts b/apps/obsidian/src/constants.ts index 73258322e..3441b9d7b 100644 --- a/apps/obsidian/src/constants.ts +++ b/apps/obsidian/src/constants.ts @@ -26,16 +26,19 @@ export const DEFAULT_RELATION_TYPES: Record = { id: generateUid("relation"), label: "supports", complement: "is supported by", + color: "#099268", }, opposes: { id: generateUid("relation"), label: "opposes", complement: "is opposed by", + color: "#e03131", }, informs: { id: generateUid("relation"), label: "informs", complement: "is informed by", + color: "#adb5bd", }, }; @@ -61,4 +64,24 @@ export const DEFAULT_SETTINGS: Settings = { ], showIdsInFrontmatter: false, nodesFolderPath: "", + canvasFolderPath: "Discourse Canvas", + canvasAttachmentsFolderPath: "attachments", }; +export const FRONTMATTER_KEY = "tldr-dg"; +export const TLDATA_DELIMITER_START = + "!!!_START_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!"; +export const TLDATA_DELIMITER_END = + "!!!_END_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!"; + +export const VIEW_TYPE_MARKDOWN = "markdown"; +export const VIEW_TYPE_TLDRAW_DG_PREVIEW = "tldraw-dg-preview"; + +export const TLDRAW_VERSION = "3.14.1"; +export const DEFAULT_SAVE_DELAY = 500; // in ms +export const WHITE_LOGO_SVG = + ''; +export const TOOL_ARROW_ICON_SVG = + ''; + +export const NODE_COLOR_ICON_SVG = + ''; diff --git a/apps/obsidian/src/icons.ts b/apps/obsidian/src/icons.ts new file mode 100644 index 000000000..e8e1d0b31 --- /dev/null +++ b/apps/obsidian/src/icons.ts @@ -0,0 +1,8 @@ +export const WHITE_LOGO_SVG = + ''; + +export const TOOL_ARROW_ICON_SVG = + ''; + +export const NODE_COLOR_ICON_SVG = + ''; diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 6a3597805..5d07d500b 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,25 +1,82 @@ -import { Plugin, Editor, Menu, TFile, Events } from "obsidian"; +import { + Plugin, + Editor, + Menu, + TFile, + MarkdownView, + WorkspaceLeaf, +} from "obsidian"; import { SettingsTab } from "~/components/Settings"; -import { Settings } from "~/types"; +import { Settings, VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types"; import { registerCommands } from "~/utils/registerCommands"; import { DiscourseContextView } from "~/components/DiscourseContextView"; -import { VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types"; +import { VIEW_TYPE_TLDRAW_DG_PREVIEW, FRONTMATTER_KEY } from "~/constants"; import { convertPageToDiscourseNode, createDiscourseNode, } from "~/utils/createNode"; import { DEFAULT_SETTINGS } from "~/constants"; import { CreateNodeModal } from "~/components/CreateNodeModal"; +import { TldrawView } from "~/components/canvas/TldrawView"; export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; private styleElement: HTMLStyleElement | null = null; + private currentViewActions: { leaf: WorkspaceLeaf; action: any }[] = []; async onload() { await this.loadSettings(); registerCommands(this); this.addSettingTab(new SettingsTab(this.app, this)); + this.registerEvent( + this.app.workspace.on( + "active-leaf-change", + (leaf: WorkspaceLeaf | null) => { + this.cleanupViewActions(); + + if (!leaf) return; + + const view = leaf.view; + if (!(view instanceof MarkdownView)) return; + + const file = view.file; + if (!file) return; + + const cache = this.app.metadataCache.getFileCache(file); + if (cache?.frontmatter?.[FRONTMATTER_KEY]) { + // Add new action and track it + const action = view.addAction( + "layout", + "View as canvas", + async () => { + await leaf.setViewState({ + type: VIEW_TYPE_TLDRAW_DG_PREVIEW, + state: view.getState(), + }); + }, + ); + + this.currentViewActions.push({ leaf, action }); + } + }, + ), + // @ts-ignore - file-open event exists but is not in the type definitions + this.app.workspace.on("file-open", (file: TFile) => { + const cache = this.app.metadataCache.getFileCache(file); + if (cache?.frontmatter?.[FRONTMATTER_KEY]) { + const leaf = + this.app.workspace.getActiveViewOfType(MarkdownView)?.leaf; + if (leaf) { + leaf.setViewState({ + type: VIEW_TYPE_TLDRAW_DG_PREVIEW, + state: leaf.view.getState(), + }); + } + } + }), + ); + this.registerView( VIEW_TYPE_DISCOURSE_CONTEXT, (leaf) => new DiscourseContextView(leaf, this), @@ -32,6 +89,11 @@ export default class DiscourseGraphPlugin extends Plugin { // Initialize frontmatter CSS this.updateFrontmatterStyles(); + this.registerView( + VIEW_TYPE_TLDRAW_DG_PREVIEW, + (leaf) => new TldrawView(leaf, this), + ); + this.registerEvent( // @ts-ignore - file-menu event exists but is not in the type definitions this.app.workspace.on("file-menu", (menu: Menu, file: TFile) => { @@ -195,11 +257,28 @@ export default class DiscourseGraphPlugin extends Plugin { this.updateFrontmatterStyles(); } + private cleanupViewActions() { + this.currentViewActions.forEach(({ leaf, action }) => { + try { + if (leaf?.view) { + if (action?.remove) { + action.remove(); + } else if (action?.detach) { + action.detach(); + } + } + } catch (e) { + console.error("Failed to cleanup view action:", e); + } + }); + this.currentViewActions = []; + } + async onunload() { + this.cleanupViewActions(); if (this.styleElement) { this.styleElement.remove(); } - this.app.workspace.detachLeavesOfType(VIEW_TYPE_DISCOURSE_CONTEXT); } } diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index fba2c83ae..019169b30 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -9,29 +9,89 @@ type AppWithPlugins = App & { plugins: { plugins: { [key: string]: { - api: any; + api: unknown; }; }; }; }; +type DatacorePage = { + $name: string; + $path?: string; +}; + export class QueryEngine { private app: App; - private dc: any; + private dc: + | { + query: (query: string) => DatacorePage[]; + } + | undefined; private readonly MIN_QUERY_LENGTH = 2; constructor(app: App) { const appWithPlugins = app as AppWithPlugins; - this.dc = appWithPlugins.plugins?.plugins?.["datacore"]?.api; + this.dc = appWithPlugins.plugins?.plugins?.["datacore"]?.api as + | { query: (query: string) => DatacorePage[] } + | undefined; this.app = app; } - async searchCompatibleNodeByTitle( + /** + * Search across all Discourse Nodes (files that have frontmatter nodeTypeId) + */ + searchDiscourseNodesByTitle = async ( query: string, - compatibleNodeTypeIds: string[], - activeFile: TFile, - selectedRelationType: string, - ): Promise { + nodeTypeId?: string, + ): Promise => { + if (!query || query.length < this.MIN_QUERY_LENGTH) { + return []; + } + if (!this.dc) { + console.warn( + "Datacore API not available. Search functionality is not available.", + ); + return []; + } + + try { + const dcQuery = nodeTypeId + ? `@page and exists(nodeTypeId) and nodeTypeId = "${nodeTypeId}"` + : "@page and exists(nodeTypeId)"; + const potentialNodes = await this.dc.query(dcQuery); + + const searchResults = potentialNodes.filter((p: DatacorePage) => + this.fuzzySearch(p.$name, query), + ); + + const files = searchResults + .map((dcFile: DatacorePage) => { + if (dcFile && dcFile.$path) { + const realFile = this.app.vault.getAbstractFileByPath(dcFile.$path); + if (realFile && realFile instanceof TFile) return realFile; + } + return null; + }) + .filter((f): f is TFile => f instanceof TFile); + + return files; + } catch (error) { + console.error("Error in searchDiscourseNodesByTitle:", error); + return []; + } + }; + + searchCompatibleNodeByTitle = async ({ + query, + compatibleNodeTypeIds, + activeFile, + selectedRelationType, + }: { + query: string; + compatibleNodeTypeIds: string[]; + activeFile: TFile; + selectedRelationType: string; + }): Promise => { if (!query || query.length < this.MIN_QUERY_LENGTH) { return []; } @@ -48,31 +108,32 @@ export class QueryEngine { .join(" or ")}`; const potentialNodes = this.dc.query(dcQuery); - const searchResults = potentialNodes.filter((p: any) => { + const searchResults = potentialNodes.filter((p: DatacorePage) => { return this.fuzzySearch(p.$name, query); }); let existingRelatedFiles: string[] = []; if (selectedRelationType) { const fileCache = this.app.metadataCache.getFileCache(activeFile); - const existingRelations = - fileCache?.frontmatter?.[selectedRelationType] || []; + const existingRelations: string[] = + (fileCache?.frontmatter?.[selectedRelationType] as string[]) || []; existingRelatedFiles = existingRelations.map((relation: string) => { const match = relation.match(/\[\[(.*?)(?:\|.*?)?\]\]/); - return match ? match[1] : relation.replace(/^\[\[|\]\]$/g, ""); + return match?.[1] ?? relation.replace(/^\[\[|\]\]$/g, ""); }); } const finalResults = searchResults - .map((dcFile: any) => { + .map((dcFile: DatacorePage) => { if (dcFile && dcFile.$path) { const realFile = this.app.vault.getAbstractFileByPath(dcFile.$path); if (realFile && realFile instanceof TFile) { return realFile; } } - return dcFile as TFile; + return null; }) + .filter((f): f is TFile => f instanceof TFile) .filter((file: TFile) => { if (file.path === activeFile.path) return false; @@ -96,7 +157,7 @@ export class QueryEngine { console.error("Error in searchNodeByTitle:", error); return []; } - } + }; /** * Enhanced fuzzy search implementation @@ -187,6 +248,7 @@ export class QueryEngine { ); if (regex.test(fileName)) { + if (!page.$path) continue; const file = this.app.vault.getAbstractFileByPath(page.$path); if (file && file instanceof TFile) { const extractedContent = extractContentFromTitle( diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css index e69de29bb..93c597c51 100644 --- a/apps/obsidian/src/styles/style.css +++ b/apps/obsidian/src/styles/style.css @@ -0,0 +1,3793 @@ + + +/* We copy the styling from tldraw/tldraw.css here due to compilation failure */ +/* This file is created by the copy-css-files.mjs script in packages/tldraw. */ +/* It combines @tldraw/editor's editor.css and tldraw's ui.css */ + +/* @tldraw/editor */ + +.tl-container { + width: 100%; + height: 100%; + font-size: 12px; + /* Spacing */ + --space-1: 2px; + --space-2: 4px; + --space-3: 8px; + --space-4: 12px; + --space-5: 16px; + --space-6: 20px; + --space-7: 28px; + --space-8: 32px; + --space-9: 64px; + --space-10: 72px; + /* Radius */ + --radius-0: 2px; + --radius-1: 4px; + --radius-2: 6px; + --radius-3: 9px; + --radius-4: 11px; + + /* Canvas z-index */ + --layer-canvas-hidden: -999999; + --layer-canvas-background: 100; + --layer-canvas-grid: 150; + --layer-watermark: 200; + --layer-canvas-shapes: 300; + --layer-canvas-overlays: 500; + --layer-canvas-blocker: 10000; + + /* Canvas overlays z-index */ + --layer-overlays-collaborator-scribble: 10; + --layer-overlays-collaborator-brush: 20; + --layer-overlays-collaborator-shape-indicator: 30; + --layer-overlays-user-scribble: 40; + --layer-overlays-user-brush: 50; + --layer-overlays-user-snapline: 90; + --layer-overlays-selection-fg: 100; + /* User handles need to be above selection edges / corners, matters for sticky note clone handles */ + --layer-overlays-user-handles: 105; + --layer-overlays-user-indicator-hint: 110; + --layer-overlays-custom: 115; + --layer-overlays-collaborator-cursor-hint: 120; + --layer-overlays-collaborator-cursor: 130; + + /* Text editor z-index */ + --layer-text-container: 1; + --layer-text-content: 3; + --layer-text-editor: 4; + + /* Error fallback z-index */ + --layer-error-overlay: 1; + --layer-error-canvas: 2; + --layer-error-canvas-after: 3; + --layer-error-content: 4; + + /* Misc */ + --tl-zoom: 1; + + /* Cursor SVGs */ + --tl-cursor-none: none; + --tl-cursor-default: + url("data:image/svg+xml,") + 12 8, + default; + --tl-cursor-pointer: + url("data:image/svg+xml,") + 14 10, + pointer; + --tl-cursor-cross: + url("data:image/svg+xml,") + 16 16, + crosshair; + --tl-cursor-move: + url("data:image/svg+xml,") + 16 16, + move; + --tl-cursor-grab: + url("data:image/svg+xml,") + 16 16, + grab; + --tl-cursor-grabbing: + url("data:image/svg+xml,") + 16 16, + grabbing; + --tl-cursor-text: + url("data:image/svg+xml,") + 4 10, + text; + --tl-cursor-zoom-in: + url("data:image/svg+xml,") + 16 16, + zoom-in; + --tl-cursor-zoom-out: + url("data:image/svg+xml,") + 16 16, + zoom-out; + + /* These cursor values get programmatically overridden */ + /* They're just here to help your editor autocomplete */ + --tl-cursor: var(--tl-cursor-default); + --tl-cursor-resize-edge: ew-resize; + --tl-cursor-resize-corner: nesw-resize; + --tl-cursor-ew-resize: ew-resize; + --tl-cursor-ns-resize: ns-resize; + --tl-cursor-nesw-resize: nesw-resize; + --tl-cursor-nwse-resize: nwse-resize; + --tl-cursor-rotate: pointer; + --tl-cursor-nwse-rotate: pointer; + --tl-cursor-nesw-rotate: pointer; + --tl-cursor-senw-rotate: pointer; + --tl-cursor-swne-rotate: pointer; + --tl-scale: calc(1 / var(--tl-zoom)); + /* fonts */ + --tl-font-draw: 'tldraw_draw', sans-serif; + --tl-font-sans: 'tldraw_sans', sans-serif; + --tl-font-serif: 'tldraw_serif', serif; + --tl-font-mono: 'tldraw_mono', monospace; + /* text outline */ + --a: calc(min(0.5, 1 / var(--tl-zoom)) * 2px); + --b: calc(min(0.5, 1 / var(--tl-zoom)) * -2px); + --tl-text-outline-reference: + 0 var(--b) 0 var(--color-background), 0 var(--a) 0 var(--color-background), + var(--b) var(--b) 0 var(--color-background), var(--a) var(--b) 0 var(--color-background), + var(--a) var(--a) 0 var(--color-background), var(--b) var(--a) 0 var(--color-background); + --tl-text-outline: var(--tl-text-outline-reference); + /* own properties */ + position: relative; + inset: 0px; + height: 100%; + width: 100%; + overflow: clip; + color: var(--color-text); +} + +.tl-theme__light { + /* Canvas */ + --color-snap: hsl(0, 76%, 60%); + --color-selection-fill: hsl(210, 100%, 56%, 24%); + --color-selection-stroke: hsl(214, 84%, 56%); + --color-background: hsl(210, 20%, 98%); + --color-brush-fill: hsl(0, 0%, 56%, 10.2%); + --color-brush-stroke: hsl(0, 0%, 56%, 25.1%); + --color-grid: hsl(0, 0%, 43%); + /* UI */ + --color-low: hsl(204, 16%, 94%); + --color-low-border: hsl(204, 16%, 92%); + --color-culled: hsl(204, 14%, 93%); + --color-muted-none: hsl(0, 0%, 0%, 0%); + --color-muted-0: hsl(0, 0%, 0%, 2%); + --color-muted-1: hsl(0, 0%, 0%, 10%); + --color-muted-2: hsl(0, 0%, 0%, 4.3%); + --color-hint: hsl(0, 0%, 0%, 5.5%); + --color-overlay: hsl(0, 0%, 0%, 20%); + --color-divider: hsl(0, 0%, 91%); + --color-panel: hsl(0, 0%, 99%); + --color-panel-contrast: hsl(0, 0%, 100%); + --color-panel-overlay: hsl(0, 0%, 100%, 82%); + --color-panel-transparent: hsla(0, 0%, 99%, 0%); + --color-selected: hsl(214, 84%, 56%); + --color-selected-contrast: hsl(0, 0%, 100%); + --color-focus: hsl(219, 65%, 50%); + /* Text */ + --color-text: hsl(0, 0%, 0%); + --color-text-0: hsl(0, 0%, 11%); + --color-text-1: hsl(0, 0%, 18%); + --color-text-3: hsl(220, 2%, 65%); + --color-text-shadow: hsl(0, 0%, 100%); + --color-text-highlight: hsl(52, 100%, 50%); + --color-text-highlight-p3: color(display-p3 0.972 0.8205 0.05); + /* Named */ + --color-primary: hsl(214, 84%, 56%); + --color-success: hsl(123, 46%, 34%); + --color-info: hsl(201, 98%, 41%); + --color-warning: hsl(27, 98%, 47%); + --color-danger: hsl(0, 90%, 43%); + --color-laser: hsl(0, 100%, 50%); + /* Shadows */ + --shadow-1: 0px 1px 2px hsl(0, 0%, 0%, 25%), 0px 1px 3px hsl(0, 0%, 0%, 9%); + --shadow-2: + 0px 0px 2px hsl(0, 0%, 0%, 16%), 0px 2px 3px hsl(0, 0%, 0%, 24%), + 0px 2px 6px hsl(0, 0%, 0%, 0.1), inset 0px 0px 0px 1px var(--color-panel-contrast); + --shadow-3: + 0px 1px 2px hsl(0, 0%, 0%, 28%), 0px 2px 6px hsl(0, 0%, 0%, 14%), + inset 0px 0px 0px 1px var(--color-panel-contrast); + --shadow-4: + 0px 0px 3px hsl(0, 0%, 0%, 19%), 0px 5px 4px hsl(0, 0%, 0%, 16%), + 0px 2px 16px hsl(0, 0%, 0%, 6%), inset 0px 0px 0px 1px var(--color-panel-contrast); +} + +.tl-theme__dark { + /* Canvas */ + --color-snap: hsl(0, 76%, 60%); + --color-selection-fill: hsl(209, 100%, 57%, 20%); + --color-selection-stroke: hsl(214, 84%, 56%); + --color-background: hsl(240, 5%, 6.5%); + --color-brush-fill: hsl(0, 0%, 71%, 5.1%); + --color-brush-stroke: hsl(0, 0%, 71%, 25.1%); + --color-grid: hsl(0, 0%, 40%); + /* UI */ + --color-low: hsl(260, 4.5%, 10.5%); + --color-low-border: hsl(207, 10%, 10%); + --color-culled: hsl(210, 11%, 19%); + --color-muted-none: hsl(0, 0%, 100%, 0%); + --color-muted-0: hsl(0, 0%, 100%, 2%); + --color-muted-1: hsl(0, 0%, 100%, 10%); + --color-muted-2: hsl(0, 0%, 100%, 5%); + --color-hint: hsl(0, 0%, 100%, 7%); + --color-overlay: hsl(0, 0%, 0%, 50%); + --color-divider: hsl(240, 9%, 22%); + --color-panel: hsl(235, 6.8%, 13.5%); + --color-panel-contrast: hsl(245, 12%, 23%); + --color-panel-overlay: hsl(210, 10%, 24%, 82%); + --color-panel-transparent: hsla(235, 6.8%, 13.5%, 0%); + --color-selected: hsl(217, 89%, 61%); + --color-selected-contrast: hsl(0, 0%, 100%); + --color-focus: hsl(217, 76%, 80%); + /* Text */ + --color-text: hsl(210, 17%, 98%); + --color-text-0: hsl(0, 9%, 94%); + --color-text-1: hsl(0, 0%, 85%); + --color-text-3: hsl(210, 6%, 45%); + --color-text-shadow: hsl(210, 13%, 18%); + --color-text-highlight: hsl(52, 100%, 41%); + --color-text-highlight-p3: color(display-p3 0.8078 0.6225 0.0312); + /* Named */ + --color-primary: hsl(214, 84%, 56%); + --color-success: hsl(123, 38%, 57%); + --color-info: hsl(199, 92%, 56%); + --color-warning: hsl(36, 100%, 57%); + --color-danger: hsl(0, 82%, 66%); + --color-laser: hsl(0, 100%, 50%); + /* Shadows */ + --shadow-1: + 0px 1px 2px hsl(0, 0%, 0%, 16.1%), 0px 1px 3px hsl(0, 0%, 0%, 22%), + inset 0px 0px 0px 1px var(--color-panel-contrast); + --shadow-2: + 0px 1px 3px hsl(0, 0%, 0%, 66.6%), 0px 2px 6px hsl(0, 0%, 0%, 33%), + inset 0px 0px 0px 1px var(--color-panel-contrast); + --shadow-3: + 0px 1px 3px hsl(0, 0%, 0%, 50%), 0px 2px 12px hsl(0, 0%, 0%, 50%), + inset 0px 0px 0px 1px var(--color-panel-contrast); +} + +.tl-counter-scaled { + transform: scale(var(--tl-scale)); + transform-origin: top left; + width: calc(100% * var(--tl-zoom)); + height: calc(100% * var(--tl-zoom)); +} + +.tl-container, +.tl-container * { + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + scrollbar-highlight-color: transparent; + -webkit-user-select: none; + user-select: none; + box-sizing: border-box; + outline: none; +} + +.tl-container a { + -webkit-touch-callout: initial; +} + +.tl-container__focused { + outline: 1px solid var(--color-low); +} + +input, +*[contenteditable], +*[contenteditable] * { + user-select: text; +} + +/* --------------------- Canvas --------------------- */ + +.tl-canvas { + position: absolute; + inset: 0px; + height: 100%; + width: 100%; + color: var(--color-text); + cursor: var(--tl-cursor); + overflow: clip; + content-visibility: auto; + touch-action: none; + contain: strict; +} + +.tl-shapes { + position: relative; + z-index: var(--layer-canvas-shapes); +} + +.tl-overlays { + position: absolute; + top: 0px; + left: 0px; + height: 100%; + width: 100%; + contain: strict; + pointer-events: none; + z-index: var(--layer-canvas-overlays); +} + +.tl-overlays__item { + position: absolute; + top: 0px; + left: 0px; + overflow: visible; + pointer-events: none; + transform-origin: top left; +} + +.tl-svg-context { + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + pointer-events: none; +} + +/* ------------------- Background ------------------- */ + +.tl-background__wrapper { + z-index: var(--layer-canvas-background); + position: absolute; + inset: 0px; + height: 100%; + width: 100%; +} + +.tl-background { + background-color: var(--color-background); + width: 100%; + height: 100%; +} + +/* --------------------- Grid Layer --------------------- */ + +.tl-grid { + position: absolute; + inset: 0px; + width: 100%; + height: 100%; + touch-action: none; + pointer-events: none; + z-index: var(--layer-canvas-grid); + contain: strict; +} + +.tl-grid-dot { + fill: var(--color-grid); +} + +/* --------------------- Layers --------------------- */ + +.tl-html-layer { + position: absolute; + top: 0px; + left: 0px; + width: 1px; + height: 1px; + contain: layout style size; +} + +/* --------------- Overlay Stack --------------- */ + +/* back of the stack, behind user's stuff */ +.tl-collaborator__scribble { + z-index: var(--layer-overlays-collaborator-scribble); +} + +.tl-collaborator__brush { + z-index: var(--layer-overlays-collaborator-brush); +} + +.tl-collaborator__shape-indicator { + z-index: var(--layer-overlays-collaborator-shape-indicator); +} + +.tl-user-scribble { + z-index: var(--layer-overlays-user-scribble); +} + +.tl-user-brush { + z-index: var(--layer-overlays-user-brush); +} + +.tl-user-handles { + z-index: var(--layer-overlays-user-handles); +} + +.tl-user-snapline { + z-index: var(--layer-overlays-user-snapline); +} + +.tl-selection__fg { + pointer-events: none; + z-index: var(--layer-overlays-selection-fg); +} + +.tl-user-indicator__hint { + z-index: var(--layer-overlays-user-indicator-hint); + stroke-width: calc(2.5px * var(--tl-scale)); +} + +.tl-custom-overlays { + z-index: var(--layer-overlays-custom); +} + +/* behind collaborator cursor */ +.tl-collaborator__cursor-hint { + z-index: var(--layer-overlays-collaborator-cursor-hint); +} + +.tl-collaborator__cursor { + z-index: var(--layer-overlays-collaborator-cursor); +} + +.tl-cursor { + overflow: visible; +} + +/* -------------- Selection foreground -------------- */ + +.tl-selection__bg { + position: absolute; + top: 0px; + left: 0px; + transform-origin: top left; + background-color: transparent; + pointer-events: all; +} + +.tl-selection__fg__outline { + fill: none; + pointer-events: none; + stroke: var(--color-selection-stroke); + stroke-width: calc(1.5px * var(--tl-scale)); +} + +.tl-corner-handle { + pointer-events: none; + stroke: var(--color-selection-stroke); + fill: var(--color-background); + stroke-width: calc(1.5px * var(--tl-scale)); +} + +.tl-text-handle { + pointer-events: none; + fill: var(--color-selection-stroke); +} + +.tl-corner-crop-handle { + pointer-events: none; + fill: none; + stroke: var(--color-selection-stroke); +} + +.tl-corner-crop-edge-handle { + pointer-events: none; + fill: none; + stroke: var(--color-selection-stroke); +} + +.tl-mobile-rotate__bg { + pointer-events: all; + cursor: var(--tl-cursor-grab); +} + +.tl-mobile-rotate__fg { + pointer-events: none; + stroke: var(--color-selection-stroke); + fill: var(--color-background); + stroke-width: calc(1.5px * var(--tl-scale)); +} + +.tl-transparent { + fill: transparent; + stroke: transparent; +} + +.tl-hidden { + opacity: 0; + pointer-events: none; +} + +/* -------------- Nametag / cursor chat ------------- */ + +.tl-nametag { + position: absolute; + top: 16px; + left: 13px; + width: fit-content; + height: fit-content; + max-width: 120px; + padding: 3px 6px; + white-space: nowrap; + position: absolute; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + font-family: var(--font-body); + border-radius: var(--radius-2); + color: var(--color-selected-contrast); +} + +.tl-nametag-title { + position: absolute; + top: -2px; + left: 13px; + width: fit-content; + height: fit-content; + padding: 0px 6px; + max-width: 120px; + white-space: nowrap; + position: absolute; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + font-family: var(--font-body); + text-shadow: var(--tl-text-outline); + color: var(--color-selected-contrast); +} + +.tl-nametag-chat { + position: absolute; + top: 16px; + left: 13px; + width: fit-content; + height: fit-content; + color: var(--color-selected-contrast); + white-space: nowrap; + position: absolute; + padding: 3px 6px; + font-size: 12px; + font-family: var(--font-body); + opacity: 1; + border-radius: var(--radius-2); +} + +.tl-cursor-chat { + position: absolute; + color: var(--color-selected-contrast); + white-space: nowrap; + padding: 3px 6px; + font-size: 12px; + font-family: var(--font-body); + pointer-events: none; + z-index: var(--layer-cursor); + margin-top: 16px; + margin-left: 13px; + opacity: 1; + border: none; + user-select: text; + border-radius: var(--radius-2); +} + +.tl-cursor-chat .tl-cursor-chat__bubble { + padding-right: 12px; +} + +.tl-cursor-chat::selection { + background: var(--color-selected); + color: var(--color-selected-contrast); + text-shadow: none; +} + +.tl-cursor-chat::placeholder { + color: var(--color-selected-contrast); + opacity: 0.7; +} + +/* ---------------------- Text ---------------------- */ + +.tl-text-shape-label { + position: relative; + font-weight: normal; + min-width: 1px; + padding: 0px; + margin: 0px; + border: none; + width: fit-content; + height: fit-content; + font-variant: normal; + font-style: normal; + pointer-events: all; + white-space: pre-wrap; + overflow-wrap: break-word; + text-shadow: var(--tl-text-outline); +} + +.tl-text-wrapper[data-font='draw'] { + font-family: var(--tl-font-draw); +} + +.tl-text-wrapper[data-font='sans'] { + font-family: var(--tl-font-sans); +} + +.tl-text-wrapper[data-font='serif'] { + font-family: var(--tl-font-serif); +} + +.tl-text-wrapper[data-font='mono'] { + font-family: var(--tl-font-mono); +} + +.tl-text-wrapper[data-align='start'], +.tl-text-wrapper[data-align='start-legacy'] { + text-align: left; +} + +.tl-text-wrapper[data-align='middle'], +.tl-text-wrapper[data-align='middle-legacy'] { + text-align: center; +} + +.tl-text-wrapper[data-align='end'], +.tl-text-wrapper[data-align='end-legacy'] { + text-align: right; +} + +.tl-plain-text-wrapper[data-isediting='true'] .tl-text-content { + opacity: 0; +} + +.tl-rich-text-wrapper[data-isediting='true'] .tl-text-content { + display: none; +} + +.tl-text { + /* remove overflow from textarea on windows */ + margin: 0px; + padding: 0px; + + appearance: auto; + background: none; + border-image: none; + border: 0px; + caret-color: var(--color-text); + color: inherit; + column-count: initial !important; + display: inline-block; + font-family: inherit; + font-feature-settings: normal; + font-kerning: auto; + font-optical-sizing: auto; + font-size: inherit; + font-stretch: 100%; + font-style: inherit; + font-variant: inherit; + font-variation-settings: normal; + font-weight: inherit; + letter-spacing: inherit; + line-height: inherit; + outline: none; + overflow-wrap: break-word; + text-align: inherit; + text-indent: 0px; + text-rendering: auto; + text-shadow: inherit; + text-transform: none; + white-space: pre-wrap; + line-break: normal; + word-spacing: 0px; + word-wrap: break-word; + writing-mode: horizontal-tb !important; +} + +.tl-text-measure { + position: absolute; + z-index: var(--layer-canvas-hidden); + top: 0px; + left: 0px; + opacity: 0; + width: max-content; + box-sizing: border-box; + pointer-events: none; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + resize: none; + border: none; + user-select: none; + contain: style paint; + visibility: hidden; + /* N.B. This property, while discouraged ("intended for Document Type Definition (DTD) designers") is necessary for ensuring correct mixed RTL/LTR behavior when exporting SVGs. */ + unicode-bidi: plaintext; + -webkit-user-select: none; +} + +.tl-text-input, +.tl-text-content { + position: absolute; + inset: 0px; + height: 100%; + width: 100%; + min-width: 1px; + min-height: 1px; + outline: none; +} + +.tl-text-content__wrapper { + position: relative; + width: fit-content; + height: fit-content; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + min-height: auto; +} + +.tl-text-content { + overflow: visible; + pointer-events: none; +} + +.tl-text-input { + resize: none; + user-select: all; + -webkit-user-select: text; + cursor: var(--tl-cursor-text); +} + +.tl-text-input:not(.tl-rich-text) { + /* + * Note: this `overflow: hidden` is key for scrollbars to not show up + * plaintext/