mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-27 12:04:56 +02:00
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:
+27
-2
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"string": {
|
||||
"IdentificationColor": "識別色",
|
||||
"ColorDefault": "デフォルト",
|
||||
"ColorLogoUnavailable": "アイコンを読み込めませんでした。既定のタブアイコンを使用します。",
|
||||
"ColorSaveFailed": "設定を保存できませんでした。もう一度お試しください。",
|
||||
"SyncWorkspaceLogo": "ロゴをブラウザーのタブと同期",
|
||||
"Setting": "設定",
|
||||
"Spaces": "スペース",
|
||||
"Integrations": "連携",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"string": {
|
||||
"IdentificationColor": "식별 색상",
|
||||
"ColorDefault": "기본값",
|
||||
"ColorLogoUnavailable": "아이콘을 불러올 수 없습니다. 기본 탭 아이콘을 사용합니다.",
|
||||
"ColorSaveFailed": "설정을 저장할 수 없습니다. 다시 시도해 주세요.",
|
||||
"SyncWorkspaceLogo": "로고를 브라우저 탭과 동기화",
|
||||
"Setting": "설정",
|
||||
"Spaces": "스페이스",
|
||||
"Integrations": "연동",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"string": {
|
||||
"IdentificationColor": "Цвет для различения",
|
||||
"ColorDefault": "По умолчанию",
|
||||
"ColorLogoUnavailable": "Не удалось загрузить значок. Используется стандартный значок вкладки.",
|
||||
"ColorSaveFailed": "Не удалось сохранить настройки. Попробуйте ещё раз.",
|
||||
"SyncWorkspaceLogo": "Синхронизировать логотип со вкладкой",
|
||||
"Setting": "Настройки",
|
||||
"Spaces": "Пространства",
|
||||
"Integrations": "Интеграции",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user