diff --git a/apps/picsa-tools/budget-tool/src/app/components/budget-tool.components.ts b/apps/picsa-tools/budget-tool/src/app/components/budget-tool.components.ts index ac3a9a20e..8720e7590 100644 --- a/apps/picsa-tools/budget-tool/src/app/components/budget-tool.components.ts +++ b/apps/picsa-tools/budget-tool/src/app/components/budget-tool.components.ts @@ -6,7 +6,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { PicsaCommonComponentsModule } from '@picsa/components'; -import { PicsaDialogsModule } from '@picsa/shared/features'; +import { PicsaDialogsModule, PicsaDrawingComponent } from '@picsa/shared/features'; import { PicsaDbModule } from '@picsa/shared/modules'; import { MobxAngularModule } from 'mobx-angular'; @@ -71,6 +71,7 @@ const components = [ RouterModule, MatTooltipModule, MatIconModule, + PicsaDrawingComponent, ], exports: components, }) diff --git a/apps/picsa-tools/budget-tool/src/app/components/card/card-image/budget-card-image.ts b/apps/picsa-tools/budget-tool/src/app/components/card/card-image/budget-card-image.ts index 911a08742..d8deafd12 100644 --- a/apps/picsa-tools/budget-tool/src/app/components/card/card-image/budget-card-image.ts +++ b/apps/picsa-tools/budget-tool/src/app/components/card/card-image/budget-card-image.ts @@ -61,17 +61,15 @@ export class BudgetCardImageComponent implements OnInit, OnDestroy { // so convert to html that embeds within an tag and div innerhtml private convertSVGToImageData(svgTag: string) { const encodedSVG = this._encodeSVG(svgTag); - const Html = ``; + const Html = ``; return this.sanitizer.bypassSecurityTrustHtml(Html); } // method taken from http://yoksel.github.io/url-encoder/ // applies selective replacement of uri characters private _encodeSVG(data: string): string { - const symbols = /[\r\n%#()<>?[\\\]^`{|}]/g; - data = data.replace(/"/g, "'"); - data = data.replace(/>\s{1,}<'); - data = data.replace(/\s{2,}/g, ' '); - return data.replace(symbols, encodeURIComponent); + // ignore already encoded + if (data.startsWith('data:')) return data; + return `data:image/svg+xml;base64,${btoa(data)}`; } } diff --git a/apps/picsa-tools/budget-tool/src/app/components/card/card-new/card-new-dialog.html b/apps/picsa-tools/budget-tool/src/app/components/card/card-new/card-new-dialog.html index bbf23d05b..9cb9ef017 100644 --- a/apps/picsa-tools/budget-tool/src/app/components/card/card-new/card-new-dialog.html +++ b/apps/picsa-tools/budget-tool/src/app/components/card/card-new/card-new-dialog.html @@ -1,10 +1,13 @@ - + + + + + {{'Card Label' | translate}} - diff --git a/apps/picsa-tools/budget-tool/src/app/components/card/card-new/card-new-dialog.scss b/apps/picsa-tools/budget-tool/src/app/components/card/card-new/card-new-dialog.scss index c79cc9662..40522116a 100644 --- a/apps/picsa-tools/budget-tool/src/app/components/card/card-new/card-new-dialog.scss +++ b/apps/picsa-tools/budget-tool/src/app/components/card/card-new/card-new-dialog.scss @@ -11,3 +11,7 @@ background: #dcdcdc; overflow: auto; } +.label-input { + width: 100%; + margin-top: 1rem; +} diff --git a/apps/picsa-tools/budget-tool/src/app/components/card/card-new/card-new-dialog.ts b/apps/picsa-tools/budget-tool/src/app/components/card/card-new/card-new-dialog.ts index 38b25b280..09a587430 100644 --- a/apps/picsa-tools/budget-tool/src/app/components/card/card-new/card-new-dialog.ts +++ b/apps/picsa-tools/budget-tool/src/app/components/card/card-new/card-new-dialog.ts @@ -9,15 +9,24 @@ import { IBudgetCard } from '../../../schema'; templateUrl: './card-new-dialog.html', styleUrls: ['./card-new-dialog.scss'], }) +// eslint-disable-next-line @angular-eslint/component-class-suffix export class BudgetCardNewDialog { public card: IBudgetCard; constructor(public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) card: IBudgetCard) { this.card = card; } + + public imgData: string; + + public setBudgetDrawing(svgData: string) { + if (svgData) { + this.imgData = `data:image/svg+xml;base64,${btoa(svgData)}`; + } + } save() { - this.card.id = this.card.label.replace(/\s+/g, '-').toLowerCase(); + this.card.id = `custom_${this.card.label.replace(/\s+/g, '-').toLowerCase()}`; this.card.customMeta = { - imgData: this.generateImage(this.card.label), + imgData: this.imgData, dateCreated: new Date().toISOString(), createdBy: '', }; @@ -26,7 +35,7 @@ export class BudgetCardNewDialog { // return an svg circle with text in the middle // text is either first 2 initials (if multiple words) or first 2 letters (if one word) - generateImage(text: string) { + private generateImage(text: string) { const byWord = text.split(' '); const abbr = byWord.length > 1 ? `${byWord[0].charAt(0)}.${byWord[1].charAt(0)}` : text.substring(0, 2); return ` { - let component: DataTableComponent; - let fixture: ComponentFixture; +import { PicsaDataTableComponent } from './data-table.component'; + +describe('PicsaDataTableComponent', () => { + let component: PicsaDataTableComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [DataTableComponent], + imports: [PicsaDataTableComponent], }).compileComponents(); - fixture = TestBed.createComponent(DataTableComponent); + fixture = TestBed.createComponent(PicsaDataTableComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/libs/shared/src/features/drawing/drawing.component.html b/libs/shared/src/features/drawing/drawing.component.html new file mode 100644 index 000000000..8f34cec02 --- /dev/null +++ b/libs/shared/src/features/drawing/drawing.component.html @@ -0,0 +1,39 @@ +
+
+ + + +
+
+ + + @for(segment of segments; track segment.id){ + + } + + + @if(activeSegment.path()){ + + } + + + @if(segments.length===0 && !activeSegment.path()){ + + + } +
+
diff --git a/libs/shared/src/features/drawing/drawing.component.scss b/libs/shared/src/features/drawing/drawing.component.scss new file mode 100644 index 000000000..76a6b4933 --- /dev/null +++ b/libs/shared/src/features/drawing/drawing.component.scss @@ -0,0 +1,28 @@ +.dialog-container { + text-align: center; +} +.svg-container { + border: 1px solid #eeeeee; + border-radius: 4px; + box-sizing: border-box; + position: relative; + overflow: hidden; +} +.draw-logo { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--color-light); + mat-icon { + font-size: 48px; + height: 48px; + width: 48px; + } +} + +.edit-buttons { + display: flex; + gap: 8px; + margin-bottom: 8px; +} diff --git a/libs/shared/src/features/drawing/drawing.component.spec.ts b/libs/shared/src/features/drawing/drawing.component.spec.ts new file mode 100644 index 000000000..94eb7c7f4 --- /dev/null +++ b/libs/shared/src/features/drawing/drawing.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PicsaDrawingComponent } from './drawing.component'; + +describe('DrawingComponent', () => { + let component: PicsaDrawingComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PicsaDrawingComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PicsaDrawingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/shared/src/features/drawing/drawing.component.ts b/libs/shared/src/features/drawing/drawing.component.ts new file mode 100644 index 000000000..d4380f6db --- /dev/null +++ b/libs/shared/src/features/drawing/drawing.component.ts @@ -0,0 +1,227 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + input, + output, + signal, + ViewChild, + WritableSignal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { getStroke, StrokeOptions } from 'perfect-freehand'; + +import { PicsaTranslateModule } from '../../modules'; +import { generateID } from '../../services/core/db/db.service'; + +type Segment = { + /** Unique id for each segment for efficient tracking */ + id: string; + /** [x,y,pressure] point representation for current segment */ + points: [number, number, number][]; + /** Generate svg path */ + path: WritableSignal; +}; + +@Component({ + selector: 'picsa-custom-drawing', + standalone: true, + imports: [CommonModule, MatButtonModule, MatDialogModule, MatIconModule, PicsaTranslateModule], + templateUrl: './drawing.component.html', + styleUrls: ['./drawing.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PicsaDrawingComponent { + /** SVG drawing viewbox and container size */ + size = input(384); + + viewbox = computed(() => `0 0 ${this.size()} ${this.size()}`); + + public onChange = output(); + + /** List of segment currently being drawn */ + public activeSegment = this.createNewSegment(); + + /** List of all saved segments */ + public segments: Segment[] = []; + + /** List of segments removed pending redo */ + private redoStack: Segment[] = []; + + /** + * When the svg is rendered in a parent element track position + * to use to offset + */ + private containerOffset = [0, 0]; + + /** Number of pixel difference required to record new point */ + private tolerance = 5; + + /** Number of decimal places svg path calculated to */ + private precision = 2; + + /** Perfect-freehand configuration */ + private strokeOptions: StrokeOptions = { + size: 24, + thinning: 0.5, + smoothing: 0.5, + streamline: 0.5, + easing: (t) => t, + start: { + taper: 0, + easing: (t) => t, + cap: true, + }, + end: { + taper: 0, + easing: (t) => t, + cap: true, + }, + }; + + @ViewChild('svgElement') svgElement: ElementRef; + + constructor(public dialog: MatDialog) {} + + handlePointerDown(event: PointerEvent) { + // Set svg element as target for future pointer events (will release automatically on pointerup) + // https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture + const target = event.target as SVGElement; + target.setPointerCapture(event.pointerId); + + // Ensure all points are calculated relative to svg container + this.calculateContainerOffset(); + + // ensure previous segment finalised (in case pointerup not fired) and start new segment + this.finaliseActiveSegment(); + + // ensure initial point set on active segment + this.addPointToActiveSegment(event.pageX, event.pageY); + } + + handlePointerMove(event: PointerEvent) { + if (event.buttons !== 1) return; + this.addPointToActiveSegment(event.pageX, event.pageY); + } + + handlePointerUp() { + if (this.activeSegment.points.length > 0) { + this.finaliseActiveSegment(); + } + // output current full svg + const data = this.sanitizeOutputSVG(); + this.onChange.emit(data); + } + + /** Replace ngcontent, inserted classes and comments from svg */ + private sanitizeOutputSVG() { + const svgEl = this.svgElement.nativeElement.outerHTML; + return svgEl + .replace(/_ngcontent(\S)* /gi, '') + .replace(/class="[^"]*"/gi, '') + .replace(//gi, '') + .replace('style="touch-action: none;"', ''); + } + + private createNewSegment() { + const segment: Segment = { id: generateID(5), path: signal(''), points: [] }; + return segment; + } + + /** Add a point to the current path, adjusting absolute position for relative container */ + private addPointToActiveSegment(x: number, y: number, pressure = 0.5) { + // calculate svg point position relative to container + const [left, top] = this.containerOffset; + const currentX = x - left; + const currentY = y - top; + // check whether points differ significantly from previous and render accordingly + const lastPoint = this.activeSegment.points[this.activeSegment.points.length - 1]; + const [lastX, lastY] = lastPoint || [-1, -1]; + if (Math.abs(lastX - currentX) > this.tolerance || Math.abs(lastY - currentY) > this.tolerance) { + this.activeSegment.points.push([x - left, y - top, pressure]); + this.renderActiveSegment(); + } + } + + /** Determine current positioning of svg drawing container to use for path offsets */ + private calculateContainerOffset() { + const svgEl = this.svgElement.nativeElement; + const { left, top } = svgEl.getBoundingClientRect(); + this.containerOffset = [left, top]; + } + + /** Render an svg path element generated from current list of points */ + private renderActiveSegment() { + const stroke = getStroke(this.activeSegment.points, this.strokeOptions); + const path = getSvgPathFromStroke(stroke, this.precision); + this.activeSegment.path.set(path); + } + + /* Clear Draw */ + public clearDraw() { + this.activeSegment = this.createNewSegment(); + this.segments = []; + } + + /* Undo previous Stroke render */ + public undoSvgStroke() { + const lastSegment = this.segments.pop(); + if (lastSegment) { + this.redoStack.push(lastSegment); + } + } + + /* Redo Stroke render */ + public redoSvgStroke() { + const lastSegment = this.redoStack.pop(); + if (lastSegment) { + this.segments.push(lastSegment); + } + } + + /* End the current stroke */ + public finaliseActiveSegment() { + if (this.activeSegment.points.length > 0) { + this.segments.push(this.activeSegment); + this.activeSegment = this.createNewSegment(); + } + } +} + +const average = (a: number, b: number) => (a + b) / 2; + +/** + * Generate an svg path from array of [x,y] point arrays + * Copied from https://github.com/steveruizok/perfect-freehand + * */ +function getSvgPathFromStroke(points: number[][], precision = 2, closed = true) { + const len = points.length; + + if (len < 2) { + return ``; + } + + let a = points[0]; + let b = points[1]; + const c = points[2]; + + let result = `M${a[0].toFixed(precision)},${a[1].toFixed(precision)} Q${b[0].toFixed(precision)},${b[1].toFixed( + precision + )} ${average(b[0], c[0]).toFixed(precision)},${average(b[1], c[1]).toFixed(precision)} T`; + + for (let i = 2, max = len - 1; i < max; i++) { + a = points[i]; + b = points[i + 1]; + result += `${average(a[0], b[0]).toFixed(precision)},${average(a[1], b[1]).toFixed(precision)} `; + } + + if (closed) { + result += 'Z'; + } + + return result; +} diff --git a/libs/shared/src/features/drawing/index.ts b/libs/shared/src/features/drawing/index.ts new file mode 100644 index 000000000..8c0ec4b6b --- /dev/null +++ b/libs/shared/src/features/drawing/index.ts @@ -0,0 +1 @@ +export * from './drawing.component' \ No newline at end of file diff --git a/libs/shared/src/features/index.ts b/libs/shared/src/features/index.ts index 9adf9998a..5fdc7d619 100644 --- a/libs/shared/src/features/index.ts +++ b/libs/shared/src/features/index.ts @@ -2,4 +2,5 @@ export * from './animations'; export * from './charts'; export * from './data-table'; export * from './dialog'; -export * from './video-player'; +export * from './drawing'; +export * from './video-player'; \ No newline at end of file diff --git a/libs/shared/src/features/video-player/video-player.component.spec.ts b/libs/shared/src/features/video-player/video-player.component.spec.ts index 117bbba91..f9672fd50 100644 --- a/libs/shared/src/features/video-player/video-player.component.spec.ts +++ b/libs/shared/src/features/video-player/video-player.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; + import { VideoPlayerComponent } from './video-player.component'; describe('VideoPlayerComponent', () => { diff --git a/libs/shared/src/services/core/supabase/components/storage-file-picker/storage-file-picker.component.spec.ts b/libs/shared/src/services/core/supabase/components/storage-file-picker/storage-file-picker.component.spec.ts index 85ff16cbe..12c4b392b 100644 --- a/libs/shared/src/services/core/supabase/components/storage-file-picker/storage-file-picker.component.spec.ts +++ b/libs/shared/src/services/core/supabase/components/storage-file-picker/storage-file-picker.component.spec.ts @@ -1,16 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { StorageFilePickerComponent } from './storage-file-picker.component'; -describe('StorageFilePickerComponent', () => { - let component: StorageFilePickerComponent; - let fixture: ComponentFixture; +import { SupabaseStoragePickerDirective } from './storage-file-picker.component'; + +describe('SupabaseStoragePickerDirective', () => { + let component: SupabaseStoragePickerDirective; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [StorageFilePickerComponent], + imports: [SupabaseStoragePickerDirective], }).compileComponents(); - fixture = TestBed.createComponent(StorageFilePickerComponent); + fixture = TestBed.createComponent(SupabaseStoragePickerDirective); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/libs/shared/src/services/core/supabase/components/upload/supabase-upload.component.spec.ts b/libs/shared/src/services/core/supabase/components/upload/supabase-upload.component.spec.ts index e4925cf7c..5a35f6c7b 100644 --- a/libs/shared/src/services/core/supabase/components/upload/supabase-upload.component.spec.ts +++ b/libs/shared/src/services/core/supabase/components/upload/supabase-upload.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; + import { SupabaseUploadComponent } from './supabase-upload.component'; describe('SupabaseUploadComponent', () => { diff --git a/package.json b/package.json index 3d9cf7e46..f65d479df 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "openapi-fetch": "^0.8.2", "papaparse": "^5.3.2", "parse": "3.4.2", + "perfect-freehand": "^1.2.2", "rxdb": "^14.11.1", "rxjs": "~7.8.1", "save-svg-as-png": "^1.4.17", diff --git a/yarn.lock b/yarn.lock index e124b7649..cb7a853f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19247,6 +19247,13 @@ __metadata: languageName: node linkType: hard +"perfect-freehand@npm:^1.2.2": + version: 1.2.2 + resolution: "perfect-freehand@npm:1.2.2" + checksum: e1c4f3b6c9e61a3ba4a5375d7cbe05d88fb4885b2bbd6c7dd38437c25ce2e5af1102d46c9b8ccfd1e4f5cf9c7855605f8e6cda073157f9bf68beea85bd98d48b + languageName: node + linkType: hard + "performance-now@npm:^2.1.0": version: 2.1.0 resolution: "performance-now@npm:2.1.0" @@ -19417,6 +19424,7 @@ __metadata: openapi-typescript: ^6.7.3 papaparse: ^5.3.2 parse: 3.4.2 + perfect-freehand: ^1.2.2 postcss: ^8.4.5 postcss-import: ~14.1.0 postcss-url: ~10.1.3