fix: properly handle single emoji as project icon

This commit is contained in:
Harshita 2025-09-06 06:00:52 +05:30
parent d35b1ef491
commit 3696d62fba
19 changed files with 575 additions and 37 deletions

View file

@ -1,4 +1,4 @@
import * as windowStateKeeper from 'electron-window-state';
import windowStateKeeper from 'electron-window-state';
import {
App,
BrowserWindow,
@ -159,7 +159,12 @@ export const createWindow = ({
} else {
log('Loading custom styles from ' + CSS_FILE_PATH);
const styles = readFileSync(CSS_FILE_PATH, { encoding: 'utf8' });
mainWin.webContents.insertCSS(styles).then(log).catch(error);
try {
mainWin.webContents.insertCSS(styles);
log('Custom styles loaded successfully');
} catch (cssError) {
error('Failed to load custom styles:', cssError);
}
}
});
});
@ -329,8 +334,8 @@ const appCloseHandler = (app: App): void => {
mainWin.on('closed', () => {
// Dereference the window object
mainWin = null;
mainWinModule.win = null;
mainWin = null as any;
mainWinModule.win = undefined;
});
mainWin.webContents.on('render-process-gone', (event, detailed) => {
@ -358,7 +363,7 @@ const appMinimizeHandler = (app: App): void => {
// For regular minimize (not to tray), also show overlay
showOverlayWindow();
if (IS_MAC) {
app.dock.show();
app.dock?.show();
}
}
});
@ -366,26 +371,37 @@ const appMinimizeHandler = (app: App): void => {
}
};
const upsertKeyValue = <T>(obj: T, keyToChange: string, value: string[]): T => {
const upsertKeyValue = <T extends Record<string, any> | undefined>(
obj: T,
keyToChange: string,
value: string[],
): T => {
if (!obj) return obj;
const keyToChangeLower = keyToChange.toLowerCase();
for (const key of Object.keys(obj)) {
if (key.toLowerCase() === keyToChangeLower) {
// Reassign old key
obj[key] = value;
(obj as any)[key] = value;
// Done
return;
return obj;
}
}
// Insert at end instead
obj[keyToChange] = value;
(obj as any)[keyToChange] = value;
return obj;
};
const removeKeyInAnyCase = <T>(obj: T, keyToRemove: string): T => {
const removeKeyInAnyCase = <T extends Record<string, any> | undefined>(
obj: T,
keyToRemove: string,
): T => {
if (!obj) return obj;
const keyToRemoveLower = keyToRemove.toLowerCase();
for (const key of Object.keys(obj)) {
if (key.toLowerCase() === keyToRemoveLower) {
delete obj[key];
return;
delete (obj as any)[key];
return obj;
}
}
return obj;
};

View file

@ -10,7 +10,8 @@
"skipLibCheck": true,
"typeRoots": ["node_modules/@types"],
"downlevelIteration": true,
"lib": ["dom"]
"lib": ["dom"],
"esModuleInterop": true
},
"include": ["main.ts", "**/*.ts"],
"exclude": ["../node_modules", "**/*.spec.ts"]

11
package-lock.json generated
View file

@ -66,6 +66,7 @@
"@ngx-translate/http-loader": "^17.0.0",
"@playwright/test": "^1.54.1",
"@schematics/angular": "^20.1.4",
"@types/electron": "^1.4.38",
"@types/electron-localshortcut": "^3.1.3",
"@types/file-saver": "^2.0.5",
"@types/jasmine": "^3.10.2",
@ -9410,6 +9411,16 @@
"@types/ms": "*"
}
},
"node_modules/@types/electron": {
"version": "1.4.38",
"resolved": "https://registry.npmjs.org/@types/electron/-/electron-1.4.38.tgz",
"integrity": "sha512-Cu6laqBamT6VSPi0LLlF9vE9Os8EbTaI/5eJSsd7CPoLUG3Znjh04u9TxMhQYPF1wGFM14Z8TFQ2914JZ+rGLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/electron-localshortcut": {
"version": "3.1.3",
"dev": true,

View file

@ -185,6 +185,7 @@
"@ngx-translate/http-loader": "^17.0.0",
"@playwright/test": "^1.54.1",
"@schematics/angular": "^20.1.4",
"@types/electron": "^1.4.38",
"@types/electron-localshortcut": "^3.1.3",
"@types/file-saver": "^2.0.5",
"@types/jasmine": "^3.10.2",

View file

@ -0,0 +1,49 @@
<div
class="planner-drag-place-holder-shared"
*cdkDragPlaceholder
></div>
<div
[style.background]="workContext().theme.primary"
class="color-bar"
></div>
<button
#routeBtn
[routerLink]="[type() === 'TAG' ? 'tag' : 'project', workContext().id, 'tasks']"
routerLinkActive="isActiveRoute"
mat-menu-item
>
<span class="badge">{{ nrOfOpenTasks() }}</span>
@if (isEmojiIcon()) {
<span class="drag-handle nav-icon-emoji">{{
workContext().icon || defaultIcon()
}}</span>
} @else {
<mat-icon class="drag-handle">{{ workContext().icon || defaultIcon() }}</mat-icon>
}
<span class="text">{{ workContext().title }}</span>
</button>
<!--[matMenuTriggerFor]="contextMenuEl"-->
<button
#settingsBtn
class="additional-btn"
mat-icon-button
>
<mat-icon>more_vert</mat-icon>
</button>
<context-menu
[contextMenu]="contextMenu"
[rightClickTriggerEl]="routeBtn"
[leftClickTriggerEl]="settingsBtn"
></context-menu>
<ng-template #contextMenu>
<work-context-menu
[contextId]="workContext().id"
[contextType]="type()"
></work-context-menu
></ng-template>

View file

@ -0,0 +1,119 @@
@use '../../../../styles/_globals.scss' as *;
:host {
button .badge {
display: none;
}
// Ensure Material icons in side nav have consistent 8px margin
button mat-icon {
margin-right: 8px !important;
}
&.hasTasks {
button .badge {
display: block;
z-index: 10;
position: absolute;
line-height: 1;
right: 100%;
text-align: center;
bottom: 6px;
font-size: 10px;
min-width: 18px;
padding: 0 4px 0;
border: 1px solid var(--extra-border-color);
border-radius: 12px;
margin-right: -49px;
// avoid affecting drag handle
pointer-events: none;
border-color: var(--extra-border-color);
background: var(--bg-lighter);
}
}
&.isHidden {
display: none !important;
}
// color bar left styles
.color-bar,
&.isActiveRoute:before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--s-half);
background-color: var(--c-primary);
}
.color-bar {
opacity: 0;
}
&.isActiveContext,
&:focus,
&:hover {
.color-bar {
opacity: 1;
}
}
}
:host-context(.cdk-drop-list-dragging) {
.color-bar {
opacity: 0 !important;
}
}
:host.isActiveContext button {
font-weight: normal;
&.isActiveRoute {
font-weight: bold;
color: var(--palette-primary-800);
mat-icon {
color: var(--palette-primary-800);
}
}
}
body.isDarkTheme :host.isActiveContext button.isActiveRoute {
color: var(--c-primary);
mat-icon {
color: var(--c-primary);
}
}
.drag-handle {
/* Firefox 1.5-26 */
position: relative;
@include grabCursor();
&:after {
content: '';
position: absolute;
top: calc(-1 * var(--s));
left: calc(-1 * var(--s));
right: calc(-1 * var(--s));
bottom: calc(-1 * var(--s));
}
}
.nav-icon-emoji {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
line-height: 1;
text-align: center;
vertical-align: middle;
width: 24px;
height: 24px;
margin-right: 8px;
}

