feat(workbench): add optional workspace logos and color badges to browser tabs (#11035)

* feat: add optional workspace favicon and identification color

Signed-off-by: Ratel-pwn <2448574643@qq.com>

* refactor: simplify workspace identification color control

Signed-off-by: Ratel-pwn <2448574643@qq.com>

* fix: preserve logo size when overlaying favicon color

Signed-off-by: Ratel-pwn <2448574643@qq.com>

* fix(workbench): separate favicon badge with a transparent cutout

Signed-off-by: Ratel-pwn <2448574643@qq.com>

* fix(workbench): restore workspace favicon before app startup

Signed-off-by: Ratel-pwn <2448574643@qq.com>

---------

Signed-off-by: Ratel-pwn <2448574643@qq.com>
This commit is contained in:
DawnRatel
2026-09-25 11:00:19 +02:00
committed by GitHub
parent 5eb50ee39b
commit bda10fa45e
26 changed files with 670 additions and 7 deletions
+27 -2
View File
@@ -4,10 +4,35 @@
<head>
<meta charset="utf8">
<title>Huly</title>
<link rel="shortcut icon" href="/huly/favicon.ico" sizes="any" id="default-favicon">
<link rel="shortcut icon" data-default-href="/huly/favicon.ico" sizes="any" id="default-favicon">
<script id="workspace-favicon-bootstrap">
// Run before the application (and before assigning a fallback URL) during tab restoration.
// Keep the cache key and PNG validation in sync with presentation/workspaceIdentity.ts.
;(function () {
var fallback = document.getElementById('default-favicon')
try {
var workspace = location.pathname.match(/^\/workbench\/[^/]+/)
var icon = workspace && localStorage.getItem('huly.workspace-favicon:' + workspace[0])
if (icon && icon.length <= 16384 && /^data:image\/png;base64,[A-Za-z0-9+/]+=*$/.test(icon)) {
var link = document.createElement('link')
link.id = 'workspace-favicon'
link.rel = 'icon'
link.type = 'image/png'
link.sizes = '32x32'
link.href = icon
document.head.appendChild(link)
return
}
} catch (_) {
// Storage can be unavailable in restricted browsing contexts.
}
fallback.href = fallback.dataset.defaultHref
})()
</script>
<noscript><link rel="icon" href="/huly/favicon.ico"></noscript>
</head>
<body style="margin: 0; overflow: hidden;">
</body>
</html>
</html>
+6 -1
View File
@@ -454,7 +454,12 @@ export async function configurePlatform() {
for (const link of links) {
const htmlLink = document.createElement('link')
htmlLink.rel = link.rel
htmlLink.href = link.href
if (link.rel.split(/\s+/).includes('icon') && document.getElementById('workspace-favicon') !== null) {
// Preserve the startup icon until workspace settings are available.
htmlLink.dataset.defaultHref = link.href
} else {
htmlLink.href = link.href
}
if (link.type !== undefined) {
htmlLink.type = link.type
+3
View File
@@ -134,6 +134,9 @@ export class TOfficeSettings extends TConfiguration implements OfficeSettings {
@Model(setting.class.WorkspaceSetting, core.class.Doc, DOMAIN_SETTING)
export class TWorkspaceSetting extends TDoc implements WorkspaceSetting {
icon?: Ref<Blob>
identificationColor?: string | null
syncWorkspaceLogo?: boolean
identificationColorEnabled?: boolean
}
@Mixin(setting.mixin.SpaceTypeEditor, core.class.Class)
@@ -0,0 +1,106 @@
// Copyright © 2026 Huly Contributors. Licensed under the Eclipse Public License, Version 2.0.
import { mixLogoColor, normalizeIdentityColor, renderWorkspaceIdentity } from '../workspaceIdentity'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { runInNewContext } from 'vm'
describe('workspace favicon before application startup', () => {
const icon = 'data:image/png;base64,iVBORw0KGgo='
const template = readFileSync(resolve(__dirname, '../../../../dev/prod/src/index.ejs'), 'utf8')
function bootstrap (pathname: string, cached?: string, denied = false): { fallback: any, icons: any[] } {
const fallback = { href: '', dataset: { defaultHref: '/huly/favicon.ico' } }
const icons: any[] = []
const script = template.match(/<script id="workspace-favicon-bootstrap">([\s\S]*?)<\/script>/)?.[1]
expect(script).toBeDefined()
runInNewContext(script ?? '', {
location: { pathname },
localStorage: { getItem: (key: string) => {
if (denied) throw new Error('Storage denied')
return key === 'huly.workspace-favicon:/workbench/company-a' ? cached ?? null : null
} },
document: {
getElementById: () => fallback,
createElement: () => ({}),
head: { appendChild: (link: any) => icons.push(link) }
}
})
return { fallback, icons }
}
it('declares the cached PNG without assigning a fallback URL or starting the app', () => {
const { fallback, icons } = bootstrap('/workbench/company-a/tracker', icon)
expect(fallback.href).toBe('')
expect(icons).toEqual([expect.objectContaining({ id: 'workspace-favicon', href: icon })])
})
it('never uses another workspace icon or applies one to the login page', () => {
for (const pathname of ['/workbench/company-b', '/login']) {
const { fallback, icons } = bootstrap(pathname, icon)
expect(fallback.href).toBe('/huly/favicon.ico')
expect(icons).toHaveLength(0)
}
})
it('falls back safely when storage is denied or contains a non-PNG URL', () => {
for (const cached of ['https://other.example/icon.png', 'javascript:alert(1)', 'broken']) {
expect(bootstrap('/workbench/company-a', cached).fallback.href).toBe('/huly/favicon.ico')
}
expect(bootstrap('/workbench/company-a', icon, true).fallback.href).toBe('/huly/favicon.ico')
})
})
describe('workspace identification colour', () => {
it('mixes colours in linear light', () => {
expect(mixLogoColor(new Uint8ClampedArray([255, 0, 0, 255, 0, 0, 255, 255]))).toBe('#bc00bc')
})
it('ignores transparent padding and weights partially transparent pixels', () => {
expect(mixLogoColor(new Uint8ClampedArray([255, 255, 255, 0, 0, 128, 255, 255]))).toBe('#0080ff')
expect(mixLogoColor(new Uint8ClampedArray([255, 0, 0, 255, 0, 0, 255, 85]))).toBe('#e10089')
})
it('uses a stable fallback for empty or transparent images', () => {
expect(mixLogoColor(new Uint8ClampedArray())).toBe('#64748b')
expect(mixLogoColor(new Uint8ClampedArray([255, 0, 0, 0]))).toBe('#64748b')
})
it('normalizes manual choices and rejects malformed persisted values', () => {
expect(normalizeIdentityColor(' #FF8800 ')).toBe('#ff8800')
for (const value of [null, undefined, '', '#fff', 'red', '#12345678', 'url(x)']) {
expect(normalizeIdentityColor(value)).toBeUndefined()
}
})
})
describe('optional workspace favicon', () => {
const originalFetch = globalThis.fetch
afterEach(() => { globalThis.fetch = originalFetch })
it('does not load images when both options are disabled', async () => {
const fetchIcon = jest.fn()
globalThis.fetch = fetchIcon
await expect(renderWorkspaceIdentity('/workspace.png', null, undefined, {
syncLogo: false, showColor: false
})).resolves.toEqual({ color: '#64748b' })
expect(fetchIcon).not.toHaveBeenCalled()
})
it('does not wait for a workspace logo when applying a manual badge to the site icon', async () => {
const fetchIcon = jest.fn().mockRejectedValue(new Error('Site icon unavailable'))
globalThis.fetch = fetchIcon
await expect(renderWorkspaceIdentity('/workspace.png', '#ff8800', undefined, {
syncLogo: false, showColor: true, defaultIconUrl: '/site.ico'
})).rejects.toThrow('Site icon unavailable')
expect(fetchIcon).toHaveBeenCalledTimes(1)
expect(fetchIcon).toHaveBeenCalledWith('/site.ico', { signal: undefined })
})
it('falls back to the unchanged site favicon if the workspace logo is unavailable', async () => {
globalThis.fetch = jest.fn().mockRejectedValue(new Error('Workspace logo unavailable'))
await expect(renderWorkspaceIdentity('/workspace.png', null, undefined, {
syncLogo: true, showColor: false
})).resolves.toEqual({ color: '#64748b' })
})
})
+1
View File
@@ -80,3 +80,4 @@ export * from './drawingCommandsProcessor'
export * from './link-preview'
export * from './communication'
export * from './pulse'
export * from './workspaceIdentity'
@@ -0,0 +1,251 @@
// Copyright © 2026 Huly Contributors. Licensed under the Eclipse Public License, Version 2.0.
export const defaultIdentityColor = '#64748b'
/** @public */
export function normalizeIdentityColor (value?: string | null): string | undefined {
if (typeof value !== 'string') return undefined
const color = value.trim().toLowerCase()
return /^#[0-9a-f]{6}$/.test(color) ? color : undefined
}
/** Mix visible pixels in linear RGB. Transparent padding contributes no colour. @public */
export function mixLogoColor (pixels: Uint8ClampedArray): string {
const total = [0, 0, 0]
let weight = 0
for (let i = 0; i + 3 < pixels.length; i += 4) {
const alpha = pixels[i + 3] / 255
weight += alpha
for (let channel = 0; channel < 3; channel++) {
const srgb = pixels[i + channel] / 255
total[channel] += (srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4) * alpha
}
}
if (weight === 0) return defaultIdentityColor
return '#' + total.map((sum) => {
const linear = sum / weight
const srgb = linear <= 0.0031308 ? linear * 12.92 : 1.055 * linear ** (1 / 2.4) - 0.055
return Math.round(Math.max(0, Math.min(1, srgb)) * 255).toString(16).padStart(2, '0')
}).join('')
}
/** @public */
export interface WorkspaceIdentityImage {
color: string
favicon?: string
}
/** @public */
export interface WorkspaceFaviconOptions {
syncLogo: boolean
showColor: boolean
defaultIconUrl?: string
}
const originalIcons = new WeakMap<Document, HTMLLinkElement[]>()
const cacheRevisions = new WeakMap<Document, number>()
const faviconCachePrefix = 'huly.workspace-favicon:'
function faviconCacheKey (ownerDocument: Document): string | undefined {
const workspace = ownerDocument.location?.pathname.match(/^\/workbench\/[^/]+/)
return workspace != null ? faviconCachePrefix + workspace[0] : undefined
}
function readCachedFavicon (ownerDocument: Document, key?: string): string | undefined {
try {
const icon = key === undefined ? null : ownerDocument.defaultView?.localStorage.getItem(key)
if (icon != null && icon.length <= 16384 && /^data:image\/png;base64,[A-Za-z0-9+/]+=*$/.test(icon)) return icon
} catch {
// Storage denial must not prevent normal favicon updates.
}
}
function cacheFavicon (ownerDocument: Document, key: string | undefined, icon?: string): void {
if (key === undefined) return
try {
const storage = ownerDocument.defaultView?.localStorage
if (icon === undefined) storage?.removeItem(key)
else if (icon.length <= 16384) storage?.setItem(key, icon)
} catch {
// Quota exhaustion must not prevent normal favicon updates.
}
}
/** Remove local workspace artwork when signing out of this browser. @public */
export function clearWorkspaceFaviconCache (ownerDocument: Document = document): void {
// Invalidate in-flight updates so they cannot repopulate the cache after logout.
cacheRevisions.set(ownerDocument, (cacheRevisions.get(ownerDocument) ?? 0) + 1)
try {
const storage = ownerDocument.defaultView?.localStorage
if (storage === undefined) return
for (let i = storage.length - 1; i >= 0; i--) {
const key = storage.key(i)
if (key?.startsWith(faviconCachePrefix) === true) storage.removeItem(key)
}
} catch {
// Signing out must also work when storage is unavailable.
}
}
function getOriginalIcons (ownerDocument: Document): HTMLLinkElement[] {
let icons = originalIcons.get(ownerDocument)
if (icons === undefined) {
icons = Array.from(ownerDocument.head.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]:not(#workspace-favicon)'))
.map((icon) => {
// The initial page keeps fallback icons inert while a cached workspace icon is visible.
if (icon.dataset.defaultHref === undefined) return icon
const original = icon.cloneNode(true) as HTMLLinkElement
original.href = new URL(icon.dataset.defaultHref, ownerDocument.baseURI).href
delete original.dataset.defaultHref
return original
})
originalIcons.set(ownerDocument, icons)
}
return icons
}
/** Resolve the site's icon even while the workspace favicon owns the head links. @public */
export function getDefaultWorkspaceFaviconUrl (ownerDocument: Document = document): string {
const icons = getOriginalIcons(ownerDocument)
return icons[icons.length - 1]?.href ?? new URL('/favicon.ico', ownerDocument.baseURI).href
}
async function decodeIcon (url: string, signal?: AbortSignal): Promise<HTMLImageElement> {
const response = await fetch(url, { signal })
if (!response.ok) throw new Error('Unable to load icon')
const objectUrl = URL.createObjectURL(await response.blob())
try {
const image = new Image()
image.src = objectUrl
await image.decode()
signal?.throwIfAborted()
return image
} finally {
URL.revokeObjectURL(objectUrl)
}
}
/** Decode a logo once, without changing the stored asset. @public */
export async function renderWorkspaceIdentity (
logoUrl?: string,
manualColor?: string | null,
signal?: AbortSignal,
options: WorkspaceFaviconOptions = { syncLogo: true, showColor: true }
): Promise<WorkspaceIdentityImage> {
const override = normalizeIdentityColor(manualColor)
let color = override ?? defaultIdentityColor
if (!options.syncLogo && !options.showColor) return { color }
let logo: HTMLImageElement | undefined
if (logoUrl !== undefined && (options.syncLogo || (options.showColor && override === undefined))) {
try {
logo = await decodeIcon(logoUrl, signal)
} catch {
signal?.throwIfAborted()
// A removed/unavailable workspace logo falls back to the original site icon.
}
}
if (logo !== undefined && options.showColor && override === undefined) {
const sample = document.createElement('canvas')
const ratio = Math.min(64 / logo.naturalWidth, 64 / logo.naturalHeight)
sample.width = Math.max(1, Math.round(logo.naturalWidth * ratio))
sample.height = Math.max(1, Math.round(logo.naturalHeight * ratio))
const sampleContext = sample.getContext('2d', { willReadFrequently: true })
if (sampleContext === null) throw new Error('Canvas is unavailable')
sampleContext.drawImage(logo, 0, 0, sample.width, sample.height)
color = mixLogoColor(sampleContext.getImageData(0, 0, sample.width, sample.height).data)
}
let image = options.syncLogo ? logo : undefined
if (image === undefined) {
if (!options.showColor || options.defaultIconUrl === undefined) return { color }
image = await decodeIcon(options.defaultIconUrl, signal)
}
const canvas = document.createElement('canvas')
canvas.width = canvas.height = 32
const context = canvas.getContext('2d')
if (context === null) throw new Error('Canvas is unavailable')
// Keep the full favicon footprint; the badge overlays the artwork without reserving space.
const scale = Math.min(canvas.width / image.naturalWidth, canvas.height / image.naturalHeight)
const width = image.naturalWidth * scale
const height = image.naturalHeight * scale
context.drawImage(image, (canvas.width - width) / 2, (canvas.height - height) / 2, width, height)
if (options.showColor) {
// Cut a transparent arc around the marker so the browser's own background separates it from the logo.
context.save()
context.globalCompositeOperation = 'destination-out'
context.beginPath()
context.arc(25, 25, 7, 0, Math.PI * 2)
context.fill()
context.restore()
context.beginPath()
context.arc(25, 25, 4.75, 0, Math.PI * 2)
context.fillStyle = color
context.fill()
}
return { color, favicon: canvas.toDataURL('image/png') }
}
/** Own favicon links only; preserve touch icons/manifest and restore on disposal. @public */
export function createWorkspaceFavicon (ownerDocument: Document = document): {
update: (logoUrl?: string, color?: string | null, options?: WorkspaceFaviconOptions) => Promise<void>
dispose: () => void
} {
const defaults = getOriginalIcons(ownerDocument)
const cacheKey = faviconCacheKey(ownerDocument)
const cacheRevision = cacheRevisions.get(ownerDocument) ?? 0
const link = ownerDocument.querySelector<HTMLLinkElement>('link#workspace-favicon') ?? ownerDocument.createElement('link')
link.rel = 'icon'
link.type = 'image/png'
link.sizes.value = '32x32'
link.id = 'workspace-favicon'
let revision = 0
let disposed = false
let request: AbortController | undefined
function removeDefaultLinks (): void {
for (const icon of ownerDocument.head.querySelectorAll('link[rel~="icon"]:not(#workspace-favicon)')) icon.remove()
}
function restore (): void {
link.remove()
removeDefaultLinks()
for (const original of defaults) {
if (!original.isConnected) ownerDocument.head.appendChild(original)
}
}
const cached = readCachedFavicon(ownerDocument, cacheKey)
if (!link.isConnected && cached !== undefined) {
link.href = cached
removeDefaultLinks()
ownerDocument.head.appendChild(link)
}
return {
async update (logoUrl, color, options) {
if (disposed || cacheRevision !== (cacheRevisions.get(ownerDocument) ?? 0)) return
const current = ++revision
request?.abort()
request = new AbortController()
try {
const result = await renderWorkspaceIdentity(logoUrl, color, request.signal,
options === undefined ? undefined : { ...options, defaultIconUrl: getDefaultWorkspaceFaviconUrl(ownerDocument) })
if (disposed || current !== revision || faviconCacheKey(ownerDocument) !== cacheKey) return
if (cacheRevision !== (cacheRevisions.get(ownerDocument) ?? 0)) return
if (result.favicon === undefined) {
cacheFavicon(ownerDocument, cacheKey)
restore()
return
}
link.href = result.favicon
cacheFavicon(ownerDocument, cacheKey, result.favicon)
removeDefaultLinks()
if (!link.isConnected) ownerDocument.head.appendChild(link)
} catch {
// A transient load failure must not erase a valid icon restored during startup.
if (!disposed && current === revision && !link.isConnected) restore()
}
},
dispose () {
disposed = true
revision++
request?.abort()
restore()
}
}
}
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Rozlišovací barva",
"ColorDefault": "Výchozí",
"ColorLogoUnavailable": "Ikonu se nepodařilo načíst. Používá se výchozí ikona karty.",
"ColorSaveFailed": "Nastavení se nepodařilo uložit. Zkuste to znovu.",
"SyncWorkspaceLogo": "Synchronizovat logo s kartou prohlížeče",
"Setting": "Nastavení",
"Spaces": "Prostory",
"Integrations": "Integrace",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Erkennungsfarbe",
"ColorDefault": "Standard",
"ColorLogoUnavailable": "Das Symbol konnte nicht geladen werden. Das Standardsymbol des Tabs wird verwendet.",
"ColorSaveFailed": "Die Einstellungen konnten nicht gespeichert werden. Bitte erneut versuchen.",
"SyncWorkspaceLogo": "Logo mit Browser-Tab synchronisieren",
"Setting": "Einstellung",
"Spaces": "Bereiche",
"Integrations": "Integrationen",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Identification color",
"ColorDefault": "Default",
"ColorLogoUnavailable": "The icon could not be loaded. The default tab icon is used.",
"ColorSaveFailed": "Could not save the settings. Please try again.",
"SyncWorkspaceLogo": "Sync logo with browser tab",
"Setting": "Setting",
"Spaces": "Spaces",
"Integrations": "Integrations",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Color identificativo",
"ColorDefault": "Predeterminado",
"ColorLogoUnavailable": "No se pudo cargar el icono. Se usa el icono predeterminado de la pestaña.",
"ColorSaveFailed": "No se pudo guardar la configuración. Inténtalo de nuevo.",
"SyncWorkspaceLogo": "Sincronizar logotipo con la pestaña",
"Setting": "Configuración",
"Spaces": "Espacios",
"Integrations": "Integraciones",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Couleur distinctive",
"ColorDefault": "Par défaut",
"ColorLogoUnavailable": "Impossible de charger l’icône. L’icône par défaut de l’onglet est utilisée.",
"ColorSaveFailed": "Impossible d’enregistrer les paramètres. Veuillez réessayer.",
"SyncWorkspaceLogo": "Synchroniser le logo avec l’onglet",
"Setting": "Paramètre",
"Spaces": "Espaces",
"Integrations": "Intégrations",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Colore identificativo",
"ColorDefault": "Predefinito",
"ColorLogoUnavailable": "Impossibile caricare l’icona. Viene utilizzata l’icona predefinita della scheda.",
"ColorSaveFailed": "Impossibile salvare le impostazioni. Riprova.",
"SyncWorkspaceLogo": "Sincronizza logo con la scheda",
"Setting": "Impostazione",
"Spaces": "Spazi",
"Integrations": "Integrazioni",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "識別色",
"ColorDefault": "デフォルト",
"ColorLogoUnavailable": "アイコンを読み込めませんでした。既定のタブアイコンを使用します。",
"ColorSaveFailed": "設定を保存できませんでした。もう一度お試しください。",
"SyncWorkspaceLogo": "ロゴをブラウザーのタブと同期",
"Setting": "設定",
"Spaces": "スペース",
"Integrations": "連携",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "식별 색상",
"ColorDefault": "기본값",
"ColorLogoUnavailable": "아이콘을 불러올 수 없습니다. 기본 탭 아이콘을 사용합니다.",
"ColorSaveFailed": "설정을 저장할 수 없습니다. 다시 시도해 주세요.",
"SyncWorkspaceLogo": "로고를 브라우저 탭과 동기화",
"Setting": "설정",
"Spaces": "스페이스",
"Integrations": "연동",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Kolor identyfikacyjny",
"ColorDefault": "Domyślny",
"ColorLogoUnavailable": "Nie udało się wczytać ikony. Używana jest domyślna ikona karty.",
"ColorSaveFailed": "Nie udało się zapisać ustawień. Spróbuj ponownie.",
"SyncWorkspaceLogo": "Synchronizuj logo z kartą przeglądarki",
"Setting": "Ustawienia",
"Spaces": "Przestrzenie",
"Integrations": "Integracje",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Cor de identificação",
"ColorDefault": "Padrão",
"ColorLogoUnavailable": "Não foi possível carregar o ícone. O ícone padrão da aba está sendo usado.",
"ColorSaveFailed": "Não foi possível salvar as configurações. Tente novamente.",
"SyncWorkspaceLogo": "Sincronizar logotipo com a aba",
"Setting": "Configuração",
"Spaces": "Espaços",
"Integrations": "Integrações",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Cor de identificação",
"ColorDefault": "Predefinido",
"ColorLogoUnavailable": "Não foi possível carregar o ícone. Está a ser utilizado o ícone predefinido do separador.",
"ColorSaveFailed": "Não foi possível guardar as definições. Tente novamente.",
"SyncWorkspaceLogo": "Sincronizar logótipo com o separador",
"Setting": "Configuração",
"Spaces": "Espaços",
"Integrations": "Integrações",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Цвет для различения",
"ColorDefault": "По умолчанию",
"ColorLogoUnavailable": "Не удалось загрузить значок. Используется стандартный значок вкладки.",
"ColorSaveFailed": "Не удалось сохранить настройки. Попробуйте ещё раз.",
"SyncWorkspaceLogo": "Синхронизировать логотип со вкладкой",
"Setting": "Настройки",
"Spaces": "Пространства",
"Integrations": "Интеграции",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Ayırt edici renk",
"ColorDefault": "Varsayılan",
"ColorLogoUnavailable": "Simge yüklenemedi. Varsayılan sekme simgesi kullanılıyor.",
"ColorSaveFailed": "Ayarlar kaydedilemedi. Lütfen tekrar deneyin.",
"SyncWorkspaceLogo": "Logoyu tarayıcı sekmesiyle eşitle",
"Setting": "Ayar",
"Spaces": "Alanlar",
"Integrations": "Entegrasyonlar",
+5
View File
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "识别色",
"ColorDefault": "默认",
"ColorLogoUnavailable": "无法加载图标,已使用默认标签页图标。",
"ColorSaveFailed": "无法保存设置,请重试。",
"SyncWorkspaceLogo": "Logo 与标签页同步",
"Setting": "设置",
"Spaces": "空间",
"Integrations": "集成",
@@ -44,6 +44,7 @@
} from '@hcengineering/ui'
import settingsRes from '../plugin'
import WorkspacePermissionEditor from './WorkspacePermissionEditor.svelte'
import WorkspaceIdentityColor from './WorkspaceIdentityColor.svelte'
let loading = true
let isEditingName = false
@@ -108,8 +109,9 @@
let workspaceSettings: WorkspaceSetting | undefined = undefined
const client = getClient()
void client.findOne(settingsRes.class.WorkspaceSetting, {}).then((r) => {
workspaceSettings = r
const workspaceSettingsQuery = createQuery()
workspaceSettingsQuery.query(settingsRes.class.WorkspaceSetting, { _id: settingsRes.ids.WorkspaceSetting }, (result) => {
workspaceSettings = result[0]
})
async function handleAvatarDone (): Promise<void> {
@@ -218,7 +220,7 @@
<div class="ws">
<EditableAvatar
person={{
avatarType: workspaceSettings?.icon !== undefined ? AvatarType.IMAGE : AvatarType.COLOR,
avatarType: workspaceSettings?.icon != null ? AvatarType.IMAGE : AvatarType.COLOR,
avatar: workspaceSettings?.icon
}}
size="medium"
@@ -247,6 +249,7 @@
<Button icon={IconClose} kind="ghost" size="small" on:click={handleCancelEditName} />
{/if}
</div>
<WorkspaceIdentityColor workspaceSetting={workspaceSettings} />
<div class="flex-col flex-gap-4 mt-6">
<div class="title"><Label label={settingsRes.string.PasswordAgingRule} /></div>
@@ -337,6 +340,7 @@
.ws {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
@@ -0,0 +1,161 @@
<!-- Copyright © 2026 Huly Contributors. Licensed under the Eclipse Public License, Version 2.0. -->
<script lang="ts">
import core, { AccountRole, getCurrentAccount } from '@hcengineering/core'
import {
defaultIdentityColor, getClient, getFileUrl, getDefaultWorkspaceFaviconUrl, normalizeIdentityColor,
renderWorkspaceIdentity, type WorkspaceIdentityImage
} from '@hcengineering/presentation'
import setting, { type WorkspaceSetting } from '@hcengineering/setting'
import { Button, Label, Toggle } from '@hcengineering/ui'
import { onDestroy, tick } from 'svelte'
export let workspaceSetting: WorkspaceSetting | undefined
const client = getClient()
const canEdit = getCurrentAccount().role === AccountRole.Owner
let preview: WorkspaceIdentityImage = { color: defaultIdentityColor }
let busy = false
let error = false
let imageError = false
let colorInput: HTMLInputElement
let revision = 0
let saveRevision = 0
let request: AbortController | undefined
const defaultIconUrl = getDefaultWorkspaceFaviconUrl()
$: syncLogo = workspaceSetting?.syncWorkspaceLogo === true
$: showColor = workspaceSetting?.identificationColorEnabled === true
$: manualColor = normalizeIdentityColor(workspaceSetting?.identificationColor)
$: logoUrl = workspaceSetting?.icon != null ? getFileUrl(workspaceSetting.icon) : undefined
$: void updatePreview(logoUrl, manualColor, syncLogo, showColor)
async function updatePreview (url: string | undefined, color: string | undefined, syncLogo: boolean, showColor: boolean): Promise<void> {
const current = ++revision
request?.abort()
request = new AbortController()
imageError = false
try {
const next = await renderWorkspaceIdentity(url, color, request.signal, { syncLogo, showColor, defaultIconUrl })
if (current === revision) preview = next
} catch {
if (current === revision) {
imageError = true
preview = { color: color ?? defaultIdentityColor }
}
}
}
async function saveColor (value: string | null): Promise<void> {
const color = value === null ? null : normalizeIdentityColor(value)
if (color !== undefined) await save({ identificationColor: color })
}
async function save (patch: Partial<Pick<WorkspaceSetting, 'identificationColor' | 'syncWorkspaceLogo' | 'identificationColorEnabled'>>): Promise<void> {
if (!canEdit || busy) return
const focused = document.activeElement as HTMLElement | null
const focusLabel = focused?.closest('label')?.getAttribute('aria-labelledby')
busy = true
error = false
try {
const existing = await client.findOne(setting.class.WorkspaceSetting, { _id: setting.ids.WorkspaceSetting })
if (existing === undefined) {
await client.createDoc(setting.class.WorkspaceSetting, core.space.Workspace,
patch, setting.ids.WorkspaceSetting)
} else {
await client.diffUpdate(existing, patch)
}
} catch {
error = true
if (colorInput !== undefined) colorInput.value = preview.color
saveRevision++
} finally {
busy = false
await tick()
if (document.activeElement === document.body) {
if (focused instanceof HTMLButtonElement && focused.disabled) colorInput?.focus()
else if (focused?.isConnected === true) focused.focus()
else if (focusLabel === 'workspace-sync-logo-label' || focusLabel === 'workspace-color-label') {
document.querySelector<HTMLInputElement>(`label[aria-labelledby="${focusLabel}"] input`)?.focus()
}
}
}
}
onDestroy(() => {
revision++
request?.abort()
})
</script>
<div class="identity" aria-busy={busy}>
<div class="setting-row">
<span id="workspace-sync-logo-label"><Label label={setting.string.SyncWorkspaceLogo} /></span>
<div class="controls">
{#key saveRevision}
<Toggle on={syncLogo} disabled={!canEdit || busy} aria-labelledby="workspace-sync-logo-label"
on:change={(event) => save({ syncWorkspaceLogo: event.detail })} />
{/key}
</div>
</div>
<div class="setting-row">
<span id="workspace-color-label"><Label label={setting.string.IdentificationColor} /></span>
<div class="controls">
{#key saveRevision}
<Toggle on={showColor} disabled={!canEdit || busy} aria-labelledby="workspace-color-label"
on:change={(event) => save({ identificationColorEnabled: event.detail })} />
{/key}
{#if showColor}
<div class="color-control" role="group" aria-labelledby="workspace-color-label">
<label class="color-value">
<span class="color-swatch" style:background={preview.color} />
<code>{preview.color.toUpperCase()}</code>
<span class="sr-only"><Label label={setting.string.IdentificationColor} /></span>
<input bind:this={colorInput} type="color" value={preview.color} disabled={!canEdit || busy}
on:change={(event) => saveColor(event.currentTarget.value)} />
</label>
<span class="color-reset">
<Button kind="ghost" size="small" label={setting.string.ColorDefault} padding="0 .5rem"
disabled={!canEdit || busy || manualColor === undefined} on:click={() => saveColor(null)} />
</span>
</div>
{/if}
</div>
</div>
{#if imageError}<div class="error" role="status"><Label label={setting.string.ColorLogoUnavailable} /></div>{/if}
{#if error}<div class="error" role="alert"><Label label={setting.string.ColorSaveFailed} /></div>{/if}
</div>
<style>
.identity { display: flex; flex-direction: column; gap: 1rem; }
.setting-row { display: grid; grid-template-columns: min(11rem, 45%) minmax(0, 1fr); align-items: center; gap: 0.75rem; min-height: 2rem; }
.controls { display: flex; align-items: center; flex-wrap: wrap; gap: 0.75rem; min-width: 0; }
.setting-row :global(.toggle:focus-within) { outline: 2px solid var(--primary-button-outline); outline-offset: 3px; border-radius: 1rem; }
.color-control {
display: inline-flex;
align-items: center;
padding: 0.125rem;
border: 1px solid var(--theme-divider-color);
border-radius: 0.5rem;
max-width: 100%;
}
.color-value {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem 0.25rem 0.25rem;
border-radius: 0.25rem;
min-width: 0;
}
.color-value:focus-within { outline: 2px solid var(--theme-content-color); outline-offset: 2px; }
.color-swatch {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
box-shadow: inset 0 0 0 1px var(--theme-divider-color);
}
.color-reset { border-left: 1px solid var(--theme-divider-color); padding-left: 0.125rem; }
input[type='color'] { position: absolute; inset: 0; opacity: 0; width: 100%; height: 100%; cursor: pointer; }
code { font-size: 0.8rem; }
.error { color: var(--theme-error-color, #e05252); font-size: 0.8125rem; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
</style>
+9
View File
@@ -165,6 +165,10 @@ export interface OfficeSettings extends Configuration {
*/
export interface WorkspaceSetting extends Doc {
icon?: Ref<Blob> | null
/** A manual colour override. Missing/null follows the current logo automatically. */
identificationColor?: string | null
syncWorkspaceLogo?: boolean
identificationColorEnabled?: boolean
}
export enum IntegrationError {
@@ -255,6 +259,11 @@ export default plugin(settingId, {
Setting: '' as IntlString,
Spaces: '' as IntlString,
WorkspaceSettings: '' as IntlString,
IdentificationColor: '' as IntlString,
SyncWorkspaceLogo: '' as IntlString,
ColorDefault: '' as IntlString,
ColorLogoUnavailable: '' as IntlString,
ColorSaveFailed: '' as IntlString,
Integrations: '' as IntlString,
Support: '' as IntlString,
Privacy: '' as IntlString,
@@ -111,6 +111,7 @@
import AppSwitcher from './AppSwitcher.svelte'
import Applications from './Applications.svelte'
import Logo from './Logo.svelte'
import WorkspaceFavicon from './WorkspaceFavicon.svelte'
import NavFooter from './NavFooter.svelte'
import NavHeader from './NavHeader.svelte'
import Navigator from './Navigator.svelte'
@@ -839,6 +840,7 @@
/>
</div>
{:else if $myEmployeeStore || account.role === AccountRole.Owner || isAdminUser()}
<WorkspaceFavicon />
<ActionHandler {currentSpace} />
<svg class="svg-mask">
<clipPath id="notify-normal">
@@ -0,0 +1,25 @@
<!-- Copyright © 2026 Huly Contributors. Licensed under the Eclipse Public License, Version 2.0. -->
<script lang="ts">
import { createQuery, createWorkspaceFavicon, getFileUrl } from '@hcengineering/presentation'
import setting, { type WorkspaceSetting } from '@hcengineering/setting'
import { onMount } from 'svelte'
let workspaceSetting: WorkspaceSetting | undefined
let settingsLoaded = false
let favicon: ReturnType<typeof createWorkspaceFavicon> | undefined
const query = createQuery()
query.query(setting.class.WorkspaceSetting, { _id: setting.ids.WorkspaceSetting }, (result) => {
workspaceSetting = result[0]
settingsLoaded = true
})
$: logoUrl = workspaceSetting?.icon != null ? getFileUrl(workspaceSetting.icon) : undefined
$: if (settingsLoaded) void favicon?.update(logoUrl, workspaceSetting?.identificationColor, {
syncLogo: workspaceSetting?.syncWorkspaceLogo === true,
showColor: workspaceSetting?.identificationColorEnabled === true
})
onMount(() => {
favicon = createWorkspaceFavicon()
return () => favicon?.dispose()
})
</script>
+2 -1
View File
@@ -29,7 +29,7 @@ import type {
import core, { hasAccountRole } from '@hcengineering/core'
import login from '@hcengineering/login'
import { getMetadata, getResource, setMetadata } from '@hcengineering/platform'
import presentation, { closeClient, getClient, setPresentationCookie } from '@hcengineering/presentation'
import presentation, { clearWorkspaceFaviconCache, closeClient, getClient, setPresentationCookie } from '@hcengineering/presentation'
import {
closePanel,
getCurrentLocation,
@@ -217,6 +217,7 @@ export async function logIn (loginInfo: { account: string, token?: string }): Pr
}
export async function logOut (): Promise<void> {
clearWorkspaceFaviconCache()
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
try {
await getAccountClient(accountsUrl).deleteCookie()