Added share by link feature

This commit is contained in:
Yann Armelin
2022-07-14 14:14:30 +02:00
parent 6bff42fb6c
commit c1984a4691
19 changed files with 416 additions and 49 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 656 KiB

View File

@@ -254,8 +254,7 @@
</ng-container>
<div style="flex:1"></div>
<button mat-icon-button [matMenuTriggerFor]="appMenu" [matMenuTriggerData]="{item:item, idx:j}"
>
<button mat-icon-button [matMenuTriggerFor]="appMenu" [matMenuTriggerData]="{item:item, idx:j}" class="more-button">
<mat-icon svgIcon="more"></mat-icon>
</button>
</div>
@@ -343,7 +342,8 @@
<mat-icon>image</mat-icon>
</button>
<app-export [path]="rawPath" [name]="pathName || '' "></app-export>
<app-export [path]="rawPath" [name]="pathName || '' " style="margin-right: 8px"></app-export>
<app-share [path]="rawPath"></app-share>
</div>
<div class="scene-bottom-actions">
<button
@@ -434,6 +434,8 @@
</a>
</div>
<app-import (importPath)="openPath($event, '')"></app-import>
<mat-menu #appMenu="matMenu" xPosition="before">
<ng-template matMenuContent let-item="item" let-idx="idx">
<button mat-menu-item [matMenuTriggerFor]="appCreate" [matMenuTriggerData]="{item:item, idx:idx}">

View File

