diff --git a/e2e/language.spec.ts b/e2e/language.spec.ts new file mode 100644 index 0000000..cdc42cd --- /dev/null +++ b/e2e/language.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from '@playwright/test' + +test('drawing UI switches between English and simplified Chinese', async ({ page }) => { + await page.goto('/app') + const toggle = page.getByTestId('language-toggle') + await expect(toggle).toBeVisible() + if (await toggle.textContent() === '中') { + await toggle.click() + } + await expect(toggle).toHaveText('EN') + await expect(page.getByRole('button', { name: '保存', exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: '属性', exact: true })).toBeVisible() + await toggle.click() + await expect(toggle).toHaveText('中') + await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeVisible() +}) diff --git a/e2e/toolbar-layout.spec.ts b/e2e/toolbar-layout.spec.ts new file mode 100644 index 0000000..9ee39b6 --- /dev/null +++ b/e2e/toolbar-layout.spec.ts @@ -0,0 +1,13 @@ +import { test, expect } from '@playwright/test' + +test('zoom control keeps fixed button widths on a narrow toolbar', async ({ page }) => { + await page.setViewportSize({ width: 520, height: 720 }) + await page.goto('/app') + const zoom = page.locator('.tb-zoom') + const fit = page.getByTestId('tb-fit') + const box = await zoom.boundingBox() + const fitBox = await fit.boundingBox() + expect(box?.width ?? 0).toBeGreaterThanOrEqual(132) + expect(fitBox?.width ?? 0).toBeGreaterThanOrEqual(50) + await expect(fit).toBeVisible() +}) diff --git a/package-lock.json b/package-lock.json index 5adc3ba..5630ea4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ipd-studio", - "version": "0.13.0", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ipd-studio", - "version": "0.13.0", + "version": "0.17.0", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@joint/core": "^4.3.1", diff --git a/src/App.tsx b/src/App.tsx index e003dc0..0892bb5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,7 +12,7 @@ import SheetTabs from './panels/SheetTabs' import StatusBar from './panels/StatusBar' import QuickLineEditor from './panels/QuickLineEditor' import WelcomeOverlay from './panels/WelcomeOverlay' - +import { useT } from './i18n' function readPref(key: string, fallback: boolean): boolean { try { const v = localStorage.getItem(key) @@ -34,19 +34,20 @@ function writePref(key: string, value: boolean): void { * Workspace switching, the command palette and the update toast belong to the * shell (EditorRoot) so they survive moving between workspaces. */ export default function App() { + const t = useT() const [showPalette, setShowPalette] = useState(() => readPref('pid.ui.palette', true)) const [showProps, setShowProps] = useState(() => readPref('pid.ui.props', true)) const togglePalette = (v: boolean) => { setShowPalette(v); writePref('pid.ui.palette', v) } const toggleProps = (v: boolean) => { setShowProps(v); writePref('pid.ui.props', v) } return ( -
+
{showPalette ? ( togglePalette(false)} /> ) : ( )}
@@ -58,7 +59,7 @@ export default function App() { toggleProps(false)} /> ) : ( )} diff --git a/src/WorkspaceRail.tsx b/src/WorkspaceRail.tsx index c3225ec..637e969 100644 --- a/src/WorkspaceRail.tsx +++ b/src/WorkspaceRail.tsx @@ -6,6 +6,7 @@ import { useEffect } from 'react' import { WORKSPACES, navigateWorkspace, type Workspace } from './routes' import { useStore } from './store/store' import { qaFor } from './validate/engine' +import { useT } from './i18n' interface Entry { icon: string @@ -26,6 +27,7 @@ const ENTRIES: Record = { } export default function WorkspaceRail({ active }: { active: Workspace }) { + const t = useT() // Only actionable counts earn a badge. Warnings and observations would cry // wolf on a drawing that is merely unfinished, so the badge counts CRITICALS // alone — the things that would stop the drawing being issued. @@ -44,7 +46,7 @@ export default function WorkspaceRail({ active }: { active: Workspace }) { }, []) return ( -
@@ -117,17 +119,17 @@ export default function AuthForm({ onDone, initialMode = 'signin' }: { > {signup && (
- + setName(e.target.value)} />
)}
- +
- + setPassword(e.target.value)} /> - {signup && At least {MIN_PASSWORD} characters.} + {signup && {t('At least 6 characters.')}}
{error &&

{error}

} {notice &&

{notice}

} {!signup && (
)} -
or
+
{t('or')}

diff --git a/src/auth/AuthScreen.tsx b/src/auth/AuthScreen.tsx index fd9e3fa..5806d5f 100644 --- a/src/auth/AuthScreen.tsx +++ b/src/auth/AuthScreen.tsx @@ -5,11 +5,13 @@ import AuthForm from './AuthForm' import { navigate } from '../routes' import './authScreen.css' +import { useT } from '../i18n' /** Stands in front of the editor. IPD Studio requires an account, so this is * the first thing most people see — it carries the product's name and what it * is, not just a bare form on an empty page. */ export default function AuthScreen() { + const t = useT() return (

@@ -17,15 +19,14 @@ export default function AuthScreen() { -

Sign in to keep drawing

+

{t('Sign in to keep drawing')}

- Your P&IDs, HMI screens and cost estimates live in your account, so they open on - whatever machine you sit down at. + {t('Your P&IDs, HMI screens and cost estimates live in your account, so they open on whatever machine you sit down at.')}

    -
  • ISA-5.1 tags parsed and validated as you draw
  • -
  • Instrument index and line list generated from the model
  • -
  • Operator screens you can run as a live simulation
  • +
  • {t('ISA-5.1 tags parsed and validated as you draw')}
  • +
  • {t('Instrument index and line list generated from the model')}
  • +
  • {t('Operator screens you can run as a live simulation')}
diff --git a/src/canvas/autoConnect.ts b/src/canvas/autoConnect.ts index 6164cf9..14941f7 100644 --- a/src/canvas/autoConnect.ts +++ b/src/canvas/autoConnect.ts @@ -101,6 +101,17 @@ export interface Dock { lineClass: LineClass } +/** A compatible symbol port close to an existing edge's unconnected point end. */ +export interface FreeEndDock { + movingPortId: string + edgeId: string + end: 'source' | 'target' + /** Where the moving symbol has to sit for its port to meet the free end. */ + x: number + y: number + at: { x: number; y: number } +} + /** Identifies a port pairing, for refusing one the user has shaken off. */ export function dockKey(dock: Pick): string { return `${dock.movingPortId}|${dock.targetNodeId}/${dock.targetPortId}` @@ -203,6 +214,47 @@ export function findDock( return best } +/** + * Find the nearest unconnected line end that a symbol can accept. + * + * A free end has no stored port kind, so its line family is the contract: + * process/pipe lines accept process ports and signal/internal lines accept + * signal ports. Only PointEnd values are considered; a line's middle is + * intentionally left to the explicit branch-tap gesture. + */ +export function findFreeEndDock(moving: PlantNode, edges: PlantEdge[], radius: number): FreeEndDock | null { + const mine = portsOf(moving) + if (!mine.length) return null + + let best: FreeEndDock | null = null + let bestDistance = radius + for (const edge of edges) { + const required: PortKind = edge.lineClass.startsWith('process') || edge.lineClass.startsWith('pipe') + ? 'process' + : 'signal' + for (const end of ['source', 'target'] as const) { + const point = edge[end] + if (isPortEnd(point)) continue + for (const mp of mine) { + const from = portWorld(moving, mp.id) + if (!from) continue + const distance = Math.hypot(point.x - from.x, point.y - from.y) + if (distance > bestDistance || !compatibleKinds(mp.kind, required)) continue + bestDistance = distance + best = { + movingPortId: mp.id, + edgeId: edge.id, + end, + x: Math.round(moving.x + point.x - from.x), + y: Math.round(moving.y + point.y - from.y), + at: { x: point.x, y: point.y }, + } + } + } + } + return best +} + /** The edge a dock creates, ready for addBatch/dockNode. */ export function dockEdge(movingId: string, dock: Dock): Omit { return { diff --git a/src/canvas/dropHandling.ts b/src/canvas/dropHandling.ts index 6546b06..84eabd5 100644 --- a/src/canvas/dropHandling.ts +++ b/src/canvas/dropHandling.ts @@ -12,7 +12,7 @@ import { nextLoopNumber } from '../isa/autonumber' import { buildTypical } from '../assist/typicals' import { activeSheet, useStore } from '../store/store' import { canvasRef } from './paperSetup' -import { type Dock, dockEdge, dockRadius, findDock, showDockHint } from './autoConnect' +import { type Dock, dockEdge, dockRadius, findDock, findFreeEndDock, showDockHint, type FreeEndDock } from './autoConnect' export function kindForSymbol(def: Pick): NodeKind { switch (def.tagRule) { @@ -119,7 +119,7 @@ function previewDrop( paper: dia.Paper, clientX: number, clientY: number, -): { node: PlantNode; dock: Dock | null } | null { +): { node: PlantNode; dock: Dock | null; freeDock: FreeEndDock | null } | null { let def: SymbolDef try { def = getSymbol(payload.symbolId) @@ -136,7 +136,8 @@ function previewDrop( state.activeLineClass, dockRadius(paper.scale().sx), ) - return { node, dock } + const freeDock = dock ? null : findFreeEndDock(node, sheet.edges, dockRadius(paper.scale().sx)) + return { node, dock, freeDock } } export function attachDropHandling(host: HTMLElement, paper: dia.Paper): () => void { @@ -156,7 +157,8 @@ export function attachDropHandling(host: HTMLElement, paper: dia.Paper): () => v // Connection dots come up across the sheet so the user can see what there // is to aim at, and the ring says which one is currently caught. paper.el.classList.add('pid-docking') - showDockHint(paper, previewDrop(payload, paper, e.clientX, e.clientY)?.dock?.at ?? null) + const preview = previewDrop(payload, paper, e.clientX, e.clientY) + showDockHint(paper, preview?.dock?.at ?? preview?.freeDock?.at ?? null) } const onDragLeave = (e: DragEvent) => { const to = e.relatedTarget @@ -179,16 +181,24 @@ export function attachDropHandling(host: HTMLElement, paper: dia.Paper): () => v const payload = JSON.parse(raw) as DragPayload const preview = previewDrop(payload, paper, e.clientX, e.clientY) if (!preview) return - const { dock } = preview + const { dock, freeDock } = preview const store = useStore.getState() const id = ulid() - const node: PlantNode = { ...preview.node, id, ...(dock ? { x: dock.x, y: dock.y } : {}) } + const node: PlantNode = { + ...preview.node, + id, + ...(dock ? { x: dock.x, y: dock.y } : freeDock ? { x: freeDock.x, y: freeDock.y } : {}), + } if (payload.presetLetters) { node.tag = { letters: payload.presetLetters, loop: nextLoopNumber(store.doc, payload.presetLetters) } } - // One batch either way: the symbol and the line it docked onto arrive - // together, and one undo takes both back. addBatch selects the new node. - store.addBatch([node], dock ? [{ ...dockEdge(id, dock), id: ulid() }] : []) + // Both forms are one undo step. A free-end dock reuses the existing line + // and changes only its coordinate endpoint into a stored port endpoint. + if (freeDock) { + store.attachNodeToFreeEnd(node, freeDock.edgeId, freeDock.end, freeDock.movingPortId) + } else { + store.addBatch([node], dock ? [{ ...dockEdge(id, dock), id: ulid() }] : []) + } } host.addEventListener('dragover', onDragOver) diff --git a/src/cloud/CloudDialog.tsx b/src/cloud/CloudDialog.tsx index d575633..bcd9c42 100644 --- a/src/cloud/CloudDialog.tsx +++ b/src/cloud/CloudDialog.tsx @@ -16,6 +16,7 @@ import { saveToCloud, type CloudDrawingMeta, } from './sync' +import { useT } from '../i18n' const btn: CSSProperties = { padding: '3px 9px', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12, cursor: 'pointer' } const btnPrimary: CSSProperties = { ...btn, borderColor: '#2b6cb0', color: '#2b6cb0' } @@ -37,6 +38,7 @@ function message(err: unknown): string { /** The signed-in user's drawings in Firestore: open, rename, delete, and save * the drawing on screen — either over the one it came from or as a new one. */ export default function CloudDialog({ open, onClose }: { open: boolean; onClose(): void }) { + const t = useT() const dirty = useStore((s) => s.dirty) const cloudId = useStore((s) => s.cloudId) const setCloudId = useStore((s) => s.setCloudId) @@ -156,8 +158,8 @@ export default function CloudDialog({ open, onClose }: { open: boolean; onClose( const idle = (style: CSSProperties): CSSProperties => (busy ? { ...style, opacity: 0.5, cursor: 'default' } : style) return ( - - {uid === undefined &&
Checking your account…
} + + {uid === undefined &&
{t('Checking your account…')}
} {uid === null && (

@@ -170,9 +172,9 @@ export default function CloudDialog({ open, onClose }: { open: boolean; onClose( <>

- + {busy ?? (dirty ? 'Unsaved changes' : '')} @@ -182,13 +184,13 @@ export default function CloudDialog({ open, onClose }: { open: boolean; onClose(
{error}
- - + +
)} - {rows === null && !error &&
Loading your drawings…
} + {rows === null && !error &&
{t('Loading your drawings…')}
} {rows !== null && rows.length === 0 && (
@@ -232,8 +234,8 @@ export default function CloudDialog({ open, onClose }: { open: boolean; onClose(
{ren ? ( <> - - + + ) : ( <> @@ -242,10 +244,10 @@ export default function CloudDialog({ open, onClose }: { open: boolean; onClose( disabled={!!busy} onClick={() => (dirty ? setConfirming({ kind: 'open', id: row.id }) : void openDrawing(row))} > - Open + {t('Open')} - - + + )}
@@ -263,9 +265,9 @@ export default function CloudDialog({ open, onClose }: { open: boolean; onClose( disabled={!!busy} onClick={() => void (conf.kind === 'open' ? openDrawing(row) : remove(row))} > - {conf.kind === 'open' ? 'Discard & open' : 'Delete'} + {conf.kind === 'open' ? t('Discard & open') : t('Delete')} - +
)}
diff --git a/src/hmi/HmiPalette.tsx b/src/hmi/HmiPalette.tsx index b062ce8..5ce571e 100644 --- a/src/hmi/HmiPalette.tsx +++ b/src/hmi/HmiPalette.tsx @@ -7,6 +7,7 @@ import { WIDGET_DEFAULT_SIZE } from './model' import { THEMES } from './theme' import { renderWidget } from './widgets/index' import { useStore } from '../store/store' +import { useT } from '../i18n' export const HMI_DRAG_MIME = 'application/x-hmi-widget' @@ -77,12 +78,13 @@ function ItemPreview({ item }: { item: PaletteItem }) { } export default function HmiPalette() { + const t = useT() const addWidget = useStore((s) => s.addWidget) return (
{SECTIONS.map((sec) => (
-

{sec.title}

+

{t(sec.title)}

{sec.items.map((it) => (
- {it.label} + {t(it.label)}
))}
diff --git a/src/hmi/HmiPropertyPanel.tsx b/src/hmi/HmiPropertyPanel.tsx index 17dfeaa..f95d137 100644 --- a/src/hmi/HmiPropertyPanel.tsx +++ b/src/hmi/HmiPropertyPanel.tsx @@ -7,6 +7,7 @@ import type { HmiWidget } from './model' import type { AlignMode } from './align' import { alignPatches, distributePatches, duplicateWidgets } from './align' import { SignalPicker, TagPicker } from './TagPicker' +import { useT } from '../i18n' /** One armed pick-on-canvas request: the next canvas click binds, not selects. */ export interface ArmedPick { kind: 'tank' | 'pipe'; widgetId: string } @@ -145,6 +146,7 @@ export default function HmiPropertyPanel({ selection, onSelect, armedPick, onArm armedPick?: ArmedPick | null onArmPick?(pick: ArmedPick | null): void }) { + const t = useT() const screen = useStore(activeHmiScreen) const screens = useStore((s) => s.doc.hmiScreens) const updateWidget = useStore((s) => s.updateWidget) @@ -158,7 +160,7 @@ export default function HmiPropertyPanel({ selection, onSelect, armedPick, onArm if (selection.length > 1) { return (
-

{selection.length} selected

+

{selection.length} {t('selected')}

{selectedWidgets.length} widget{selectedWidgets.length === 1 ? '' : 's'}, {selection.length - selectedWidgets.length} pipe{selection.length - selectedWidgets.length === 1 ? '' : 's'}

@@ -171,8 +173,8 @@ export default function HmiPropertyPanel({ selection, onSelect, armedPick, onArm if (pipe) { return (
-

Pipe

- +

{t('Pipe')}

+ updateHmiPipe(pipe.id, { width: Number(e.target.value) || 4 })} /> @@ -184,10 +186,10 @@ export default function HmiPropertyPanel({ selection, onSelect, armedPick, onArm if (!w) { return (
-

Screen

- +

{t('Screen')}

+ @@ -206,14 +208,14 @@ export default function HmiPropertyPanel({ selection, onSelect, armedPick, onArm return (
-

{w.type}

+

{t(w.type)}

{w.type !== 'nav' && w.type !== 'panel' && w.type !== 'label' && ( - + updateWidget(w.id, { tag })} /> )} - + burst(() => updateWidget(w.id, { label: e.target.value || undefined }))} /> @@ -222,7 +224,7 @@ export default function HmiPropertyPanel({ selection, onSelect, armedPick, onArm {geom('w')}{geom('h')} {w.type === 'nav' && ( - + )} - {w.type === 'tank' && (<>)} + {w.type === 'tank' && (<>)} {(w.type === 'tank' || w.type === 'display' || w.type === 'gauge' || w.type === 'bar' || w.type === 'trend') && ( <> -
Alarm limits
+
{t('Alarm limits')}
- - - + + + update({ baseUrl: e.target.value })} /> + + +
+
{testResult && {testResult}}
+
当前图纸:{doc.meta.name} · {sheet.nodes.length} 个设备 · {sheet.edges.length} 条管线
+ {messages.length > 0 &&
{messages.map((message, i) =>
{message.role === 'user' ? '你' : 'AI'}{message.content}
)}
} +