Drawing board on theme dependent palette (#9750)

* Drawing board colors palette now depends on UI color theme

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

* Desktop app for windows bug fixes. Formatting and style fixes.

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

* Desktop app bug fix.

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

* Theme unsubscribing fix.

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

* Formatting.

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

* Typo fix.

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

---------

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>
This commit is contained in:
Denis 'GeneralGDA' Gladkiy
2025-09-02 12:45:30 +06:00
committed by GitHub
parent e92eb593fd
commit 1b1bbe7053
46 changed files with 1820 additions and 636 deletions
+12 -1
View File
@@ -1,3 +1,5 @@
const SVELTE_MOCKS_PATH = '<rootDir>/../packages/presentation/src/__mocks__'
module.exports = {
projects: [
{
@@ -10,7 +12,16 @@ module.exports = {
displayName: 'jsdom',
testEnvironment: 'jsdom',
preset: 'ts-jest',
testMatch: ['<rootDir>/src/__test__/ui/**/*.test.ts']
testMatch: ['<rootDir>/src/__test__/ui/**/*.test.ts'],
moduleNameMapper: {
'^@hcengineering/platform-rig/profiles/ui/svelte$': `${SVELTE_MOCKS_PATH}/svelte-runtime.ts`,
'^svelte/store$': `${SVELTE_MOCKS_PATH}/svelte-store.ts`,
'^svelte/transition$': `${SVELTE_MOCKS_PATH}/svelte-transition.ts`,
'^svelte/animate$': `${SVELTE_MOCKS_PATH}/svelte-animate.ts`,
'^svelte$': `${SVELTE_MOCKS_PATH}/svelte.ts`,
'\\.svelte$': `${SVELTE_MOCKS_PATH}/svelte-component.ts`
},
setupFilesAfterEnv: [`${SVELTE_MOCKS_PATH}/setup.ts`]
}
],
roots: ["./src", "./tests"],
+2 -2
View File
@@ -38,7 +38,7 @@
--huly-history-box-left-indent: 0;
}
[data-theme="dark"] {
[data-theme="theme-dark"] {
--bg-secondary: #323233;
--bg-tertiary: #252526;
--bg-hover: #2a2d2e;
@@ -125,7 +125,7 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] .desktop-app-dropdown-menu {
[data-theme="theme-dark"] .desktop-app-dropdown-menu {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
+4 -5
View File
@@ -29,7 +29,7 @@ import { setupTitleBarMenu } from './titleBarMenu'
import { defineScreenShare, defineGetDisplayMedia } from './screenShare'
import { CommandLogout, CommandSelectWorkspace, CommandOpenSettings, CommandOpenInbox, CommandOpenPlanner, CommandOpenOffice, CommandOpenApplication, LaunchApplication, NotificationParams } from './types'
import { ipcMainExposed } from './typesUtils'
import { themeStore } from '@hcengineering/theme'
import { themeStore, ThemeVariant } from '@hcengineering/theme'
import type { Application } from '@hcengineering/workbench'
import { isAllowedToRole } from '@hcengineering/workbench-resources'
@@ -50,15 +50,14 @@ window.addEventListener('DOMContentLoaded', () => {
themeStore.subscribe((themeOptions) => {
if (themeOptions != null) {
const isDarkTheme = themeOptions.dark
menuBar.setTheme(isDarkTheme ? 'dark' : 'light')
menuBar.setTheme(themeOptions.variant)
}
})
void ipcMain.isOsUsingDarkTheme().then((isDarkTheme) => {
menuBar.setTheme(isDarkTheme ? 'dark' : 'light')
menuBar.setTheme(isDarkTheme ? ThemeVariant.Dark : ThemeVariant.Light)
}).catch(() => {
menuBar.setTheme('light') // fallback
menuBar.setTheme(ThemeVariant.Light) // fallback
})
}
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
@echo off
for /f %%i in ('node ../common/scripts/show_version.js') do set MODEL_VERSION=%%i
for /f %%i in ('node ../common/scripts/show_tag.js') do set VERSION=%%i
set NODE_ENV=development
electron --no-sandbox --trace-warnings ./
+11
View File
@@ -0,0 +1,11 @@
$MODEL_VERSION = node ../common/scripts/show_version.js | Out-String
$MODEL_VERSION = $MODEL_VERSION.Trim()
$VERSION = node ../common/scripts/show_tag.js | Out-String
$VERSION = $VERSION.Trim()
# Set environment variable
$env:NODE_ENV = "development"
# Run Electron
electron --no-sandbox ./
+19 -3
View File
@@ -1,6 +1,8 @@
const SVELTE_MOCKS_PATH = '<rootDir>/src/__mocks__'
module.exports = {
projects: [
// Default configuration for most tests (node environment)
// default configuration for most tests (node environment)
{
displayName: 'node',
preset: 'ts-jest',
@@ -8,12 +10,26 @@ module.exports = {
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
testPathIgnorePatterns: ['.*drawing\\.test\\.ts$']
},
// Configuration for drawing tests (jsdom environment)
// configuration for drawing tests (jsdom environment)
{
displayName: 'jsdom',
preset: 'ts-jest',
testEnvironment: 'jsdom',
testMatch: ['**/drawing.test.ts']
testMatch: ['**/drawing.test.ts'],
moduleNameMapper: {
'^@hcengineering/platform-rig/profiles/ui/svelte$': `${SVELTE_MOCKS_PATH}/svelte-runtime.ts`,
'^svelte/store$': `${SVELTE_MOCKS_PATH}/svelte-store.ts`,
'^svelte/transition$': `${SVELTE_MOCKS_PATH}/svelte-transition.ts`,
'^svelte/animate$': `${SVELTE_MOCKS_PATH}/svelte-animate.ts`,
'^svelte$': `${SVELTE_MOCKS_PATH}/svelte.ts`,
'\\.svelte$': `${SVELTE_MOCKS_PATH}/svelte-component.ts`
},
// setup files to run before tests
setupFilesAfterEnv: [`${SVELTE_MOCKS_PATH}/setup.ts`],
// allow transformation of ES modules in case we encounter them
transformIgnorePatterns: [
'node_modules/(?!(svelte|@sveltejs|@testing-library)/)'
]
}
]
}
-1
View File
@@ -53,7 +53,6 @@
"EraserTool": "Nástroj guma",
"PanTool": "Nástroj posun",
"TextTool": "Nástroj text",
"ColorTooltip": "{color}",
"PaletteManagementMenu": "Spravovat barevné předvolby"
},
"status": {
-1
View File
@@ -53,7 +53,6 @@
"EraserTool": "Radiergummi-Werkzeug",
"PanTool": "Verschieben-Werkzeug",
"TextTool": "Text-Werkzeug",
"ColorTooltip": "{color}",
"PaletteManagementMenu": "Farbpresets verwalten"
},
"status": {
-1
View File
@@ -53,7 +53,6 @@
"EraserTool": "Eraser tool",
"PanTool": "Pan tool",
"TextTool": "Text tool",
"ColorTooltip": "{color}",
"PaletteManagementMenu": "Manage color presets"
},
"status": {
-1
View File
@@ -53,7 +53,6 @@
"EraserTool": "Herramienta borrador",
"PanTool": "Herramienta mover",
"TextTool": "Herramienta texto",
"ColorTooltip": "{color}",
"PaletteManagementMenu": "Gestionar preajustes de color"
},
"status": {
-1
View File
@@ -53,7 +53,6 @@
"EraserTool": "Outil gomme",
"PanTool": "Outil déplacement",
"TextTool": "Outil texte",
"ColorTooltip": "{color}",
"PaletteManagementMenu": "Gérer les préréglages de couleur"
},
"status": {
-1
View File
@@ -53,7 +53,6 @@
"EraserTool": "Strumento gomma",
"PanTool": "Strumento sposta",
"TextTool": "Strumento testo",
"ColorTooltip": "{color}",
"PaletteManagementMenu": "Gestisci i preset di colore"
},
"status": {
-1
View File
@@ -53,7 +53,6 @@
"EraserTool": "消しゴムツール",
"PanTool": "パンツール",
"TextTool": "テキストツール",
"ColorTooltip": "{color}",
"PaletteManagementMenu": "カラープリセットを管理"
},
"status": {
-1
View File
@@ -53,7 +53,6 @@
"EraserTool": "Ferramenta borracha",
"PanTool": "Ferramenta mover",
"TextTool": "Ferramenta texto",
"ColorTooltip": "{color}",
"PaletteManagementMenu": "Gerenciar predefinições de cor"
},
"status": {
-1
View File
@@ -53,7 +53,6 @@
"EraserTool": "Инструмент ластик",
"PanTool": "Инструмент перемещения",
"TextTool": "Инструмент текст",
"ColorTooltip": "{color}",
"PaletteManagementMenu": "Управление цветовыми пресетами"
},
"status": {
-1
View File
@@ -53,7 +53,6 @@
"EraserTool": "橡皮擦工具",
"PanTool": "移动工具",
"TextTool": "文字工具",
"ColorTooltip": "{color}",
"PaletteManagementMenu": "管理颜色预设"
},
"status": {
+1
View File
@@ -59,6 +59,7 @@
"@hcengineering/uploader": "^0.6.0",
"@hcengineering/view": "^0.6.13",
"@hcengineering/emoji": "^0.6.0",
"@hcengineering/theme": "^0.6.5",
"fast-equals": "^5.2.2",
"png-chunks-extract": "^1.0.0",
"svelte": "^4.2.19",
@@ -15,9 +15,10 @@
import '@testing-library/jest-dom'
import { makeCommandUid, drawing } from '../drawing'
import type { DrawTextCmd, CommandUid, DrawingTool, DrawingProps, DrawingCmd } from '../drawing'
import { makeCanvasPoint } from '../drawingUtils'
import { makeCommandUid, type CommandUid, type DrawTextCmd, type DrawingCmd } from '../drawingCommand'
import { ThemeAwareColor, type ColorsList } from '../drawingColors'
import { drawing, type DrawingTool, type DrawingProps } from '../drawing'
import { type ColorMetaNameOrHex, makeCanvasPoint } from '../drawingUtils'
const fakeCanvasContext = {
clearRect: jest.fn(),
@@ -120,7 +121,10 @@ describe('drawing module tests', () => {
it('create a drawing board', () => {
const drawingBoard = drawing(drawingPlugInPoint, {
colorsList: [['alpha', new ThemeAwareColor('red', 'yellow')]],
readonly: false,
getCurrentTheme: () => 'theme-light',
subscribeOnThemeChange: () => {},
imageWidth: 40,
imageHeight: 40,
commands: []
@@ -130,7 +134,7 @@ describe('drawing module tests', () => {
})
describe('text editing', () => {
const DefaultPenColor: string = 'red'
const DefaultPenColor: ColorMetaNameOrHex = 'red' as ColorMetaNameOrHex
const DefaultTool: DrawingTool = 'pen'
const EmptyCommandUid = '' as CommandUid
const DefaultDrawingBoardWidth = 200
@@ -147,7 +151,7 @@ describe('drawing module tests', () => {
pos: makeCanvasPoint(10, 10),
fontSize: 12,
fontFace: '"IBM Plex Sans"',
color: 'green',
color: 'green' as ColorMetaNameOrHex,
...overrides
}
return { textCommandUid, textCommand }
@@ -159,7 +163,11 @@ describe('drawing module tests', () => {
): { drawingBoard: ReturnType<typeof drawing>, initialState: DrawingProps } => {
const commands = existingTextCommand === undefined ? [] : [existingTextCommand]
const initialState = {
const colorsList: ColorsList = [['alpha', new ThemeAwareColor('red', 'yellow')]]
const initialState: any = {
colorsList,
getCurrentTheme: () => 'theme-light',
subscribeOnThemeChange: () => {},
readonly: false,
imageWidth: DefaultDrawingBoardWidth,
imageHeight: DefaultDrawingBoardHeight,
@@ -222,7 +230,7 @@ describe('drawing module tests', () => {
const { drawingBoard, initialState: boardState } = createDrawingBoard(undefined, { cmdAdded: commandAddedSpy })
{
const colorToSet = 'blue'
const colorToSet = 'blue' as ColorMetaNameOrHex
// simulating: user selected text tool
drawingBoard.update?.({ ...boardState, tool: 'text' })
// simulating: user selected a color
@@ -235,12 +243,12 @@ describe('drawing module tests', () => {
// change color to something else
{
const colorToSet = 'blue'
const colorToSet = 'blue' as ColorMetaNameOrHex
drawingBoard.update?.({ ...boardState, tool: 'text', penColor: colorToSet, changingCmdId: EmptyCommandUid })
expect(getTextEditorColor(drawingPlugInPoint)).toBe(colorToSet)
}
const lastSetColor = 'red'
const lastSetColor = 'red' as ColorMetaNameOrHex
drawingBoard.update?.({ ...boardState, tool: 'text', penColor: lastSetColor, changingCmdId: EmptyCommandUid })
expect(getTextEditorColor(drawingPlugInPoint)).toBe(lastSetColor)
@@ -250,7 +258,12 @@ describe('drawing module tests', () => {
// new tool selection - editor should have been closed
drawingBoard.update?.({ ...boardState, tool: 'pen', penColor: lastSetColor, changingCmdId: EmptyCommandUid })
// changing color for the pen
drawingBoard.update?.({ ...boardState, tool: 'pen', penColor: 'magenta', changingCmdId: undefined })
drawingBoard.update?.({
...boardState,
tool: 'pen',
penColor: 'magenta' as ColorMetaNameOrHex,
changingCmdId: undefined
})
setTextEditorText(drawingPlugInPoint, setTextContent + setTextContent)
@@ -342,7 +355,7 @@ describe('drawing module tests', () => {
const newText = 'New Text'
setTextEditorText(drawingPlugInPoint, newText)
const newColor = 'yellow'
const newColor = 'yellow' as ColorMetaNameOrHex
drawingBoard.update?.({ ...boardState, tool: 'text', changingCmdId: textCommandUid, penColor: newColor })
// commit text editing
@@ -453,7 +466,10 @@ describe('drawing module tests', () => {
const pointerMovedSpy = jest.fn()
drawing(drawingPlugInPoint, {
colorsList: [['alpha', new ThemeAwareColor('red', 'yellow')]],
readonly: false,
getCurrentTheme: () => 'theme-light',
subscribeOnThemeChange: () => {},
imageWidth: 400,
imageHeight: 300,
commands: [],
@@ -485,13 +501,16 @@ describe('drawing module tests', () => {
const backgroundImageWidth = 400
const backgroundImageHeight = 300
drawing(drawingPlugInPoint, {
colorsList: [['alpha', new ThemeAwareColor('red', 'yellow')]],
readonly: false,
getCurrentTheme: () => 'theme-light',
subscribeOnThemeChange: () => {},
autoSize: false,
imageWidth: backgroundImageWidth,
imageHeight: backgroundImageHeight,
commands: [],
tool: 'pen',
penColor: 'red',
penColor: 'red' as ColorMetaNameOrHex,
cmdAdded: commandAddedSpy,
pointerMoved: pointerMovedSpy
})
@@ -14,9 +14,9 @@
//
import { DrawingCommandsProcessor, UndoRedoAvailability } from '../drawingCommandsProcessor'
import { makeCommandUid, type DrawingCmd, type DrawTextCmd, type DrawLineCmd } from '../drawing'
import { makeCommandUid, type DrawingCmd, type DrawTextCmd, type DrawLineCmd } from '../drawingCommand'
import { type Array as YArray, Doc as YDoc } from 'yjs'
import { makeCanvasPoint } from '../drawingUtils'
import { type ColorMetaNameOrHex, makeCanvasPoint } from '../drawingUtils'
const makeTextCommand = (overrides: Partial<DrawTextCmd> = {}): DrawTextCmd => ({
id: makeCommandUid(),
@@ -25,7 +25,7 @@ const makeTextCommand = (overrides: Partial<DrawTextCmd> = {}): DrawTextCmd => (
pos: makeCanvasPoint(13, 17),
fontSize: 11,
fontFace: 'Arial',
color: 'red',
color: 'red' as ColorMetaNameOrHex,
...overrides
})
@@ -34,7 +34,7 @@ const makeLineCommand = (overrides: Partial<DrawLineCmd> = {}): DrawLineCmd => (
type: 'line',
lineWidth: 3,
erasing: false,
penColor: 'blue',
penColor: 'blue' as ColorMetaNameOrHex,
points: [makeCanvasPoint(1, 3), makeCanvasPoint(11, 13)],
...overrides
})
@@ -163,13 +163,13 @@ describe('DrawingCommandsProcessor Tests', () => {
it('existing command change', () => {
const originalCommand = makeTextCommand({
text: 'Original',
color: 'red'
color: 'red' as ColorMetaNameOrHex
})
const changedCommand: DrawTextCmd = {
...originalCommand,
text: 'Changed',
color: 'blue'
color: 'blue' as ColorMetaNameOrHex
}
systemUnderTest.addCommand(originalCommand)
@@ -189,19 +189,19 @@ describe('DrawingCommandsProcessor Tests', () => {
it('order preservation', () => {
const first = makeTextCommand({
text: 'First',
color: 'red'
color: 'red' as ColorMetaNameOrHex
})
const second = makeTextCommand({
text: 'Second',
pos: makeCanvasPoint(20, 20),
color: 'blue'
color: 'blue' as ColorMetaNameOrHex
})
const third = makeTextCommand({
text: 'Third',
pos: makeCanvasPoint(30, 30),
color: 'green'
color: 'green' as ColorMetaNameOrHex
})
systemUnderTest.addCommand(first)
@@ -391,7 +391,7 @@ describe('DrawingCommandsProcessor Tests', () => {
const changedTextCommand: DrawTextCmd = {
...textCommand,
text: 'Changed Text',
color: 'green'
color: 'green' as ColorMetaNameOrHex
}
systemUnderTest.changeCommand(changedTextCommand)
expect(systemUnderTest.snapshot()).toEqual([changedTextCommand, lineCommand])
@@ -419,7 +419,7 @@ describe('DrawingCommandsProcessor Tests', () => {
const cmd = makeTextCommand({
text: `Command ${i}`,
pos: makeCanvasPoint(i * 10, i * 10),
color: 'black'
color: 'black' as ColorMetaNameOrHex
})
commands.push(cmd)
systemUnderTest.addCommand(cmd)
@@ -13,7 +13,83 @@
// limitations under the License.
//
import { rescaleToFitAspectRatio, scalePoint, offsetPoint, offsetInParent, type Point } from '../drawingUtils'
import { ThemeAwareColor, type ColorsList, DrawingBoardColoringSetup, metaColorNameToHex } from '../drawingColors'
import {
rescaleToFitAspectRatio,
scalePoint,
offsetPoint,
offsetInParent,
type Point,
type ColorMetaName,
type ColorMetaNameOrHex
} from '../drawingUtils'
import { ThemeVariant, type ThemeVariantType } from '@hcengineering/theme'
jest.mock('@hcengineering/theme', () => ({
ThemeVariant: {
Dark: 'dark',
Light: 'light'
}
}))
const StubPlatformColors: Record<string, { light: any, dark: any }> = {
Firework: {
light: {
name: 'Firework',
color: '#D15045',
title: '#C03B2F',
icon: '#D15045',
number: '#C03B2F',
background: '#C03B2F'
},
dark: {
name: 'Firework',
color: '#D15045',
title: '#FFFFFF',
icon: '#D15045',
number: '#FFFFFF',
background: '#C03B2F'
}
},
Sky: {
light: {
name: 'Sky',
color: '#4CA6EE',
title: '#1F90EA',
icon: '#4CA6EE',
number: '#1F90EA',
background: '#1F90EA'
},
dark: { name: 'Sky', color: '#4CA6EE', title: '#FFFFFF', icon: '#4CA6EE', number: '#FFFFFF', background: '#1F90EA' }
},
Grass: {
light: {
name: 'Grass',
color: '#83AF12',
title: '#60810E',
icon: '#83AF12',
number: '#60810E',
background: '#60810E'
},
dark: {
name: 'Grass',
color: '#83AF12',
title: '#FFFFFF',
icon: '#83AF12',
number: '#FFFFFF',
background: '#83AF12'
}
}
}
jest.mock('@hcengineering/ui', () => ({
getPlatformColorByName: jest.fn().mockImplementation((name: string, darkTheme: boolean) => {
const colorDefinition = StubPlatformColors[name]
if (colorDefinition == null) {
return undefined
}
return darkTheme ? colorDefinition.dark : colorDefinition.light
})
}))
describe('drawingUtils module tests', () => {
describe('scalePoint', () => {
@@ -210,4 +286,128 @@ describe('drawingUtils module tests', () => {
expect(result).toEqual(expected)
})
})
describe('ThemeAwareColor', () => {
interface ThemeTestCase {
theme: ThemeVariantType
expected: string
}
const themeUnknownColorsTestCases: ThemeTestCase[] = [
{ theme: ThemeVariant.Dark, expected: 'DarkColor' },
{ theme: ThemeVariant.Light, expected: 'LightColor' }
]
it.each(themeUnknownColorsTestCases)("materialize unknown color for '$theme' theme", ({ theme, expected }) => {
const systemUnderTest = new ThemeAwareColor('DarkColor', 'LightColor')
const result = systemUnderTest.materialize(theme)
expect(result).toBe(expected)
})
const themeKnownColorsTestCases: ThemeTestCase[] = [
{ theme: ThemeVariant.Dark, expected: StubPlatformColors.Firework.dark.color },
{ theme: ThemeVariant.Light, expected: StubPlatformColors.Firework.light.color }
]
it.each(themeKnownColorsTestCases)("materialize known color for '$theme' theme", ({ theme, expected }) => {
const systemUnderTest = new ThemeAwareColor(
StubPlatformColors.Firework.dark.name,
StubPlatformColors.Firework.light.name
)
const result = systemUnderTest.materialize(theme)
expect(result).toBe(expected)
})
it('materialize hex colors', () => {
const expectedDarkColor = '#000000'
const expectedLightColor = '#FFFFFF'
const systemUnderTest = new ThemeAwareColor(expectedDarkColor, expectedLightColor)
expect(systemUnderTest.materialize(ThemeVariant.Dark)).toBe(expectedDarkColor)
expect(systemUnderTest.materialize(ThemeVariant.Light)).toBe(expectedLightColor)
})
})
describe('DrawingBoardColoringSetup', () => {
const stubColorsList: ColorsList = [
['alpha', new ThemeAwareColor('#000000', '#FFFFFF')],
['beta', new ThemeAwareColor('#FF0000', '#00FF00')],
['gamma', new ThemeAwareColor('#0000FF', '#FFFF00')]
]
it('construction', () => {
const systemUnderTest = new DrawingBoardColoringSetup(stubColorsList)
expect(systemUnderTest.allColors).toBe(stubColorsList)
})
it('colorByName, known color', () => {
const systemUnderTest = new DrawingBoardColoringSetup(stubColorsList)
const actualColor = systemUnderTest.colorByName('alpha')
const expectedColor = stubColorsList[0][1]
expect(actualColor).toBe(expectedColor)
})
it('colorByName, unknown color', () => {
const systemUnderTest = new DrawingBoardColoringSetup(stubColorsList)
const actualColor = systemUnderTest.colorByName('unknown' as ColorMetaName)
expect(actualColor).toBeUndefined()
})
it('construction with empty list', () => {
const systemUnderTest = new DrawingBoardColoringSetup([])
expect(systemUnderTest.allColors).toEqual([])
expect(systemUnderTest.colorByName('alpha')).toBeUndefined()
})
})
describe('metaColorNameToHex', () => {
const AlphaDarkColor = '#000000'
const AlphaLightColor = '#FFFFFF'
const testColorsList: ColorsList = [
['alpha', new ThemeAwareColor(AlphaDarkColor, AlphaLightColor)],
['beta', new ThemeAwareColor('Firework', 'Sky')],
['gamma', new ThemeAwareColor('Grass', 'Grass')]
]
const colorsSetupStub = new DrawingBoardColoringSetup(testColorsList)
it('hex color, 7 characters', () => {
const hexColor = '#FF5733' as ColorMetaNameOrHex
const actualColor = metaColorNameToHex(hexColor, ThemeVariant.Dark, colorsSetupStub)
expect(actualColor).toBe(hexColor)
})
it('hex color, 4 characters', () => {
const hexColor = '#F53' as ColorMetaNameOrHex
const actualColor = metaColorNameToHex(hexColor, ThemeVariant.Dark, colorsSetupStub)
expect(actualColor).toBe(hexColor)
})
it('meta color name, dark theme', () => {
const colorName = 'alpha' as ColorMetaNameOrHex
const actualColor = metaColorNameToHex(colorName, ThemeVariant.Dark, colorsSetupStub)
expect(actualColor).toBe(AlphaDarkColor)
})
it('meta color name, light theme', () => {
const colorName = 'alpha' as ColorMetaNameOrHex
const actualColor = metaColorNameToHex(colorName, ThemeVariant.Light, colorsSetupStub)
expect(actualColor).toBe(AlphaLightColor)
})
it('unknown color name', () => {
const unknownColor = 'unknown' as ColorMetaNameOrHex
const actualColor = metaColorNameToHex(unknownColor, ThemeVariant.Dark, colorsSetupStub)
expect(actualColor).toBe(unknownColor)
})
it('should handle platform colors with same name for both themes', () => {
const gammaColor = 'gamma' as ColorMetaNameOrHex
const darkResult = metaColorNameToHex(gammaColor, ThemeVariant.Dark, colorsSetupStub)
const lightResult = metaColorNameToHex(gammaColor, ThemeVariant.Light, colorsSetupStub)
expect(darkResult).toBe('#83AF12')
expect(lightResult).toBe('#83AF12')
})
})
})
@@ -0,0 +1,119 @@
# Svelte Testing Strategy
This project uses an enhanced mocking approach for Svelte components and stores in Jest tests, inspired by Svelte Testing Library patterns but adapted for our current Jest setup.
## Current Approach: Enhanced Svelte Mocking
### What We Have
- **Robust Svelte Store Mocks**: Our `svelte-store.js` mock provides functional implementations of `writable`, `derived`, and `readable` stores with testing helpers
- **Complete Svelte Ecosystem Coverage**: Mocks for `svelte`, `svelte/transition`, `svelte/animate`, and `.svelte` components
- **Jest Integration**: Works seamlessly with our existing Jest configuration without requiring ESM mode
### Benefits
-**Simple Setup**: No complex ESM configuration required
-**Fast Tests**: Lightweight mocks don't slow down test execution
-**Focused Testing**: Tests logic rather than UI components
-**Backward Compatible**: Works with existing Jest setup
### When This Approach Works Best
- Testing business logic that uses Svelte stores
- Testing utility functions that depend on Svelte ecosystem
- Integration tests that need to mock Svelte dependencies
- When you don't need to test actual Svelte component rendering
## Future: Migration to Svelte Testing Library
### When to Consider Migration
Consider migrating to full Svelte Testing Library when you need to:
- **Test Svelte Components**: Render and interact with actual `.svelte` components
- **Test User Interactions**: Simulate clicks, form inputs, etc. on Svelte components
- **Test Component Props**: Verify how components respond to different prop values
- **Test Component Events**: Verify custom event dispatching
### Migration Steps (Future)
If you need full Svelte Testing Library support:
1. **Install Dependencies**:
```bash
npm install --save-dev @testing-library/svelte svelte-jester jest-environment-jsdom
```
2. **Update Jest Configuration** to ESM mode:
```javascript
export default {
transform: {
'^.+\\.svelte(\\.(js|ts))?$': 'svelte-jester',
},
transformIgnorePatterns: [
'/node_modules/(?!@testing-library/svelte/)',
],
moduleFileExtensions: ['js', 'svelte', 'ts'],
extensionsToTreatAsEsm: ['.svelte'],
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest-setup.js'],
}
```
3. **Update Package.json** scripts:
```json
{
"scripts": {
"test": "npx --node-options=\"--experimental-vm-modules\" jest src"
}
}
```
### Example Test with Svelte Testing Library
```javascript
import { render, screen, fireEvent } from '@testing-library/svelte'
import MyComponent from './MyComponent.svelte'
test('renders and handles click', async () => {
render(MyComponent, { props: { title: 'Hello' } })
const button = screen.getByRole('button')
await fireEvent.click(button)
expect(screen.getByText('Clicked!')).toBeInTheDocument()
})
```
## Current Mock Files
### `__mocks__/svelte-store.js`
- Functional implementations of Svelte stores
- Testing helpers like `_getValue()` and `get()`
- Proper subscriber management
### `__mocks__/svelte.js`
- Core Svelte lifecycle functions (`onMount`, `onDestroy`)
- Utility functions (`tick`, `createEventDispatcher`)
### `__mocks__/svelte-component.js`
- Mock for `.svelte` files
- Returns simple DOM element for component imports
### `__mocks__/svelte-transition.js` & `__mocks__/svelte-animate.js`
- Animation and transition function mocks
- Return basic CSS transformation objects
## Best Practices
1. **Use Store Testing Helpers**: Leverage `_getValue()` for easy store value assertions
2. **Focus on Logic**: Test business logic, not UI rendering details
3. **Mock Appropriately**: Only mock what you need to avoid test complexity
4. **Consider Migration**: Plan for Svelte Testing Library when component testing becomes necessary
## Example Usage
```javascript
import { get } from '../__mocks__/svelte-store.js'
test('store behavior', () => {
const store = writable(0)
store.set(5)
expect(get(store)).toBe(5)
expect(store._getSubscriberCount()).toBe(0)
})
```
@@ -0,0 +1,81 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
// TypeScript setup for Jest test environment
declare global {
interface Window {
getComputedStyle: (element: Element, pseudoElt?: string | null) => CSSStyleDeclaration
}
}
// Mock getComputedStyle
Object.defineProperty(window, 'getComputedStyle', {
value: (): Partial<CSSStyleDeclaration> => ({
getPropertyValue: (): string => ''
})
})
// Canvas API mock interface
interface MockCanvasRenderingContext2D {
clearRect: jest.MockedFunction<() => void>
beginPath: jest.MockedFunction<() => void>
moveTo: jest.MockedFunction<() => void>
lineTo: jest.MockedFunction<() => void>
stroke: jest.MockedFunction<() => void>
fill: jest.MockedFunction<() => void>
strokeText: jest.MockedFunction<() => void>
fillText: jest.MockedFunction<() => void>
arc: jest.MockedFunction<() => void>
save: jest.MockedFunction<() => void>
restore: jest.MockedFunction<() => void>
translate: jest.MockedFunction<() => void>
scale: jest.MockedFunction<() => void>
rotate: jest.MockedFunction<() => void>
setTransform: jest.MockedFunction<() => void>
drawImage: jest.MockedFunction<() => void>
createImageData: jest.MockedFunction<() => void>
getImageData: jest.MockedFunction<() => void>
putImageData: jest.MockedFunction<() => void>
measureText: jest.MockedFunction<() => { width: number }>
}
// Canvas API mock if needed
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
value: (): MockCanvasRenderingContext2D => ({
clearRect: jest.fn(),
beginPath: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(),
fill: jest.fn(),
strokeText: jest.fn(),
fillText: jest.fn(),
arc: jest.fn(),
save: jest.fn(),
restore: jest.fn(),
translate: jest.fn(),
scale: jest.fn(),
rotate: jest.fn(),
setTransform: jest.fn(),
drawImage: jest.fn(),
createImageData: jest.fn(),
getImageData: jest.fn(),
putImageData: jest.fn(),
measureText: jest.fn().mockReturnValue({ width: 0 })
})
})
export {}
@@ -0,0 +1,39 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
// TypeScript mock for svelte/animate
export type EasingFunction = (t: number) => number
export interface AnimationConfig {
delay?: number
duration?: number | ((len: number) => number)
easing?: EasingFunction
css?: (t: number, u: number) => string
tick?: (t: number, u: number) => void
}
export interface FlipParams {
delay?: number
duration?: number | ((len: number) => number)
easing?: EasingFunction
}
export const flip = (params: FlipParams = {}): AnimationConfig => ({
delay: params.delay ?? 0,
duration: params.duration ?? ((d: number) => Math.sqrt(d) * 120),
easing: params.easing ?? ((t: number) => t),
css: (t: number, u: number) => `transform: translate(${u * t}px, ${u * t}px)`
})
@@ -0,0 +1,18 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
// TypeScript mock for .svelte components
export default 'div' // mock as a simple div element
@@ -0,0 +1,22 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
// TypeScript mock for @hcengineering/platform-rig/profiles/ui/svelte
// This is used in Jest tests to avoid loading the actual Svelte runtime
// which uses ES modules that Jest doesn't handle well by default
// This file is intentionally empty as the import is just for TypeScript definitions
// and doesn't need any runtime behavior in tests
export default {}
@@ -0,0 +1,137 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Enhanced TypeScript mock for svelte/store inspired by Svelte Testing Library patterns
// This provides more accurate Jest-compatible implementations of Svelte stores
export type Subscriber<T> = (value: T) => void
export type Unsubscriber = () => void
export type Updater<T> = (value: T) => T
export type StartStopNotifier<T> = (set: (value: T) => void) => Unsubscriber | undefined
export interface Readable<T> {
subscribe: (run: Subscriber<T>, invalidate?: any) => Unsubscriber
_getValue?: () => T
}
export interface Writable<T> extends Readable<T> {
set: (value: T) => void
update: (updater: Updater<T>) => void
_getSubscriberCount?: () => number
}
export interface Derived<T> extends Readable<T> {
_getValue?: () => T
}
export const writable = <T>(initialValue: T): Writable<T> => {
let value = initialValue
const subscribers = new Set<Subscriber<T>>()
const store: Writable<T> = {
subscribe: (callback: Subscriber<T>) => {
subscribers.add(callback)
callback(value)
return () => subscribers.delete(callback)
},
set: (newValue: T) => {
value = newValue
subscribers.forEach((callback) => {
callback(value)
})
},
update: (updater: Updater<T>) => {
value = updater(value)
subscribers.forEach((callback) => {
callback(value)
})
},
_getValue: () => value,
_getSubscriberCount: () => subscribers.size
}
return store
}
export const derived = <T>(
stores: Readable<any> | Array<Readable<any>>,
fn: (values: any) => T,
initialValue?: T
): Derived<T> => {
const storeArray = Array.isArray(stores) ? stores : [stores]
let computedValue: T
try {
// provide reasonable defaults for testing
const values = storeArray.map((store) => {
if (store != null && typeof store.subscribe === 'function') {
return store._getValue != null ? store._getValue() : []
}
return []
})
computedValue = Array.isArray(stores) ? fn(values) : fn(values[0])
} catch (e) {
computedValue = initialValue ?? ([] as any)
}
const store: Derived<T> = {
subscribe: (callback: Subscriber<T>) => {
callback(computedValue)
return () => {}
},
_getValue: () => computedValue
}
return store
}
export const readable = <T>(initialValue: T, startStopNotifier?: StartStopNotifier<T>): Readable<T> => {
const store: Readable<T> = {
subscribe: (callback: Subscriber<T>) => {
callback(initialValue)
if (typeof startStopNotifier === 'function') {
const stop = startStopNotifier((newValue: T) => {
callback(newValue)
})
return () => {
if (typeof stop === 'function') {
stop()
}
}
}
return () => {}
},
_getValue: () => initialValue
}
return store
}
export const get = <T>(store: Readable<T>): T => {
if (store?._getValue != null) {
return store._getValue()
}
let value: T | undefined
const unsubscribe = store.subscribe((v: T) => {
value = v
})
unsubscribe()
return value as T
}
@@ -0,0 +1,102 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
// TypeScript mock for svelte/transition
export type EasingFunction = (t: number) => number
export interface TransitionConfig {
delay?: number
duration?: number | ((from: number, to: number) => number)
easing?: EasingFunction
css?: (t: number, u: number) => string
tick?: (t: number, u: number) => void
}
export interface FadeParams {
delay?: number
duration?: number
easing?: EasingFunction
}
export interface FlyParams extends FadeParams {
x?: number
y?: number
opacity?: number
}
export interface SlideParams extends FadeParams {
axis?: 'x' | 'y'
}
export interface ScaleParams extends FadeParams {
start?: number
opacity?: number
}
export interface DrawParams extends FadeParams {
speed?: number
}
export const fade = (params: FadeParams = {}): TransitionConfig => ({
delay: params.delay ?? 0,
duration: params.duration ?? 400,
easing: params.easing ?? ((t: number) => t),
css: (t: number, u: number) => `opacity: ${t}`
})
export const fly = (params: FlyParams = {}): TransitionConfig => ({
delay: params.delay ?? 0,
duration: params.duration ?? 400,
easing: params.easing ?? ((t: number) => t),
css: (t: number, u: number) =>
`transform: translate(${u * (params.x ?? 20)}px, ${u * (params.y ?? 0)}px); opacity: ${t * (params.opacity ?? 1)}`
})
export const slide = (params: SlideParams = {}): TransitionConfig => ({
delay: params.delay ?? 0,
duration: params.duration ?? 400,
easing: params.easing ?? ((t: number) => t),
css: (t: number, u: number) => {
const property = params.axis === 'x' ? 'width' : 'height'
return `${property}: ${t * 100}%`
}
})
export const scale = (params: ScaleParams = {}): TransitionConfig => ({
delay: params.delay ?? 0,
duration: params.duration ?? 400,
easing: params.easing ?? ((t: number) => t),
css: (t: number, u: number) => `transform: scale(${t * (params.start ?? 1)}); opacity: ${t * (params.opacity ?? 1)}`
})
export const draw = (params: DrawParams = {}): TransitionConfig => ({
delay: params.delay ?? 0,
duration: params.duration ?? 800,
easing: params.easing ?? ((t: number) => t),
css: (t: number) => `stroke-dasharray: ${t * (params.speed ?? 100)}`
})
export const crossfade = (): {
fallback: typeof fade
send: typeof fade
receive: typeof fade
} => {
return {
fallback: fade,
send: fade,
receive: fade
}
}
@@ -0,0 +1,43 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
// TypeScript mock for svelte
export type MountCallback = () => undefined | (() => void)
export type DestroyCallback = () => void
export type EventDispatcher<T extends Record<string, any> = any> = <K extends keyof T>(
type: K,
detail?: T[K]
) => boolean
export const onMount = (fn: MountCallback): undefined | (() => void) => {
if (typeof fn === 'function') {
return fn()
}
}
export const onDestroy = (fn: DestroyCallback): DestroyCallback => {
return fn
}
export const tick = async (): Promise<void> => {
await Promise.resolve()
}
export const createEventDispatcher = <T extends Record<string, any> = any>(): EventDispatcher<T> => {
return <K extends keyof T>(eventType: K, detail?: T[K]): boolean => {
return true
}
}
@@ -16,17 +16,14 @@
import { Analytics } from '@hcengineering/analytics'
import { resizeObserver } from '@hcengineering/ui'
import { onMount, onDestroy } from 'svelte'
import {
CommandUid,
drawing,
type DrawingCmd,
type DrawingData,
type DrawingTool,
type DrawTextCmd
} from '../drawing'
import { drawing, type DrawingData, type DrawingTool } from '../drawing'
import DrawingBoardToolbar from './DrawingBoardToolbar.svelte'
import { DrawingCommandsProcessor } from '../drawingCommandsProcessor'
import { Doc as YDoc } from 'yjs'
import { ColorMetaNameOrHex } from '../drawingUtils'
import { themeStore } from '@hcengineering/theme'
import { ColorsList, ThemeAwareColor } from '../drawingColors'
import { DrawingCmd, CommandUid, DrawTextCmd } from '../drawingCommand'
export let active = false
export let readonly = true
@@ -36,7 +33,7 @@
export let createDrawing: (data: any) => Promise<void>
let tool: DrawingTool
let penColor: string
let penColor: ColorMetaNameOrHex
let penWidth: number
let eraserWidth: number
let fontSize: number
@@ -52,6 +49,21 @@
let disableUndo: boolean = false
let disableRedo: boolean = false
const themeChangeUnsubscribe: Array<() => void> = []
const DrawingColorPalette: ColorsList = [
['alpha', new ThemeAwareColor('#000', '#000')],
['beta', new ThemeAwareColor('#FFF', '#FFF')],
['gamma', new ThemeAwareColor('Fuchsia', 'Fuchsia')],
['delta', new ThemeAwareColor('Houseplant', 'Houseplant')],
['epsilon', new ThemeAwareColor('Sky', 'Sky')],
['zeta', new ThemeAwareColor('Turquoise', 'Turquoise')],
['eta', new ThemeAwareColor('Pink', 'Pink')],
['theta', new ThemeAwareColor('Cloud', 'Cloud')],
['iota', new ThemeAwareColor('#FFC114', '#FFC114')],
['kappa', new ThemeAwareColor('Mauve', 'Mauve')]
]
const document: YDoc = new YDoc()
const undoableCommands = document.getArray<DrawingCmd>('drawing-commands')
const commandProcessor = new DrawingCommandsProcessor(document, undoableCommands)
@@ -185,7 +197,13 @@
})
onDestroy(() => {
themeChangeUnsubscribe.forEach((unsubscribe) => {
unsubscribe()
})
themeChangeUnsubscribe.length = 0
undoableCommands.unobserve(onSavedCommandsChanged)
saveDrawing()
})
</script>
@@ -200,6 +218,11 @@
}}
use:drawing={{
autoSize: imageWidth === undefined || imageHeight === undefined,
colorsList: DrawingColorPalette,
getCurrentTheme: () => $themeStore.variant,
subscribeOnThemeChange: (callback) => {
themeChangeUnsubscribe.push(themeStore.subscribe(callback))
},
readonly,
imageWidth,
imageHeight,
@@ -225,6 +248,7 @@
{#if !readonly}
<DrawingBoardToolbar
placeInside={toolbarInside}
colorsList={DrawingColorPalette}
{cmdEditor}
bind:toolbar
bind:tool
@@ -0,0 +1,34 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { themeStore } from '@hcengineering/theme'
import { ColorMetaNameOrHex } from '../drawingUtils'
import { DrawingBoardColoringSetup, metaColorNameToHex } from '../drawingColors'
export let color: ColorMetaNameOrHex = 'alpha'
export let palette: DrawingBoardColoringSetup
</script>
<div class="colorIcon" style:background={metaColorNameToHex(color, $themeStore.variant, palette)} />
<style lang="scss">
.colorIcon {
width: 10rem;
height: 1rem;
border-radius: 10px;
box-shadow: 0px 0px 0.15rem 0px var(--theme-button-contrast-enabled);
}
</style>
@@ -27,6 +27,15 @@
showPopup
} from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
import IconEraser from './icons/Eraser.svelte'
import IconMove from './icons/Move.svelte'
import IconText from './icons/Text.svelte'
import { DrawingTool } from '../drawing'
import presentation from '../plugin'
import { ColorMetaName, ColorMetaNameOrHex } from '../drawingUtils'
import DrawingBoardToolbarColorIcon from './DrawingBoardToolbarColorIcon.svelte'
import DrawingBoardColorSelectorIcon from './DrawingBoardColorSelectorIcon.svelte'
import { ColorsList, DrawingBoardColoringSetup } from '../drawingColors'
interface DrawingBoardToolbarEvents {
undo: undefined
@@ -34,17 +43,11 @@
clear: undefined
}
import IconEraser from './icons/Eraser.svelte'
import IconMove from './icons/Move.svelte'
import IconText from './icons/Text.svelte'
import { DrawingTool } from '../drawing'
import presentation from '../plugin'
const dispatch = createEventDispatcher<DrawingBoardToolbarEvents>()
const maxColors = 8
const minColors = 0
const defaultColor = '#0000ff'
const defaultColors = ['#ff0000', '#00ff00', '#0000ff', '#ffffff', '#000000']
const defaultColor: ColorMetaName = 'alpha'
const defaultColors: Array<ColorMetaName> = ['alpha', 'gamma', 'delta', 'epsilon']
const storageKey = {
color: 'drawingBoard.color',
colors: 'drawingBoard.colors',
@@ -54,7 +57,7 @@
}
export let tool: DrawingTool = 'pen'
export let penColor: string
export let penColor: ColorMetaNameOrHex
export let penWidth: number
export let eraserWidth: number
export let fontSize: number
@@ -64,22 +67,23 @@
export let cmdEditor: HTMLDivElement | undefined
export let disableUndo: boolean = false
export let disableRedo: boolean = false
export let colorsList: ColorsList
let colorSelector: HTMLInputElement
let colorsPalette: string[] = defaultColors
const availableColors = new DrawingBoardColoringSetup(colorsList)
let userSelectedPalette: ColorMetaNameOrHex[] = defaultColors
type PaletteCommandId = 'add-color' | 'remove-color' | 'reset-colors'
function showPaletteManagementMenu (ev: MouseEvent): void {
const items: Array<Omit<SelectPopupValueType, 'id'> & { id: PaletteCommandId }> = []
if (colorsPalette.length < maxColors) {
if (userSelectedPalette.length < maxColors) {
items.push({
id: 'add-color',
label: presentation.string.ColorAdd,
icon: IconAdd
})
}
if (colorsPalette.length > minColors) {
if (userSelectedPalette.length > minColors) {
items.push({
id: 'remove-color',
label: presentation.string.ColorRemove,
@@ -91,26 +95,34 @@
label: presentation.string.ColorReset,
icon: IconRedo
})
showPopup(SelectPopup, { value: items }, eventToHTMLElement(ev), (id: PaletteCommandId | undefined) => {
switch (id) {
case 'add-color': {
if (colorSelector !== undefined) {
colorSelector.value = penColor
colorSelector.showPicker()
}
const colorsRange: Array<SelectPopupValueType> = colorsList.map((color, index) => ({
id: index,
icon: DrawingBoardColorSelectorIcon,
iconProps: { color: color[0], palette: availableColors }
}))
showPopup(SelectPopup, { value: colorsRange }, eventToHTMLElement(ev), (id) => {
if (id != null) {
penColor = colorsList[id][0]
addColorPreset()
}
})
break
}
case 'remove-color': {
colorsPalette = colorsPalette.filter((c: string) => c !== penColor)
localStorage.setItem(storageKey.colors, JSON.stringify(colorsPalette))
selectColor(colorsPalette[0])
userSelectedPalette = userSelectedPalette.filter((c: string) => c !== penColor)
localStorage.setItem(storageKey.colors, JSON.stringify(userSelectedPalette))
selectColor(userSelectedPalette[0])
focusEditor()
break
}
case 'reset-colors': {
colorsPalette = defaultColors
userSelectedPalette = defaultColors
localStorage.removeItem(storageKey.colors)
selectColor(colorsPalette[0])
selectColor(userSelectedPalette[0])
focusEditor()
break
}
@@ -127,15 +139,14 @@
}
function addColorPreset (): void {
penColor = penColor.toLowerCase()
if (!colorsPalette.includes(penColor)) {
colorsPalette = [...colorsPalette, penColor]
localStorage.setItem(storageKey.colors, JSON.stringify(colorsPalette))
if (!userSelectedPalette.includes(penColor)) {
userSelectedPalette = [...userSelectedPalette, penColor]
localStorage.setItem(storageKey.colors, JSON.stringify(userSelectedPalette))
}
focusEditor()
}
function selectColor (color: string): void {
function selectColor (color: ColorMetaNameOrHex): void {
penColor = color ?? defaultColor
localStorage.setItem(storageKey.color, penColor)
}
@@ -143,13 +154,13 @@
onMount(() => {
try {
const savedColors = localStorage.getItem(storageKey.colors)
colorsPalette = savedColors !== null ? JSON.parse(savedColors.toLowerCase()) : defaultColors
userSelectedPalette = savedColors !== null ? JSON.parse(savedColors.toLowerCase()) : defaultColors
} catch {
colorsPalette = defaultColors
userSelectedPalette = defaultColors
}
penColor = (localStorage.getItem(storageKey.color) ?? penColor ?? defaultColor).toLowerCase()
if (!colorsPalette.includes(penColor)) {
penColor = colorsPalette[0] ?? defaultColor
penColor = (localStorage.getItem(storageKey.color) ?? penColor ?? defaultColor) as ColorMetaNameOrHex
if (!userSelectedPalette.includes(penColor)) {
penColor = userSelectedPalette[0] ?? defaultColor
}
penWidth = parseInt(localStorage.getItem(storageKey.penWidth) ?? '4')
eraserWidth = parseInt(localStorage.getItem(storageKey.eraserWidth) ?? '50')
@@ -288,12 +299,11 @@
/>
<div class="divider buttons-divider" />
{/if}
{#each colorsPalette as color}
{#each userSelectedPalette as color}
<Button
kind="icon"
noFocus
selected={penColor === color}
showTooltip={{ label: presentation.string.ColorTooltip, props: { color } }}
on:click={() => {
if (tool === 'erase') {
tool = 'pen'
@@ -302,17 +312,10 @@
focusEditor()
}}
>
<div slot="content" class="colorIcon" style:background={color} />
<DrawingBoardToolbarColorIcon {color} palette={availableColors} slot="content" />
</Button>
{/each}
<div>
<input
type="color"
class="colorSelector"
bind:this={colorSelector}
bind:value={penColor}
on:change={addColorPreset}
/>
<Button
kind="icon"
icon={IconMoreH}
@@ -355,12 +358,6 @@
margin: 0 0.25rem;
}
.colorSelector {
position: absolute;
width: 0;
opacity: 0;
}
.widthSelector {
width: 80px;
}
@@ -0,0 +1,35 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { themeStore } from '@hcengineering/theme'
import { ColorMetaNameOrHex } from '../drawingUtils'
import { DrawingBoardColoringSetup, metaColorNameToHex } from '../drawingColors'
export let color: ColorMetaNameOrHex = 'alpha'
export let palette: DrawingBoardColoringSetup
</script>
<div class="colorIcon" style:background={metaColorNameToHex(color, $themeStore.variant, palette)} />
<style lang="scss">
.colorIcon {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
margin: -0.15rem;
box-shadow: 0px 0px 0.15rem 0px var(--theme-button-contrast-enabled);
}
</style>
+39 -49
View File
@@ -13,7 +13,7 @@
// limitations under the License.
//
import { generateId } from '@hcengineering/core'
import { type ThemeVariantType } from '@hcengineering/theme'
import {
type CanvasPoint,
easeInOutCubic,
@@ -28,8 +28,11 @@ import {
type MouseScaledPoint,
makeMouseScaledPoint,
makeNodePoint,
offsetCanvasPoint
offsetCanvasPoint,
type ColorMetaNameOrHex
} from './drawingUtils'
import { type DrawingCmd, type CommandUid, type DrawTextCmd, type DrawLineCmd, makeCommandUid } from './drawingCommand'
import { type ColorsList, DrawingBoardColoringSetup, metaColorNameToHex } from './drawingColors'
export interface DrawingData {
content?: string
@@ -37,13 +40,16 @@ export interface DrawingData {
export interface DrawingProps {
readonly: boolean
colorsList: ColorsList
getCurrentTheme: () => ThemeVariantType
subscribeOnThemeChange: (callback: () => void) => void
autoSize?: boolean
imageWidth?: number
imageHeight?: number
commands?: DrawingCmd[]
offset?: Point
tool?: DrawingTool
penColor?: string
penColor?: ColorMetaNameOrHex
penWidth?: number
eraserWidth?: number
fontSize?: number
@@ -63,36 +69,10 @@ export interface DrawingProps {
panned?: (offset: Point) => void
}
export type CommandUid = string & { readonly __brand: 'CommandUid' }
export interface DrawingCmd {
id: CommandUid
type: 'line' | 'text'
}
export interface DrawTextCmd extends DrawingCmd {
text: string
pos: CanvasPoint
fontSize: number
fontFace: string
color: string
}
export interface DrawLineCmd extends DrawingCmd {
lineWidth: number
erasing: boolean
penColor: string
points: CanvasPoint[]
}
export type DrawingTool = 'pen' | 'erase' | 'pan' | 'text'
const maxTextLength = 500
export const makeCommandUid = (): CommandUid => {
return (crypto?.randomUUID?.() ?? generateId()) as CommandUid
}
const crossSvg = `<svg height="8" width="8" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="m1.29 2.71 5.3 5.29-5.3 5.29c-.92.92.49 2.34 1.41 1.41l5.3-5.29 5.29 5.3c.92.92 2.34-.49 1.41-1.41l-5.29-5.3 5.3-5.29c.92-.93-.49-2.34-1.42-1.42l-5.29 5.3-5.29-5.3c-.93-.92-2.34.49-1.42 1.42z"/>
</svg>`
@@ -102,7 +82,7 @@ type PointStatus = 'last-point' | 'intermediate-point'
class DrawState {
on = false
tool: DrawingTool = 'pen'
penColor = 'blue'
penColor: ColorMetaNameOrHex = 'alpha'
penWidth = 4
eraserWidth = 30
minLineLength = 6
@@ -113,11 +93,11 @@ class DrawState {
points: CanvasPoint[] = []
scale: Point = { x: 1, y: 1 }
cssTransformScale: Point = { x: 1, y: 1 }
ctx: CanvasRenderingContext2D
constructor (ctx: CanvasRenderingContext2D) {
this.ctx = ctx
}
constructor (
readonly ctx: CanvasRenderingContext2D,
readonly colors: DrawingBoardColoringSetup
) {}
cursorWidth = (): number => {
return Math.max(8, this.tool === 'erase' ? this.eraserWidth : this.penWidth)
@@ -153,7 +133,7 @@ class DrawState {
this.ctx.translate(this.offset.x + this.center.x, this.offset.y + this.center.y)
}
drawLine = (point: MouseScaledPoint, status: PointStatus): void => {
drawLine = (point: MouseScaledPoint, status: PointStatus, currentTheme: ThemeVariantType): void => {
window.requestAnimationFrame(() => {
if (status === 'intermediate-point' || this.points.length <= 1) {
this.addPoint(point)
@@ -164,7 +144,7 @@ class DrawState {
this.translateCtx()
this.ctx.beginPath()
this.ctx.lineCap = 'round'
this.ctx.strokeStyle = this.penColor
this.ctx.strokeStyle = metaColorNameToHex(this.penColor, currentTheme, this.colors)
this.ctx.lineWidth = erasing ? this.eraserWidth : this.penWidth
this.ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over'
if (this.points.length === 1) {
@@ -179,20 +159,20 @@ class DrawState {
})
}
drawCommand = (cmd: DrawingCmd): void => {
drawCommand = (cmd: DrawingCmd, currentTheme: ThemeVariantType): void => {
if (cmd.type === 'text') {
this.drawTextCommand(cmd as DrawTextCmd)
this.drawTextCommand(cmd as DrawTextCmd, currentTheme)
} else {
this.drawLineCommand(cmd as DrawLineCmd)
this.drawLineCommand(cmd as DrawLineCmd, currentTheme)
}
}
drawLineCommand = (cmd: DrawLineCmd): void => {
drawLineCommand = (cmd: DrawLineCmd, currentTheme: ThemeVariantType): void => {
this.ctx.save()
this.translateCtx()
this.ctx.beginPath()
this.ctx.lineCap = 'round'
this.ctx.strokeStyle = cmd.penColor
this.ctx.strokeStyle = metaColorNameToHex(cmd.penColor, currentTheme, this.colors)
this.ctx.lineWidth = cmd.lineWidth
this.ctx.globalCompositeOperation = cmd.erasing ? 'destination-out' : 'source-over'
if (cmd.points.length === 1) {
@@ -207,12 +187,12 @@ class DrawState {
this.ctx.restore()
}
drawTextCommand = (cmd: DrawTextCmd): void => {
drawTextCommand = (cmd: DrawTextCmd, currentTheme: ThemeVariantType): void => {
const p = { ...cmd.pos }
this.ctx.save()
this.translateCtx()
this.ctx.font = `${cmd.fontSize}px ${cmd.fontFace}`
this.ctx.fillStyle = cmd.color
this.ctx.fillStyle = metaColorNameToHex(cmd.color, currentTheme, this.colors)
this.ctx.textBaseline = 'top'
const lines = cmd.text.split('\n').map((l) => l.trim())
for (let i = 0; i < lines.length; i++) {
@@ -318,7 +298,8 @@ export function drawing (
let personCursorPos: CanvasPoint = makeCanvasPoint(0, 0)
let isPersonCursorAnimating = false
const draw = new DrawState(ctx)
const colorsSetup = new DrawingBoardColoringSetup(props.colorsList)
const draw = new DrawState(ctx, colorsSetup)
draw.tool = props.tool ?? draw.tool
draw.penColor = props.penColor ?? draw.penColor
draw.penWidth = props.penWidth ?? draw.penWidth
@@ -353,6 +334,11 @@ export function drawing (
replayCommands(currentCommands)
props.subscribeOnThemeChange(() => {
updateToolCursor()
replayCommands(currentCommands)
})
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === canvas) {
@@ -509,7 +495,7 @@ export function drawing (
if (draw.on) {
if (Math.hypot(prevPos.x - scaledPoint.x, prevPos.y - scaledPoint.y) >= draw.minLineLength) {
draw.drawLine(scaledPoint, 'intermediate-point')
draw.drawLine(scaledPoint, 'intermediate-point', props.getCurrentTheme())
prevPos = scaledPoint
}
}
@@ -540,7 +526,7 @@ export function drawing (
const scaledPoint = rescaleWithCss(p)
if (draw.on) {
if (draw.isDrawingTool()) {
draw.drawLine(scaledPoint, 'last-point')
draw.drawLine(scaledPoint, 'last-point', props.getCurrentTheme())
storeLineCommand()
} else if (draw.tool === 'pan') {
props.panned?.(draw.offset)
@@ -816,7 +802,7 @@ export function drawing (
function updateLiveTextBox (): void {
if (liveTextBox !== undefined) {
liveTextBox.editor.style.color = draw.penColor
liveTextBox.editor.style.color = metaColorNameToHex(draw.penColor, props.getCurrentTheme(), colorsSetup)
liveTextBox.editor.style.lineHeight = `${draw.fontSize / draw.lineScale()}px`
liveTextBox.editor.style.fontSize = `${draw.fontSize / draw.lineScale()}px`
liveTextBox.editor.style.fontFamily = draw.fontFace
@@ -891,7 +877,9 @@ export function drawing (
toolCursor.style.visibility = 'visible'
const erasing = draw.tool === 'erase'
const w = draw.cursorWidth()
toolCursor.style.background = erasing ? 'none' : draw.penColor
toolCursor.style.background = erasing
? 'none'
: metaColorNameToHex(draw.penColor, props.getCurrentTheme(), colorsSetup)
toolCursor.style.boxShadow = erasing
? '0px 0px 1px 1px white inset, 0px 0px 2px 1px black'
: '0px 0px 3px 0px var(--theme-button-contrast-enabled)'
@@ -996,9 +984,11 @@ export function drawing (
*/
draw.ctx.clearRect(0, 0, canvas.width, canvas.height)
const currentTheme: ThemeVariantType = props.getCurrentTheme()
traverseCommands(drawing, (command) => {
if (command.id === undefined || liveTextBox?.cmdId !== command.id) {
draw.drawCommand(command)
draw.drawCommand(command, currentTheme)
}
return false
})
@@ -0,0 +1,64 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { ThemeVariant, type ThemeVariantType } from '@hcengineering/theme'
import { getPlatformColorByName } from '@hcengineering/ui'
import { type ColorMetaName, type ColorMetaNameOrHex } from './drawingUtils'
export class ThemeAwareColor {
constructor (
private readonly darkThemeName: string,
private readonly lightThemeName: string
) {}
materialize (target: ThemeVariantType): string {
const darkTheme = target === ThemeVariant.Dark
const platformColorName = darkTheme ? this.darkThemeName : this.lightThemeName
const colorDefinition = getPlatformColorByName(platformColorName, darkTheme)
if (colorDefinition == null) {
return platformColorName
}
return colorDefinition.color
}
}
export type ColorsList = Array<[ColorMetaName, ThemeAwareColor]>
export class DrawingBoardColoringSetup {
private readonly _colorByName: Map<ColorMetaName, ThemeAwareColor>
constructor (private readonly _allColors: ColorsList) {
this._colorByName = new Map<ColorMetaName, ThemeAwareColor>(_allColors)
}
get allColors (): ColorsList {
return this._allColors
}
colorByName (name: ColorMetaName): ThemeAwareColor | undefined {
return this._colorByName.get(name)
}
}
export function metaColorNameToHex (
color: ColorMetaNameOrHex,
theme: ThemeVariantType,
setup: DrawingBoardColoringSetup
): string {
const backend = color as string
if (backend.startsWith('#') && (backend.length === 7 || backend.length === 4)) {
return backend
}
return setup.colorByName(color as ColorMetaName)?.materialize(theme) ?? backend
}
@@ -0,0 +1,43 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { generateId } from '@hcengineering/core'
import { type ColorMetaNameOrHex, type CanvasPoint } from './drawingUtils'
export type CommandUid = string & { readonly __brand: 'CommandUid' }
export interface DrawingCmd {
id: CommandUid
type: 'line' | 'text'
}
export interface DrawTextCmd extends DrawingCmd {
text: string
pos: CanvasPoint
fontSize: number
fontFace: string
color: ColorMetaNameOrHex
}
export interface DrawLineCmd extends DrawingCmd {
lineWidth: number
erasing: boolean
penColor: ColorMetaNameOrHex
points: CanvasPoint[]
}
export const makeCommandUid = (): CommandUid => {
return (crypto?.randomUUID?.() ?? generateId()) as CommandUid
}
@@ -13,7 +13,7 @@
// limitations under the License.
//
import { makeCommandUid, type CommandUid, type DrawingCmd } from './drawing'
import { makeCommandUid, type CommandUid, type DrawingCmd } from './drawingCommand'
import { type Array as YArray, type Doc as YDoc, UndoManager as YUndoManager } from 'yjs'
export class UndoRedoAvailability {
+32
View File
@@ -13,6 +13,38 @@
// limitations under the License.
//
export type ColorMetaName =
| 'alpha'
| 'beta'
| 'gamma'
| 'delta'
| 'epsilon'
| 'zeta'
| 'eta'
| 'theta'
| 'iota'
| 'kappa'
// | 'lambda'
// | 'mu'
// | 'nu'
// | 'xi'
// | 'omicron'
// | 'pi'
// | 'rho'
// | 'sigma'
// | 'tau'
// | 'upsilon'
// | 'phi'
// | 'chi'
// | 'psi'
// | 'omega'
/*
We need to be backward compatible with user data, that already contains arbitrarily colors.
First version of the drawing board allowed selection of any RGB color.
*/
export type ColorMetaNameOrHex = (string & { readonly __brand: 'ColorMetaNameOrHex' }) | ColorMetaName
export interface Point {
x: number
y: number
+2
View File
@@ -73,6 +73,8 @@ export * from './sound'
export * from './stats'
export * from './drawing'
export * from './drawingUtils'
export * from './drawingColors'
export * from './drawingCommand'
export * from './drawingCommandsProcessor'
export * from './link-preview'
export * from './communication'
-1
View File
@@ -154,7 +154,6 @@ export default plugin(presentationId, {
EraserTool: '' as IntlString,
PanTool: '' as IntlString,
TextTool: '' as IntlString,
ColorTooltip: '' as IntlString,
PaletteManagementMenu: '' as IntlString
},
extension: {
+2 -1
View File
@@ -17,6 +17,7 @@
import platform, { loadPluginStrings, setMetadata } from '@hcengineering/platform'
import { onMount, setContext } from 'svelte'
import { writable } from 'svelte/store'
import { ThemeVariant } from './variants'
import {
ThemeOptions,
getCurrentFontSize,
@@ -35,7 +36,7 @@
themeOptions.set(new ThemeOptions(currentFont === 'normal-font' ? 16 : 14, isThemeDark(theme), language))
}
const getRealTheme = (theme: string): string => (isThemeDark(theme) ? 'theme-dark' : 'theme-light')
const getRealTheme = (theme: string): string => (isThemeDark(theme) ? ThemeVariant.Dark : ThemeVariant.Light)
const setRootColors = (theme: string, set = true) => {
currentTheme.set(theme)
if (set) {
+6 -1
View File
@@ -16,9 +16,11 @@
import { Analytics } from '@hcengineering/analytics'
import '@hcengineering/platform-rig/profiles/ui/svelte'
import { derived, writable } from 'svelte/store'
import { ThemeVariant, type ThemeVariantType } from './variants'
export { default as Theme } from './Theme.svelte'
export { default as InvertedTheme } from './InvertedTheme.svelte'
export { ThemeVariant, type ThemeVariantType } from './variants'
/**
* @public
@@ -62,11 +64,14 @@ export const getCurrentLanguage = (): string => {
}
export class ThemeOptions {
readonly variant: ThemeVariantType
constructor (
readonly fontSize: number,
readonly dark: boolean,
readonly language: string
) {}
) {
this.variant = dark ? ThemeVariant.Dark : ThemeVariant.Light
}
}
export const themeStore = writable<ThemeOptions>()
+21
View File
@@ -0,0 +1,21 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
export const ThemeVariant = {
Light: 'theme-light',
Dark: 'theme-dark'
} as const
export type ThemeVariantType = (typeof ThemeVariant)[keyof typeof ThemeVariant]
+57
View File
@@ -0,0 +1,57 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { getPlatformColorByName, getPlatformAvatarColorByName, avatarWhiteColors, avatarDarkColors } from '../colors'
describe('colors module tests', () => {
describe('getPlatformColorByName', () => {
it('get existing color, light theme', () => {
const result = getPlatformColorByName('Firework', false)
expect(result).toBeDefined()
expect(result?.name).toBe('Firework')
expect(result?.title).toBe('#C03B2F')
})
it('get existing color, dark theme', () => {
const result = getPlatformColorByName('Firework', true)
expect(result).toBeDefined()
expect(result?.name).toBe('Firework')
expect(result?.title).toBe('#FFFFFF')
})
it.each([
{ theme: 'light', darkTheme: false },
{ theme: 'dark', darkTheme: true }
])('get non-existent color ($theme theme)', ({ darkTheme }) => {
const result = getPlatformColorByName('NonExistentColor', darkTheme)
expect(result).toBeUndefined()
})
})
describe('getPlatformAvatarColorByName', () => {
it.each([
{ theme: 'light', darkTheme: false, expectedPalette: avatarWhiteColors },
{ theme: 'dark', darkTheme: true, expectedPalette: avatarDarkColors }
])('get non-existent color ($theme theme)', ({ darkTheme, expectedPalette }) => {
const result = getPlatformAvatarColorByName('NonExistentAvatarColor', darkTheme)
const firstColorFromPalette = expectedPalette[0]
expect(result).toBeDefined()
expect(result).toEqual(firstColorFromPalette)
})
})
})
+42 -5
View File
@@ -74,7 +74,7 @@ export enum PaletteColorIndexes {
Firework,
Watermelon,
Pink,
Fuschia,
Fuchsia,
Lavander,
Mauve,
Heather,
@@ -104,7 +104,7 @@ export const whitePalette = Object.freeze<ColorDefinition[]>([
define('Firework', 'D15045', 'D15045', 'C03B2F', 'C03B2F', 'C03B2F', [60, 20]),
define('Watermelon', 'DB877D', 'DB877D', 'D2685B', 'D2685B', 'D2685B', [60, 20]),
define('Pink', 'EF86AA', 'EF86AA', 'E9588A', 'E9588A', 'E9588A', [60, 20]),
define('Fuschia', 'EB5181', 'EB5181', 'E62360', 'E62360', 'E62360', [60, 20]),
define('Fuchsia', 'EB5181', 'EB5181', 'E62360', 'E62360', 'E62360', [60, 20]),
define('Lavander', 'DC85F5', 'DC85F5', 'CE55F1', 'CE55F1', 'CE55F1', [60, 20]),
define('Mauve', '925CB1', '925CB1', '784794', '784794', '784794', [60, 20]),
define('Heather', '7B86C6', '7B86C6', '5866B7', '5866B7', '5866B7', [60, 20]),
@@ -134,7 +134,7 @@ export const darkPalette = Object.freeze<ColorDefinition[]>([
define('Firework', 'D15045', 'D15045', 'FFFFFF', 'FFFFFF', 'C03B2F', [60, 15, 0], true),
define('Watermelon', 'DB877D', 'DB877D', 'FFFFFF', 'FFFFFF', 'D2685B', [60, 15, 0], true),
define('Pink', 'EF86AA', 'EF86AA', 'FFFFFF', 'FFFFFF', 'E9588A', [60, 15, 0], true),
define('Fuschia', 'EB5181', 'EB5181', 'FFFFFF', 'FFFFFF', 'E62360', [60, 15, 0], true),
define('Fuchsia', 'EB5181', 'EB5181', 'FFFFFF', 'FFFFFF', 'E62360', [60, 15, 0], true),
define('Lavander', 'DC85F5', 'DC85F5', 'FFFFFF', 'FFFFFF', 'CE55F1', [60, 15, 0], true),
define('Mauve', '925CB1', '925CB1', 'FFFFFF', 'FFFFFF', '784794', [60, 15, 0], true),
define('Heather', '7B86C6', '7B86C6', 'FFFFFF', 'FFFFFF', '5866B7', [60, 15, 0], true),
@@ -272,8 +272,45 @@ export function getPlatformAvatarColorForTextDef (text: string, darkTheme: boole
* @public
*/
export function getPlatformAvatarColorByName (name: string, darkTheme: boolean): ColorDefinition {
const palette = darkTheme ? avatarDarkColors : avatarWhiteColors
return palette.find((col) => col.name === name) ?? palette[0]
const defaultIndex = 0
return getColorByName(name, darkTheme, avatarWhiteColors, avatarDarkColors, defaultIndex)
}
/**
* @public
*/
export function getPlatformColorByName (name: string, darkTheme: boolean): ColorDefinition | undefined {
const defaultIndex: number | undefined = undefined
return getColorByName(name, darkTheme, whitePalette, darkPalette, defaultIndex)
}
function getColorByName (
name: string,
darkTheme: boolean,
paletteLight: readonly ColorDefinition[],
paletteDark: readonly ColorDefinition[],
defaultIndex: number
): ColorDefinition
function getColorByName (
name: string,
darkTheme: boolean,
paletteLight: readonly ColorDefinition[],
paletteDark: readonly ColorDefinition[],
defaultIndex: undefined
): ColorDefinition | undefined
function getColorByName (
name: string,
darkTheme: boolean,
paletteLight: readonly ColorDefinition[],
paletteDark: readonly ColorDefinition[],
defaultIndex: number | undefined
): ColorDefinition | undefined {
const targetPalette = darkTheme ? paletteDark : paletteLight
const found = targetPalette.find((col) => col.name === name)
if (found != null) {
return found
}
return defaultIndex != null ? targetPalette[defaultIndex] : undefined
}
/**
@@ -22,13 +22,17 @@
drawing,
CommandUid,
Point,
DrawingCommandsProcessor
DrawingCommandsProcessor,
ThemeAwareColor,
ColorsList,
ColorMetaNameOrHex
} from '@hcengineering/presentation'
import presence from '@hcengineering/presence'
import { getResource } from '@hcengineering/platform'
import { Loading, Component } from '@hcengineering/ui'
import { Loading, Component, themeStore } from '@hcengineering/ui'
import { onMount, onDestroy } from 'svelte'
import { Array as YArray, Map as YMap, Doc as YDoc } from 'yjs'
import { get } from 'svelte/store'
export let boardId: string
export let document: YDoc
@@ -43,7 +47,7 @@
export let fullSize = false
let tool: DrawingTool
let penColor: string
let penColor: ColorMetaNameOrHex
let penWidth: number
let eraserWidth: number
let fontSize: number
@@ -61,6 +65,21 @@
let getFollowee: (() => Promise<Person | undefined>) | undefined
let panning = false
let followee: Person | undefined
const themeChangeUnsubscribe: Array<() => void> = []
const DrawingColorPalette: ColorsList = [
['alpha', new ThemeAwareColor('#FFF', '#000')],
['gamma', new ThemeAwareColor('Fuchsia', 'Fuchsia')],
['delta', new ThemeAwareColor('Houseplant', 'Houseplant')],
['epsilon', new ThemeAwareColor('Sky', 'Waterway')],
['zeta', new ThemeAwareColor('Turquoise', 'Ocean')],
['eta', new ThemeAwareColor('Pink', 'Firework')],
['theta', new ThemeAwareColor('Cloud', 'Porpoise')],
['iota', new ThemeAwareColor('#705201', '#FFC114')],
['kappa', new ThemeAwareColor('Lavander', 'Mauve')]
]
const dataTopicOffset = 'drawing-board-offset'
const dataTopicCursor = 'drawing-board-cursor'
@@ -190,6 +209,11 @@
})
onDestroy(() => {
themeChangeUnsubscribe.forEach((unsubscribe) => {
unsubscribe()
})
themeChangeUnsubscribe.length = 0
savedCmds.unobserve(onSavedCommandsChanged)
getResource(presence.function.FolloweeDataUnsubscribe)
@@ -223,7 +247,12 @@
style:flex-grow={resizeable ? undefined : '1'}
style:height={resizeable ? `${height}px` : undefined}
use:drawing={{
colorsList: DrawingColorPalette,
readonly,
getCurrentTheme: () => $themeStore.variant,
subscribeOnThemeChange: (callback) => {
themeChangeUnsubscribe.push(themeStore.subscribe(callback))
},
autoSize: true,
commands: model,
offset,
@@ -271,6 +300,7 @@
<DrawingBoardToolbar
placeInside={true}
showPanTool={true}
colorsList={DrawingColorPalette}
{cmdEditor}
{disableUndo}
{disableRedo}