@@ -10,6 +10,10 @@ import { UploadImageComponent } from './upload-image/upload-image.component';
import { ConfigService } from './config.service';
import { browserComputePathBoundingBox } from './svg-bbox';
export const kDefaultPath = `M 4 8 L 10 1 L 13 0 L 12 3 L 5 9 C 6 10 6 11 7 10 C 7 11 8 12 7 12 A 1.42 1.42 0 0 1 6 13 `
+ `A 5 5 0 0 0 4 10 Q 3.5 9.9 3.5 10.5 T 2 11.8 T 1.2 11 T 2.5 9.5 T 3 9 A 5 5 90 0 0 0 7 A 1.42 1.42 0 0 1 1 6 `
+ `C 1 5 2 6 3 6 C 2 7 3 7 4 8 M 10 1 L 10 3 L 12 3 L 10.2 2.8 L 10 1`;
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
@@ -32,10 +36,7 @@ export class AppComponent implements AfterViewInit {
controlPoints: SvgControlPoint[] = [];
// Raw path:
_rawPath = this.storage.getPath()?.path
|| `M 4 8 L 10 1 L 13 0 L 12 3 L 5 9 C 6 10 6 11 7 10 C 7 11 8 12 7 12 A 1.42 1.42 0 0 1 6 13 `
+ `A 5 5 0 0 0 4 10 Q 3.5 9.9 3.5 10.5 T 2 11.8 T 1.2 11 T 2.5 9.5 T 3 9 A 5 5 90 0 0 0 7 A 1.42 1.42 0 0 1 1 6 `
+ `C 1 5 2 6 3 6 C 2 7 3 7 4 8 M 10 1 L 10 3 L 12 3 L 10.2 2.8 L 10 1`;
_rawPath = this.storage.getPath()?.path || kDefaultPath;
pathName: string = '';
invalidSyntax = false;
@@ -105,10 +106,16 @@ export class AppComponent implements AfterViewInit {
} else if (!$event.metaKey && !$event.ctrlKey && /^[mlvhcsqtaz]$/i.test($event.key)) {
const isLower = $event.key === $event.key.toLowerCase();
const key = $event.key.toUpperCase();
if (isLower && this.focusedItem && this.canInsertAfter(this.focusedItem, key)) {
this.insert(key, this.focusedItem, false);
$event.preventDefault();
if (isLower) {
// Item insertion
const lastItem = this.parsedPath.path.length ? this.parsedPath.path[this.parsedPath.path.length - 1] : null;
const prevItem = this.focusedItem || lastItem;
if(this.canInsertAfter(prevItem, key)) {
this.insert(key, prevItem, false);
$event.preventDefault();
}
} else if (!isLower && this.focusedItem && this.canConvert(this.focusedItem, key)) {
// Item convertion
this.insert(key, this.focusedItem, true);
$event.preventDefault();
}
@@ -221,11 +228,13 @@ export class AppComponent implements AfterViewInit {
this.strokeWidth = this.cfg.viewPortWidth / this.canvasWidth;
}
insert(type: string, after: SvgItem, convert: boolean) {
insert(type: string, after: SvgItem | null, convert: boolean) {
if (convert) {
this.focusedItem =
this.parsedPath.changeType(after, after.relative ? type.toLowerCase() : type);
this.afterModelChange();
if(after) {
this.focusedItem =
this.parsedPath.changeType(after, after.relative ? type.toLowerCase() : type);
this.afterModelChange();
}
} else {
this.draggedIsNew = true;
const pts = this.targetPoints;
@@ -263,7 +272,7 @@ export class AppComponent implements AfterViewInit {
newItem = SvgItem.Make([type]);
}
if(newItem) {
this.parsedPath.insert(newItem, after);
this.parsedPath.insert(newItem, after ?? undefined);
}
}
this.setHistoryDisabled(true);

View File

@@ -2,6 +2,8 @@ import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ServiceWorkerModule } from '@angular/service-worker';
import { HttpClientModule } from '@angular/common/http';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -9,27 +11,29 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatMenuModule } from '@angular/material/menu';
import {MatSnackBarModule} from '@angular/material/snack-bar';
import { MatTooltipModule, MAT_TOOLTIP_SCROLL_STRATEGY } from '@angular/material/tooltip';
import { MatSliderModule } from '@angular/material/slider';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { Overlay, ScrollStrategy } from '@angular/cdk/overlay';
import { AppComponent } from './app.component';
import { HttpClientModule } from '@angular/common/http';
import { ExpandableComponent } from './expandable/expandable.component';
import { CanvasComponent } from './canvas/canvas.component';
import { OpenComponent, OpenDialogComponent } from './open/open.component';
import { SaveComponent, SaveDialogComponent } from './save/save.component';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTableModule } from '@angular/material/table';
import { MatSortModule } from '@angular/material/sort';
import { Overlay, ScrollStrategy } from '@angular/cdk/overlay';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { environment } from '../environments/environment';
import { AppComponent } from './app.component';
import { FormatterDirective } from './formatter/formatter.directive';
import { KeyboardNavigableDirective } from './keyboard-navigable/keyboard-navigable.directive';
import { ExpandableComponent } from './expandable/expandable.component';
import { CanvasComponent } from './canvas/canvas.component';
import { PathPreviewComponent } from './path-preview/path-preview.component';
import { OpenComponent, OpenDialogComponent } from './open/open.component';
import { SaveComponent, SaveDialogComponent } from './save/save.component';
import { ExportComponent, ExportDialogComponent } from './export/export.component';
import { UploadImageComponent, UploadImageDialogComponent } from './upload-image/upload-image.component';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
import { ImportComponent, ImportDialogComponent } from './import/import.component';
import { ShareComponent, ShareDialogComponent, ShareDialogSnackbarComponent } from './share/share.component';
@NgModule({
declarations: [
@@ -44,8 +48,14 @@ import { environment } from '../environments/environment';
ExportDialogComponent,
UploadImageComponent,
UploadImageDialogComponent,
ImportComponent,
ImportDialogComponent,
ShareComponent,
ShareDialogComponent,
ShareDialogSnackbarComponent,
FormatterDirective,
KeyboardNavigableDirective
KeyboardNavigableDirective,
PathPreviewComponent
],
imports: [
BrowserModule,
@@ -62,6 +72,7 @@ import { environment } from '../environments/environment';
MatTableModule,
MatSortModule,
MatSliderModule,
MatSnackBarModule,
BrowserAnimationsModule,
ScrollingModule,
ServiceWorkerModule.register('ngsw-worker.js', {

View File

@@ -63,21 +63,17 @@
</div>
</div>
<div class="preview">
<svg [attr.viewBox]="x+' '+y+' '+width+' '+height"
width="300" height="300"
xmlns="http://www.w3.org/2000/svg"
>
<pattern id="preview-pattern" x="0" y="0" width="16" height="16" patternUnits="userSpaceOnUse" [attr.patternTransform]="'scale('+patternScale(400, 400)+')'">
<rect x="0" y="0" width="16" height="16" fill="white"></rect>
<rect x="0" y="0" width="8" height="8" fill="#cccccc"></rect>
<rect x="8" y="8" width="8" height="8" fill="#cccccc"></rect>
</pattern>
<clipPath id="preview-clippath">
<rect [attr.x]="x" [attr.y]="y" [attr.width]="width" [attr.height]="height"></rect>
</clipPath>
<rect [attr.x]="x" [attr.y]="y" [attr.width]="width" [attr.height]="height" fill="url(#preview-pattern)" ></rect>
<path [attr.d]="data.path" [attr.fill]="cfg.fill?cfg.fillColor:'none'" [attr.stroke-width]="cfg.strokeWidth" [attr.stroke]="cfg.stroke?cfg.strokeColor:'none'" clip-path="url(#preview-clippath)"></path>
</svg>
<app-path-preview
[x]="x"
[y]="y"
[width]="width"
[height]="height"
[path]="data.path"
[fillColor]="cfg.fill?cfg.fillColor:'none'"
[strokeColor]="cfg.stroke?cfg.strokeColor:'none'"
[strokeWidth]="cfg.strokeWidth"
></app-path-preview>
</div>
</div>
</div>

View File

@@ -43,7 +43,7 @@ mat-form-field {
}
.preview {
width:324px;
svg {
app-path-preview {
margin:0 0 0 24px;
}
}

View File

@@ -54,10 +54,6 @@ export class ExportDialogComponent {
this.dialogRef.close();
}
patternScale(containterWidth: number, containerHeight: number): number {
return Math.max(this.width / containterWidth, this.height / containerHeight);
}
refreshViewbox() {
const p = new Svg(this.data.path);
const locs = p.targetLocations();

View File

@@ -0,0 +1,20 @@
<h1 mat-dialog-title>Import shared path</h1>
<div mat-dialog-content>
<div style="display: flex;">
<app-path-preview [path]="data.path ?? ''"></app-path-preview>
<div style="padding:0 32px;">
<p>
Press <b>Import</b> to import this SVG path in the editor.
</p>
<p>
Please note it will erase any unsaved change.
</p>
</div>
</div>
</div>
<div mat-dialog-actions align="end">
<button mat-raised-button (click)="onCancel()" color="basic">Discard</button>
<button mat-raised-button (click)="onConfirm()" color="primary" [disabled]="false">
Import
</button>
</div>

View File

@@ -0,0 +1,28 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { ImportComponent } from './import.component';
describe('ImportComponent', () => {
let component: ImportComponent;
let fixture: ComponentFixture<ImportComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ MatDialogModule, MatIconModule ],
declarations: [ ImportComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ImportComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,89 @@
import { Component, Output, EventEmitter, OnInit, Inject } from '@angular/core';
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { kDefaultPath } from '../app.component';
import { StorageService } from '../storage.service';
import { Svg } from '../svg';
export class DialogData {
path?: string;
}
@Component({
selector: 'app-import-dialog',
templateUrl: 'import-dialog.component.html'
})
export class ImportDialogComponent {
constructor(
public dialogRef: MatDialogRef<ImportDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: DialogData
) {
}
onConfirm(): void {
this.dialogRef.close(true);
}
onCancel(): void {
this.dialogRef.close();
}
}
@Component({
selector: 'app-import',
template: ''
})
export class ImportComponent implements OnInit {
private urlPath?: string;
@Output() importPath = new EventEmitter<string>();
constructor(
public dialog: MatDialog,
public storageService: StorageService,
) {
this.urlPath = this.readPath();
}
private readPath(): string {
const fragment = decodeURIComponent(window.location.hash.slice(1));
const check = /^P=[mMlLvVhHcCsSqQtTaAzZ0-9\-e._]+$/;
if(check.test(fragment)) {
const path = fragment.slice(2).replace(/_/g, ' ');
try {
const _ = new Svg(path);
return path;
} catch (e) {}
}
return '';
}
ngOnInit() {
const openedPath = this.storageService.getPath();
const unsavedChanges = openedPath && openedPath.path !== kDefaultPath;
if(this.urlPath && this.urlPath !== openedPath?.path) {
if(unsavedChanges) {
this.openDialog();
} else {
this.finalize(true);
}
}
}
openDialog(): void {
const dialogRef = this.dialog.open(ImportDialogComponent, {
width: '800px',
panelClass: 'dialog',
autoFocus: false,
data: {path: this.urlPath}
});
dialogRef.afterClosed().subscribe((result: boolean) => {
this.finalize(result);
});
}
private finalize(result: boolean): void {
if(result) {
this.importPath.emit(this.urlPath);
}
window.location.hash = '';
}
}

View File

@@ -0,0 +1,15 @@
<svg [attr.viewBox]="x+' '+y+' '+width+' '+height"
width="300" height="300"
xmlns="http://www.w3.org/2000/svg"
>
<pattern id="preview-pattern" x="0" y="0" width="16" height="16" patternUnits="userSpaceOnUse" [attr.patternTransform]="'scale('+patternScale(400, 400)+')'">
<rect x="0" y="0" width="16" height="16" fill="white"></rect>
<rect x="0" y="0" width="8" height="8" fill="#cccccc"></rect>
<rect x="8" y="8" width="8" height="8" fill="#cccccc"></rect>
</pattern>
<clipPath id="preview-clippath">
<rect [attr.x]="x" [attr.y]="y" [attr.width]="width" [attr.height]="height"></rect>
</clipPath>
<rect [attr.x]="x" [attr.y]="y" [attr.width]="width" [attr.height]="height" fill="url(#preview-pattern)" ></rect>
<path [attr.d]="path" [attr.fill]="fillColor||'none'" [attr.stroke-width]="strokeWidth" [attr.stroke]="strokeColor||'none'" clip-path="url(#preview-clippath)"></path>
</svg>

After

Width:  |  Height:  |  Size: 912 B

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PathPreviewComponent } from './path-preview.component';
describe('PathPreviewComponent', () => {
let component: PathPreviewComponent;
let fixture: ComponentFixture<PathPreviewComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PathPreviewComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(PathPreviewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,35 @@
import { Component, Input, OnInit } from '@angular/core';
import { browserComputePathBoundingBox } from '../svg-bbox';
@Component({
selector: 'app-path-preview',
templateUrl: './path-preview.component.html'
})
export class PathPreviewComponent implements OnInit {
@Input() x?: number;
@Input() y?: number;
@Input() width?: number;
@Input() height?: number;
@Input() fillColor?: string = '#000000';
@Input() strokeColor?: string;
@Input() strokeWidth?: number;
@Input() path = '';
constructor() { }
ngOnInit(): void {
if(this.x === undefined || this.y === undefined || this.width === undefined || this.height === undefined) {
const bbox = browserComputePathBoundingBox(this.path);
this.x = bbox.x;
this.y = bbox.y;
this.width = bbox.width;
this.height = bbox.height;
}
}
patternScale(containterWidth: number, containerHeight: number): number {
return Math.max((this.width??0) / containterWidth, (this.height??0) / containerHeight);
}
}

View File

@@ -0,0 +1,4 @@
<div style="display:flex; align-items:center">
<span class="mat-simple-snackbar" style="flex:1">Copied to clipboard</span>
<mat-icon>content_pasted</mat-icon>
</div>

View File

@@ -0,0 +1,19 @@
<h1 mat-dialog-title>Share path as URL</h1>
<div mat-dialog-content>
<div style="display: flex;">
<app-path-preview [path]="data.path ?? ''"></app-path-preview>
<div style="padding:0 32px; flex:1">
<p>The current path can be shared using the following link:</p>
<mat-form-field floatLabel="always" appearance="fill" style="width: 100%;">
<mat-label>URL</mat-label>
<input matInput readonly [ngModel]="getUrl()" #input>
<button mat-icon-button matSuffix matTooltip="Copy to clipboard" (click)="copy()">
<mat-icon>content_pasted</mat-icon>
</button>
</mat-form-field>
</div>
</div>
</div>
<div mat-dialog-actions align="end">
<button mat-raised-button (click)="onCancel()" color="default">Close</button>
</div>

View File

@@ -0,0 +1,8 @@
<button mat-mini-fab
color="basic"
matTooltip="Share as link"
matTooltipPosition="below"
(click)="openDialog()"
>
<mat-icon>share</mat-icon>
</button>

View File

@@ -0,0 +1,28 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { ShareComponent } from './share.component';
describe('ShareComponent', () => {
let component: ShareComponent;
let fixture: ComponentFixture<ShareComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ MatDialogModule, MatIconModule ],
declarations: [ ShareComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ShareComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,84 @@
import { Component, Output, EventEmitter, Inject, Input, ViewChild, AfterViewInit, ElementRef } from '@angular/core';
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { StorageService } from '../storage.service';
export class DialogData {
path?: string;
}
@Component({
selector: 'app-share-snackbar',
templateUrl: 'share-dialog-snackbar.component.html'
})
export class ShareDialogSnackbarComponent {}
@Component({
selector: 'app-share-dialog',
templateUrl: 'share-dialog.component.html'
})
export class ShareDialogComponent implements AfterViewInit {
@ViewChild('input') inputField?: ElementRef;
constructor(
public dialogRef: MatDialogRef<ShareDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: DialogData,
private snackBar: MatSnackBar
) {
}
ngAfterViewInit(): void {
setTimeout(() => this.selectText());
}
private selectText(): void {
const el = this.inputField?.nativeElement;
el?.focus();
el?.select();
}
onCancel(): void {
this.dialogRef.close();
}
copy(): void {
this.selectText();
navigator.clipboard.writeText(this.inputField?.nativeElement.value);
this.snackBar.openFromComponent(ShareDialogSnackbarComponent, {
horizontalPosition:'center',
verticalPosition: 'top',
duration: 2000
});
}
getUrl(): string {
const loc = window.location;
const fragment = this.data.path?.replace(/ +/g, '_');
return `${loc.protocol}//${loc.host}${loc.pathname}#P=${fragment}`;
}
}
@Component({
selector: 'app-share',
templateUrl: './share.component.html'
})
export class ShareComponent {
@Input() path: string = '';
@Output() importPath = new EventEmitter<string>();
constructor(
public dialog: MatDialog,
public storageService: StorageService,
) {
}
openDialog(): void {
const dialogRef = this.dialog.open(ShareDialogComponent, {
width: '800px',
panelClass: 'dialog',
autoFocus: false,
data: {path: this.path}
});
}
}

View File

@@ -107,7 +107,7 @@ button.mat-flat-button {
font-size: 12px;
}
button.mat-icon-button {
button.mat-icon-button.more-button {
width:20px;
height:20px;
line-height: 20px !important;