diff --git a/app/src/core/Project.tsx b/app/src/core/Project.tsx index 0a9e22cd..11f0c25f 100644 --- a/app/src/core/Project.tsx +++ b/app/src/core/Project.tsx @@ -27,6 +27,7 @@ import type { WorldRenderUtils } from "@/core/render/canvas2d/utilsRenderer/Worl import type { InputElement } from "@/core/render/domElement/inputElement"; import type { AutoLayoutFastTree } from "@/core/service/controlService/autoLayoutEngine/autoLayoutFastTreeMode"; import type { AutoLayout } from "@/core/service/controlService/autoLayoutEngine/mainTick"; +import type { ForceDirectedLayout } from "@/core/service/controlService/autoLayoutEngine/forceDirectedLayout"; import type { ControllerUtils } from "@/core/service/controlService/controller/concrete/utilsControl"; import type { Controller } from "@/core/service/controlService/controller/Controller"; import type { KeyboardOnlyEngine } from "@/core/service/controlService/keyboardOnlyEngine/keyboardOnlyEngine"; @@ -530,6 +531,7 @@ declare module "./Project" { copyEngine: CopyEngine; autoLayout: AutoLayout; autoLayoutFastTree: AutoLayoutFastTree; + forceDirectedLayout: ForceDirectedLayout; layoutManager: LayoutManager; autoAlign: AutoAlign; mouseInteraction: MouseInteraction; diff --git a/app/src/core/loadAllServices.tsx b/app/src/core/loadAllServices.tsx index 38524b72..e49c31ce 100644 --- a/app/src/core/loadAllServices.tsx +++ b/app/src/core/loadAllServices.tsx @@ -29,6 +29,7 @@ import { WorldRenderUtils } from "@/core/render/canvas2d/utilsRenderer/WorldRend import { InputElement } from "@/core/render/domElement/inputElement"; import { AutoLayoutFastTree } from "@/core/service/controlService/autoLayoutEngine/autoLayoutFastTreeMode"; import { AutoLayout } from "@/core/service/controlService/autoLayoutEngine/mainTick"; +import { ForceDirectedLayout } from "@/core/service/controlService/autoLayoutEngine/forceDirectedLayout"; import { ControllerUtils } from "@/core/service/controlService/controller/concrete/utilsControl"; import { Controller } from "@/core/service/controlService/controller/Controller"; import { KeyboardOnlyEngine } from "@/core/service/controlService/keyboardOnlyEngine/keyboardOnlyEngine"; @@ -115,6 +116,7 @@ export function loadAllServicesBeforeInit(project: Project): void { project.loadService(CopyEngine); project.loadService(AutoLayout); project.loadService(AutoLayoutFastTree); + project.loadService(ForceDirectedLayout); project.loadService(LayoutManager); project.loadService(AutoAlign); project.loadService(MouseInteraction); diff --git a/app/src/core/render/canvas2d/entityRenderer/section/SectionRenderer.tsx b/app/src/core/render/canvas2d/entityRenderer/section/SectionRenderer.tsx index 1cff254a..4164807f 100644 --- a/app/src/core/render/canvas2d/entityRenderer/section/SectionRenderer.tsx +++ b/app/src/core/render/canvas2d/entityRenderer/section/SectionRenderer.tsx @@ -2,7 +2,7 @@ import { Project, service } from "@/core/Project"; import { Renderer } from "@/core/render/canvas2d/renderer"; import { Settings } from "@/core/service/Settings"; import { Section } from "@/core/stage/stageObject/entity/Section"; -import { getTextSize } from "@/utils/font"; +import { getTextSize, textToTextArray } from "@/utils/font"; import { Color, colorInvert, mixColors, Vector } from "@graphif/data-structures"; import { CubicBezierCurve, Rectangle } from "@graphif/shapes"; @@ -93,6 +93,44 @@ export class SectionRenderer { } } + private renderCaption(section: Section) { + const borderWidth = 2 * this.project.camera.currentScale; + const rect = section.rectangle; + + this.project.shapeRenderer.renderRect( + new Rectangle( + this.project.renderer.transformWorld2View(rect.location), + rect.size.multiply(this.project.camera.currentScale), + ), + Color.Transparent, + this.project.stageStyleManager.currentStyle.StageObjectBorder, + borderWidth, + Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale, + ); + + if (section.text !== "" && this.project.camera.currentScale > 0.065 && !section.isEditingTitle) { + const padding = 10; + const lineHeight = 1.2; + const limitWidth = rect.size.x - padding * 2; + const lines = textToTextArray(section.text, Renderer.FONT_SIZE, limitWidth); + const captionHeight = lines.length * Renderer.FONT_SIZE * lineHeight + padding * 2; + const captionLocation = new Vector( + rect.location.x + padding, + rect.location.y + rect.size.y - captionHeight + padding, + ); + this.project.textRenderer.renderMultiLineText( + section.text, + this.project.renderer.transformWorld2View(captionLocation), + Renderer.FONT_SIZE * this.project.camera.currentScale, + limitWidth * this.project.camera.currentScale, + section.color.a === 1 + ? colorInvert(section.color) + : colorInvert(this.project.stageStyleManager.currentStyle.Background), + lineHeight, + ); + } + } + renderBackgroundColor(section: Section) { if (Settings.sectionBackgroundFillMode === "titleOnly") { // 只填充顶部标题条(不透明),标题为空时跳过 @@ -216,42 +254,16 @@ export class SectionRenderer { this.project.textRenderer.renderText(section.text, leftTopFontViewLocation, fontSize, textColor); } - // private getFontSizeBySectionSize(section: Section): Vector { - // // 使用getTextSize获取准确的文本尺寸 - // const baseFontSize = 100; - // const measuredSize = getTextSize(section.text, baseFontSize); - // const ratio = measuredSize.x / measuredSize.y; - // const sectionRatio = section.rectangle.size.x / section.rectangle.size.y; - - // // 计算最大可用字体高度 - // let fontHeight; - // const paddingRatio = 0.9; // 增加边距比例,确保文字不会贴边 - // if (sectionRatio < ratio) { - // // 宽度受限 - // fontHeight = (section.rectangle.size.x / ratio) * paddingRatio; - // } else { - // // 高度受限 - // fontHeight = section.rectangle.size.y * paddingRatio; - // } - - // // 确保字体大小合理 - // const minFontSize = 8; - // const maxFontSize = Math.max(section.rectangle.size.x, section.rectangle.size.y) * 0.8; // 限制最大字体 - // fontHeight = Math.max(minFontSize, Math.min(fontHeight, maxFontSize)); - - // return new Vector(ratio * fontHeight, fontHeight); - // } - render(section: Section): void { if (section.isHiddenBySectionCollapse) { return; } if (section.isCollapsed) { - // 折叠状态 this.renderCollapsed(section); + } else if (section.mode === "caption") { + this.renderCaption(section); } else { - // 非折叠状态 this.renderNoCollapse(section); } diff --git a/app/src/core/service/Settings.tsx b/app/src/core/service/Settings.tsx index 40efb5bc..05e55e2f 100644 --- a/app/src/core/service/Settings.tsx +++ b/app/src/core/service/Settings.tsx @@ -1,4 +1,4 @@ -import { LazyStore } from "@tauri-apps/plugin-store"; +import { LazyStore } from "@tauri-apps/plugin-store"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import z from "zod"; @@ -57,6 +57,14 @@ export const settingsSchema = z.object({ compatibilityMode: z.boolean().default(false), isEnableEntityCollision: z.boolean().default(false), isEnableSectionCollision: z.boolean().default(false), + isEnableForceDirected: z.boolean().default(false), + forceDirectedLinkDistance: z.number().min(50).max(500).default(200), + forceDirectedLinkStrength: z.number().min(0.001).max(0.1).multipleOf(0.001).default(0.01), + forceDirectedCollisionStrength: z.number().min(0.01).max(1).multipleOf(0.01).default(0.5), + forceDirectedVelocityDecay: z.number().min(0.1).max(0.99).multipleOf(0.01).default(0.6), + forceDirectedMaxMovePerFrame: z.number().int().min(10).max(200).default(50), + forceDirectedConvergenceThreshold: z.number().min(0.01).max(10).multipleOf(0.01).default(0.5), + forceDirectedMinDistance: z.number().int().min(5).max(100).default(30), autoNamerTemplate: z.string().default("..."), autoNamerSectionTemplate: z.string().default("Section_{{i}}"), autoNamerDetailsTemplate: z.string().default(""), @@ -374,6 +382,7 @@ export const settingsSchema = z.object({ { type: "item", id: "openTextNodeByContentExternal", label: "将内容视为路径并打开", icon: "ExternalLink" }, { type: "item", id: "folderSection", icon: "Package" }, { type: "item", id: "toggleSectionLock", label: "锁定/解锁 section 框", icon: "Lock" }, + { type: "item", id: "toggleSectionMode", label: "转为解说框/分组框", icon: "Repeat2" }, { type: "item", id: "refreshReferenceBlockNode", label: "刷新引用块", icon: "RefreshCcwDot" }, { type: "item", id: "goToReferenceBlockSource", label: "进入该引用块所在的源头位置", icon: "CornerUpRight" }, { type: "item", id: "switchEdgeToUndirectedEdge", label: "转换为无向边", icon: "Spline" }, @@ -447,6 +456,8 @@ export const settingsSchema = z.object({ { type: "item", id: "setSelectedImageAsBackground", label: "转化为背景图片", icon: "Images" }, { type: "item", id: "unsetSelectedImageAsBackground", label: "取消背景化", icon: "SquareSquare" }, { type: "item", id: "saveSelectedImagesToProjectDirectory", label: "另存图片到当前prg所在目录下", icon: "Save" }, + { type: "item", id: "wrapImageInCaptionSection", label: "将图片包裹到说明框中", icon: "ImagePlus" }, + { type: "item", id: "toggleForceDirected", label: "开启/关闭力导向", icon: "Network" }, ]), disabledExtensions: z.array(z.string()).default([]), extensionSettings: z.record(z.record(z.unknown())).default({}), diff --git a/app/src/core/service/SettingsIcons.tsx b/app/src/core/service/SettingsIcons.tsx index 506dced8..fec8ce9c 100644 --- a/app/src/core/service/SettingsIcons.tsx +++ b/app/src/core/service/SettingsIcons.tsx @@ -10,6 +10,7 @@ import { ChevronUp, CircleDot, Crosshair, + Cpu, Database, Delete, FileStack, @@ -25,9 +26,11 @@ import { ImageMinus, ImageUpscale, Keyboard, + KeyRound, Languages, Layers, Lightbulb, + Link, MessageSquareText, LineSquiggle, ListCheck, @@ -85,6 +88,7 @@ import { MouseRight, MouseLeft, LoaderPinwheel, + Network, Circle, } from "lucide-react"; @@ -162,6 +166,14 @@ export const settingsIcons = { antialiasing: Calculator, compatibilityMode: Turtle, isEnableEntityCollision: Ungroup, + isEnableForceDirected: Network, + forceDirectedLinkDistance: MoveHorizontal, + forceDirectedLinkStrength: Spline, + forceDirectedCollisionStrength: Ungroup, + forceDirectedVelocityDecay: TrendingUpDown, + forceDirectedMaxMovePerFrame: Move, + forceDirectedConvergenceThreshold: ScanEye, + forceDirectedMinDistance: Minus, language: Languages, showTipsOnUI: AppWindow, useNativeTitleBar: AppWindowMac, @@ -218,4 +230,8 @@ export const settingsIcons = { enableAutoEdgeWidth: Minus, showKeyBindHint: Lightbulb, showEditModeHint: MessageSquareText, + aiApiBaseUrl: Link, + aiApiKey: KeyRound, + aiModel: Cpu, + aiShowTokenCount: Tally4, }; diff --git a/app/src/core/service/controlService/autoLayoutEngine/forceDirectedLayout.tsx b/app/src/core/service/controlService/autoLayoutEngine/forceDirectedLayout.tsx new file mode 100644 index 00000000..ba0d24a2 --- /dev/null +++ b/app/src/core/service/controlService/autoLayoutEngine/forceDirectedLayout.tsx @@ -0,0 +1,244 @@ +import { Project, service } from "@/core/Project"; +import { Settings } from "@/core/service/Settings"; +import { ConnectableEntity } from "@/core/stage/stageObject/abstract/ConnectableEntity"; +import { Edge } from "@/core/stage/stageObject/association/Edge"; +import { Section } from "@/core/stage/stageObject/entity/Section"; +import { Vector } from "@graphif/data-structures"; + +@service("forceDirectedLayout") +export class ForceDirectedLayout { + constructor(private readonly project: Project) {} + + /** 是否正在模拟中 */ + private isSimulating = false; + + /** 上次激活时间,用于开关变化时重新激活 */ + private lastEnabledState = false; + + /** 保存启用力导向前的碰撞设置,退出时恢复 */ + private savedEntityCollision = false; + private savedSectionCollision = false; + + /** 收敛阈值:总动能低于此值停止模拟 */ + private get convergenceThreshold() { + return Settings.forceDirectedConvergenceThreshold; + } + + /** 速度衰减系数(每一帧乘此值) */ + private get velocityDecay() { + return Settings.forceDirectedVelocityDecay; + } + + /** 弹簧力目标距离 */ + private get linkDistance() { + return Settings.forceDirectedLinkDistance; + } + + /** 弹簧力强度 */ + private get linkStrength() { + return Settings.forceDirectedLinkStrength; + } + + /** 最近距离限制(节点不会比这更近) */ + private get minDistance() { + return Settings.forceDirectedMinDistance; + } + + /** 碰撞力强度 */ + private get collisionStrength() { + return Settings.forceDirectedCollisionStrength; + } + + /** 移动限制,防止爆炸 */ + private get maxMovePerFrame() { + return Settings.forceDirectedMaxMovePerFrame; + } + + /** 节点速度映射 */ + private velocities = new Map(); + + tick() { + const isEnabled = Settings.isEnableForceDirected; + + // 开关从关变开:保存碰撞设置,禁用外部碰撞系统 + if (isEnabled && !this.lastEnabledState) { + this.savedEntityCollision = Settings.isEnableEntityCollision; + this.savedSectionCollision = Settings.isEnableSectionCollision; + Settings.isEnableEntityCollision = false; + Settings.isEnableSectionCollision = false; + this.isSimulating = true; + this.velocities.clear(); + } + + // 开关从开变关:恢复碰撞设置,停止模拟 + if (!isEnabled && this.lastEnabledState) { + Settings.isEnableEntityCollision = this.savedEntityCollision; + Settings.isEnableSectionCollision = this.savedSectionCollision; + this.isSimulating = false; + this.velocities.clear(); + } + + this.lastEnabledState = isEnabled; + + if (!this.isSimulating) return; + + // 模拟一帧 + this.simulationTick(); + } + + private simulationTick() { + const allEntities = this.project.stageManager.getEntities(); + const allConnectables = allEntities.filter((e) => e instanceof ConnectableEntity) as ConnectableEntity[]; + + // 收集所有在 Section 内部的子节点 UUID(策略一:Section 作为虚拟大节点, + // 子节点不独立参与力计算,随 Section 移动) + const allSections = allConnectables.filter((e) => e instanceof Section) as Section[]; + const childUuids = new Set(); + for (const section of allSections) { + for (const child of section.children) { + childUuids.add(child.uuid); + } + } + + // 只对不在 Section 内部的实体(含 Section 自身)施力 + const connectableEntities = allConnectables.filter((e) => !childUuids.has(e.uuid)); + + // 没有可移动的实体 + if (connectableEntities.length === 0) return; + + // ===== 初始化速度为0 ===== + for (const entity of connectableEntities) { + if (!this.velocities.has(entity.uuid)) { + this.velocities.set(entity.uuid, Vector.getZero()); + } + } + + // 构建 UUID → Entity 快速查找 + const entityMap = new Map(); + for (const entity of connectableEntities) { + entityMap.set(entity.uuid, entity); + } + + // ===== 1. 计算弹簧力(仅沿边) ===== + // 星系模型:只有有连线的节点之间才有力反馈 + const linkForces = new Map(); + for (const entity of connectableEntities) { + linkForces.set(entity.uuid, Vector.getZero()); + } + + const edges = this.project.stageManager.getAssociations().filter((a) => a instanceof Edge) as Edge[]; + for (const edge of edges) { + const source = edge.source; + const target = edge.target; + // 两个端点都必须在当前场景的可连接实体中 + if (!entityMap.has(source.uuid) || !entityMap.has(target.uuid)) continue; + + const centerSource = source.collisionBox.getRectangle().center; + const centerTarget = target.collisionBox.getRectangle().center; + const delta = centerTarget.subtract(centerSource); + const distance = delta.magnitude(); + + if (distance < 1) continue; + + // 弹簧力:偏离目标距离时产生力 + // < linkDistance → 排斥(推开),> linkDistance → 吸引(拉回) + const displacement = distance - this.linkDistance; + const forceMagnitude = displacement * this.linkStrength; + let force = delta.normalize().multiply(forceMagnitude); + + // 最近距离限制:如果距离小于 minDistance,额外施加强排斥防止重叠 + if (distance < this.minDistance) { + const extraRepel = ((this.minDistance - distance) / this.minDistance) * this.linkStrength * 50; + force = force.add(delta.normalize().multiply(-extraRepel)); + } + + linkForces.set(source.uuid, linkForces.get(source.uuid)!.add(force)); + linkForces.set(target.uuid, linkForces.get(target.uuid)!.add(force.multiply(-1))); + } + + // ===== 2. 碰撞力(防止任何重叠) ===== + const collisionForces = new Map(); + for (const entity of connectableEntities) { + collisionForces.set(entity.uuid, Vector.getZero()); + } + + for (let i = 0; i < connectableEntities.length; i++) { + for (let j = i + 1; j < connectableEntities.length; j++) { + const a = connectableEntities[i]; + const b = connectableEntities[j]; + const rectA = a.collisionBox.getRectangle(); + const rectB = b.collisionBox.getRectangle(); + + if (!rectA.isCollideWith(rectB)) continue; + + const overlap = rectA.getOverlapSize(rectB); + const delta = rectB.center.subtract(rectA.center); + const forceDir = delta.magnitude() < 1 ? new Vector(1, 0) : delta.normalize(); + const force = forceDir.multiply(Math.min(Math.abs(overlap.x), Math.abs(overlap.y)) * this.collisionStrength); + + collisionForces.set(a.uuid, collisionForces.get(a.uuid)!.add(force.multiply(-1))); + collisionForces.set(b.uuid, collisionForces.get(b.uuid)!.add(force)); + } + } + + // ===== 3. 合并力,更新速度 ===== + let totalKineticEnergy = 0; + const movedEntities: Array<{ entity: ConnectableEntity; delta: Vector }> = []; + + for (const entity of connectableEntities) { + const link = linkForces.get(entity.uuid) || Vector.getZero(); + const collision = collisionForces.get(entity.uuid) || Vector.getZero(); + + let totalForce = link.add(collision); + + // 限制单帧力的大小,防止爆炸 + const forceMagnitude = totalForce.magnitude(); + if (forceMagnitude > this.maxMovePerFrame) { + totalForce = totalForce.normalize().multiply(this.maxMovePerFrame); + } + + // 更新速度(F = ma, 假设质量=1) + let velocity = this.velocities.get(entity.uuid) || Vector.getZero(); + velocity = velocity.add(totalForce); + // 衰减 + velocity = velocity.multiply(this.velocityDecay); + this.velocities.set(entity.uuid, velocity); + + const speed = velocity.magnitude(); + totalKineticEnergy += speed * speed; + + if (speed > 0.1) { + movedEntities.push({ entity, delta: velocity }); + } + } + + // ===== 4. 应用移动 ===== + for (const { entity, delta } of movedEntities) { + entity.move(delta); + } + + // 更新所有 Section 的大小和位置(策略一:Section 自适应包裹子节点) + const sections = connectableEntities.filter((e) => e instanceof Section) as Section[]; + for (const section of sections) { + section.adjustLocationAndSize(); + } + + // ===== 5. 收敛检测 ===== + if (totalKineticEnergy < this.convergenceThreshold) { + this.isSimulating = false; + this.velocities.clear(); + } + } + + /** 重新激活力导向模拟 */ + public restartSimulation() { + this.isSimulating = true; + this.velocities.clear(); + } + + /** 停止力导向模拟 */ + public stopSimulation() { + this.isSimulating = false; + this.velocities.clear(); + } +} diff --git a/app/src/core/service/controlService/controller/concrete/ControllerEntityClickSelectAndMove.tsx b/app/src/core/service/controlService/controller/concrete/ControllerEntityClickSelectAndMove.tsx index 82450cf3..988f9083 100644 --- a/app/src/core/service/controlService/controller/concrete/ControllerEntityClickSelectAndMove.tsx +++ b/app/src/core/service/controlService/controller/concrete/ControllerEntityClickSelectAndMove.tsx @@ -220,6 +220,11 @@ export class ControllerEntityClickSelectAndMoveClass extends ControllerClass { } } + // 力导向模式下,拖拽后重启仿真,防止收敛检测误判为停止 + if (Settings.isEnableForceDirected) { + this.project.forceDirectedLayout.restartSimulation(); + } + // 预瞄反馈 if (Settings.enableDragAutoAlign) { this.project.autoAlign.preAlignAllSelected(); @@ -265,6 +270,11 @@ export class ControllerEntityClickSelectAndMoveClass extends ControllerClass { } this.project.historyManager.recordStep(); // 记录一次历史 + + // 力导向模式下,拖拽结束后重启仿真,防止收敛检测误判为停止 + if (Settings.isEnableForceDirected) { + this.project.forceDirectedLayout.restartSimulation(); + } } } diff --git a/app/src/core/service/controlService/controller/concrete/ControllerSectionEdit.tsx b/app/src/core/service/controlService/controller/concrete/ControllerSectionEdit.tsx index 63a5bb28..a44ebb75 100644 --- a/app/src/core/service/controlService/controller/concrete/ControllerSectionEdit.tsx +++ b/app/src/core/service/controlService/controller/concrete/ControllerSectionEdit.tsx @@ -3,7 +3,8 @@ import { Project } from "@/core/Project"; import { Renderer } from "@/core/render/canvas2d/renderer"; import { ControllerClass } from "@/core/service/controlService/controller/ControllerClass"; import { Section } from "@/core/stage/stageObject/entity/Section"; -import { Vector } from "@graphif/data-structures"; +import { textToTextArray } from "@/utils/font"; +import { colorInvert, Vector } from "@graphif/data-structures"; import { toast } from "sonner"; /** @@ -63,20 +64,28 @@ export class ControllerSectionEditClass extends ControllerClass { }; private editSectionTitle(section: Section) { - // 检查section是否被锁定(包括祖先section的锁定状态) if (this.project.sectionMethods.isObjectBeLockedBySection(section)) { toast.error("无法编辑已锁定的section"); return; } this.project.controller.isCameraLocked = true; - // 停止摄像机漂移 this.project.camera.stopImmediately(); - // 编辑节点 section.isEditingTitle = true; + + if (section.mode === "caption") { + this.editCaptionTitle(section); + } else { + this.editGroupTitle(section); + } + } + + private editGroupTitle(section: Section) { + const inputLocation = section.rectangle.location.subtract(new Vector(0, section.text === "" ? 50 : 0)); + this.project.inputElement .input( this.project.renderer - .transformWorld2View(section.rectangle.location.subtract(new Vector(0, section.text === "" ? 50 : 0))) + .transformWorld2View(inputLocation) .add(Vector.same(Renderer.NODE_PADDING).multiply(this.project.camera.currentScale)), section.text, (text) => { @@ -99,4 +108,60 @@ export class ControllerSectionEditClass extends ControllerClass { this.project.historyManager.recordStep(); }); } + + private editCaptionTitle(section: Section) { + const padding = 10; + const lineHeight = 1.2; + const rect = section.rectangle; + const scale = this.project.camera.currentScale; + + // 计算 caption 区域位置,与 renderCaption 中的逻辑一致 + const limitWidth = rect.size.x - padding * 2; + const lines = section.text === "" ? [] : textToTextArray(section.text, Renderer.FONT_SIZE, limitWidth); + const captionHeight = lines.length === 0 ? 0 : lines.length * Renderer.FONT_SIZE * lineHeight + padding * 2; + + // caption 文本起始位置(世界坐标),与 renderCaption 中的 captionLocation 一致 + const captionLocation = new Vector( + rect.location.x + padding, + rect.location.y + rect.size.y - captionHeight + padding, + ); + const captionViewLocation = this.project.renderer.transformWorld2View(captionLocation); + const captionViewWidth = limitWidth * scale; + + this.project.inputElement + .textarea( + section.text, + (text, ele) => { + section.rename(text); + ele.style.height = "auto"; + ele.style.height = `${ele.scrollHeight}px`; + }, + { + position: "fixed", + resize: "none", + boxSizing: "border-box", + overflow: "hidden", + whiteSpace: "pre-wrap", + wordBreak: "break-all", + left: `${captionViewLocation.x}px`, + top: `${captionViewLocation.y}px`, + width: `${captionViewWidth}px`, + minWidth: `${captionViewWidth}px`, + fontSize: `${Renderer.FONT_SIZE * scale}px`, + backgroundColor: "transparent", + color: (section.color.a === 1 + ? colorInvert(section.color) + : colorInvert(this.project.stageStyleManager.currentStyle.Background) + ).toHexStringWithoutAlpha(), + outline: `solid ${1 * scale}px ${this.project.stageStyleManager.currentStyle.effects.successShadow.toNewAlpha(0.1).toString()}`, + padding: `${padding * scale}px`, + }, + true, + ) + .then(() => { + section.isEditingTitle = false; + this.project.controller.isCameraLocked = false; + this.project.historyManager.recordStep(); + }); + } } diff --git a/app/src/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister.tsx b/app/src/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister.tsx index 01204e25..3c15255d 100644 --- a/app/src/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister.tsx +++ b/app/src/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister.tsx @@ -1,4 +1,4 @@ -import { Dialog } from "@/components/ui/dialog"; +import { Dialog } from "@/components/ui/dialog"; import { Project, ProjectState } from "@/core/Project"; import { MouseLocation } from "@/core/service/controlService/MouseLocation"; import { ViewFlashEffect } from "@/core/service/feedbackService/effectEngine/concrete/ViewFlashEffect"; @@ -91,6 +91,7 @@ import { Grip, History, Images, + ImagePlus, Layers, LayoutDashboard, LayoutPanelTop, @@ -116,6 +117,7 @@ import { RefreshCcwDot, RefreshCw, Repeat, + Repeat2, Save, Scissors, Search, @@ -958,8 +960,22 @@ export const allKeyBinds: KeyBindItem[] = [ when: whenHasSelectedSections, onPress: (project) => project!.sectionPackManager.unpackSelectedSections(), }, + { + id: "toggleSectionMode", + defaultKey: "", + icon: Repeat2, + when: whenHasSelectedSections, + onPress: (project) => project!.sectionPackManager.toggleSectionMode(), + }, + { + id: "wrapImageInCaptionSection", + defaultKey: "", + icon: Images, + when: whenHasSelectedImageNodes, + onPress: async (project) => project!.sectionPackManager.wrapImageInCaptionSection(), + }, - /*------- 隐私模式 -------*/ + /*------- privacy mode -------*/ { id: "checkoutProtectPrivacy", defaultKey: "C-2", @@ -970,6 +986,17 @@ export const allKeyBinds: KeyBindItem[] = [ }, }, + /*------- force directed layout -------*/ + { + id: "toggleForceDirected", + defaultKey: "", + icon: Network, + when: whenAlways, + onPress: async () => { + Settings.isEnableForceDirected = !Settings.isEnableForceDirected; + }, + }, + /*------- 搜索/外部打开 -------*/ { id: "searchText", @@ -1819,6 +1846,15 @@ export const allKeyBinds: KeyBindItem[] = [ } }, }, + { + id: "wrapImageInCaptionSection", + defaultKey: "i w", + icon: ImagePlus, + when: whenHasSelectedImageNodes, + onPress: async (project) => { + await project!.sectionPackManager.wrapImageInCaptionSection(); + }, + }, /*------- 主题切换 -------*/ { diff --git a/app/src/core/service/dataManageService/aiEngine/AIEngine.tsx b/app/src/core/service/dataManageService/aiEngine/AIEngine.tsx index 8bc8bbaa..488bcbe8 100644 --- a/app/src/core/service/dataManageService/aiEngine/AIEngine.tsx +++ b/app/src/core/service/dataManageService/aiEngine/AIEngine.tsx @@ -2,7 +2,6 @@ import { Project, service } from "@/core/Project"; import { Settings } from "@/core/service/Settings"; import { AITools } from "@/core/service/dataManageService/aiEngine/AITools"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; -import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { convertToModelMessages, DefaultChatTransport, stepCountIs, streamText, type UIMessage } from "ai"; const SYSTEM_PROMPT = @@ -28,17 +27,56 @@ export class AIEngine { const body = await this.readRequestBody(options?.body); const messages = Array.isArray(body.messages) ? (body.messages as UIMessage[]) : []; + // Pre-flight validation + if (!Settings.aiApiBaseUrl) { + throw new Error("AI API 地址未配置 (aiApiBaseUrl),请在设置中填写。"); + } + if (!Settings.aiApiKey) { + throw new Error("AI API 密钥未配置 (aiApiKey),请在设置中填写。"); + } + const provider = createOpenAICompatible({ name: "project-graph", baseURL: Settings.aiApiBaseUrl, apiKey: Settings.aiApiKey || undefined, - fetch: tauriFetch as typeof fetch, + fetch: async (url, init) => { + try { + const response = await fetch(url.toString(), { + ...init, + headers: { + ...init?.headers, + "Content-Type": "application/json", + }, + mode: "cors", + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "unknown error"); + throw new Error(`API 请求失败 (${response.status}): ${errorText}`); + } + + return response; + } catch (err) { + // Network-level errors (DNS, CORS, timeout, etc.) + if (err instanceof TypeError && err.message === "Failed to fetch") { + throw new Error( + `无法连接到 AI API 服务器 (${Settings.aiApiBaseUrl})。请检查:\n` + + "1. 网络连接是否正常\n" + + "2. API 地址是否正确\n" + + "3. API 密钥是否有效\n" + + `原始错误: ${err.message}`, + { cause: err }, + ); + } + throw err; + } + }, includeUsage: true, }); const tools = AITools.createTools(project); - const result = streamText({ + const textStream = streamText({ model: provider.chatModel(Settings.aiModel), system: SYSTEM_PROMPT, messages: await convertToModelMessages(messages, { @@ -48,9 +86,10 @@ export class AIEngine { tools, stopWhen: stepCountIs(8), abortSignal: options?.signal ?? undefined, + maxRetries: 0, }); - return result.toUIMessageStreamResponse>({ + return textStream.toUIMessageStreamResponse>({ originalMessages: messages as UIMessage[], messageMetadata: ({ part }) => { if (part.type !== "finish") return; diff --git a/app/src/core/stage/stageManager/concreteMethods/StageSectionInOutManager.tsx b/app/src/core/stage/stageManager/concreteMethods/StageSectionInOutManager.tsx index 10df8f65..e57bf584 100644 --- a/app/src/core/stage/stageManager/concreteMethods/StageSectionInOutManager.tsx +++ b/app/src/core/stage/stageManager/concreteMethods/StageSectionInOutManager.tsx @@ -2,6 +2,7 @@ import { Project, service } from "@/core/Project"; import { Entity } from "@/core/stage/stageObject/abstract/StageEntity"; import { Section } from "@/core/stage/stageObject/entity/Section"; import { TextNode } from "@/core/stage/stageObject/entity/TextNode"; +import { ImageNode } from "@/core/stage/stageObject/entity/ImageNode"; import { Edge } from "@/core/stage/stageObject/association/Edge"; import { MultiTargetUndirectedEdge } from "@/core/stage/stageObject/association/MutiTargetUndirectedEdge"; import { CollisionBox } from "../../stageObject/collisionBox/collisionBox"; @@ -76,9 +77,15 @@ export class SectionInOutManager { } section.children = newChildren; - // 当section的最后一个子元素被移除时,将section转换为TextNode if (section.children.length === 0) { this.convertSectionToTextNode(section); + } else if (section.mode === "caption") { + // caption 模式下删除了图片后,如果没有图片子节点了,回退为 group 模式 + const hasImageChild = section.children.some((child) => child instanceof ImageNode); + if (!hasImageChild) { + section.mode = "group"; + section.adjustLocationAndSize(); + } } } diff --git a/app/src/core/stage/stageManager/concreteMethods/StageSectionPackManager.tsx b/app/src/core/stage/stageManager/concreteMethods/StageSectionPackManager.tsx index c3797a3d..a294ce5d 100644 --- a/app/src/core/stage/stageManager/concreteMethods/StageSectionPackManager.tsx +++ b/app/src/core/stage/stageManager/concreteMethods/StageSectionPackManager.tsx @@ -1,4 +1,4 @@ -// import { Section } from "@/core/stageObject/entity/Section"; +// import { Section } from "@/core/stageObject/entity/Section"; // import { Entity } from "@/core/stageObject/StageEntity"; import { Project, service } from "@/core/Project"; import { Settings } from "@/core/service/Settings"; @@ -6,6 +6,7 @@ import { Entity } from "@/core/stage/stageObject/abstract/StageEntity"; import { Edge } from "@/core/stage/stageObject/association/Edge"; import { Section } from "@/core/stage/stageObject/entity/Section"; import { TextNode } from "@/core/stage/stageObject/entity/TextNode"; +import { ImageNode } from "@/core/stage/stageObject/entity/ImageNode"; import { ConnectPoint } from "@/core/stage/stageObject/entity/ConnectPoint"; import { CollisionBox } from "@/core/stage/stageObject/collisionBox/collisionBox"; import { toast } from "sonner"; @@ -420,4 +421,44 @@ export class SectionPackManager { if (!root || !(root instanceof TextNode)) return ""; return root.text; } + + /** + * 将选中的图片包裹到一个带说明文字的Section中 + */ + async wrapImageInCaptionSection(): Promise { + const selectedImages = this.project.stageManager + .getEntities() + .filter((entity) => entity.isSelected && entity instanceof ImageNode) as ImageNode[]; + + if (selectedImages.length === 0) return; + + // 将图片从父Section中剥离 + const firstParents = this.project.sectionMethods.getFatherSections(selectedImages[0]); + for (const fatherSection of firstParents) { + this.project.stageManager.goOutSection(selectedImages, fatherSection); + } + + // 创建Section包裹图片,默认文字为"..." + const section = Section.fromEntities(this.project, selectedImages); + section.text = "..."; + section.mode = "caption"; + section.adjustLocationAndSize(); + + // 将Section添加到舞台 + this.project.stageManager.add(section); + for (const fatherSection of firstParents) { + this.project.stageManager.goInSection([section], fatherSection); + } + + this.project.historyManager.recordStep(); + } + + toggleSectionMode(): void { + const selectedSections = this.project.stageManager.getSections().filter((section) => section.isSelected); + for (const section of selectedSections) { + section.mode = section.mode === "caption" ? "group" : "caption"; + section.adjustLocationAndSize(); + } + this.project.historyManager.recordStep(); + } } diff --git a/app/src/core/stage/stageObject/entity/Section.tsx b/app/src/core/stage/stageObject/entity/Section.tsx index 45c7cd3e..205d934c 100644 --- a/app/src/core/stage/stageObject/entity/Section.tsx +++ b/app/src/core/stage/stageObject/entity/Section.tsx @@ -5,12 +5,14 @@ import { Settings } from "@/core/service/Settings"; import { ConnectableEntity } from "@/core/stage/stageObject/abstract/ConnectableEntity"; import { Entity } from "@/core/stage/stageObject/abstract/StageEntity"; import { CollisionBox } from "@/core/stage/stageObject/collisionBox/collisionBox"; -import { getTextSize } from "@/utils/font"; +import { getTextSize, textToTextArray } from "@/utils/font"; import { Color, ProgressNumber, Vector } from "@graphif/data-structures"; import { id, passExtraAtArg1, passObject, serializable } from "@graphif/serializer"; import { Line, Rectangle, Shape } from "@graphif/shapes"; import { Value } from "platejs"; +export type SectionMode = "group" | "caption"; + @passExtraAtArg1 @passObject export class Section extends ConnectableEntity { @@ -79,6 +81,13 @@ export class Section extends ConnectableEntity { */ @serializable locked: boolean = false; + /** + * Section 的模式 + * group: 普通分组框,标题在顶部 + * caption: 图片解说框,图片在上,文字在下方 + */ + @serializable + mode: SectionMode = "group"; isHiddenBySectionCollapse = false; constructor( @@ -91,6 +100,7 @@ export class Section extends ConnectableEntity { color = Color.Transparent, locked = false, isCollapsed = false, + mode = "group" as SectionMode, children = [] as Entity[], details = [] as Value, } = {}, @@ -114,6 +124,7 @@ export class Section extends ConnectableEntity { this.text = text; this.locked = locked; this.isCollapsed = isCollapsed; + this.mode = mode; this.details = details; this.children = children; // 一定要放在最后 @@ -144,6 +155,11 @@ export class Section extends ConnectableEntity { * 自动调整大小为 标题+padding,位置为 当前碰撞箱外接矩形的左上角 */ adjustLocationAndSize() { + if (this.mode === "caption") { + this.adjustLocationAndSizeCaption(); + return; + } + let rectangle: Rectangle; const titleSize = getTextSize(this.text, Renderer.FONT_SIZE); @@ -175,6 +191,55 @@ export class Section extends ConnectableEntity { // 调整折叠状态 this._collisionBoxWhenCollapsed = this.collapsedCollisionBox(); } + + private adjustLocationAndSizeCaption() { + const padding = 10; + const lineHeight = 1.2; + + // 先算出子内容区域宽度,用于确定文字换行的 limitWidth + let childrenRect: Rectangle | undefined; + if (this.children.length > 0) { + childrenRect = Rectangle.getBoundingRectangle( + this.children.map((child) => child.collisionBox.getRectangle()), + padding, + ); + } + + const sectionWidth = childrenRect + ? childrenRect.size.x + : Math.max(getTextSize(this.text, Renderer.FONT_SIZE).x + padding * 2, 100); + const limitWidth = sectionWidth - padding * 2; + const lines = this.text === "" ? [] : textToTextArray(this.text, Renderer.FONT_SIZE, limitWidth); + const captionHeight = lines.length === 0 ? 0 : lines.length * Renderer.FONT_SIZE * lineHeight + padding * 2; + + if (this.children.length === 0) { + const maxTextWidth = + lines.length > 0 ? Math.max(...lines.map((line) => getTextSize(line, Renderer.FONT_SIZE).x)) : 0; + const rectangle = new Rectangle( + this.collisionBox.getRectangle().location, + new Vector(Math.max(maxTextWidth + padding * 2, 100), captionHeight > 0 ? captionHeight : 100), + ); + this._collisionBoxNormal.shapes = rectangle.getBoundingLines(); + this._collisionBoxWhenCollapsed = this.collapsedCollisionBox(); + return; + } + + const totalHeight = childrenRect!.size.y + captionHeight; + const rectangle = new Rectangle(childrenRect!.location, new Vector(childrenRect!.size.x, totalHeight)); + + this._collisionBoxNormal.shapes = rectangle.getBoundingLines(); + + if (captionHeight > 0) { + const captionRect = new Rectangle( + new Vector(rectangle.location.x, rectangle.location.y + childrenRect!.size.y), + new Vector(rectangle.size.x, captionHeight), + ); + this._collisionBoxNormal.shapes.push(captionRect); + } + + this._collisionBoxWhenCollapsed = this.collapsedCollisionBox(); + } + /** * 根据自身的折叠状态调整子节点的状态 * 以屏蔽触碰和显示 @@ -273,7 +338,7 @@ export class Section extends ConnectableEntity { } /** - * 将某个物体 的最小外接矩形的左上角位置 移动到某个位置 + * 将某个物体 的最小外接矩形的左上角位置 积动到某个位置 * @param location */ moveTo(location: Vector): void { diff --git a/app/src/locales/en.yml b/app/src/locales/en.yml index 02152d98..8d6f7cfd 100644 --- a/app/src/locales/en.yml +++ b/app/src/locales/en.yml @@ -312,6 +312,32 @@ settings: title: Enable Section Collision description: | When enabled, sibling sections will automatically push each other apart to avoid overlapping. + isEnableForceDirected: + title: Enable Force-Directed Layout + description: | + When enabled, nodes simulate physical forces (repulsion, spring, centering) to automatically arrange the layout. + Nodes can be dragged, and the force simulation continues after release. + forceDirectedLinkDistance: + title: Link Distance + description: Target distance between connected nodes in the force-directed layout. + forceDirectedLinkStrength: + title: Link Strength + description: Spring force strength between connected nodes. Higher values pull nodes together more quickly. + forceDirectedCollisionStrength: + title: Collision Strength + description: Force applied when nodes overlap. Higher values push overlapping nodes apart more aggressively. + forceDirectedVelocityDecay: + title: Velocity Decay + description: Speed damping per frame. Lower values slow nodes down faster, making the simulation converge sooner. + forceDirectedMaxMovePerFrame: + title: Max Move Per Frame + description: Maximum distance a node can move in a single frame. Prevents simulation from exploding. + forceDirectedConvergenceThreshold: + title: Convergence Threshold + description: Total kinetic energy below which the simulation stops. Higher values stop the simulation earlier. + forceDirectedMinDistance: + title: Min Distance + description: Minimum allowed distance between connected nodes. Prevents nodes from overlapping completely. autoNamerTemplate: title: Auto-Naming Template for Node Creation description: | @@ -875,17 +901,26 @@ settings: aiApiBaseUrl: title: AI API Base URL description: | - Currently only supports OpenAI format API + Base URL for OpenAI-compatible API. + Default: https://generativelanguage.googleapis.com/v1beta/openai/ (Gemini API) + Can be set to https://api.openai.com/v1 (OpenAI) or other compatible APIs. aiApiKey: title: AI API Key description: | - The key will be stored in plain text locally + Authentication key for the API. The key is stored locally only. + Gemini key: https://aistudio.google.com/apikey + OpenAI key: https://platform.openai.com/api-keys aiModel: title: AI Model + description: | + Name of the AI model to use. + Default: gemini-2.5-flash + OpenAI: gpt-4o, gpt-4-turbo + Others: Use the model name provided by your service provider. aiShowTokenCount: title: Show AI token count description: | - When enabled, shows the token count for AI operations + When enabled, displays input/output token count at the bottom of the AI chat window. soundPitchVariationRange: title: Sound Pitch Variation description: Controls how much the pitch of sound effects varies randomly. Range for 0-1200 cents (1200 cents = 1 octave, 100 cents = 1 semitone). Higher values create more noticeable variations. @@ -1258,6 +1293,9 @@ keyBinds: toggleSectionLock: title: Lock/Unlock Section Box description: Toggle lock state of selected section boxes. Locked sections prevent moving internal objects. + toggleForceDirected: + title: Toggle Force-Directed Layout + description: Toggle the force-directed layout on and off reverseEdges: title: Reverse Connection Direction description: | diff --git a/app/src/locales/id.yml b/app/src/locales/id.yml index b4c3d36b..a12470fc 100644 --- a/app/src/locales/id.yml +++ b/app/src/locales/id.yml @@ -420,6 +420,32 @@ settings: title: Aktifkan Tabrakan Bagian description: | Saat diaktifkan, bagian-bagian yang berdampingan akan saling mendorong untuk menghindari tumpang tindih. + isEnableForceDirected: + title: Aktifkan Tata Letak Force-Directed + description: | + Saat diaktifkan, node akan mensimulasikan gaya fisik (tolakan, pegas, sentripetal) untuk mengatur tata letak secara otomatis. + Node dapat diseret, dan simulasi gaya akan berlanjut setelah dilepaskan. + forceDirectedLinkDistance: + title: Jarak Tautan + description: Jarak target antara node yang terhubung dalam tata letak force-directed. + forceDirectedLinkStrength: + title: Kekuatan Tautan + description: Kekuatan gaya pegas antara node yang terhubung. Nilai lebih tinggi menarik node lebih cepat ke jarak target. + forceDirectedCollisionStrength: + title: Kekuatan Tabrakan + description: Gaya yang diterapkan saat node tumpang tindih. Nilai lebih tinggi mendorong node lebih kuat. + forceDirectedVelocityDecay: + title: Peluruhan Kecepatan + description: Redaman kecepatan per frame. Nilai lebih rendah memperlambat node lebih cepat. + forceDirectedMaxMovePerFrame: + title: Gerakan Maks per Frame + description: Jarak maksimum node dapat bergerak dalam satu frame. Mencegah simulasi meledak. + forceDirectedConvergenceThreshold: + title: Ambang Konvergensi + description: Energi kinetik total di bawah ini simulasi berhenti. Nilai lebih tinggi menghentikan simulasi lebih awal. + forceDirectedMinDistance: + title: Jarak Minimum + description: Jarak minimum yang diizinkan antara node yang terhubung. Mencegah node tumpang tindih sepenuhnya. autoRefreshStageByMouseAction: title: Segarkan Panggung Otomatis Saat Operasi Mouse description: | @@ -1299,6 +1325,9 @@ keyBinds: toggleSectionLock: title: Kunci/Buka Kunci Kotak Section description: Alihkan status kunci kotak section yang dipilih. Section terkunci mencegah pergerakan objek internal. + toggleForceDirected: + title: Alihkan Tata Letak Force-Directed + description: Mengaktifkan atau menonaktifkan tata letak force-directed reverseEdges: title: Balikkan Arah Garis description: | diff --git a/app/src/locales/zh_CN.yml b/app/src/locales/zh_CN.yml index 508ce587..28677443 100644 --- a/app/src/locales/zh_CN.yml +++ b/app/src/locales/zh_CN.yml @@ -430,6 +430,32 @@ settings: title: 启用框碰撞 description: | 开启后,框与框之间会自动进行碰撞排斥(推开重叠的同级框),避免框重叠。 + isEnableForceDirected: + title: 启用力导向布局 + description: | + 开启后,节点之间会模拟物理力(斥力、弹簧力、向心力),自动排列布局。 + 节点可以被拖拽,松开后力导向会继续作用。 + forceDirectedLinkDistance: + title: 连线目标距离 + description: 力导向布局中连线节点之间的目标距离。 + forceDirectedLinkStrength: + title: 弹簧力强度 + description: 连线节点之间的弹簧力强度。值越大,节点越快地拉向目标距离。 + forceDirectedCollisionStrength: + title: 碰撞力强度 + description: 节点重叠时施加的排斥力强度。值越大,重叠节点被推开得越剧烈。 + forceDirectedVelocityDecay: + title: 速度衰减 + description: 每帧的速度阻尼系数。值越小,节点减速越快,仿真越快收敛。 + forceDirectedMaxMovePerFrame: + title: 每帧最大移动距离 + description: 节点每帧可移动的最大距离。防止仿真爆炸。 + forceDirectedConvergenceThreshold: + title: 收敛阈值 + description: 总动能低于此值时仿真停止。值越高,仿真越早停止。 + forceDirectedMinDistance: + title: 最小距离 + description: 连线节点之间允许的最小距离。防止节点完全重叠。 autoRefreshStageByMouseAction: title: 鼠标操作时自动刷新舞台 description: | @@ -928,17 +954,26 @@ settings: aiApiBaseUrl: title: AI API 地址 description: | - 目前仅支持 OpenAI 格式的 API + OpenAI 兼容 API 的基础地址。 + 默认: https://generativelanguage.googleapis.com/v1beta/openai/ (Gemini API) + 可改为 https://api.openai.com/v1 (OpenAI) 或其他兼容 API。 aiApiKey: title: AI API 密钥 description: | - 密钥将会明文存储在本地 + API 的认证密钥。密钥仅存储在本地。 + Gemini 密钥: https://aistudio.google.com/apikey + OpenAI 密钥: https://platform.openai.com/api-keys aiModel: title: AI 模型 + description: | + 使用的 AI 模型名称。 + 默认: gemini-2.5-flash + OpenAI: gpt-4o, gpt-4-turbo + 其他: 按服务商提供的模型名填写 aiShowTokenCount: - title: 显示 AI 消耗的token数 + title: 显示 Token 计数 description: | - 启用后,在 AI 操作时显示消耗的token数 + 在 AI 聊天窗口底部显示输入/输出的 Token 数量。 cacheTextAsBitmap: title: 开启位图式渲染文本 description: | @@ -1301,6 +1336,9 @@ keyBinds: toggleSectionLock: title: 锁定/解锁分组框 description: 切换选中分组框的锁定状态,锁定后内部物体不可移动 + toggleForceDirected: + title: 切换力导向布局 + description: 切换力导向布局的开启和关闭 reverseEdges: title: 反转连线的方向 description: | diff --git a/app/src/locales/zh_TW.yml b/app/src/locales/zh_TW.yml index 2b6edde0..407c29fe 100644 --- a/app/src/locales/zh_TW.yml +++ b/app/src/locales/zh_TW.yml @@ -430,6 +430,32 @@ settings: title: 啟用框碰撞 description: | 開啟後,框與框之間會自動進行碰撞排斥(推開重疊的同級框),避免框重疊。 + isEnableForceDirected: + title: 啟用力導向佈局 + description: | + 開啟後,節點之間會模擬物理力(斥力、彈簧力、向心力),自動排列布局。 + 節點可以被拖拽,鬆開後力導向會繼續作用。 + forceDirectedLinkDistance: + title: 連線目標距離 + description: 力導向佈局中連線節點之間的目標距離。 + forceDirectedLinkStrength: + title: 彈簧力強度 + description: 連線節點之間的彈簧力強度。值越大,節點越快地拉向目標距離。 + forceDirectedCollisionStrength: + title: 碰撞力強度 + description: 節點重疊時施加的排斥力強度。值越大,重疊節點被推開得越劇烈。 + forceDirectedVelocityDecay: + title: 速度衰減 + description: 每幀的速度阻尼係數。值越小,節點減速越快,仿真越快收斂。 + forceDirectedMaxMovePerFrame: + title: 每幀最大移動距離 + description: 節點每幀可移動的最大距離。防止仿真爆炸。 + forceDirectedConvergenceThreshold: + title: 收斂閾值 + description: 總動能低於此值時仿真停止。值越高,仿真越早停止。 + forceDirectedMinDistance: + title: 最小距離 + description: 連線節點之間允許的最小距離。防止節點完全重疊。 autoRefreshStageByMouseAction: title: 鼠標操作時自動刷新舞臺 description: | @@ -1301,6 +1327,9 @@ keyBinds: toggleSectionLock: title: 鎖定/解鎖分組框 description: 切換選中分組框的鎖定狀態,鎖定後內部物體不可移動 + toggleForceDirected: + title: 切換力導向佈局 + description: 切換力導向佈局的開啟和關閉 reverseEdges: title: 反轉連線的方向 description: | diff --git a/app/src/locales/zh_TWC.yml b/app/src/locales/zh_TWC.yml index c186424a..a0e69da0 100644 --- a/app/src/locales/zh_TWC.yml +++ b/app/src/locales/zh_TWC.yml @@ -458,6 +458,32 @@ settings: title: 启用框碰撞 description: | 开启后,框与框之间会自动进行碰撞排斥(推开重叠的同级框),避免框重叠。 + isEnableForceDirected: + title: 启用力导向布局 + description: | + 开启后,节点之间会模拟物理力(斥力、弹簣力、向心力),自动排列布局。 + 节点可以被拖拽,松开后力导向会继续作用。 + forceDirectedLinkDistance: + title: 连线目标距离 + description: 力导向布局中连线节点之间的目标距离。 + forceDirectedLinkStrength: + title: 弹簣力强度 + description: 连线节点之间的弹簣力强度。值越大,节点越快地拉向目标距离。 + forceDirectedCollisionStrength: + title: 碰撞力强度 + description: 节点重叠时施加的排斥力强度。值越大,重叠节点被推开得越剧烈。 + forceDirectedVelocityDecay: + title: 速度衰减 + description: 每帧的速度阻尼系数。值越小,节点减速越快,仿真越快收敛。 + forceDirectedMaxMovePerFrame: + title: 每帧最大移动距离 + description: 节点每帧可移动的最大距离。防止仿真爆炸。 + forceDirectedConvergenceThreshold: + title: 收敛阈值 + description: 总动能低于此值时仿真停止。值越高,仿真越早停止。 + forceDirectedMinDistance: + title: 最小距离 + description: 连线节点之间允许的最小距离。防止节点完全重叠。 autoRefreshStageByMouseAction: title: 滑鼠操作時自動刷新舞台 description: '開啟後,滑鼠操作(拖曳移動視野)會自動重新整理舞台 @@ -1250,6 +1276,9 @@ keyBinds: toggleSectionLock: title: 鎖定/解鎖分组框 description: 切換選中分组框的鎖定狀態,鎖定後內部物體不可移動 + toggleForceDirected: + title: 切換力導向佈局 + description: 切換力導向佈局的開啟和關閉 reverseEdges: title: 反转连线的方向 description: '按下後,選取的連線的方向會變成相反方向 diff --git a/app/src/sub/AIWindow.tsx b/app/src/sub/AIWindow.tsx index de996178..8aa00f15 100644 --- a/app/src/sub/AIWindow.tsx +++ b/app/src/sub/AIWindow.tsx @@ -69,6 +69,9 @@ function AIChatPanel({ project, winId }: { project: Project; winId: string }) { const { messages, sendMessage, stop, status, error } = useChat>({ transport, experimental_throttle: 50, + onError: (err) => { + toast.error(`AI 请求失败: ${err.message || err.toString() || JSON.stringify(err)}`); + }, }); const requesting = status === "submitted" || status === "streaming"; const tokenUsage = useMemo(() => getTokenUsage(messages), [messages]); @@ -78,7 +81,7 @@ function AIChatPanel({ project, winId }: { project: Project; winId: string }) { }, [messages]); useEffect(() => { - if (error) toast.error(error.message); + if (error) console.error("AI Chat state error:", error); }, [error]); useEffect(() => { diff --git a/app/src/sub/SettingsWindow/keybinds.tsx b/app/src/sub/SettingsWindow/keybinds.tsx index 699b56ca..6a740017 100644 --- a/app/src/sub/SettingsWindow/keybinds.tsx +++ b/app/src/sub/SettingsWindow/keybinds.tsx @@ -737,7 +737,14 @@ export const shortcutKeysGroups: ShortcutKeysGroup[] = [ { title: "section", icon: , - keys: ["folderSection", "packEntityToSection", "unpackEntityFromSection", "textNodeToSection", "toggleSectionLock"], + keys: [ + "folderSection", + "packEntityToSection", + "unpackEntityFromSection", + "textNodeToSection", + "toggleSectionLock", + "toggleForceDirected", + ], }, { title: "leftMouseModeCheckout", diff --git a/app/src/sub/SettingsWindow/settings.tsx b/app/src/sub/SettingsWindow/settings.tsx index b5b872fc..3efd6a91 100644 --- a/app/src/sub/SettingsWindow/settings.tsx +++ b/app/src/sub/SettingsWindow/settings.tsx @@ -305,7 +305,16 @@ const categories = { "newNodeScaleByCamera", "newNodeScaleByCameraOffset", ], - section: ["isEnableSectionCollision"], + section: ["isEnableSectionCollision", "isEnableForceDirected"], + forceDirected: [ + "forceDirectedLinkDistance", + "forceDirectedLinkStrength", + "forceDirectedCollisionStrength", + "forceDirectedVelocityDecay", + "forceDirectedMaxMovePerFrame", + "forceDirectedConvergenceThreshold", + "forceDirectedMinDistance", + ], edge: [ "allowAddCycleEdge", "enableDragNodeShakeDetachFromEdge", @@ -364,6 +373,7 @@ const categoryIcons = { objectSelect: SquareDashedMousePointer, textNode: TextCursorInput, section: SquareDashedTopSolid, + forceDirected: Network, edge: SplinePointer, generateNode: Network, gamepad: Gamepad2, diff --git a/app/src/types/node.tsx b/app/src/types/node.tsx index ebd43beb..2f3e4715 100644 --- a/app/src/types/node.tsx +++ b/app/src/types/node.tsx @@ -32,6 +32,7 @@ export namespace Serialized { return obj.type === "core:text_node"; } + export type SectionMode = "group" | "caption"; export type Section = Entity & { type: "core:section"; size: Vector; @@ -41,6 +42,7 @@ export namespace Serialized { children: string[]; // uuid[] isHidden: boolean; isCollapsed: boolean; + mode?: SectionMode; // 默认 "group",旧文件可能没有此字段 }; export function isSection(obj: StageObject): obj is Section {