mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
fix: properly handle single emoji as project icon
This commit is contained in:
parent
d35b1ef491
commit
3696d62fba
19 changed files with 575 additions and 37 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
11
package-lock.json
generated
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
<input
|
||||
[ngModel]="formControl.value"
|
||||
(ngModelChange)="onInputValueChange($event)"
|
||||
(paste)="onPaste($event)"
|
||||
[formlyAttributes]="field"
|
||||
[matAutocomplete]="auto"
|
||||
matInput
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
66
src/app/util/extract-first-emoji.spec.ts
Normal file
66
src/app/util/extract-first-emoji.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
32
src/app/util/extract-first-emoji.ts
Normal file
32
src/app/util/extract-first-emoji.ts
Normal 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
27
src/app/util/is-emoji.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
},
|
||||
{
|
||||
"resourceType": "total",
|
||||
"budget": 135
|
||||
"budget": 150
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue