feat: localize and brand HiCAD workspace
This commit is contained in:
parent
ae76c45e27
commit
46e5135592
@ -26,7 +26,6 @@ export default async function AiCadPage() {
|
||||
return (
|
||||
<ZooDesignStudioFrame
|
||||
isReady={isReady}
|
||||
sourceLabel="HiCAD"
|
||||
src={ZOO_MODELING_APP_SRC}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
49
vendor/zoo-modeling-app/Logo.tsx
vendored
Normal 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
182
vendor/zoo-modeling-app/localization.ts
vendored
Normal 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,
|
||||
})
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user