feat: localize and brand HiCAD workspace

This commit is contained in:
134 2026-07-13 16:20:08 +08:00
parent ae76c45e27
commit 46e5135592
5 changed files with 263 additions and 46 deletions

View File

@ -26,7 +26,6 @@ export default async function AiCadPage() {
return (
<ZooDesignStudioFrame
isReady={isReady}
sourceLabel="HiCAD"
src={ZOO_MODELING_APP_SRC}
/>
);

View File

@ -1,45 +1,13 @@
"use client";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AlertTriangle } from "lucide-react";
type ZooDesignStudioFrameProps = {
isReady: boolean;
sourceLabel: string;
src: string;
};
export function ZooDesignStudioFrame({
isReady,
sourceLabel,
src,
}: ZooDesignStudioFrameProps) {
const [loaded, setLoaded] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
if (!isReady) {
return;
}
const iframe = iframeRef.current;
if (!iframe) {
return;
}
const markLoaded = () => setLoaded(true);
iframe.addEventListener("load", markLoaded);
try {
if (iframe.contentDocument?.readyState === "complete") {
markLoaded();
}
} catch {
// The regular load event remains the fallback if access is unavailable.
}
return () => iframe.removeEventListener("load", markLoaded);
}, [isReady, src]);
export function ZooDesignStudioFrame({ isReady, src }: ZooDesignStudioFrameProps) {
if (!isReady) {
return (
<div className="flex h-svh w-full items-center justify-center overflow-hidden bg-[#10100e] p-6 text-[#f8f6ef]">
@ -65,20 +33,10 @@ export function ZooDesignStudioFrame({
}
return (
<div className="relative h-svh w-full overflow-hidden bg-[#10100e]">
{!loaded && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[#10100e] text-[#d3cabb]">
<div className="flex items-center gap-3 rounded-lg border border-[#3a352c] bg-[#171510]/95 px-4 py-3 shadow-xl">
<RefreshCw className="h-4 w-4 animate-spin" />
<span className="text-sm">正在加载 {sourceLabel}</span>
</div>
</div>
)}
<div className="h-svh w-full overflow-hidden bg-[#10100e]">
<iframe
allow="clipboard-read; clipboard-write; fullscreen; gamepad; pointer-lock; web-share; xr-spatial-tracking"
className="h-full w-full border-0"
onLoad={() => setLoaded(true)}
ref={iframeRef}
referrerPolicy="strict-origin-when-cross-origin"
sandbox="allow-downloads allow-forms allow-modals allow-pointer-lock allow-popups allow-presentation allow-same-origin allow-scripts"
src={src}

View File

@ -25,6 +25,7 @@ const workspaceDir = path.join(root, ".zoo-modeling-app");
const sourceDir = path.join(workspaceDir, "modeling-app");
const sourceRefFile = path.join(sourceDir, ".hicad-source-ref");
const outputDir = path.join(root, "public/vendor/zoo-modeling-app");
const customizationDir = path.join(root, "vendor/zoo-modeling-app");
const proxyToken = "hicad-session";
function run(command, args, options = {}) {
@ -109,6 +110,14 @@ function replaceSource(relativePath, before, after) {
}
function patchOfficialSource() {
copyFileSync(
path.join(customizationDir, "Logo.tsx"),
path.join(sourceDir, "src/components/Logo.tsx")
);
copyFileSync(
path.join(customizationDir, "localization.ts"),
path.join(sourceDir, "src/lib/hicadLocalization.ts")
);
replaceSource(
"index.html",
' <meta name="description" content="An open-source CAD modeling tool from the future by Zoo." />',
@ -204,6 +213,26 @@ function patchOfficialSource() {
" new URL(env().VITE_MLEPHANT_WEBSOCKET_URL ?? '')",
" new URL(env().VITE_MLEPHANT_WEBSOCKET_URL ?? '', window.location.href)"
);
replaceSource(
"src/index.tsx",
"import { markOnce } from '@src/lib/performance'\n",
"import { installHiCadLocalization } from '@src/lib/hicadLocalization'\nimport { markOnce } from '@src/lib/performance'\n"
);
replaceSource(
"src/index.tsx",
"function launchApp(app: App) {\n initSingletonBehavior(app)",
"function launchApp(app: App) {\n installHiCadLocalization()\n initSingletonBehavior(app)"
);
replaceSource(
"src/routes/Onboarding/utils.tsx",
` <p className="m-0 text-center text-xs italic text-chalkboard-70 dark:text-chalkboard-30">
To view your account, manage payment methods, or change tiers, simply{' '}`,
` <p
data-hicad-official-account-link
className="m-0 text-center text-xs italic text-chalkboard-70 dark:text-chalkboard-30"
>
To view your account, manage payment methods, or change tiers, simply{' '}`
);
}
function ensureDependencies() {

49
vendor/zoo-modeling-app/Logo.tsx vendored Normal file
View File

@ -0,0 +1,49 @@
import { useEffect, useState, type ImgHTMLAttributes } from 'react'
type BrandingSettings = {
siteLogo?: string | null
siteLogoDark?: string | null
siteName?: string | null
}
const fallbackLogo = '/default-favicon.ico'
export const Logo = ({
className = 'w-auto h-5',
...props
}: Omit<ImgHTMLAttributes<HTMLImageElement>, 'alt' | 'src'>) => {
const [branding, setBranding] = useState<BrandingSettings>({})
useEffect(() => {
const controller = new AbortController()
fetch('/api/site-settings?scope=branding', {
credentials: 'same-origin',
signal: controller.signal,
})
.then((response) => (response.ok ? response.json() : null))
.then((settings: BrandingSettings | null) => {
if (settings) setBranding(settings)
})
.catch(() => undefined)
return () => controller.abort()
}, [])
const logo = branding.siteLogoDark || branding.siteLogo || fallbackLogo
const siteName = branding.siteName?.trim() || 'HiCAD'
return (
<img
{...props}
alt={siteName}
className={`${className} object-contain`}
onError={(event) => {
if (!event.currentTarget.src.endsWith(fallbackLogo)) {
event.currentTarget.src = fallbackLogo
}
}}
src={logo}
/>
)
}

182
vendor/zoo-modeling-app/localization.ts vendored Normal file
View File

@ -0,0 +1,182 @@
const translations = new Map<string, string>([
['3D View', '3D 视图'],
['Add file to project', '添加项目文件'],
['All', '全部'],
['Angularity', '倾斜度'],
['Ask me anything', '有问题尽管问我'],
['Back to HiCAD', '返回 HiCAD'],
['Bodies', '实体'],
['Chamfer', '倒角'],
['Choose your adventure', '选择一种建模方式'],
['Circular Pattern', '圆周阵列'],
['Code', '代码'],
['Code Editor', '代码编辑器'],
['Code-driven CAD', '代码驱动 CAD'],
['Commands', '命令'],
['Connected', '已连接'],
['Connecting and setting up scene...', '正在连接并初始化场景...'],
['Create a new project', '新建项目'],
['Design using traditional point-and-click', '使用传统点击方式设计'],
['Dismiss', '关闭'],
['Edit', '编辑'],
['Export', '导出'],
['Export part', '导出零件'],
['Extrude', '拉伸'],
['Feature Tree', '特征树'],
['Fillet', '圆角'],
['Flip Surface', '翻转曲面'],
['Front plane', '前视基准面'],
['Helix', '螺旋线'],
['Hello there, I\u2019m Zookeeper.', '你好,我是 HiCAD AI 助手。'],
["Hello there, I'm Zookeeper.", '你好,我是 HiCAD AI 助手。'],
['Help', '帮助'],
['Hole', '孔'],
['How would you like to start?', '想从哪里开始?'],
['Insert', '插入'],
['Logs', '日志'],
['Loft', '放样'],
['Mirror', '镜像'],
['No selection', '未选择'],
['No selection.', '未选择。'],
['Offset Plane', '偏移基准面'],
['Open project', '打开项目'],
['Project Files', '项目文件'],
['Publish', '发布'],
['Redo', '重做'],
['Revolve', '旋转'],
['Settings', '设置'],
['Shell', '抽壳'],
['Side plane', '侧视基准面'],
['Sketch', '草图'],
['Split', '分割'],
['Start Sketch', '新建草图'],
['Sweep', '扫掠'],
['Top plane', '顶视基准面'],
['Translate', '平移'],
['Undo', '撤销'],
['Union', '并集'],
['Variables', '变量'],
['View', '视图'],
['View settings', '视图设置'],
['Zookeeper', 'HiCAD AI 助手'],
['Zookeeper, our conversational CAD agent', 'HiCAD 对话式 CAD 助手'],
['Zoom to fit', '缩放至合适大小'],
])
const translatableAttributes = ['aria-label', 'placeholder', 'title'] as const
const excludedSelector = [
'code',
'pre',
'script',
'style',
'textarea',
'input',
'[contenteditable="true"]',
'.cm-editor',
'.cm-content',
'.cm-line',
].join(',')
function isExcluded(node: Node) {
const element =
node.nodeType === Node.ELEMENT_NODE
? (node as Element)
: node.parentElement
return Boolean(element?.closest(excludedSelector))
}
function translateTextNode(node: Text) {
if (isExcluded(node)) return
const value = node.nodeValue || ''
const normalized = value.trim()
const translated = translations.get(normalized)
if (!translated || translated === normalized) return
const leadingWhitespace = value.match(/^\s*/)?.[0] || ''
const trailingWhitespace = value.match(/\s*$/)?.[0] || ''
node.nodeValue = `${leadingWhitespace}${translated}${trailingWhitespace}`
}
function translateElement(element: Element) {
if (isExcluded(element)) return
for (const attribute of translatableAttributes) {
const value = element.getAttribute(attribute)?.trim()
if (!value) continue
const translated = translations.get(value)
if (translated) element.setAttribute(attribute, translated)
}
}
function translateTree(root: Node) {
if (root.nodeType === Node.TEXT_NODE) {
translateTextNode(root as Text)
return
}
if (root.nodeType === Node.ELEMENT_NODE) {
translateElement(root as Element)
}
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
)
let current = walker.nextNode()
while (current) {
if (current.nodeType === Node.TEXT_NODE) {
translateTextNode(current as Text)
} else {
translateElement(current as Element)
}
current = walker.nextNode()
}
}
export function installHiCadLocalization() {
document.documentElement.lang = 'zh-CN'
document.documentElement.dataset.hicadEmbedded = 'true'
const style = document.createElement('style')
style.textContent = `
html[data-hicad-embedded='true'] body {
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
}
html[data-hicad-embedded='true'] [data-testid='download-desktop-app'],
html[data-hicad-embedded='true'] [data-testid='billing-remaining-bar'],
html[data-hicad-embedded='true'] [data-hicad-official-account-link] {
display: none !important;
}
`
document.head.appendChild(style)
translateTree(document.documentElement)
let frame = 0
const pendingRoots = new Set<Node>()
const flush = () => {
frame = 0
for (const root of pendingRoots) translateTree(root)
pendingRoots.clear()
}
const schedule = (root: Node) => {
pendingRoots.add(root)
if (!frame) frame = window.requestAnimationFrame(flush)
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) schedule(node)
} else {
schedule(mutation.target)
}
}
})
observer.observe(document.documentElement, {
attributeFilter: [...translatableAttributes],
attributes: true,
characterData: true,
childList: true,
subtree: true,
})
}