From 7e845d6dfecba835c3ae4c699b28225fcad7a5e8 Mon Sep 17 00:00:00 2001 From: nejimakibird <23066051+nejimakibird@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:02:48 +0900 Subject: [PATCH] feat: add screen flow view for flow diagrams --- docs/formats/FORMAT-flow_diagram.md | 8 +- docs/ja/formats/FORMAT-flow_diagram.md | 8 +- main.js | 192 +++++++++++++++++++++++-- src/core/flow-diagram-view-mode.ts | 80 +++++++++++ src/core/vault-index.ts | 1 + src/i18n/en.ts | 7 + src/i18n/ja.ts | 5 + src/main.ts | 31 ++++ src/parsers/dfd-diagram-parser.ts | 4 + src/renderers/dfd-mermaid.ts | 23 ++- src/renderers/diagram-renderer.ts | 3 +- src/settings/model-weave-settings.ts | 13 ++ src/types/models.ts | 2 + src/views/modeling-preview-view.ts | 121 +++++++++++++++- test/flow-diagram.test.mjs | 87 +++++++++-- 15 files changed, 552 insertions(+), 33 deletions(-) create mode 100644 src/core/flow-diagram-view-mode.ts diff --git a/docs/formats/FORMAT-flow_diagram.md b/docs/formats/FORMAT-flow_diagram.md index 7c96c29..8837325 100644 --- a/docs/formats/FORMAT-flow_diagram.md +++ b/docs/formats/FORMAT-flow_diagram.md @@ -43,6 +43,12 @@ kind: screen_communication | `flow_view` | no | `detail` (default) or `screen` | +## Viewer Settings + +`flow_view` selects the initial view only when a Flow Diagram is first opened in the Viewer. After that, the toolbar selection is kept as Viewer state for that file and does not modify Markdown. + +When `flow_view` is absent or invalid, **Default Flow Diagram view** in plugin settings selects the initial mode. Its default is `detail`. + ## Domain Sources / Domains `flow_diagram` may use the same Domain placement support as DFD and App Process views. @@ -127,7 +133,7 @@ Screen Flow edge labels are taken from the incoming flow to the visible target, Screen Flow is a derived rendering projection. It does not change the source Markdown, does not create data nodes from `Flows.data`, and does not replace the detailed communication model. -Toolbar switching is not part of the MVP; use `flow_view` frontmatter to choose the default view for a file. +`flow_view` supplies the Viewer initial view when no current Viewer state exists for the file. The Viewer toolbar can temporarily switch between Detail and Screen without changing the Markdown file. Detail is for inspecting the full communication graph; Screen is for following the projected handoff flow between user-visible objects. ## AI Generation Notes diff --git a/docs/ja/formats/FORMAT-flow_diagram.md b/docs/ja/formats/FORMAT-flow_diagram.md index 8f91813..b4caa4d 100644 --- a/docs/ja/formats/FORMAT-flow_diagram.md +++ b/docs/ja/formats/FORMAT-flow_diagram.md @@ -43,6 +43,12 @@ kind: screen_communication | `flow_view` | no | `detail` (default) または `screen` | +## Viewer Settings + +`flow_view` は Flow Diagram を Viewer で最初に開くときだけ初期 view を指定します。その後は toolbar の選択がファイルごとの Viewer state として保持され、Markdown は変更されません。 + +`flow_view` が未指定または不正な場合は、plugin settings の **Default Flow Diagram view** を初期値に使います。既定値は `detail` です。 + ## Domain Sources / Domains `flow_diagram` では、DFD や App Process view と同じ Domain placement support を利用できます。 @@ -127,7 +133,7 @@ Screen Flow の edge label は、表示対象 target へ入る flow から `cond Screen Flow は派生レンダリング projection です。source Markdown は変更せず、`Flows.data` から data node を作らず、詳細な communication model を置き換えるものではありません。 -Toolbar 切り替えは MVP には含めません。ファイルの既定 view を選ぶ場合は frontmatter の `flow_view` を使います。 +`flow_view` は、そのファイルの現在の Viewer state が無い場合に初期 view を選びます。Viewer toolbar では Markdown を変更せずに Detail と Screen を一時切り替えできます。Detail は communication graph 全体の確認、Screen は user-visible object 間の投影された handoff flow の確認に使います。 ## AI生成時の注意 diff --git a/main.js b/main.js index 2491f0c..25d3181 100644 --- a/main.js +++ b/main.js @@ -9975,6 +9975,7 @@ var DEFAULT_MODEL_WEAVE_SETTINGS = { defaultProcessRenderMode: "custom", defaultBusinessFlowDirection: "LR", defaultScreenRenderMode: "custom", + defaultFlowDiagramViewMode: "detail", defaultDomainsViewMode: "mindmap", defaultDomainDiagramViewMode: "mindmap", defaultZoom: "fit", @@ -10020,6 +10021,10 @@ var VALID_DOMAIN_VIEW_MODES = /* @__PURE__ */ new Set([ "area", "tree" ]); +var VALID_FLOW_DIAGRAM_VIEW_MODES = /* @__PURE__ */ new Set([ + "detail", + "screen" +]); var VALID_UI_LANGUAGES = /* @__PURE__ */ new Set(["auto", "en", "ja"]); function normalizeModelWeaveSettings(value) { const raw = isRecord(value) ? value : {}; @@ -10057,6 +10062,11 @@ function normalizeModelWeaveSettings(value) { SCREEN_RENDER_MODES, DEFAULT_MODEL_WEAVE_SETTINGS.defaultScreenRenderMode ), + defaultFlowDiagramViewMode: normalizeEnumValue( + raw.defaultFlowDiagramViewMode, + VALID_FLOW_DIAGRAM_VIEW_MODES, + DEFAULT_MODEL_WEAVE_SETTINGS.defaultFlowDiagramViewMode + ), defaultDomainsViewMode: normalizeEnumValue( raw.defaultDomainsViewMode, VALID_DOMAIN_VIEW_MODES, @@ -11774,6 +11784,8 @@ function parseDfdLikeDiagramFile(markdown, path2, options) { const level = typeof frontmatter.level === "string" || typeof frontmatter.level === "number" ? String(frontmatter.level).trim() : void 0; const kind = typeof frontmatter.kind === "string" && frontmatter.kind.trim() ? frontmatter.kind.trim() : options.defaultKind; const flowView = parseFlowDiagramViewMode(frontmatter.flow_view); + const flowViewRaw = frontmatter.flow_view; + const flowViewSpecified = options.schema === "flow_diagram" && !isUnknownFlowDiagramViewMode(frontmatter.flow_view) && typeof frontmatter.flow_view === "string" && frontmatter.flow_view.trim().length > 0; const rawType = typeof frontmatter.type === "string" ? frontmatter.type.trim() : ""; const isAcceptedType = rawType === options.type || options.type === "flow_diagram" && rawType === "flow-diagram"; if (!isAcceptedType) { @@ -11911,6 +11923,8 @@ function parseDfdLikeDiagramFile(markdown, path2, options) { kind: "screen_communication", description: joinSectionLines2(sections.Summary), flowView, + flowViewSpecified, + flowViewRaw, domainSources: domainSourcesTable.rows, domains: domainsTable.rows, objectRefs, @@ -15043,6 +15057,7 @@ function createShallowModel(path2, fileType, frontmatter) { name, kind: "screen_communication", flowView: "detail", + flowViewSpecified: false, domainSources: [], domains: [], objectRefs: [], @@ -18751,7 +18766,7 @@ function renderDfdMermaidDiagram(diagram, options) { exportAndOpenPngLabel: options?.exportAndOpenPngLabel, exportAndOpenPngTitle: options?.exportAndOpenPngTitle }); - const renderedDiagram = getFlowDiagramRenderedDiagram(diagram); + const renderedDiagram = getFlowDiagramRenderedDiagram(diagram, options?.flowDiagramViewMode); if (!options?.hideDetails) { const domainDetails = createDomainPlacementDetails(renderedDiagram, options?.dfdDetailLabels); if (domainDetails) { @@ -19029,9 +19044,9 @@ function buildDfdMermaidInteractionTargets(diagram, sourcePath) { return target; }).filter((target) => Boolean(target)); } -function buildDfdMermaidSource(diagram, colorScheme) { +function buildDfdMermaidSource(diagram, colorScheme, flowDiagramViewMode) { if (isFlowDiagramModel(diagram.diagram)) { - return buildFlowDiagramMermaidSource(getFlowDiagramRenderedDiagram(diagram), colorScheme); + return buildFlowDiagramMermaidSource(getFlowDiagramRenderedDiagram(diagram, flowDiagramViewMode), colorScheme); } const palette = getModelWeaveMermaidPalette(); const lines = ["flowchart LR"]; @@ -19113,15 +19128,12 @@ function buildDfdMermaidSource(diagram, colorScheme) { } return lines.join("\n"); } -function getFlowDiagramRenderedDiagram(diagram) { - if (!isFlowDiagramModel(diagram.diagram) || resolveFlowDiagramViewMode(diagram.diagram) !== "screen") { +function getFlowDiagramRenderedDiagram(diagram, effectiveViewMode) { + if (!isFlowDiagramModel(diagram.diagram) || effectiveViewMode !== "screen") { return diagram; } return buildFlowDiagramScreenFlowProjection(diagram); } -function resolveFlowDiagramViewMode(diagram, toolbarOverride) { - return toolbarOverride ?? diagram.flowView ?? "detail"; -} function buildFlowDiagramScreenFlowProjection(diagram) { if (!isFlowDiagramModel(diagram.diagram)) { return diagram; @@ -19804,6 +19816,51 @@ function createReservedKindFallback(kind) { return root; } +// src/core/flow-diagram-view-mode.ts +function normalizeFlowDiagramViewMode(value) { + if (typeof value !== "string") { + return void 0; + } + const normalized = value.trim().toLowerCase(); + return normalized === "detail" || normalized === "screen" ? normalized : void 0; +} +function resolveInitialFlowDiagramViewMode(diagram, defaultViewMode) { + return diagram.flowViewSpecified ? diagram.flowView : normalizeFlowDiagramViewMode(defaultViewMode) ?? "detail"; +} +function buildFlowDiagramViewModeInitializationKey(diagram, defaultViewMode) { + if (diagram.flowViewSpecified) { + return `frontmatter:${diagram.flowView}`; + } + const defaultMode = normalizeFlowDiagramViewMode(defaultViewMode) ?? "detail"; + const rawValue = typeof diagram.flowViewRaw === "string" ? diagram.flowViewRaw.trim() : ""; + return rawValue ? `invalid:${rawValue}:settings:${defaultMode}` : `settings:${defaultMode}`; +} +var FlowDiagramViewModeState = class { + constructor() { + this.entriesByFilePath = /* @__PURE__ */ new Map(); + } + synchronize(filePath, diagram, defaultViewMode) { + const initializationKey = buildFlowDiagramViewModeInitializationKey(diagram, defaultViewMode); + const existing = this.entriesByFilePath.get(filePath); + if (existing?.initializationKey === initializationKey) { + return { mode: existing.mode, initializationChanged: false }; + } + const mode = resolveInitialFlowDiagramViewMode(diagram, defaultViewMode); + this.entriesByFilePath.set(filePath, { mode, initializationKey }); + return { mode, initializationChanged: true }; + } + getOrInitialize(filePath, diagram, defaultViewMode) { + return this.synchronize(filePath, diagram, defaultViewMode).mode; + } + set(filePath, mode) { + const existing = this.entriesByFilePath.get(filePath); + if (!existing) { + return; + } + existing.mode = mode; + } +}; + // src/core/app-process-step-interaction-target.ts function resolveAppProcessStepInteractionTarget(model, step, context) { const index = context.index ?? null; @@ -20851,6 +20908,9 @@ var EN_MESSAGES = { "appProcess.businessFlow.direction": "Business flow direction", "appProcess.businessFlow.direction.lr": "Left to right", "appProcess.businessFlow.direction.td": "Top down", + "flowDiagram.viewMode": "Flow view", + "flowDiagram.viewMode.detail": "Detail", + "flowDiagram.viewMode.screen": "Screen", /* eslint-disable obsidianmd/ui/sentence-case-locale-module -- Connect Flow is the requested toolbar label. */ "appProcess.businessFlow.connectFlow.short": "Connect Flow", "appProcess.businessFlow.connectFlow.title": "Connect Flow", @@ -21178,6 +21238,10 @@ var EN_MESSAGES = { "settings.defaultBusinessFlowDirection.desc": "Used for app_process business flow previews when frontmatter.flow_direction is not set.", "settings.defaultBusinessFlowDirection.lr": "Left to right", "settings.defaultBusinessFlowDirection.td": "Top down", + // eslint-disable-next-line obsidianmd/ui/sentence-case-locale-module -- Flow Diagram is the requested format name. + "settings.defaultFlowDiagramViewMode.name": "Default Flow Diagram view", + // eslint-disable-next-line obsidianmd/ui/sentence-case-locale-module -- Flow Diagram is the requested format name. + "settings.defaultFlowDiagramViewMode.desc": "Used for a Flow Diagram initial view when frontmatter.flow_view is not set or is invalid.", "settings.defaultScreenRenderMode.name": "Default screen render mode", "settings.defaultScreenRenderMode.desc": "Used for screen files when frontmatter.render_mode is not set.", // eslint-disable-next-line obsidianmd/ui/sentence-case-locale-module @@ -21296,6 +21360,9 @@ var JA_MESSAGES = { "appProcess.businessFlow.direction": "Business Flow \u65B9\u5411", "appProcess.businessFlow.direction.lr": "\u5DE6\u304B\u3089\u53F3", "appProcess.businessFlow.direction.td": "\u4E0A\u304B\u3089\u4E0B", + "flowDiagram.viewMode": "Flow view", + "flowDiagram.viewMode.detail": "Detail", + "flowDiagram.viewMode.screen": "Screen", "appProcess.businessFlow.connectFlow.short": "Flow\u63A5\u7D9A", "appProcess.businessFlow.connectFlow.title": "Flow\u63A5\u7D9A", "appProcess.businessFlow.connectFlow.selectedTitle": "Flow\u63A5\u7D9A: {stepId} \u3092\u9078\u629E\u4E2D", @@ -21579,6 +21646,8 @@ var JA_MESSAGES = { "settings.defaultBusinessFlowDirection.desc": "app_process Business Flow preview \u3067 frontmatter.flow_direction \u304C\u672A\u8A2D\u5B9A\u306E\u5834\u5408\u306B\u4F7F\u7528\u3057\u307E\u3059\u3002", "settings.defaultBusinessFlowDirection.lr": "\u5DE6\u304B\u3089\u53F3", "settings.defaultBusinessFlowDirection.td": "\u4E0A\u304B\u3089\u4E0B", + "settings.defaultFlowDiagramViewMode.name": "Flow Diagram \u306E\u521D\u671F\u8868\u793A\u30E2\u30FC\u30C9", + "settings.defaultFlowDiagramViewMode.desc": "Flow Diagram \u3067 frontmatter.flow_view \u304C\u672A\u8A2D\u5B9A\u307E\u305F\u306F\u4E0D\u6B63\u306A\u5834\u5408\u306E\u521D\u671F\u8868\u793A\u306B\u4F7F\u7528\u3057\u307E\u3059\u3002", "settings.defaultScreenRenderMode.name": "Screen \u306E\u521D\u671F render_mode", "settings.defaultScreenRenderMode.desc": "screen \u30D5\u30A1\u30A4\u30EB\u3067 frontmatter.render_mode \u304C\u672A\u8A2D\u5B9A\u306E\u5834\u5408\u306B\u4F7F\u7528\u3057\u307E\u3059\u3002", "settings.defaultDomainsViewMode.name": "Domains \u306E\u521D\u671F\u8868\u793A\u30E2\u30FC\u30C9", @@ -22753,6 +22822,7 @@ var DEFAULT_VIEWER_PREFERENCES = { defaultDomainsViewMode: "mindmap", defaultDomainDiagramViewMode: "mindmap", defaultBusinessFlowDirection: "LR", + defaultFlowDiagramViewMode: "detail", localSourceRoot: "", uiLanguage: "auto", showMermaidRenderDebug: false @@ -22813,6 +22883,7 @@ var ModelingPreviewView = class extends import_obsidian7.ItemView { this.appProcessBusinessFlowDirectionOverride = null; this.appProcessBusinessFlowDirectionFilePath = null; this.appProcessFlowConnectFilePath = null; + this.flowDiagramViewModes = new FlowDiagramViewModeState(); this.appProcessFlowConnectModeEnabled = false; this.appProcessFlowConnectSourceStepId = null; this.domainsDiagramModeState = null; @@ -22986,6 +23057,11 @@ var ModelingPreviewView = class extends import_obsidian7.ItemView { this.prepareDomainsDiagramMode(state, nextFilePath); this.prepareAppProcessBusinessFlowDirection(state, nextFilePath); this.prepareAppProcessFlowConnectMode(nextFilePath); + const flowViewReinitialized = this.prepareFlowDiagramViewMode(state, nextFilePath); + if (flowViewReinitialized && nextFilePath) { + this.viewportStateCache.delete(nextFilePath); + resetGraphViewportState(this.diagramViewportState); + } this.prepareViewportState(state, reason); this.state = state; this.renderCurrentState(); @@ -23216,6 +23292,16 @@ var ModelingPreviewView = class extends import_obsidian7.ItemView { this.domainsDiagramModeFilePath = nextFilePath; this.domainsDiagramModeState = state.mode; } + prepareFlowDiagramViewMode(state, nextFilePath) { + if (state.mode !== "diagram" || state.diagram.diagram.schema !== "flow_diagram" || !nextFilePath) { + return false; + } + return this.flowDiagramViewModes.synchronize( + nextFilePath, + state.diagram.diagram, + this.viewerPreferences.defaultFlowDiagramViewMode + ).initializationChanged; + } rememberViewportState(filePath, state) { if (!state.hasAutoFitted && !state.hasUserInteracted) { return; @@ -23269,6 +23355,7 @@ var ModelingPreviewView = class extends import_obsidian7.ItemView { forExport: true, renderMode: getStandardRenderMode(state.rendererSelection), colorScheme: state.colorScheme, + flowDiagramViewMode: state.diagram.diagram.schema === "flow_diagram" ? this.getFlowDiagramViewMode(state) : void 0, ...getMermaidSourceLabels(this.t) }) }; @@ -24378,6 +24465,35 @@ var ModelingPreviewView = class extends import_obsidian7.ItemView { const rightGroup = toolbar.querySelector(".model-weave-zoom-toolbar-right") ?? toolbar; rightGroup.appendChild(wrapper); } + appendFlowDiagramViewSelector(container, filePath) { + const toolbar = container.querySelector(".mdspec-zoom-toolbar"); + if (!toolbar) { + return; + } + toolbar.addClass("model-weave-render-mode-toolbar-host"); + toolbar.querySelector(".model-weave-flow-diagram-view-select-group")?.remove(); + const wrapper = container.ownerDocument.createElement("div"); + wrapper.className = "model-weave-flow-diagram-view-select-group model-weave-render-mode-row"; + const label = container.ownerDocument.createElement("span"); + label.addClass("model-weave-render-mode-label"); + label.textContent = this.t("flowDiagram.viewMode"); + wrapper.appendChild(label); + const select = container.ownerDocument.createElement("select"); + select.addClass("model-weave-flow-diagram-view-select"); + for (const mode of ["detail", "screen"]) { + const option = container.ownerDocument.createElement("option"); + option.value = mode; + option.textContent = this.t(`flowDiagram.viewMode.${mode}`); + option.selected = this.getFlowDiagramViewModeForFile(filePath) === mode; + select.appendChild(option); + } + select.addEventListener("change", () => { + this.setFlowDiagramViewMode(select.value, filePath); + }); + wrapper.appendChild(select); + const rightGroup = toolbar.querySelector(".model-weave-zoom-toolbar-right") ?? toolbar; + rightGroup.appendChild(wrapper); + } appendAppProcessFlowConnectControl(container, filePath) { const toolbar = container.querySelector(".mdspec-zoom-toolbar"); if (!toolbar) { @@ -24503,6 +24619,41 @@ var ModelingPreviewView = class extends import_obsidian7.ItemView { getAppProcessBusinessFlowDirectionOverride(filePath) { return this.appProcessBusinessFlowDirectionFilePath === filePath ? this.appProcessBusinessFlowDirectionOverride : null; } + getFlowDiagramViewMode(state) { + const diagram = state.diagram.diagram; + if (diagram.schema !== "flow_diagram") { + return "detail"; + } + return this.flowDiagramViewModes.getOrInitialize( + diagram.path, + diagram, + this.viewerPreferences.defaultFlowDiagramViewMode + ); + } + getFlowDiagramViewModeForFile(filePath) { + const diagramState = this.state.mode === "diagram" && this.state.diagram.diagram.path === filePath ? this.state : null; + if (!diagramState || diagramState.diagram.diagram.schema !== "flow_diagram") { + return "detail"; + } + return this.flowDiagramViewModes.getOrInitialize( + filePath, + diagramState.diagram.diagram, + this.viewerPreferences.defaultFlowDiagramViewMode + ); + } + setFlowDiagramViewMode(mode, filePath) { + if (mode !== "detail" && mode !== "screen") { + return; + } + if (this.getFlowDiagramViewModeForFile(filePath) === mode) { + return; + } + this.flowDiagramViewModes.set(filePath, mode); + this.viewportStateCache.delete(filePath); + resetGraphViewportState(this.diagramViewportState); + this.renderCurrentState(); + this.restoreCurrentScrollPosition(); + } setAppProcessBusinessFlowDirection(direction, filePath) { const nextDirection = normalizeAppProcessBusinessFlowDirection(direction); if (!nextDirection) { @@ -25761,6 +25912,7 @@ var ModelingPreviewView = class extends import_obsidian7.ItemView { onOpenObject: state.onOpenObject ?? void 0, app: this.app, interactionSourcePath: filePath, + flowDiagramViewMode: state.diagram.diagram.schema === "flow_diagram" ? this.getFlowDiagramViewMode(state) : void 0, renderMode: getStandardRenderMode(state.rendererSelection), colorScheme: state.colorScheme, viewportState: this.diagramViewportState, @@ -25777,6 +25929,9 @@ var ModelingPreviewView = class extends import_obsidian7.ItemView { ensureGraphIdentityTitle(diagramRoot, buildGraphIdentityTitle(state.diagram.diagram)); this.appendRendererSelection(diagramRoot, state.rendererSelection); this.appendViewerToolbarControls(diagramRoot); + if (isFlowDiagramViewSelectorVisible(state.diagram)) { + this.appendFlowDiagramViewSelector(diagramRoot, filePath); + } this.moveDetailSections(diagramRoot, lowerSlots.details); this.renderImpactSummarySection( lowerSlots.impact, @@ -27813,6 +27968,9 @@ function getFrontmatterString2(value, key) { function getModelDisplayName(value) { return getStringField(value, "name") ?? getStringField(value, "title") ?? getStringField(value, "logicalName") ?? getStringField(value, "physicalName") ?? getFrontmatterString2(value, "name") ?? getFrontmatterString2(value, "title") ?? getModelId3(value); } +function isFlowDiagramViewSelectorVisible(diagram) { + return diagram.diagram.schema === "flow_diagram"; +} function getModelId3(value) { return getStringField(value, "id") ?? getFrontmatterString2(value, "id"); } @@ -27945,6 +28103,10 @@ var DOMAIN_VIEW_MODE_OPTIONS = [ "area", "tree" ]; +var FLOW_DIAGRAM_VIEW_MODE_OPTIONS = [ + "detail", + "screen" +]; function isClassRenderModeOption(value) { return CLASS_RENDER_MODE_OPTIONS.some((candidate) => candidate === value); } @@ -27960,6 +28122,9 @@ function isProcessRenderModeOption(value) { function isBusinessFlowDirectionOption(value) { return BUSINESS_FLOW_DIRECTION_OPTIONS.some((candidate) => candidate === value); } +function isFlowDiagramViewModeOption(value) { + return FLOW_DIAGRAM_VIEW_MODE_OPTIONS.some((candidate) => candidate === value); +} function isScreenRenderModeOption(value) { return SCREEN_RENDER_MODE_OPTIONS.some((candidate) => candidate === value); } @@ -28407,6 +28572,7 @@ var ModelWeavePlugin = class extends import_obsidian8.Plugin { defaultDomainDiagramViewMode: this.settings.defaultDomainDiagramViewMode, defaultBusinessFlowDirection: this.settings.defaultBusinessFlowDirection, localSourceRoot: this.settings.localSourceRoot, + defaultFlowDiagramViewMode: this.settings.defaultFlowDiagramViewMode, uiLanguage: this.settings.uiLanguage, showMermaidRenderDebug: this.settings.showMermaidRenderDebug }; @@ -30956,6 +31122,16 @@ var ModelWeaveSettingTab = class extends import_obsidian8.PluginSettingTab { }); }); }); + new import_obsidian8.Setting(containerEl).setName(t("settings.defaultFlowDiagramViewMode.name")).setDesc(t("settings.defaultFlowDiagramViewMode.desc")).addDropdown((dropdown) => { + dropdown.addOption("detail", t("flowDiagram.viewMode.detail")).addOption("screen", t("flowDiagram.viewMode.screen")).setValue(settings.defaultFlowDiagramViewMode).onChange(async (value) => { + if (!isFlowDiagramViewModeOption(value)) { + return; + } + await this.plugin.updateSettings({ + defaultFlowDiagramViewMode: value + }); + }); + }); new import_obsidian8.Setting(containerEl).setName(t("settings.defaultScreenRenderMode.name")).setDesc(t("settings.defaultScreenRenderMode.desc")).addDropdown((dropdown) => { dropdown.addOption("custom", t("settings.option.custom")).setValue(settings.defaultScreenRenderMode).onChange(async (value) => { if (!isScreenRenderModeOption(value)) { diff --git a/src/core/flow-diagram-view-mode.ts b/src/core/flow-diagram-view-mode.ts new file mode 100644 index 0000000..526d76b --- /dev/null +++ b/src/core/flow-diagram-view-mode.ts @@ -0,0 +1,80 @@ +import type { FlowDiagramModel, FlowDiagramViewMode } from "../types/models"; + +interface FlowDiagramViewModeEntry { + mode: FlowDiagramViewMode; + initializationKey: string; +} + +export interface FlowDiagramViewModeSyncResult { + mode: FlowDiagramViewMode; + initializationChanged: boolean; +} + +export function normalizeFlowDiagramViewMode(value: unknown): FlowDiagramViewMode | undefined { + if (typeof value !== "string") { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + return normalized === "detail" || normalized === "screen" ? normalized : undefined; +} + +export function resolveInitialFlowDiagramViewMode( + diagram: Pick, + defaultViewMode?: unknown +): FlowDiagramViewMode { + return diagram.flowViewSpecified + ? diagram.flowView + : normalizeFlowDiagramViewMode(defaultViewMode) ?? "detail"; +} + +export function buildFlowDiagramViewModeInitializationKey( + diagram: Pick, + defaultViewMode?: unknown +): string { + if (diagram.flowViewSpecified) { + return `frontmatter:${diagram.flowView}`; + } + + const defaultMode = normalizeFlowDiagramViewMode(defaultViewMode) ?? "detail"; + const rawValue = typeof diagram.flowViewRaw === "string" ? diagram.flowViewRaw.trim() : ""; + return rawValue + ? `invalid:${rawValue}:settings:${defaultMode}` + : `settings:${defaultMode}`; +} + +export class FlowDiagramViewModeState { + private readonly entriesByFilePath = new Map(); + + synchronize( + filePath: string, + diagram: Pick, + defaultViewMode?: unknown + ): FlowDiagramViewModeSyncResult { + const initializationKey = buildFlowDiagramViewModeInitializationKey(diagram, defaultViewMode); + const existing = this.entriesByFilePath.get(filePath); + if (existing?.initializationKey === initializationKey) { + return { mode: existing.mode, initializationChanged: false }; + } + + const mode = resolveInitialFlowDiagramViewMode(diagram, defaultViewMode); + this.entriesByFilePath.set(filePath, { mode, initializationKey }); + return { mode, initializationChanged: true }; + } + + getOrInitialize( + filePath: string, + diagram: Pick, + defaultViewMode?: unknown + ): FlowDiagramViewMode { + return this.synchronize(filePath, diagram, defaultViewMode).mode; + } + + set(filePath: string, mode: FlowDiagramViewMode): void { + const existing = this.entriesByFilePath.get(filePath); + if (!existing) { + return; + } + existing.mode = mode; + } +} diff --git a/src/core/vault-index.ts b/src/core/vault-index.ts index 1b1f710..3367b41 100644 --- a/src/core/vault-index.ts +++ b/src/core/vault-index.ts @@ -944,6 +944,7 @@ function createShallowModel( name, kind: "screen_communication", flowView: "detail", + flowViewSpecified: false, domainSources: [], domains: [], objectRefs: [], diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b1d7bf9..71383a6 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -80,6 +80,9 @@ export const EN_MESSAGES: ModelWeaveMessageDictionary = { "appProcess.businessFlow.direction": "Business flow direction", "appProcess.businessFlow.direction.lr": "Left to right", "appProcess.businessFlow.direction.td": "Top down", + "flowDiagram.viewMode": "Flow view", + "flowDiagram.viewMode.detail": "Detail", + "flowDiagram.viewMode.screen": "Screen", /* eslint-disable obsidianmd/ui/sentence-case-locale-module -- Connect Flow is the requested toolbar label. */ "appProcess.businessFlow.connectFlow.short": "Connect Flow", @@ -407,6 +410,10 @@ export const EN_MESSAGES: ModelWeaveMessageDictionary = { "settings.defaultBusinessFlowDirection.desc": "Used for app_process business flow previews when frontmatter.flow_direction is not set.", "settings.defaultBusinessFlowDirection.lr": "Left to right", "settings.defaultBusinessFlowDirection.td": "Top down", + // eslint-disable-next-line obsidianmd/ui/sentence-case-locale-module -- Flow Diagram is the requested format name. + "settings.defaultFlowDiagramViewMode.name": "Default Flow Diagram view", + // eslint-disable-next-line obsidianmd/ui/sentence-case-locale-module -- Flow Diagram is the requested format name. + "settings.defaultFlowDiagramViewMode.desc": "Used for a Flow Diagram initial view when frontmatter.flow_view is not set or is invalid.", "settings.defaultScreenRenderMode.name": "Default screen render mode", "settings.defaultScreenRenderMode.desc": "Used for screen files when frontmatter.render_mode is not set.", // eslint-disable-next-line obsidianmd/ui/sentence-case-locale-module diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index ad6fe18..70412a7 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -74,6 +74,9 @@ export const JA_MESSAGES: ModelWeaveMessageDictionary = { "appProcess.businessFlow.direction": "Business Flow 方向", "appProcess.businessFlow.direction.lr": "左から右", "appProcess.businessFlow.direction.td": "上から下", + "flowDiagram.viewMode": "Flow view", + "flowDiagram.viewMode.detail": "Detail", + "flowDiagram.viewMode.screen": "Screen", "appProcess.businessFlow.connectFlow.short": "Flow接続", "appProcess.businessFlow.connectFlow.title": "Flow接続", @@ -358,6 +361,8 @@ export const JA_MESSAGES: ModelWeaveMessageDictionary = { "settings.defaultBusinessFlowDirection.desc": "app_process Business Flow preview で frontmatter.flow_direction が未設定の場合に使用します。", "settings.defaultBusinessFlowDirection.lr": "左から右", "settings.defaultBusinessFlowDirection.td": "上から下", + "settings.defaultFlowDiagramViewMode.name": "Flow Diagram の初期表示モード", + "settings.defaultFlowDiagramViewMode.desc": "Flow Diagram で frontmatter.flow_view が未設定または不正な場合の初期表示に使用します。", "settings.defaultScreenRenderMode.name": "Screen の初期 render_mode", "settings.defaultScreenRenderMode.desc": "screen ファイルで frontmatter.render_mode が未設定の場合に使用します。", "settings.defaultDomainsViewMode.name": "Domains の初期表示モード", diff --git a/src/main.ts b/src/main.ts index ae3aa9b..49068cf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -166,11 +166,15 @@ const BUSINESS_FLOW_DIRECTION_OPTIONS: readonly ModelWeaveSettings["defaultBusin "LR", "TD" ]; + const DOMAIN_VIEW_MODE_OPTIONS: readonly ModelWeaveSettings["defaultDomainsViewMode"][] = [ "mindmap", "area", "tree" ]; +const FLOW_DIAGRAM_VIEW_MODE_OPTIONS: readonly ModelWeaveSettings["defaultFlowDiagramViewMode"][] = [ + "detail", "screen" +]; function isClassRenderModeOption( value: string @@ -202,9 +206,16 @@ function isBusinessFlowDirectionOption( return BUSINESS_FLOW_DIRECTION_OPTIONS.some((candidate) => candidate === value); } +function isFlowDiagramViewModeOption( + value: string +): value is ModelWeaveSettings["defaultFlowDiagramViewMode"] { + return FLOW_DIAGRAM_VIEW_MODE_OPTIONS.some((candidate) => candidate === value); +} + function isScreenRenderModeOption( value: string ): value is ModelWeaveSettings["defaultScreenRenderMode"] { + return SCREEN_RENDER_MODE_OPTIONS.some((candidate) => candidate === value); } @@ -740,6 +751,7 @@ export default class ModelWeavePlugin extends Plugin { defaultDomainDiagramViewMode: this.settings.defaultDomainDiagramViewMode, defaultBusinessFlowDirection: this.settings.defaultBusinessFlowDirection, localSourceRoot: this.settings.localSourceRoot, + defaultFlowDiagramViewMode: this.settings.defaultFlowDiagramViewMode, uiLanguage: this.settings.uiLanguage, showMermaidRenderDebug: this.settings.showMermaidRenderDebug }; @@ -4048,6 +4060,25 @@ class ModelWeaveSettingTab extends PluginSettingTab { }); }); }); + new Setting(containerEl) + .setName(t("settings.defaultFlowDiagramViewMode.name")) + .setDesc(t("settings.defaultFlowDiagramViewMode.desc")) + .addDropdown((dropdown) => { + dropdown + .addOption("detail", t("flowDiagram.viewMode.detail")) + .addOption("screen", t("flowDiagram.viewMode.screen")) + .setValue(settings.defaultFlowDiagramViewMode) + .onChange(async (value) => { + if (!isFlowDiagramViewModeOption(value)) { + return; + } + + await this.plugin.updateSettings({ + defaultFlowDiagramViewMode: value + }); + }); + }); + new Setting(containerEl) .setName(t("settings.defaultScreenRenderMode.name")) .setDesc(t("settings.defaultScreenRenderMode.desc")) diff --git a/src/parsers/dfd-diagram-parser.ts b/src/parsers/dfd-diagram-parser.ts index 7460e62..1eb492d 100644 --- a/src/parsers/dfd-diagram-parser.ts +++ b/src/parsers/dfd-diagram-parser.ts @@ -111,6 +111,8 @@ function parseDfdLikeDiagramFile( ? frontmatter.kind.trim() : options.defaultKind; const flowView = parseFlowDiagramViewMode(frontmatter.flow_view); + const flowViewRaw = frontmatter.flow_view; + const flowViewSpecified = options.schema === "flow_diagram" && !isUnknownFlowDiagramViewMode(frontmatter.flow_view) && typeof frontmatter.flow_view === "string" && frontmatter.flow_view.trim().length > 0; const rawType = typeof frontmatter.type === "string" ? frontmatter.type.trim() : ""; const isAcceptedType = rawType === options.type || @@ -260,6 +262,8 @@ function parseDfdLikeDiagramFile( kind: "screen_communication", description: joinSectionLines(sections.Summary), flowView, + flowViewSpecified, + flowViewRaw, domainSources: domainSourcesTable.rows, domains: domainsTable.rows, objectRefs, diff --git a/src/renderers/dfd-mermaid.ts b/src/renderers/dfd-mermaid.ts index a25775a..1f5950a 100644 --- a/src/renderers/dfd-mermaid.ts +++ b/src/renderers/dfd-mermaid.ts @@ -86,6 +86,7 @@ export function renderDfdMermaidDiagram( exportPngTitle?: string; exportAndOpenPngLabel?: string; exportAndOpenPngTitle?: string; + flowDiagramViewMode?: FlowDiagramViewMode; colorScheme?: ResolvedColorScheme; dfdDetailLabels?: DfdDetailLabels; } @@ -108,7 +109,7 @@ export function renderDfdMermaidDiagram( exportAndOpenPngTitle: options?.exportAndOpenPngTitle }); - const renderedDiagram = getFlowDiagramRenderedDiagram(diagram); + const renderedDiagram = getFlowDiagramRenderedDiagram(diagram, options?.flowDiagramViewMode); if (!options?.hideDetails) { const domainDetails = createDomainPlacementDetails(renderedDiagram, options?.dfdDetailLabels); @@ -441,10 +442,11 @@ function buildDfdMermaidInteractionTargets( export function buildDfdMermaidSource( diagram: ResolvedDiagram, - colorScheme?: ResolvedColorScheme + colorScheme?: ResolvedColorScheme, + flowDiagramViewMode?: FlowDiagramViewMode ): string { if (isFlowDiagramModel(diagram.diagram)) { - return buildFlowDiagramMermaidSource(getFlowDiagramRenderedDiagram(diagram), colorScheme); + return buildFlowDiagramMermaidSource(getFlowDiagramRenderedDiagram(diagram, flowDiagramViewMode), colorScheme); } const palette = getModelWeaveMermaidPalette(); @@ -539,19 +541,16 @@ export function buildDfdMermaidSource( } -function getFlowDiagramRenderedDiagram(diagram: ResolvedDiagram): ResolvedDiagram { - if (!isFlowDiagramModel(diagram.diagram) || resolveFlowDiagramViewMode(diagram.diagram) !== "screen") { +function getFlowDiagramRenderedDiagram( + diagram: ResolvedDiagram, + effectiveViewMode?: FlowDiagramViewMode +): ResolvedDiagram { + if (!isFlowDiagramModel(diagram.diagram) || effectiveViewMode !== "screen") { return diagram; } return buildFlowDiagramScreenFlowProjection(diagram); } -export function resolveFlowDiagramViewMode( - diagram: FlowDiagramModel, - toolbarOverride?: FlowDiagramViewMode -): FlowDiagramViewMode { - return toolbarOverride ?? diagram.flowView ?? "detail"; -} export function buildFlowDiagramScreenFlowProjection(diagram: ResolvedDiagram): ResolvedDiagram { if (!isFlowDiagramModel(diagram.diagram)) { @@ -687,7 +686,7 @@ function getProjectionEdgeVisitKey(edge: DiagramEdge): string { function buildFlowDiagramMermaidSource( diagram: ResolvedDiagram, - colorScheme?: ResolvedColorScheme + colorScheme?: ResolvedColorScheme, ): string { const palette = getModelWeaveMermaidPalette(); const lines: string[] = ["flowchart LR"]; diff --git a/src/renderers/diagram-renderer.ts b/src/renderers/diagram-renderer.ts index c231993..fddb765 100644 --- a/src/renderers/diagram-renderer.ts +++ b/src/renderers/diagram-renderer.ts @@ -1,5 +1,5 @@ import type { App } from "obsidian"; -import type { ResolvedColorScheme, ResolvedDiagram } from "../types/models"; +import type { FlowDiagramViewMode, ResolvedColorScheme, ResolvedDiagram } from "../types/models"; import type { RenderMode } from "../core/render-mode"; import { modelWeaveText } from "../i18n/language"; import type { @@ -42,6 +42,7 @@ export function renderDiagramModel( onExportPng?: () => void | Promise; onExportAndOpenPng?: () => void | Promise; exportPngLabel?: string; + flowDiagramViewMode?: FlowDiagramViewMode; exportPngTitle?: string; exportAndOpenPngLabel?: string; exportAndOpenPngTitle?: string; diff --git a/src/settings/model-weave-settings.ts b/src/settings/model-weave-settings.ts index 26d5da0..62544d5 100644 --- a/src/settings/model-weave-settings.ts +++ b/src/settings/model-weave-settings.ts @@ -3,12 +3,14 @@ import { normalizeAppProcessBusinessFlowDirectionWithFallback, type AppProcessBusinessFlowDirection } from "../core/app-process-business-flow-direction"; +import type { FlowDiagramViewMode } from "../types/models"; import type { ModelWeaveUiLanguage } from "../i18n/messages"; export type ModelWeaveDefaultZoom = "fit" | "100"; export type ModelWeaveFontSize = "small" | "normal" | "large"; export type ModelWeaveNodeDensity = "compact" | "normal" | "relaxed"; export type ModelWeaveDomainViewMode = "mindmap" | "area" | "tree"; +export type ModelWeaveFlowDiagramViewMode = FlowDiagramViewMode; export const DOMAIN_VIEW_MODE_SETTING_OPTIONS: ReadonlyArray<{ value: ModelWeaveDomainViewMode; @@ -26,6 +28,7 @@ export interface ModelWeaveSettings { defaultProcessRenderMode: RenderMode; defaultBusinessFlowDirection: AppProcessBusinessFlowDirection; defaultScreenRenderMode: RenderMode; + defaultFlowDiagramViewMode: ModelWeaveFlowDiagramViewMode; defaultDomainsViewMode: ModelWeaveDomainViewMode; defaultDomainDiagramViewMode: ModelWeaveDomainViewMode; defaultZoom: ModelWeaveDefaultZoom; @@ -43,6 +46,7 @@ export type ModelWeaveViewerPreferences = Pick< | "defaultZoom" | "defaultBusinessFlowDirection" | "defaultDomainsViewMode" + | "defaultFlowDiagramViewMode" | "defaultDomainDiagramViewMode" | "fontSize" | "nodeDensity" @@ -59,6 +63,7 @@ export const DEFAULT_MODEL_WEAVE_SETTINGS: ModelWeaveSettings = { defaultProcessRenderMode: "custom", defaultBusinessFlowDirection: "LR", defaultScreenRenderMode: "custom", + defaultFlowDiagramViewMode: "detail", defaultDomainsViewMode: "mindmap", defaultDomainDiagramViewMode: "mindmap", defaultZoom: "fit", @@ -105,6 +110,9 @@ const VALID_DOMAIN_VIEW_MODES = new Set([ "area", "tree" ]); +const VALID_FLOW_DIAGRAM_VIEW_MODES = new Set([ + "detail", "screen" +]); const VALID_UI_LANGUAGES = new Set(["auto", "en", "ja"]); export function normalizeModelWeaveSettings( @@ -146,6 +154,11 @@ export function normalizeModelWeaveSettings( SCREEN_RENDER_MODES, DEFAULT_MODEL_WEAVE_SETTINGS.defaultScreenRenderMode ), + defaultFlowDiagramViewMode: normalizeEnumValue( + raw.defaultFlowDiagramViewMode, + VALID_FLOW_DIAGRAM_VIEW_MODES, + DEFAULT_MODEL_WEAVE_SETTINGS.defaultFlowDiagramViewMode + ), defaultDomainsViewMode: normalizeEnumValue( raw.defaultDomainsViewMode, VALID_DOMAIN_VIEW_MODES, diff --git a/src/types/models.ts b/src/types/models.ts index 6d211a1..695538a 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -704,6 +704,8 @@ export interface FlowDiagramModel extends BaseFileModel<"flow-diagram"> { kind: "screen_communication"; description?: string; flowView: FlowDiagramViewMode; + flowViewSpecified: boolean; + flowViewRaw?: unknown; domainSources: DomainSourceRef[]; domainSourceSummaries?: DomainDiagramSourceSummary[]; domains?: DomainEntry[]; diff --git a/src/views/modeling-preview-view.ts b/src/views/modeling-preview-view.ts index 679c814..2384173 100644 --- a/src/views/modeling-preview-view.ts +++ b/src/views/modeling-preview-view.ts @@ -22,7 +22,10 @@ import { exportDiagramSnapshotAsPng } from "../export/png-export"; import { renderDiagramModel } from "../renderers/diagram-renderer"; -import { getDfdMermaidColorSchemeTargets } from "../renderers/dfd-mermaid"; +import { + getDfdMermaidColorSchemeTargets +} from "../renderers/dfd-mermaid"; +import { FlowDiagramViewModeState } from "../core/flow-diagram-view-mode"; import { getAppProcessBusinessFlowColorSchemeTargets, renderAppProcessBusinessFlow, @@ -90,6 +93,7 @@ import type { ErEntity, ImpactReference, ImpactSourceLink, + FlowDiagramViewMode, ImpactSummary, ObjectModel, RelationsFileModel, @@ -442,6 +446,7 @@ const DEFAULT_VIEWER_PREFERENCES: ModelWeaveViewerPreferences = { defaultDomainsViewMode: "mindmap", defaultDomainDiagramViewMode: "mindmap", defaultBusinessFlowDirection: "LR", + defaultFlowDiagramViewMode: "detail", localSourceRoot: "", uiLanguage: "auto", showMermaidRenderDebug: false @@ -508,6 +513,7 @@ export class ModelingPreviewView extends ItemView { private appProcessBusinessFlowDirectionOverride: AppProcessBusinessFlowDirection | null = null; private appProcessBusinessFlowDirectionFilePath: string | null = null; private appProcessFlowConnectFilePath: string | null = null; + private readonly flowDiagramViewModes = new FlowDiagramViewModeState(); private appProcessFlowConnectModeEnabled = false; private appProcessFlowConnectSourceStepId: string | null = null; private domainsDiagramModeState: "domains" | "domain-diagram" | null = null; @@ -709,6 +715,11 @@ export class ModelingPreviewView extends ItemView { this.prepareDomainsDiagramMode(state, nextFilePath); this.prepareAppProcessBusinessFlowDirection(state, nextFilePath); this.prepareAppProcessFlowConnectMode(nextFilePath); + const flowViewReinitialized = this.prepareFlowDiagramViewMode(state, nextFilePath); + if (flowViewReinitialized && nextFilePath) { + this.viewportStateCache.delete(nextFilePath); + resetGraphViewportState(this.diagramViewportState); + } this.prepareViewportState(state, reason); this.state = state; this.renderCurrentState(); @@ -1019,6 +1030,23 @@ export class ModelingPreviewView extends ItemView { this.domainsDiagramModeState = state.mode; } + + + private prepareFlowDiagramViewMode( + state: PreviewState, + nextFilePath: string | null + ): boolean { + if (state.mode !== "diagram" || state.diagram.diagram.schema !== "flow_diagram" || !nextFilePath) { + return false; + } + + return this.flowDiagramViewModes.synchronize( + nextFilePath, + state.diagram.diagram, + this.viewerPreferences.defaultFlowDiagramViewMode + ).initializationChanged; + } + private rememberViewportState(filePath: string, state: GraphViewportState): void { if ( !state.hasAutoFitted && @@ -1091,6 +1119,9 @@ export class ModelingPreviewView extends ItemView { forExport: true, renderMode: getStandardRenderMode(state.rendererSelection), colorScheme: state.colorScheme, + flowDiagramViewMode: state.diagram.diagram.schema === "flow_diagram" + ? this.getFlowDiagramViewMode(state) + : undefined, ...getMermaidSourceLabels(this.t) }) }; @@ -2447,6 +2478,42 @@ export class ModelingPreviewView extends ItemView { rightGroup.appendChild(wrapper); } + private appendFlowDiagramViewSelector(container: HTMLElement, filePath: string): void { + const toolbar = container.querySelector(".mdspec-zoom-toolbar"); + if (!toolbar) { + return; + } + + toolbar.addClass("model-weave-render-mode-toolbar-host"); + toolbar.querySelector(".model-weave-flow-diagram-view-select-group")?.remove(); + + const wrapper = container.ownerDocument.createElement("div"); + wrapper.className = + "model-weave-flow-diagram-view-select-group model-weave-render-mode-row"; + + const label = container.ownerDocument.createElement("span"); + label.addClass("model-weave-render-mode-label"); + label.textContent = this.t("flowDiagram.viewMode"); + wrapper.appendChild(label); + + const select = container.ownerDocument.createElement("select"); + select.addClass("model-weave-flow-diagram-view-select"); + for (const mode of ["detail", "screen"] as const) { + const option = container.ownerDocument.createElement("option"); + option.value = mode; + option.textContent = this.t(`flowDiagram.viewMode.${mode}`); + option.selected = this.getFlowDiagramViewModeForFile(filePath) === mode; + select.appendChild(option); + } + select.addEventListener("change", () => { + this.setFlowDiagramViewMode(select.value, filePath); + }); + wrapper.appendChild(select); + + const rightGroup = toolbar.querySelector(".model-weave-zoom-toolbar-right") ?? toolbar; + rightGroup.appendChild(wrapper); + } + private appendAppProcessFlowConnectControl( container: HTMLElement, filePath: string @@ -2626,6 +2693,48 @@ export class ModelingPreviewView extends ItemView { ? this.appProcessBusinessFlowDirectionOverride : null; } + + private getFlowDiagramViewMode(state: Extract): FlowDiagramViewMode { + const diagram = state.diagram.diagram; + if (diagram.schema !== "flow_diagram") { + return "detail"; + } + return this.flowDiagramViewModes.getOrInitialize( + diagram.path, + diagram, + this.viewerPreferences.defaultFlowDiagramViewMode + ); + } + + private getFlowDiagramViewModeForFile(filePath: string): FlowDiagramViewMode { + const diagramState = this.state.mode === "diagram" && this.state.diagram.diagram.path === filePath + ? this.state + : null; + if (!diagramState || diagramState.diagram.diagram.schema !== "flow_diagram") { + return "detail"; + } + return this.flowDiagramViewModes.getOrInitialize( + filePath, + diagramState.diagram.diagram, + this.viewerPreferences.defaultFlowDiagramViewMode + ); + } + + private setFlowDiagramViewMode(mode: unknown, filePath: string): void { + if (mode !== "detail" && mode !== "screen") { + return; + } + if (this.getFlowDiagramViewModeForFile(filePath) === mode) { + return; + } + + this.flowDiagramViewModes.set(filePath, mode); + this.viewportStateCache.delete(filePath); + resetGraphViewportState(this.diagramViewportState); + this.renderCurrentState(); + this.restoreCurrentScrollPosition(); + } + private setAppProcessBusinessFlowDirection( direction: unknown, filePath: string @@ -4176,6 +4285,9 @@ export class ModelingPreviewView extends ItemView { onOpenObject: state.onOpenObject ?? undefined, app: this.app, interactionSourcePath: filePath, + flowDiagramViewMode: state.diagram.diagram.schema === "flow_diagram" + ? this.getFlowDiagramViewMode(state) + : undefined, renderMode: getStandardRenderMode(state.rendererSelection), colorScheme: state.colorScheme, viewportState: this.diagramViewportState, @@ -4192,6 +4304,9 @@ export class ModelingPreviewView extends ItemView { ensureGraphIdentityTitle(diagramRoot, buildGraphIdentityTitle(state.diagram.diagram)); this.appendRendererSelection(diagramRoot, state.rendererSelection); this.appendViewerToolbarControls(diagramRoot); + if (isFlowDiagramViewSelectorVisible(state.diagram)) { + this.appendFlowDiagramViewSelector(diagramRoot, filePath); + } this.moveDetailSections(diagramRoot, lowerSlots.details); this.renderImpactSummarySection( lowerSlots.impact, @@ -6928,6 +7043,10 @@ function getModelDisplayName(value: unknown): string | undefined { getModelId(value) ); } +export function isFlowDiagramViewSelectorVisible(diagram: ResolvedDiagram): boolean { + return diagram.diagram.schema === "flow_diagram"; +} + function getModelId(value: unknown): string | undefined { return ( diff --git a/test/flow-diagram.test.mjs b/test/flow-diagram.test.mjs index 7d51303..cb69030 100644 --- a/test/flow-diagram.test.mjs +++ b/test/flow-diagram.test.mjs @@ -8,16 +8,18 @@ await build({ stdin: { contents: [ 'export { parseFlowDiagramFile } from "./src/parsers/dfd-diagram-parser";', + 'export { FlowDiagramViewModeState, resolveInitialFlowDiagramViewMode } from "./src/core/flow-diagram-view-mode";', + 'export { DEFAULT_MODEL_WEAVE_SETTINGS, normalizeModelWeaveSettings } from "./src/settings/model-weave-settings";', 'export { buildVaultIndex } from "./src/core/vault-index";', 'export { detectFileType } from "./src/core/schema-detector";', 'export { isDiagramPreviewRouteFileType, isDfdLikeDiagramPreviewFileType } from "./src/core/preview-routing";', 'export { isModelWeavePreviewSupportedFileType } from "./src/core/supported-formats";', 'export { resolveDiagramRelations } from "./src/core/relation-resolver";', - 'export { buildDfdMermaidSource, buildFlowDiagramHoverMetadata, buildFlowDiagramScreenFlowProjection, getDfdMermaidColorSchemeTargets, resolveFlowDiagramViewMode } from "./src/renderers/dfd-mermaid";', + 'export { buildDfdMermaidSource, buildFlowDiagramHoverMetadata, buildFlowDiagramScreenFlowProjection, getDfdMermaidColorSchemeTargets } from "./src/renderers/dfd-mermaid";', 'export { getAppliedColorSchemeRowsForTargets } from "./src/core/color-scheme";', 'export { buildCurrentDiagramDiagnostics } from "./src/core/current-file-diagnostics";', 'export { createModelWeaveTranslator } from "./src/i18n/messages";', - 'export { getAppliedColorSchemeLowerPaneSlot, getDiagnosticActionCandidates, getDiagnosticDetailEntries } from "./src/views/modeling-preview-view";', + 'export { getAppliedColorSchemeLowerPaneSlot, getDiagnosticActionCandidates, getDiagnosticDetailEntries, isFlowDiagramViewSelectorVisible } from "./src/views/modeling-preview-view";', 'export { getExpectedHeaderForDiagnostic } from "./src/core/diagnostic-section-guidance";' ].join("\n"), resolveDir: ".", @@ -80,6 +82,10 @@ await build({ const { buildCurrentDiagramDiagnostics, + FlowDiagramViewModeState, + resolveInitialFlowDiagramViewMode, + DEFAULT_MODEL_WEAVE_SETTINGS, + normalizeModelWeaveSettings, buildDfdMermaidSource, buildFlowDiagramHoverMetadata, buildFlowDiagramScreenFlowProjection, @@ -95,9 +101,9 @@ const { isDfdLikeDiagramPreviewFileType, isDiagramPreviewRouteFileType, isModelWeavePreviewSupportedFileType, + isFlowDiagramViewSelectorVisible, parseFlowDiagramFile, resolveDiagramRelations, - resolveFlowDiagramViewMode } = await import(`../${outputFile}?t=${Date.now()}`); globalThis.activeDocument = { @@ -168,7 +174,7 @@ test("flow_diagram defaults flow_view to detail", () => { const { file } = parseFlow(); assert.equal(file.flowView, "detail"); - assert.equal(resolveFlowDiagramViewMode(file), "detail"); + assert.equal(resolveInitialFlowDiagramViewMode(file), "detail"); }); test("flow_diagram parses and resolves screen flow_view", () => { @@ -176,8 +182,7 @@ test("flow_diagram parses and resolves screen flow_view", () => { const { file, warnings } = parseFlow(markdown); assert.equal(file.flowView, "screen"); - assert.equal(resolveFlowDiagramViewMode(file), "screen"); - assert.equal(resolveFlowDiagramViewMode(file, "detail"), "detail"); + assert.equal(resolveInitialFlowDiagramViewMode(file), "screen"); assert.equal(warnings.some((warning) => warning.field === "flow_view"), false); }); @@ -186,12 +191,63 @@ test("flow_diagram unknown flow_view warns and falls back to detail", () => { const { file, warnings } = parseFlow(markdown); assert.equal(file.flowView, "detail"); - assert.equal(resolveFlowDiagramViewMode(file), "detail"); + assert.equal(resolveInitialFlowDiagramViewMode(file), "detail"); assert.equal( warnings.some((warning) => warning.field === "flow_view" && /unknown flow_view/.test(warning.message)), true ); }); + +test("flow_diagram view state initializes once and keeps toolbar changes per file", () => { + const screenMarkdown = flowMarkdown.replace("kind: screen_communication", "kind: screen_communication\nflow_view: screen"); + const noFrontmatter = parseFlow(); + const screen = parseFlow(screenMarkdown); + const unknown = parseFlow(flowMarkdown.replace("kind: screen_communication", "kind: screen_communication\nflow_view: storyboard")); + + const detail = parseFlow(screenMarkdown.replace("flow_view: screen", "flow_view: detail")); + const state = new FlowDiagramViewModeState(); + + assert.equal(screen.file.flowViewSpecified, true); + assert.deepEqual(state.synchronize("A.md", screen.file, "detail"), { + mode: "screen", + initializationChanged: true + }); + state.set("A.md", "detail"); + assert.deepEqual(state.synchronize("A.md", screen.file, "screen"), { + mode: "detail", + initializationChanged: false + }); + assert.deepEqual(state.synchronize("A.md", detail.file, "screen"), { + mode: "detail", + initializationChanged: true + }); + assert.deepEqual(state.synchronize("B.md", noFrontmatter.file, "screen"), { + mode: "screen", + initializationChanged: true + }); + assert.deepEqual(state.synchronize("B.md", noFrontmatter.file, "detail"), { + mode: "detail", + initializationChanged: true + }); + assert.deepEqual(state.synchronize("C.md", unknown.file, "screen"), { + mode: "screen", + initializationChanged: true + }); + assert.equal(resolveInitialFlowDiagramViewMode(unknown.file, "invalid"), "detail"); + assert.equal(DEFAULT_MODEL_WEAVE_SETTINGS.defaultFlowDiagramViewMode, "detail"); + assert.equal(normalizeModelWeaveSettings({ defaultFlowDiagramViewMode: "screen" }).defaultFlowDiagramViewMode, "screen"); + assert.equal(normalizeModelWeaveSettings({ defaultFlowDiagramViewMode: "invalid" }).defaultFlowDiagramViewMode, "detail"); + assert.equal(unknown.warnings.some((warning) => warning.field === "flow_view"), true); +}); + +test("flow_diagram selector appears only for Flow Diagram previews", () => { + const markdown = flowMarkdown.replace("kind: screen_communication", "kind: screen_communication\nflow_view: screen"); + const { file } = parseFlow(markdown); + + assert.equal(resolveInitialFlowDiagramViewMode(file), "screen"); + assert.equal(isFlowDiagramViewSelectorVisible({ diagram: file }), true); + assert.equal(isFlowDiagramViewSelectorVisible({ diagram: { schema: "dfd_diagram" } }), false); +}); test("flow_diagram follows the same preview support and diagram route checks", () => { const fileType = detectFileType({ type: "flow_diagram" }); @@ -355,7 +411,7 @@ test("flow_diagram screen view deduplicates projected edges and merges labels", test("flow_diagram screen flow Mermaid hides collapsed nodes and uses projected labels", () => { const { resolved } = resolveFlow(screenFlowMarkdown); - const source = buildDfdMermaidSource(resolved); + const source = buildDfdMermaidSource(resolved, undefined, "screen"); assert.match(source, /checkout_screen@\{ shape: curv-trap, label: "Checkout Screen" \}/); assert.match(source, /completion_screen@\{ shape: curv-trap, label: "Completion Screen" \}/); @@ -367,6 +423,19 @@ test("flow_diagram screen flow Mermaid hides collapsed nodes and uses projected assert.match(source, /checkout_screen -->\|failed\| error_message/); assert.doesNotMatch(source, /Checkout input|Session data/); }); +test("flow_diagram effective mode drives Mermaid source in both directions", () => { + const { resolved } = resolveFlow(screenFlowMarkdown); + const detailSource = buildDfdMermaidSource(resolved, undefined, "detail"); + const screenSource = buildDfdMermaidSource(resolved, undefined, "screen"); + const detailAgain = buildDfdMermaidSource(resolved, undefined, "detail"); + + assert.match(detailSource, /submit_action@/); + assert.match(detailSource, /session_store@/); + assert.doesNotMatch(screenSource, /submit_action@/); + assert.doesNotMatch(screenSource, /session_store@/); + assert.equal(detailAgain, detailSource); +}); + test("flow_diagram screen view falls back to detail when no visible nodes exist", () => { const markdown = `--- type: flow_diagram @@ -927,7 +996,7 @@ test("flow_diagram screen view preserves resolved Domain Sources for visible scr { target: "domain", kind: "data", fill: "#f3e8ff", stroke: "#7c3bbd", text: "#2d124c", rowIndex: 3 } ], defaultStyle: { fill: "#f5f5f5", stroke: "#9e9e9e", text: "#111111" } - }); + }, "screen"); assert.match(source, /subgraph DOMAIN_checkout_system\["Checkout System"\]/); assert.match(source, /subgraph DOMAIN_frontend_area\["Frontend Area"\]/);