View file

@ -0,0 +1,88 @@
import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
HostBinding,
inject,
input,
viewChild,
} from '@angular/core';
import { RouterLink, RouterModule } from '@angular/router';
import {
WorkContextCommon,
WorkContextType,
} from '../../../features/work-context/work-context.model';
import { Project } from '../../../features/project/project.model';
import { WorkContextMenuComponent } from '../../work-context-menu/work-context-menu.component';
import { ContextMenuComponent } from '../../../ui/context-menu/context-menu.component';
import { CdkDragPlaceholder } from '@angular/cdk/drag-drop';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuItem } from '@angular/material/menu';
import { toSignal } from '@angular/core/rxjs-interop';
import { selectAllDoneIds } from '../../../features/tasks/store/task.selectors';
import { Store } from '@ngrx/store';
import { isEmoji } from '../../../util/is-emoji';
@Component({
selector: 'side-nav-item',
imports: [
RouterLink,
RouterModule,
WorkContextMenuComponent,
ContextMenuComponent,
CdkDragPlaceholder,
MatIconButton,
MatIcon,
MatMenuItem,
],
templateUrl: './side-nav-item.component.html',
styleUrl: './side-nav-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'g-multi-btn-wrapper' },
standalone: true,
})
export class SideNavItemComponent {
private readonly _store = inject(Store);
workContext = input.required<WorkContextCommon>();
type = input.required<WorkContextType>();
defaultIcon = input.required<string>();
activeWorkContextId = input.required<string>();
allUndoneTaskIds = toSignal(this._store.select(selectAllDoneIds), { initialValue: [] });
nrOfOpenTasks = computed<number>(() => {
// const allUndoneTasks
const allUndoneTaskIds = this.allUndoneTaskIds();
return this.workContext().taskIds.filter((tid) => !allUndoneTaskIds.includes(tid))
.length;
});
readonly routeBtn = viewChild.required('routeBtn', { read: ElementRef });
@HostBinding('class.hasTasks')
get workContextHasTasks(): boolean {
return this.workContext().taskIds.length > 0;
}
@HostBinding('class.isActiveContext')
get isActiveContext(): boolean {
return this.workContext().id === this.activeWorkContextId();
}
@HostBinding('class.isHidden')
get isHidden(): boolean {
return !!(this.workContext() as Project)?.isHiddenFromMenu;
}
isEmojiIcon = computed<boolean>(() => {
const icon = this.workContext().icon || this.defaultIcon();
return isEmoji(icon);
});
focus(): void {
this.routeBtn().nativeElement.focus();
}
}

