@anchrd/intel 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +496 -0
- package/bin/intel.mjs +134 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
- package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
- package/dist/adapters/cloudflare/cloudflare.js +416 -0
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
- package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
- package/dist/adapters/content/content.d.ts +3 -0
- package/dist/adapters/content/content.js +128 -0
- package/dist/adapters/content/content.types.d.ts +38 -0
- package/dist/adapters/content/content.types.js +1 -0
- package/dist/adapters/db/db-audit.d.ts +62 -0
- package/dist/adapters/db/db-audit.js +150 -0
- package/dist/adapters/db/db-boards.d.ts +6 -0
- package/dist/adapters/db/db-boards.js +324 -0
- package/dist/adapters/db/db-feed.d.ts +30 -0
- package/dist/adapters/db/db-feed.js +276 -0
- package/dist/adapters/db/db-flows.d.ts +42 -0
- package/dist/adapters/db/db-flows.js +1311 -0
- package/dist/adapters/db/db-grants.d.ts +133 -0
- package/dist/adapters/db/db-grants.js +269 -0
- package/dist/adapters/db/db-indexing.d.ts +3 -0
- package/dist/adapters/db/db-indexing.js +200 -0
- package/dist/adapters/db/db-oauth.d.ts +3 -0
- package/dist/adapters/db/db-oauth.js +26 -0
- package/dist/adapters/db/db-prompts.d.ts +46 -0
- package/dist/adapters/db/db-prompts.js +144 -0
- package/dist/adapters/db/db.d.ts +6 -0
- package/dist/adapters/db/db.js +1687 -0
- package/dist/adapters/db/db.types.d.ts +16 -0
- package/dist/adapters/db/db.types.js +1 -0
- package/dist/adapters/document-converter/document-converter.d.ts +2 -0
- package/dist/adapters/document-converter/document-converter.js +26 -0
- package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
- package/dist/adapters/document-converter/document-converter.types.js +1 -0
- package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
- package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
- package/dist/adapters/index-queue/index-queue.d.ts +10 -0
- package/dist/adapters/index-queue/index-queue.js +12 -0
- package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
- package/dist/adapters/index-queue/index-queue.types.js +1 -0
- package/dist/adapters/json-schema/json-schema.d.ts +2 -0
- package/dist/adapters/json-schema/json-schema.js +77 -0
- package/dist/adapters/openid/openid.d.ts +5 -0
- package/dist/adapters/openid/openid.js +301 -0
- package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
- package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
- package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
- package/dist/adapters/remote-tools/remote-tools.js +129 -0
- package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
- package/dist/adapters/semantic-index/semantic-index.js +139 -0
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
- package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
- package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
- package/dist/adapters/session-cookie/session-cookie.js +87 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
- package/dist/audit/audit.d.ts +2 -0
- package/dist/audit/audit.js +87 -0
- package/dist/audit/audit.types.d.ts +23 -0
- package/dist/audit/audit.types.js +1 -0
- package/dist/auth/auth.d.ts +2 -0
- package/dist/auth/auth.js +284 -0
- package/dist/auth/auth.types.d.ts +108 -0
- package/dist/auth/auth.types.js +1 -0
- package/dist/boards/boards.d.ts +44 -0
- package/dist/boards/boards.js +447 -0
- package/dist/boards/boards.types.d.ts +117 -0
- package/dist/boards/boards.types.js +1 -0
- package/dist/build/build.d.ts +2 -0
- package/dist/build/build.js +192 -0
- package/dist/build/build.types.d.ts +17 -0
- package/dist/build/build.types.js +1 -0
- package/dist/bundle/bundle.d.ts +4 -0
- package/dist/bundle/bundle.js +1194 -0
- package/dist/bundle/bundle.types.d.ts +33 -0
- package/dist/bundle/bundle.types.js +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.js +262 -0
- package/dist/cli/cli.types.d.ts +14 -0
- package/dist/cli/cli.types.js +1 -0
- package/dist/contract/audit.d.ts +50 -0
- package/dist/contract/audit.js +72 -0
- package/dist/contract/board.d.ts +150 -0
- package/dist/contract/board.js +262 -0
- package/dist/contract/bundle.d.ts +147 -0
- package/dist/contract/bundle.js +139 -0
- package/dist/contract/contract.d.ts +112 -0
- package/dist/contract/contract.js +143 -0
- package/dist/contract/feed.d.ts +126 -0
- package/dist/contract/feed.js +149 -0
- package/dist/contract/flow-run.d.ts +346 -0
- package/dist/contract/flow-run.js +194 -0
- package/dist/contract/flow.d.ts +1057 -0
- package/dist/contract/flow.js +552 -0
- package/dist/contract/node.d.ts +551 -0
- package/dist/contract/node.js +456 -0
- package/dist/contract/share.d.ts +216 -0
- package/dist/contract/share.js +134 -0
- package/dist/contract/table.d.ts +171 -0
- package/dist/contract/table.js +117 -0
- package/dist/contract/tool.d.ts +122 -0
- package/dist/contract/tool.js +172 -0
- package/dist/feed/feed.d.ts +2 -0
- package/dist/feed/feed.js +77 -0
- package/dist/feed/feed.types.d.ts +23 -0
- package/dist/feed/feed.types.js +1 -0
- package/dist/flows/flows.d.ts +41 -0
- package/dist/flows/flows.js +2216 -0
- package/dist/flows/flows.types.d.ts +339 -0
- package/dist/flows/flows.types.js +1 -0
- package/dist/http/http.d.ts +9 -0
- package/dist/http/http.js +874 -0
- package/dist/http/http.types.d.ts +22 -0
- package/dist/http/http.types.js +1 -0
- package/dist/indexing/indexing.d.ts +6 -0
- package/dist/indexing/indexing.js +239 -0
- package/dist/indexing/indexing.types.d.ts +11 -0
- package/dist/indexing/indexing.types.js +1 -0
- package/dist/intel/intel.d.ts +3 -0
- package/dist/intel/intel.js +194 -0
- package/dist/intel/intel.types.d.ts +24 -0
- package/dist/intel/intel.types.js +1 -0
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +1292 -0
- package/dist/mcp/mcp.types.d.ts +25 -0
- package/dist/mcp/mcp.types.js +1 -0
- package/dist/nodes/document-links/document-links.d.ts +20 -0
- package/dist/nodes/document-links/document-links.js +57 -0
- package/dist/nodes/nodes.d.ts +2 -0
- package/dist/nodes/nodes.js +1667 -0
- package/dist/nodes/nodes.types.d.ts +511 -0
- package/dist/nodes/nodes.types.js +1 -0
- package/dist/prepare/prepare.d.ts +4 -0
- package/dist/prepare/prepare.js +16 -0
- package/dist/prepare/prepare.types.d.ts +9 -0
- package/dist/prepare/prepare.types.js +1 -0
- package/dist/prompts/prompts.d.ts +2 -0
- package/dist/prompts/prompts.js +65 -0
- package/dist/prompts/prompts.types.d.ts +71 -0
- package/dist/prompts/prompts.types.js +1 -0
- package/dist/shared/callers/callers.d.ts +28 -0
- package/dist/shared/callers/callers.js +30 -0
- package/dist/shared/csv/csv.d.ts +13 -0
- package/dist/shared/csv/csv.js +85 -0
- package/dist/shared/document-text/document-text.d.ts +21 -0
- package/dist/shared/document-text/document-text.js +31 -0
- package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
- package/dist/shared/gate-authorization/gate-authorization.js +26 -0
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/dist/shared/intel-error/intel-error.d.ts +22 -0
- package/dist/shared/intel-error/intel-error.js +27 -0
- package/dist/shared/plain-title/plain-title.d.ts +16 -0
- package/dist/shared/plain-title/plain-title.js +45 -0
- package/dist/shared/problem-details/problem-details.d.ts +30 -0
- package/dist/shared/problem-details/problem-details.js +18 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
- package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
- package/dist/shared/safe-return-path/safe-return-path.js +11 -0
- package/dist/shared/sha256/sha256.d.ts +2 -0
- package/dist/shared/sha256/sha256.js +14 -0
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +177 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +207 -0
- package/dist/tools/tools.types.d.ts +56 -0
- package/dist/tools/tools.types.js +1 -0
- package/examples/branding/favicon.svg +5 -0
- package/examples/branding/logo.svg +5 -0
- package/examples/branding/theme.css +16 -0
- package/examples/dev.vars.example +7 -0
- package/examples/intel.json +9 -0
- package/migrations/0000_intel_foundation.sql +203 -0
- package/migrations/0001_portal_native_tools.sql +21 -0
- package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
- package/migrations/0003_folder_permissions.sql +211 -0
- package/migrations/0004_subflow_runs.sql +14 -0
- package/migrations/0005_flow_node_cleanup.sql +28 -0
- package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
- package/migrations/0006_links_are_written_in_the_text.sql +20 -0
- package/migrations/0007_no_node_waits.sql +69 -0
- package/migrations/0008_three_layers.sql +130 -0
- package/migrations/0009_no_context_policy.sql +48 -0
- package/migrations/0010_no_run_waits_a_year.sql +27 -0
- package/migrations/0011_one_name_for_the_tree.sql +53 -0
- package/migrations/0012_table_snapshots.sql +29 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +97 -0
- package/migrations/0019_one_name_for_the_grants.sql +52 -0
- package/migrations/0020_cascade_purge_replay.sql +11 -0
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
- package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
- package/migrations/0023_a_board_and_its_tasks.sql +116 -0
- package/migrations/0024_the_archive_is_a_column.sql +33 -0
- package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
- package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
- package/migrations/0027_the_runs_of_every_flow.sql +27 -0
- package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
- package/package.json +118 -0
- package/src/contract/CLAUDE.md +152 -0
- package/src/contract/README.md +102 -0
- package/src/contract/audit.ts +85 -0
- package/src/contract/board.ts +306 -0
- package/src/contract/bundle.ts +157 -0
- package/src/contract/contract.ts +161 -0
- package/src/contract/feed.ts +165 -0
- package/src/contract/flow-run.ts +241 -0
- package/src/contract/flow.ts +675 -0
- package/src/contract/node.ts +583 -0
- package/src/contract/share.ts +196 -0
- package/src/contract/table.ts +178 -0
- package/src/contract/tool.ts +202 -0
- package/ui/README.md +110 -0
- package/ui/components.json +21 -0
- package/ui/index.html +13 -0
- package/ui/package.json +11 -0
- package/ui/scripts/lint-tokens.mjs +187 -0
- package/ui/src/access-summary/access-summary.tsx +385 -0
- package/ui/src/app/action-slot/action-slot.tsx +27 -0
- package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/ui/src/app/app-tree/app-tree.tsx +1175 -0
- package/ui/src/app/app.tsx +87 -0
- package/ui/src/app/header-search/header-search.tsx +294 -0
- package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
- package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
- package/ui/src/app/tree-move/tree-move.tsx +427 -0
- package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
- package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
- package/ui/src/app/user-footer/user-footer.tsx +188 -0
- package/ui/src/app/view-menu/view-menu.tsx +115 -0
- package/ui/src/app-root/app-root.tsx +109 -0
- package/ui/src/app-root/app-root.types.ts +27 -0
- package/ui/src/archive/archive.tsx +417 -0
- package/ui/src/attachment-detail/attachment-detail.ts +52 -0
- package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
- package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
- package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
- package/ui/src/board/board-assignee/board-assignee.ts +48 -0
- package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
- package/ui/src/board/board-chip/board-chip.tsx +46 -0
- package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
- package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
- package/ui/src/board/board-data/board-data.ts +195 -0
- package/ui/src/board/board-data/board-data.types.ts +38 -0
- package/ui/src/board/board-dates/board-dates.ts +40 -0
- package/ui/src/board/board-dates/board-dates.tsx +105 -0
- package/ui/src/board/board-kanban/board-kanban.ts +162 -0
- package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
- package/ui/src/board/board-open-task/board-open-task.ts +32 -0
- package/ui/src/board/board-panel/board-panel.tsx +259 -0
- package/ui/src/board/board-settings/board-settings.tsx +226 -0
- package/ui/src/board/board-status/board-status.tsx +60 -0
- package/ui/src/board/board-stripes/board-stripes.ts +128 -0
- package/ui/src/board/board-table/board-table.ts +53 -0
- package/ui/src/board/board-table/board-table.tsx +610 -0
- package/ui/src/board/board-task/board-task.ts +108 -0
- package/ui/src/board/board-task/board-task.tsx +335 -0
- package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
- package/ui/src/branding/branding.tsx +17 -0
- package/ui/src/branding/custom-logo.ts +1 -0
- package/ui/src/branding/favicon.default.svg +5 -0
- package/ui/src/branding/favicon.svg +5 -0
- package/ui/src/components/ui/avatar.tsx +39 -0
- package/ui/src/components/ui/button.tsx +64 -0
- package/ui/src/components/ui/collapsible.tsx +20 -0
- package/ui/src/components/ui/command.tsx +160 -0
- package/ui/src/components/ui/dialog.tsx +143 -0
- package/ui/src/components/ui/dropdown-menu.tsx +187 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/popover.tsx +41 -0
- package/ui/src/components/ui/select.tsx +163 -0
- package/ui/src/components/ui/separator.tsx +26 -0
- package/ui/src/components/ui/sheet.tsx +136 -0
- package/ui/src/components/ui/sidebar.tsx +693 -0
- package/ui/src/components/ui/skeleton.tsx +13 -0
- package/ui/src/components/ui/switch.tsx +25 -0
- package/ui/src/components/ui/table.tsx +82 -0
- package/ui/src/components/ui/tabs.tsx +52 -0
- package/ui/src/components/ui/tooltip.tsx +51 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
- package/ui/src/data/query-client.ts +18 -0
- package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/ui/src/data/request-refusal/request-refusal.ts +51 -0
- package/ui/src/data/sign-in/sign-in.ts +97 -0
- package/ui/src/data/sign-in/sign-in.types.ts +15 -0
- package/ui/src/document-link/document-link.tsx +114 -0
- package/ui/src/document-markdown/document-markdown.ts +195 -0
- package/ui/src/editor-schema/editor-schema.ts +23 -0
- package/ui/src/entry-picker/entry-picker.tsx +238 -0
- package/ui/src/feed/feed.tsx +344 -0
- package/ui/src/file-preview/file-preview-view.tsx +496 -0
- package/ui/src/file-preview/file-preview.tsx +28 -0
- package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
- package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
- package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
- package/ui/src/file-preview/word-file-preview.tsx +5 -0
- package/ui/src/flow-runs/flow-runs.tsx +238 -0
- package/ui/src/flows/flows.tsx +1646 -0
- package/ui/src/flows/node-icon/node-icon.ts +34 -0
- package/ui/src/flows/node-palette/node-palette.tsx +214 -0
- package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
- package/ui/src/folder-contents/folder-contents.tsx +321 -0
- package/ui/src/folder-index/folder-index.ts +55 -0
- package/ui/src/frontmatter/frontmatter.tsx +193 -0
- package/ui/src/graph-pane/graph-pane.tsx +53 -0
- package/ui/src/hooks/use-mobile.ts +19 -0
- package/ui/src/i18n/custom.json +4 -0
- package/ui/src/i18n/de.json +609 -0
- package/ui/src/i18n/en.json +609 -0
- package/ui/src/i18n/es.json +609 -0
- package/ui/src/i18n/i18n-context.tsx +65 -0
- package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/ui/src/i18n/i18n.ts +32 -0
- package/ui/src/i18n/i18n.types.ts +49 -0
- package/ui/src/kind-icon.ts +97 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +68 -0
- package/ui/src/modal/modal.tsx +53 -0
- package/ui/src/node-details/node-details.tsx +545 -0
- package/ui/src/node-editor/node-editor.tsx +304 -0
- package/ui/src/node-graph/graph-notice.tsx +21 -0
- package/ui/src/node-graph/node-graph.ts +214 -0
- package/ui/src/node-graph/node-graph.tsx +295 -0
- package/ui/src/node-import/node-import.tsx +418 -0
- package/ui/src/node-save-error.ts +46 -0
- package/ui/src/node-source/node-source.tsx +128 -0
- package/ui/src/node-table/node-table.tsx +107 -0
- package/ui/src/nodes/nodes.tsx +240 -0
- package/ui/src/rename-resource/rename-resource.ts +80 -0
- package/ui/src/resolved-names/resolved-names.tsx +67 -0
- package/ui/src/resource-error.ts +33 -0
- package/ui/src/resource-menu/resource-menu.tsx +1156 -0
- package/ui/src/router/router-context.ts +6 -0
- package/ui/src/router/router.tsx +67 -0
- package/ui/src/router/router.types.ts +8 -0
- package/ui/src/router/selection-search.ts +88 -0
- package/ui/src/save-button/save-button.tsx +94 -0
- package/ui/src/section-hint/section-hint.tsx +40 -0
- package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
- package/ui/src/styles.css +189 -0
- package/ui/src/table-columns/table-columns.tsx +379 -0
- package/ui/src/theme/custom.css +1 -0
- package/ui/src/theme/theme-context.tsx +89 -0
- package/ui/src/theme/theme.ts +70 -0
- package/ui/src/time/relative-time.tsx +41 -0
- package/ui/src/time/time-context.tsx +78 -0
- package/ui/src/time/time.ts +165 -0
- package/ui/src/title-row/title-row.tsx +185 -0
- package/ui/src/tools/tools.tsx +546 -0
- package/ui/src/user-name/user-name.ts +61 -0
- package/ui/src/verbatim/verbatim.tsx +68 -0
- package/ui/tsconfig.json +17 -0
- package/ui/vite.config.ts +129 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { filterSuggestionItems } from "@blocknote/core";
|
|
2
|
+
import { SuggestionMenuController, useCreateBlockNote } from "@blocknote/react";
|
|
3
|
+
import {
|
|
4
|
+
BlockNoteDocument,
|
|
5
|
+
BlockNoteMediaType,
|
|
6
|
+
DocumentLinkInlineType,
|
|
7
|
+
type Node,
|
|
8
|
+
type NodeDocument,
|
|
9
|
+
} from "@contract/node.ts";
|
|
10
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
11
|
+
import { Link2 } from "lucide-react";
|
|
12
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
13
|
+
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
14
|
+
import { BlockNoteView, defaultSlashMenuItems } from "@/blocknote-view/blocknote-view.tsx";
|
|
15
|
+
import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
16
|
+
import { DocumentLinkProvider, documentLinkIds } from "@/document-link/document-link.tsx";
|
|
17
|
+
import { blocksToMarkdown, markdownToBlocks } from "@/document-markdown/document-markdown.ts";
|
|
18
|
+
import {
|
|
19
|
+
type IntelEditor,
|
|
20
|
+
type IntelEditorPartialBlock,
|
|
21
|
+
intelEditorSchema,
|
|
22
|
+
} from "@/editor-schema/editor-schema.ts";
|
|
23
|
+
import { FrontmatterProvider } from "@/frontmatter/frontmatter.tsx";
|
|
24
|
+
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
25
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
26
|
+
import { saveErrorKey } from "@/node-save-error.ts";
|
|
27
|
+
import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
|
|
28
|
+
import { TitleRowScrollArea } from "@/title-row/title-row.tsx";
|
|
29
|
+
import { VerbatimProvider } from "@/verbatim/verbatim.tsx";
|
|
30
|
+
|
|
31
|
+
function storedBlocks(content: string | null): IntelEditorPartialBlock[] | undefined {
|
|
32
|
+
if (!content) return undefined;
|
|
33
|
+
try {
|
|
34
|
+
const parsed = BlockNoteDocument.safeParse(JSON.parse(content));
|
|
35
|
+
return parsed.success ? (parsed.data.blocks as IntelEditorPartialBlock[]) : undefined;
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What the editor holds, in a form two states can be compared by.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ This is what "unsaved" is answered with, and it deliberately is not "`onChange` fired". The
|
|
45
|
+
* editor changes for reasons that are not edits — loading the content is one (`replaceBlocks`
|
|
46
|
+
* below), and BlockNote normalizing a freshly mounted document is another. Neither is anything a
|
|
47
|
+
* reader did, and a warning that appears on every open is a warning nobody reads on the one day it
|
|
48
|
+
* is true. The canvas next door draws the same line with `editsNodes`/`editsEdges`.
|
|
49
|
+
*
|
|
50
|
+
* `id`s are part of it on purpose: BlockNote keeps them stable across edits within a session, and
|
|
51
|
+
* dropping them would call two different paragraphs equal whenever their text happens to match.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ It runs on every keystroke, and it walks the whole document to do so. That is affordable
|
|
54
|
+
* because `documentLinkIds` beside it already does, and because it is what buys the case a one-way
|
|
55
|
+
* flag cannot have: typing something and taking it back leaves nothing to save. There is no early
|
|
56
|
+
* exit — checking "already dirty, skip" is exactly what would lose that case. If documents ever
|
|
57
|
+
* grow far enough for this to be felt, the answer is to compare less often, not less carefully.
|
|
58
|
+
*/
|
|
59
|
+
function editorSnapshot(blocks: unknown): string {
|
|
60
|
+
return JSON.stringify(blocks);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function NodeEditor({
|
|
64
|
+
data,
|
|
65
|
+
document,
|
|
66
|
+
i18n,
|
|
67
|
+
onSaved,
|
|
68
|
+
}: {
|
|
69
|
+
data: IntelDataProvider;
|
|
70
|
+
document: NodeDocument;
|
|
71
|
+
i18n: I18n;
|
|
72
|
+
onSaved(document: NodeDocument): void;
|
|
73
|
+
}) {
|
|
74
|
+
const initialBlocks = useMemo(() => storedBlocks(document.content), [document.content]);
|
|
75
|
+
// ⚠️ `initialContent` is always passed, never spread in conditionally: a conditional spread makes
|
|
76
|
+
// the options a union type, and BlockNote then infers the default schema instead of Intel's —
|
|
77
|
+
// which typechecks and then refuses to insert a document link at run time. One empty paragraph is
|
|
78
|
+
// what the editor would have started with anyway.
|
|
79
|
+
const editor = useCreateBlockNote({
|
|
80
|
+
schema: intelEditorSchema,
|
|
81
|
+
initialContent: initialBlocks?.length ? initialBlocks : [{ type: "paragraph" }],
|
|
82
|
+
// ⚠️ The one cast, in the one place. `useCreateBlockNote` decides its return type with a
|
|
83
|
+
// conditional that this repo's `exactOptionalPropertyTypes` defeats, so it hands back the
|
|
84
|
+
// default-schema editor even though it built Intel's. Same upstream seam as `BlockNoteView`.
|
|
85
|
+
}) as unknown as IntelEditor;
|
|
86
|
+
// The content as it stands on the server, in comparable form. Every question of "is there
|
|
87
|
+
// anything to save" is answered against this, and saving moves it forward.
|
|
88
|
+
//
|
|
89
|
+
// ⚠️ A ref, not state: it is read inside `onChange` on every keystroke and must be the current
|
|
90
|
+
// value there, not the one captured when that handler was created. Nothing renders from it — what
|
|
91
|
+
// renders is `dirty` — so there is nothing to re-render when it moves.
|
|
92
|
+
const savedSnapshot = useRef(editorSnapshot(editor.document));
|
|
93
|
+
const [dirty, setDirty] = useState(false);
|
|
94
|
+
const [picking, setPicking] = useState(false);
|
|
95
|
+
// Which documents the text links to right now. It is recomputed from the editor rather than from
|
|
96
|
+
// the saved content, so a link resolves the moment it is inserted instead of after a save.
|
|
97
|
+
const [linkedIds, setLinkedIds] = useState<string[]>(() => documentLinkIds(initialBlocks ?? []));
|
|
98
|
+
const resolved = useQuery({
|
|
99
|
+
// The key is the set of targets: two documents linking to the same thing share one answer, and
|
|
100
|
+
// adding a link asks only because the set changed.
|
|
101
|
+
queryKey: ["node-link-titles", [...linkedIds].sort()],
|
|
102
|
+
queryFn: () => data.resolveNodeLinks({ nodeIds: linkedIds }),
|
|
103
|
+
enabled: linkedIds.length > 0,
|
|
104
|
+
});
|
|
105
|
+
// One place decides whether there is anything to save, so the answer cannot differ between the
|
|
106
|
+
// button, the guard and the save itself.
|
|
107
|
+
function settle(): void {
|
|
108
|
+
setDirty(editorSnapshot(editor.document) !== savedSnapshot.current);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const save = useMutation({
|
|
112
|
+
mutationFn: async () => {
|
|
113
|
+
const blocks = editor.document;
|
|
114
|
+
const payload = BlockNoteDocument.parse({
|
|
115
|
+
format: "blocknote",
|
|
116
|
+
schemaVersion: 1,
|
|
117
|
+
blocks,
|
|
118
|
+
markdown: blocksToMarkdown(editor, blocks),
|
|
119
|
+
});
|
|
120
|
+
const node = await data.saveNode({
|
|
121
|
+
nodeId: document.node.id,
|
|
122
|
+
baseVersionId: document.version?.id ?? null,
|
|
123
|
+
content: JSON.stringify(payload),
|
|
124
|
+
mediaType: BlockNoteMediaType,
|
|
125
|
+
idempotencyKey: crypto.randomUUID(),
|
|
126
|
+
});
|
|
127
|
+
// ⚠️ An answer that carries no version did not save anything (#432). Letting it through would
|
|
128
|
+
// move `savedSnapshot` forward, drop the unsaved warning and leave the reader free to close
|
|
129
|
+
// the tab — over a document the server still reports as empty. It belongs on the error path,
|
|
130
|
+
// which is the one place the screen already says that the writing failed.
|
|
131
|
+
if (!node.version) throw new Error("save_without_version");
|
|
132
|
+
// ⚠️ The snapshot of what was SENT, not of what stands in the editor when the answer comes
|
|
133
|
+
// back. Saving crosses the network, and typing during it is normal — taking the state at
|
|
134
|
+
// arrival would call that typing saved and lose it at the next navigation.
|
|
135
|
+
return { node, snapshot: editorSnapshot(blocks) };
|
|
136
|
+
},
|
|
137
|
+
onSuccess: ({ node, snapshot }) => {
|
|
138
|
+
savedSnapshot.current = snapshot;
|
|
139
|
+
settle();
|
|
140
|
+
onSaved(node);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!document.content || initialBlocks) return;
|
|
146
|
+
const blocks = markdownToBlocks(editor, document.content);
|
|
147
|
+
editor.replaceBlocks(editor.document, blocks);
|
|
148
|
+
// ⚠️ Loading the content IS a change to the editor, and `onChange` reports it as one. This is
|
|
149
|
+
// the line that keeps a document nobody has touched from asking to be saved: what was just
|
|
150
|
+
// loaded is by definition what is stored.
|
|
151
|
+
savedSnapshot.current = editorSnapshot(editor.document);
|
|
152
|
+
setDirty(false);
|
|
153
|
+
}, [document.content, editor, initialBlocks]);
|
|
154
|
+
|
|
155
|
+
function insertLink(nodeId: string): void {
|
|
156
|
+
editor.insertInlineContent([
|
|
157
|
+
{ type: DocumentLinkInlineType, props: { nodeId } },
|
|
158
|
+
// A trailing space, or the caret stays inside an element that takes no text and the next
|
|
159
|
+
// keystroke has nowhere to go.
|
|
160
|
+
" ",
|
|
161
|
+
]);
|
|
162
|
+
setPicking(false);
|
|
163
|
+
settle();
|
|
164
|
+
setLinkedIds(documentLinkIds(editor.document));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
169
|
+
{/* ⚠️ Up into the title line, not a strip of its own (#24). The button used to cost a whole
|
|
170
|
+
screen row and a rule under it to say nothing most of the time. It is rendered from here
|
|
171
|
+
because this is where `dirty` lives — moving the state up to the screen would mean the
|
|
172
|
+
screen tracking every keystroke in an editor it does not own. */}
|
|
173
|
+
<ActionSlot name="title-actions">
|
|
174
|
+
<SaveButton dirty={dirty} saving={save.isPending} onSave={() => save.mutate()} />
|
|
175
|
+
</ActionSlot>
|
|
176
|
+
<UnsavedChangesGuard dirty={dirty} />
|
|
177
|
+
{save.isError && (
|
|
178
|
+
<p role="alert" className="mx-6 mt-4 text-sm text-destructive">
|
|
179
|
+
{i18n.t(saveErrorKey(save.error))}
|
|
180
|
+
</p>
|
|
181
|
+
)}
|
|
182
|
+
<TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto py-6">
|
|
183
|
+
<DocumentLinkProvider
|
|
184
|
+
value={{
|
|
185
|
+
titles: new Map((resolved.data?.items ?? []).map((item) => [item.nodeId, item.title])),
|
|
186
|
+
pending: linkedIds.length > 0 && resolved.isPending,
|
|
187
|
+
unresolvedLabel: i18n.t("node.link.unresolved"),
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
<FrontmatterProvider
|
|
191
|
+
value={{
|
|
192
|
+
summary: i18n.t("node.frontmatter.summary"),
|
|
193
|
+
expand: i18n.t("node.frontmatter.toggle"),
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
<VerbatimProvider value={i18n.t("node.verbatim.label")}>
|
|
197
|
+
<BlockNoteView
|
|
198
|
+
editor={editor}
|
|
199
|
+
onChange={() => {
|
|
200
|
+
settle();
|
|
201
|
+
setLinkedIds(documentLinkIds(editor.document));
|
|
202
|
+
}}
|
|
203
|
+
>
|
|
204
|
+
{/* The `/` line BlockNote already has, with one item added. A second way to reach the
|
|
205
|
+
same thing would be the second way #41 exists to remove. */}
|
|
206
|
+
<SuggestionMenuController
|
|
207
|
+
triggerCharacter="/"
|
|
208
|
+
getItems={async (query) =>
|
|
209
|
+
filterSuggestionItems(
|
|
210
|
+
[
|
|
211
|
+
...defaultSlashMenuItems(editor),
|
|
212
|
+
{
|
|
213
|
+
title: i18n.t("node.link.insert"),
|
|
214
|
+
group: i18n.t("node.link.group"),
|
|
215
|
+
icon: <Link2 aria-hidden="true" className="size-4" />,
|
|
216
|
+
onItemClick: () => setPicking(true),
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
query,
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
/>
|
|
223
|
+
</BlockNoteView>
|
|
224
|
+
</VerbatimProvider>
|
|
225
|
+
</FrontmatterProvider>
|
|
226
|
+
</DocumentLinkProvider>
|
|
227
|
+
</TitleRowScrollArea>
|
|
228
|
+
{picking ? (
|
|
229
|
+
<DocumentPicker
|
|
230
|
+
data={data}
|
|
231
|
+
i18n={i18n}
|
|
232
|
+
exclude={document.node.id}
|
|
233
|
+
close={() => setPicking(false)}
|
|
234
|
+
choose={insertLink}
|
|
235
|
+
/>
|
|
236
|
+
) : null}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The documents the author may link to.
|
|
243
|
+
*
|
|
244
|
+
* ⚠️ The candidate list is the authorized graph — the same one the server would answer with — so
|
|
245
|
+
* the picker cannot offer a document the author may not see. The reader's side filters again, on
|
|
246
|
+
* its own account: the author seeing a title is not the reader seeing it (#41).
|
|
247
|
+
*/
|
|
248
|
+
function DocumentPicker({
|
|
249
|
+
data,
|
|
250
|
+
i18n,
|
|
251
|
+
exclude,
|
|
252
|
+
close,
|
|
253
|
+
choose,
|
|
254
|
+
}: {
|
|
255
|
+
data: IntelDataProvider;
|
|
256
|
+
i18n: I18n;
|
|
257
|
+
exclude: string;
|
|
258
|
+
close(): void;
|
|
259
|
+
choose(nodeId: string): void;
|
|
260
|
+
}) {
|
|
261
|
+
const [query, setQuery] = useState("");
|
|
262
|
+
const graph = useQuery({
|
|
263
|
+
queryKey: ["node-graph"],
|
|
264
|
+
queryFn: () => data.getNodeGraph(),
|
|
265
|
+
});
|
|
266
|
+
const candidates = (graph.data?.nodes ?? [])
|
|
267
|
+
.filter((node: Node) => node.id !== exclude && node.kind !== "folder")
|
|
268
|
+
.filter((node: Node) => node.title.toLowerCase().includes(query.trim().toLowerCase()))
|
|
269
|
+
.slice(0, 50);
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<Modal title={i18n.t("node.link.insert")} close={close}>
|
|
273
|
+
<label className="block text-sm font-medium">
|
|
274
|
+
{i18n.t("node.link.search")}
|
|
275
|
+
<input
|
|
276
|
+
// biome-ignore lint/a11y/noAutofocus: the dialog opens from a keystroke to take this term
|
|
277
|
+
autoFocus
|
|
278
|
+
value={query}
|
|
279
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
280
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
281
|
+
/>
|
|
282
|
+
</label>
|
|
283
|
+
<ul className="mt-4 max-h-64 space-y-1 overflow-y-auto">
|
|
284
|
+
{graph.isPending ? (
|
|
285
|
+
<li className="text-sm text-muted-foreground">{i18n.t("common.loading")}</li>
|
|
286
|
+
) : null}
|
|
287
|
+
{candidates.map((node: Node) => (
|
|
288
|
+
<li key={node.id}>
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
onClick={() => choose(node.id)}
|
|
292
|
+
className="w-full rounded-md px-3 py-2 text-left text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
293
|
+
>
|
|
294
|
+
{node.title}
|
|
295
|
+
</button>
|
|
296
|
+
</li>
|
|
297
|
+
))}
|
|
298
|
+
{!graph.isPending && candidates.length === 0 ? (
|
|
299
|
+
<li className="text-sm text-muted-foreground">{i18n.t("node.link.noMatches")}</li>
|
|
300
|
+
) : null}
|
|
301
|
+
</ul>
|
|
302
|
+
</Modal>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RelationGraph } from "@contract/flow.ts";
|
|
2
|
+
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one line above a graph, and it appears only when there is something to say (#88).
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ It counted what was drawn directly below it — "3 nodes and 0 relationships you are allowed to
|
|
8
|
+
* see" — permanently, above every graph. Three nodes are three nodes; the reader sees them. What
|
|
9
|
+
* they cannot see is what the view stopped short of, and that is what stayed.
|
|
10
|
+
*
|
|
11
|
+
* Its own file so a case about this one line does not have to render the whole radial tree beside
|
|
12
|
+
* it — the two are meant to change for different reasons (#825).
|
|
13
|
+
*/
|
|
14
|
+
export function GraphNotice({ data, i18n }: { data: RelationGraph; i18n: I18n }) {
|
|
15
|
+
if (data.omitted === 0) return null;
|
|
16
|
+
return (
|
|
17
|
+
<p className="border-b px-6 py-3 text-sm text-muted-foreground">
|
|
18
|
+
{i18n.t("view.graphTruncated", { omitted: data.omitted, limit: data.limit })}
|
|
19
|
+
</p>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { RelationEdge, RelationGraph, RelationNode } from "@contract/flow.ts";
|
|
2
|
+
|
|
3
|
+
// The radial tree's own geometry (#825), the same four rings the calculated draft used: they get
|
|
4
|
+
// closer together further out because the circumference at that radius keeps growing, so the same
|
|
5
|
+
// angular slice buys more room to draw in.
|
|
6
|
+
const RING_RADIUS = [0, 130, 235, 320] as const;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The last ring drawn as individual circles ("drei Ringe offen", #825): depth 0 to 2 are always
|
|
10
|
+
* their own node, icon and label. A node AT this depth is drawn too, but as a bundle once it has
|
|
11
|
+
* descendants — everything past it is rolled into that one marker rather than drawn at all.
|
|
12
|
+
*/
|
|
13
|
+
export const BUNDLE_DEPTH = 3;
|
|
14
|
+
|
|
15
|
+
// One ring further out than the bundle ring, so a user's line always starts outside every node it
|
|
16
|
+
// can point at, tree member or bundle alike.
|
|
17
|
+
const USER_RADIUS = 448;
|
|
18
|
+
// How far outside its own ring a node reached only by `reads`/`calls` sits (#825): far enough to
|
|
19
|
+
// read as attached rather than filed, never mistaken for a member of the ring it borrows depth from.
|
|
20
|
+
const SATELLITE_OFFSET = 46;
|
|
21
|
+
|
|
22
|
+
const OUTERMOST_RING_RADIUS = RING_RADIUS[RING_RADIUS.length - 1] ?? 0;
|
|
23
|
+
|
|
24
|
+
function radiusFor(depth: number): number {
|
|
25
|
+
return RING_RADIUS[Math.min(depth, RING_RADIUS.length - 1)] ?? OUTERMOST_RING_RADIUS;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function polarPoint(radius: number, angle: number): { x: number; y: number } {
|
|
29
|
+
return { x: radius * Math.cos(angle), y: radius * Math.sin(angle) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The one line the whole picture hangs on (#825's own ⚠️): a straight line from centre to centre
|
|
34
|
+
* turns 53 nodes into a starburst and the tree is gone. This draws the arc on the PARENT's own
|
|
35
|
+
* radius from the parent's angle to the child's, then a straight radial piece out to the child —
|
|
36
|
+
* so a fork in the tree reads as a fork, not as two lines that happen to start near each other.
|
|
37
|
+
*/
|
|
38
|
+
export function elbowPath(
|
|
39
|
+
parentRadius: number,
|
|
40
|
+
parentAngle: number,
|
|
41
|
+
childRadius: number,
|
|
42
|
+
childAngle: number,
|
|
43
|
+
): string {
|
|
44
|
+
const arcStart = polarPoint(parentRadius, parentAngle);
|
|
45
|
+
const arcEnd = polarPoint(parentRadius, childAngle);
|
|
46
|
+
const lineEnd = polarPoint(childRadius, childAngle);
|
|
47
|
+
if (parentRadius === 0) {
|
|
48
|
+
// The centre has no arc to draw — every child starts at the same point, so the elbow is only
|
|
49
|
+
// ever the radial half.
|
|
50
|
+
return `M${arcStart.x},${arcStart.y} L${lineEnd.x},${lineEnd.y}`;
|
|
51
|
+
}
|
|
52
|
+
const delta = (((childAngle - parentAngle) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
|
53
|
+
const sweep = delta < Math.PI ? 1 : 0;
|
|
54
|
+
return (
|
|
55
|
+
`M${arcStart.x},${arcStart.y} ` +
|
|
56
|
+
`A${parentRadius},${parentRadius} 0 0,${sweep} ${arcEnd.x},${arcEnd.y} ` +
|
|
57
|
+
`L${lineEnd.x},${lineEnd.y}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PositionedNode {
|
|
62
|
+
node: RelationNode;
|
|
63
|
+
angle: number;
|
|
64
|
+
radius: number;
|
|
65
|
+
x: number;
|
|
66
|
+
y: number;
|
|
67
|
+
parent: PositionedNode | null;
|
|
68
|
+
/** Nodes rolled up into this one because they sit past the bundled ring (#825). Named, not hidden. */
|
|
69
|
+
bundled: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PositionedUser {
|
|
73
|
+
id: string;
|
|
74
|
+
angle: number;
|
|
75
|
+
x: number;
|
|
76
|
+
y: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RadialLayout {
|
|
80
|
+
/** Every node actually drawn as its own circle — a tree member up to the bundled ring, or a
|
|
81
|
+
* satellite reached only by `reads`/`calls`. A bundled descendant past `BUNDLE_DEPTH` is not here;
|
|
82
|
+
* it is counted on its ancestor's `bundled` instead. */
|
|
83
|
+
nodes: PositionedNode[];
|
|
84
|
+
users: PositionedUser[];
|
|
85
|
+
byId: Map<string, PositionedNode>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A stable order for children of one parent, so the same graph always draws the same picture
|
|
89
|
+
// (#825: "dieselbe Eingabe, dasselbe Bild") regardless of what order the server happened to answer
|
|
90
|
+
// its rows in.
|
|
91
|
+
function byId(a: RelationNode, b: RelationNode): number {
|
|
92
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The sunburst angle inheritance (#825): a node's sector is split among its children in proportion
|
|
97
|
+
* to how much each one carries — measured in nodes, not leaves, so a subfolder with one huge
|
|
98
|
+
* document and one with fifty small ones do not fight for the same sliver.
|
|
99
|
+
*/
|
|
100
|
+
function subtreeSize(id: string, childrenOf: Map<string, RelationNode[]>): number {
|
|
101
|
+
const children = childrenOf.get(id) ?? [];
|
|
102
|
+
return children.reduce((sum, child) => sum + 1 + subtreeSize(child.id, childrenOf), 0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Turns the flat answer `flow_graph` gives into a picture: every tree member's ring and angle, every
|
|
107
|
+
* satellite's borrowed position, and the outer ring of users (#825).
|
|
108
|
+
*
|
|
109
|
+
* ⚠️ Only `inScope` members feed the sunburst split. A node reached solely through a `reads`/`calls`
|
|
110
|
+
* edge carries no coherent parent in this drawing (`node-graph.ts` on the API side sets `parentId`
|
|
111
|
+
* to `null` for exactly that reason) — it is positioned next to whichever flow reached it instead,
|
|
112
|
+
* so a dashed line still shows what connects it without inventing a membership arc for it.
|
|
113
|
+
*/
|
|
114
|
+
export function buildRadialLayout(data: RelationGraph): RadialLayout {
|
|
115
|
+
const treeMembers = data.nodes.filter((node) => node.inScope);
|
|
116
|
+
const satellites = data.nodes.filter((node) => !node.inScope);
|
|
117
|
+
const treeById = new Map(treeMembers.map((node) => [node.id, node]));
|
|
118
|
+
|
|
119
|
+
const childrenOf = new Map<string, RelationNode[]>();
|
|
120
|
+
const roots: RelationNode[] = [];
|
|
121
|
+
for (const node of treeMembers) {
|
|
122
|
+
if (node.parentId !== null && treeById.has(node.parentId)) {
|
|
123
|
+
const list = childrenOf.get(node.parentId) ?? [];
|
|
124
|
+
list.push(node);
|
|
125
|
+
childrenOf.set(node.parentId, list);
|
|
126
|
+
} else {
|
|
127
|
+
// No parent drawn alongside it: the real centre (depth 0), a top-level item when the whole
|
|
128
|
+
// tree is being shown, or — the one edge case — a tree member whose own folder was cut by the
|
|
129
|
+
// size limit. All three start a fresh sector rather than vanish.
|
|
130
|
+
roots.push(node);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const list of childrenOf.values()) list.sort(byId);
|
|
134
|
+
roots.sort(byId);
|
|
135
|
+
|
|
136
|
+
const positioned = new Map<string, PositionedNode>();
|
|
137
|
+
|
|
138
|
+
function place(node: RelationNode, angle: number, parent: PositionedNode | null): PositionedNode {
|
|
139
|
+
const radius = radiusFor(Math.min(node.depth, BUNDLE_DEPTH));
|
|
140
|
+
const { x, y } = polarPoint(radius, angle);
|
|
141
|
+
const entry: PositionedNode = { node, angle, radius, x, y, parent, bundled: 0 };
|
|
142
|
+
positioned.set(node.id, entry);
|
|
143
|
+
return entry;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function walk(node: RelationNode, a0: number, a1: number, parent: PositionedNode | null): void {
|
|
147
|
+
const mid = (a0 + a1) / 2;
|
|
148
|
+
const entry = place(node, mid, parent);
|
|
149
|
+
if (node.depth >= BUNDLE_DEPTH) {
|
|
150
|
+
// Past the open rings: everything beneath is counted on this node rather than drawn (#825).
|
|
151
|
+
entry.bundled = subtreeSize(node.id, childrenOf);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const children = childrenOf.get(node.id) ?? [];
|
|
155
|
+
if (children.length === 0) return;
|
|
156
|
+
const total = children.reduce((sum, child) => sum + 1 + subtreeSize(child.id, childrenOf), 0);
|
|
157
|
+
let cursor = a0;
|
|
158
|
+
for (const child of children) {
|
|
159
|
+
const weight = (1 + subtreeSize(child.id, childrenOf)) / total;
|
|
160
|
+
const span = (a1 - a0) * weight;
|
|
161
|
+
walk(child, cursor, cursor + span, entry);
|
|
162
|
+
cursor += span;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A real centre (a named folder or the flow being inspected) takes the whole circle for itself,
|
|
167
|
+
// exactly like the reference draft. Without one — the whole tree, `folderId: null` — the top-level
|
|
168
|
+
// items split the circle between themselves instead, as if a root stood there unseen (#825).
|
|
169
|
+
const singleRoot = roots.length === 1 && roots[0]?.depth === 0 ? roots[0] : null;
|
|
170
|
+
if (singleRoot) {
|
|
171
|
+
walk(singleRoot, -Math.PI / 2, (Math.PI * 3) / 2, null);
|
|
172
|
+
} else {
|
|
173
|
+
const total = roots.reduce((sum, root) => sum + 1 + subtreeSize(root.id, childrenOf), 0) || 1;
|
|
174
|
+
let cursor = -Math.PI / 2;
|
|
175
|
+
for (const root of roots) {
|
|
176
|
+
const weight = (1 + subtreeSize(root.id, childrenOf)) / total;
|
|
177
|
+
const span = Math.PI * 2 * weight;
|
|
178
|
+
walk(root, cursor, cursor + span, null);
|
|
179
|
+
cursor += span;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Satellites borrow their referrer's angle. More than one from the same flow fan out a little so
|
|
184
|
+
// they do not sit on top of one another, in the stable order above.
|
|
185
|
+
const byReferrer = new Map<string, RelationNode[]>();
|
|
186
|
+
for (const edge of data.edges as RelationEdge[]) {
|
|
187
|
+
const target = satellites.find((node) => node.id === edge.target);
|
|
188
|
+
if (!target) continue;
|
|
189
|
+
const list = byReferrer.get(edge.source) ?? [];
|
|
190
|
+
if (!list.some((node) => node.id === target.id)) list.push(target);
|
|
191
|
+
byReferrer.set(edge.source, list);
|
|
192
|
+
}
|
|
193
|
+
for (const [referrerId, targets] of byReferrer) {
|
|
194
|
+
const referrer = positioned.get(referrerId);
|
|
195
|
+
if (!referrer) continue;
|
|
196
|
+
targets.sort(byId);
|
|
197
|
+
const spread = 0.16;
|
|
198
|
+
targets.forEach((node, index) => {
|
|
199
|
+
const offset = targets.length === 1 ? 0 : (index / (targets.length - 1) - 0.5) * spread;
|
|
200
|
+
const angle = referrer.angle + offset;
|
|
201
|
+
const radius = referrer.radius + SATELLITE_OFFSET;
|
|
202
|
+
const { x, y } = polarPoint(radius, angle);
|
|
203
|
+
positioned.set(node.id, { node, angle, radius, x, y, parent: null, bundled: 0 });
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const users: PositionedUser[] = data.users.map((user, index) => {
|
|
208
|
+
const angle = -Math.PI / 2 + (index / Math.max(1, data.users.length)) * Math.PI * 2;
|
|
209
|
+
const { x, y } = polarPoint(USER_RADIUS, angle);
|
|
210
|
+
return { id: user.id, angle, x, y };
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return { nodes: [...positioned.values()], users, byId: positioned };
|
|
214
|
+
}
|