Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions models/setting/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions packages/presentation/src/___tests___/workspaceIdentity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright © 2026 Huly Contributors. Licensed under the Eclipse Public License, Version 2.0.

import { mixLogoColor, normalizeIdentityColor, renderWorkspaceIdentity } from '../workspaceIdentity'

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 change: 1 addition & 0 deletions packages/presentation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,4 @@ export * from './drawingCommandsProcessor'
export * from './link-preview'
export * from './communication'
export * from './pulse'
export * from './workspaceIdentity'
184 changes: 184 additions & 0 deletions packages/presentation/src/workspaceIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// 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[]>()
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)'))
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) {
// At 16px this is a 5px marker. Dual edging works on light and dark browser chrome.
context.beginPath()
context.arc(25, 25, 6, 0, Math.PI * 2)
context.fillStyle = '#ffffff'
context.fill()
context.strokeStyle = '#334155'
context.lineWidth = 0.75
context.stroke()
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 link = 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 restore (): void {
link.remove()
for (const original of defaults) {
if (!original.isConnected) ownerDocument.head.appendChild(original)
}
}
return {
async update (logoUrl, color, options) {
if (disposed) 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) return
if (result.favicon === undefined) {
restore()
return
}
link.href = result.favicon
for (const original of defaults) original.remove()
if (!link.isConnected) ownerDocument.head.appendChild(link)
} catch {
if (!disposed && current === revision) restore()
}
},
dispose () {
disposed = true
revision++
request?.abort()
restore()
}
}
}
5 changes: 5 additions & 0 deletions plugins/setting-assets/lang/cs.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions plugins/setting-assets/lang/de.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions plugins/setting-assets/lang/en.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions plugins/setting-assets/lang/es.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions plugins/setting-assets/lang/fr.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions plugins/setting-assets/lang/it.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions plugins/setting-assets/lang/ja.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "識別色",
"ColorDefault": "デフォルト",
"ColorLogoUnavailable": "アイコンを読み込めませんでした。既定のタブアイコンを使用します。",
"ColorSaveFailed": "設定を保存できませんでした。もう一度お試しください。",
"SyncWorkspaceLogo": "ロゴをブラウザーのタブと同期",
"Setting": "設定",
"Spaces": "スペース",
"Integrations": "連携",
Expand Down
5 changes: 5 additions & 0 deletions plugins/setting-assets/lang/ko.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "식별 색상",
"ColorDefault": "기본값",
"ColorLogoUnavailable": "아이콘을 불러올 수 없습니다. 기본 탭 아이콘을 사용합니다.",
"ColorSaveFailed": "설정을 저장할 수 없습니다. 다시 시도해 주세요.",
"SyncWorkspaceLogo": "로고를 브라우저 탭과 동기화",
"Setting": "설정",
"Spaces": "스페이스",
"Integrations": "연동",
Expand Down
5 changes: 5 additions & 0 deletions plugins/setting-assets/lang/pl.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions plugins/setting-assets/lang/pt-br.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions plugins/setting-assets/lang/pt.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions plugins/setting-assets/lang/ru.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"string": {
"IdentificationColor": "Цвет для различения",
"ColorDefault": "По умолчанию",
"ColorLogoUnavailable": "Не удалось загрузить значок. Используется стандартный значок вкладки.",
"ColorSaveFailed": "Не удалось сохранить настройки. Попробуйте ещё раз.",
"SyncWorkspaceLogo": "Синхронизировать логотип со вкладкой",
"Setting": "Настройки",
"Spaces": "Пространства",
"Integrations": "Интеграции",
Expand Down
Loading