View file

@ -5,6 +5,7 @@
<input
[ngModel]="formControl.value"
(ngModelChange)="onInputValueChange($event)"
(paste)="onPaste($event)"
[formlyAttributes]="field"
[matAutocomplete]="auto"
matInput

View file

@ -9,6 +9,7 @@ import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autoc
import { MatOption } from '@angular/material/core';
import { IS_ELECTRON } from '../../../app.constants';
import { MatTooltip } from '@angular/material/tooltip';
import { extractFirstEmoji, isSingleEmoji } from '../../../util/extract-first-emoji';
@Component({
selector: 'icon-input',
@ -49,22 +50,33 @@ export class IconInputComponent extends FieldType<FormlyFieldConfig> {
arr.length = Math.min(150, arr.length);
this.filteredIcons.set(arr);
const isEmoji = /\p{Emoji}/u.test(val);
console.log(1, { isEmoji });
const hasEmoji = /\p{Emoji}/u.test(val);
if (isEmoji) {
this.formControl.setValue(val);
if (hasEmoji) {
const firstEmoji = extractFirstEmoji(val);
if (firstEmoji && isSingleEmoji(firstEmoji)) {
this.formControl.setValue(firstEmoji);
this.isEmoji.set(true);
} else if (firstEmoji) {
this.formControl.setValue(firstEmoji);
this.isEmoji.set(true);
} else {
this.formControl.setValue('');
this.isEmoji.set(false);
}
} else if (!val) {
this.formControl.setValue('');
this.isEmoji.set(false);
} else {
this.isEmoji.set(false);
}
this.isEmoji.set(isEmoji && !this.filteredIcons().includes(val));
}
onIconSelect(icon: string): void {
this.formControl.setValue(icon);
const isEmoji = /\p{Emoji}/u.test(icon);
console.log(2, { isEmoji });
this.isEmoji.set(false);
this.isEmoji.set(isEmoji && !this.filteredIcons().includes(icon));
}
openEmojiPicker(): void {
@ -73,6 +85,21 @@ export class IconInputComponent extends FieldType<FormlyFieldConfig> {
}
}
onPaste(event: ClipboardEvent): void {
event.preventDefault();
const pastedText = event.clipboardData?.getData('text') || '';
if (pastedText) {
const firstEmoji = extractFirstEmoji(pastedText);
if (firstEmoji) {
this.formControl.setValue(firstEmoji);
this.isEmoji.set(true);
}
}
}
// onKeyDown(ev: KeyboardEvent): void {
// if (ev.key === 'Enter') {
// const ico = (ev as any)?.target?.value;

View file

@ -1,9 +1,17 @@
@if (tag().icon) {
<mat-icon
[style.color]="color()"
class="tag-ico"
>{{ tag().icon }}
</mat-icon>
@if (isEmojiIcon()) {
<span
[style.color]="color()"
class="tag-ico tag-ico-emoji"
>{{ tag().icon }}
</span>
} @else {
<mat-icon
[style.color]="color()"
class="tag-ico"
>{{ tag().icon }}
</mat-icon>
}
} @else {
<div
class="circle"

View file

@ -47,6 +47,19 @@ $ico-size: 13px;
width: $ico-size;
}
.tag-ico-emoji {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
line-height: 1;
text-align: center;
vertical-align: middle;
width: $ico-size;
height: $ico-size;
margin-right: 3px !important;
}
$circle-size: 9px;
.circle {
font-size: $circle-size;

View file

@ -6,6 +6,7 @@ import {
Signal,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { isEmoji } from '../../../util/is-emoji';
export interface TagComponentTag {
title: string;
@ -35,4 +36,9 @@ export class TagComponent {
const currentTag = this.tag();
return currentTag.color || (currentTag.theme && currentTag.theme.primary);
});
isEmojiIcon: Signal<boolean> = computed(() => {
const currentTag = this.tag();
return currentTag.icon ? isEmoji(currentTag.icon) : false;
});
}

View file

@ -9,9 +9,18 @@
[class.menu-open]="isProjectMenuOpen()"
(click)="onProjectMenuClick()"
>
<mat-icon [style.color]="selectedProject()?.theme?.primary">
{{ selectedProject()?.icon || 'folder' }}
</mat-icon>
@if (isProjectEmojiIcon()) {
<span
class="project-icon-emoji"
[style.color]="selectedProject()?.theme?.primary"
>
{{ selectedProject()?.icon || 'folder' }}
</span>
} @else {
<mat-icon [style.color]="selectedProject()?.theme?.primary">
{{ selectedProject()?.icon || 'folder' }}
</mat-icon>
}
<span>{{ selectedProject()?.title }}</span>
</button>
@ -129,9 +138,18 @@
mat-menu-item
(click)="stateService.updateProjectId(project.id)"
>
<mat-icon [style.color]="project.theme.primary">
{{ project.icon || 'folder' }}
</mat-icon>
@if (isTagEmojiIcon(project)) {
<span
class="menu-icon-emoji"
[style.color]="project.theme.primary"
>
{{ project.icon || 'folder' }}
</span>
} @else {
<mat-icon [style.color]="project.theme.primary">
{{ project.icon || 'folder' }}
</mat-icon>
}
{{ project.title }}
</button>
}
@ -149,11 +167,19 @@
} @else {
<mat-icon class="check-ico">check_box_outline_blank</mat-icon>
}
<mat-icon
[style.color]="tag.color || tag.theme.primary"
class="tag-ico"
>{{ tag.icon || 'label' }}
</mat-icon>
@if (isTagEmojiIcon(tag)) {
<span
[style.color]="tag.color || tag.theme.primary"
class="tag-ico tag-ico-emoji"
>{{ tag.icon || 'label' }}
</span>
} @else {
<mat-icon
[style.color]="tag.color || tag.theme.primary"
class="tag-ico"
>{{ tag.icon || 'label' }}
</mat-icon>
}
{{ tag.title }}
</button>
}

View file

@ -36,6 +36,39 @@
}
}
.tag-ico-emoji {
display: inline-block;
font-size: 12px;
line-height: 1;
text-align: center;
vertical-align: middle;
width: 16px;
height: 16px;
margin-right: 4px;
}
.project-icon-emoji {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 1;
text-align: center;
vertical-align: middle;
margin-right: 8px;
}
.menu-icon-emoji {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 1;
text-align: center;
vertical-align: middle;
margin-right: 8px;
}
.action-bar {
position: relative;
padding-top: 0;

View file

@ -28,6 +28,7 @@ import { T } from '../../../../t.const';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { dateStrToUtcDate } from '../../../../util/date-str-to-utc-date';
import { getDbDateStr } from '../../../../util/get-db-date-str';
import { isEmoji } from '../../../../util/is-emoji';
@Component({
selector: 'add-task-bar-actions',
@ -127,6 +128,19 @@ export class AddTaskBarActionsComponent {
return estimate ? msToString(estimate) : null;
});
// Emoji detection for project icons
isProjectEmojiIcon = computed(() => {
const project = this.selectedProject();
const icon = project?.icon || 'folder';
return isEmoji(icon);
});
// Emoji detection for tag icons
isTagEmojiIcon(tag: any): boolean {
const icon = tag?.icon || 'label';
return isEmoji(icon);
}
openScheduleDialog(): void {
const state = this.state();
const dialogRef = this._matDialog.open(DialogScheduleTaskComponent, {

View file

@ -0,0 +1,66 @@
import { extractFirstEmoji, isSingleEmoji } from './extract-first-emoji';
describe('extractFirstEmoji', () => {
it('should extract the first emoji from a string with multiple emojis', () => {
expect(extractFirstEmoji('😀🚀✅')).toBe('😀');
expect(extractFirstEmoji('🎉🎊🎈')).toBe('🎉');
expect(extractFirstEmoji('❤️💙💚')).toBe('❤️');
});
it('should extract the first emoji from a string with emojis and text', () => {
expect(extractFirstEmoji('Hello 😀 world')).toBe('😀');
expect(extractFirstEmoji('🚀 Rocket ship')).toBe('🚀');
expect(extractFirstEmoji('Task ✅ completed')).toBe('✅');
});
it('should return empty string for strings without emojis', () => {
expect(extractFirstEmoji('Hello world')).toBe('');
expect(extractFirstEmoji('123')).toBe('');
expect(extractFirstEmoji('')).toBe('');
});
it('should handle edge cases', () => {
expect(extractFirstEmoji(' ')).toBe('');
expect(extractFirstEmoji('😀')).toBe('😀');
expect(extractFirstEmoji('😀 ')).toBe('😀');
});
it('should handle emojis with skin tone modifiers', () => {
expect(extractFirstEmoji('👍🏻👍🏿')).toBe('👍🏻');
expect(extractFirstEmoji('👋🏽 Hello')).toBe('👋🏽');
});
});
describe('isSingleEmoji', () => {
it('should return true for single emojis', () => {
expect(isSingleEmoji('😀')).toBe(true);
expect(isSingleEmoji('🚀')).toBe(true);
expect(isSingleEmoji('✅')).toBe(true);
});
it('should return true for emojis with skin tone modifiers', () => {
expect(isSingleEmoji('👍🏻')).toBe(true);
expect(isSingleEmoji('👋🏽')).toBe(true);
});
it('should return false for multiple emojis', () => {
expect(isSingleEmoji('😀🚀')).toBe(false);
expect(isSingleEmoji('🎉🎊')).toBe(false);
});
it('should return false for emojis with text', () => {
expect(isSingleEmoji('😀 Hello')).toBe(false);
expect(isSingleEmoji('Hello 😀')).toBe(false);
});
it('should return false for non-emoji strings', () => {
expect(isSingleEmoji('Hello')).toBe(false);
expect(isSingleEmoji('123')).toBe(false);
expect(isSingleEmoji('')).toBe(false);
});
it('should handle edge cases', () => {
expect(isSingleEmoji(' ')).toBe(false);
expect(isSingleEmoji('😀 ')).toBe(true); // Trimming removes the space
});
});

View file

@ -0,0 +1,32 @@
export const extractFirstEmoji = (str: string): string => {
if (!str || typeof str !== 'string') {
return '';
}
const trimmed = str.trim();
if (trimmed.length === 0) {
return '';
}
const emojiRegex =
/(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F018}-\u{1F0FF}]|[\u{1F200}-\u{1F2FF}]|[\u{1FA70}-\u{1FAFF}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F170}-\u{1F251}])(?:\u{1F3FB}|\u{1F3FC}|\u{1F3FD}|\u{1F3FE}|\u{1F3FF}|\uFE0F)?/u;
const emojiMatch = trimmed.match(emojiRegex);
return emojiMatch ? emojiMatch[0] : '';
};
export const isSingleEmoji = (str: string): boolean => {
if (!str || typeof str !== 'string') {
return false;
}
const trimmed = str.trim();
if (trimmed.length === 0) {
return false;
}
const emojiRegex =
/^(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F018}-\u{1F0FF}]|[\u{1F200}-\u{1F2FF}]|[\u{1FA70}-\u{1FAFF}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F170}-\u{1F251}])(?:\u{1F3FB}|\u{1F3FC}|\u{1F3FD}|\u{1F3FE}|\u{1F3FF}|\uFE0F)?$/u;
return emojiRegex.test(trimmed);
};

27
src/app/util/is-emoji.ts Normal file
View file

@ -0,0 +1,27 @@
/**
* Utility function to detect if a string is an emoji
* @param str - The string to check
* @returns true if the string is an emoji, false otherwise
*/
export const isEmoji = (str: string): boolean => {
if (!str || typeof str !== 'string') {
return false;
}
// Remove whitespace and check if it's a single character
const trimmed = str.trim();
if (trimmed.length === 0) {
return false;
}
// Check if it's a single emoji character
// This regex matches most emoji characters including:
// - Basic emojis (😀, 🚀, ✅, etc.)
// - Emojis with skin tone modifiers
// - Flag emojis
// - Other Unicode emoji ranges
const emojiRegex =
/^[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F018}-\u{1F0FF}]|[\u{1F200}-\u{1F2FF}]|[\u{1FA70}-\u{1FAFF}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F170}-\u{1F251}]$/u;
return emojiRegex.test(trimmed);
};

View file

@ -42,7 +42,7 @@
},
{
"resourceType": "total",
"budget": 135
"budget": 150
}
]
}