0
Fork 0
mirror of https://github.com/penpot/penpot-plugins.git synced 2025-01-06 14:50:21 -05:00

feat: rename layers plugin

This commit is contained in:
Marina López 2024-06-05 10:52:26 +02:00
parent b4e7415891
commit 2331e347b1
10 changed files with 457 additions and 54 deletions

View file

@ -466,6 +466,7 @@
<span class="icon icon-arrow-top"></span>
<span class="icon icon-arrow-right"></span>
<span class="icon icon-arrow-left"></span>
<span class="icon icon-arrow-right-full"></span>
<span class="icon icon-close"></span>
<span class="icon icon-close-l"></span>
<span class="icon icon-delete"></span>
@ -488,6 +489,7 @@
<span class="icon icon-arrow-top"></span>
<span class="icon icon-arrow-right"></span>
<span class="icon icon-arrow-left"></span>
<span class="icon icon-arrow-right-full"></span>
<span class="icon icon-close"></span>
<span class="icon icon-close-l"></span>
<span class="icon icon-delete"></span>

View file

@ -1,8 +1,29 @@
.nav-tabs {
background-color: var(--background-secondary);
border-radius: var(--spacing-8);
display: flex;
margin-block-start: var(--spacing-24);
& .tab {
background-color: var(--background-secondary);
border: 2px solid transparent;
color: var(--foreground-secondary);
font-size: var(--font-size-s);
padding-inline: var(--spacing-8);
&.active {
background-color: var(--db-quaternary);
border: 2px solid var(--background-secondary);
color: var(--accent-primary);
}
}
}
.explanation {
margin-block-end: var(--spacing-8);
}
.form {
padding-block: var(--spacing-12);
padding-block-start: var(--spacing-8);
}
.form-group {
@ -13,3 +34,85 @@
button {
inline-size: 100%;
}
[data-appearance='primary'].btn-feedback {
background-color: var(--accent-tertiary);
border: 2px solid var(--accent-tertiary);
}
.icon-btn {
margin: auto;
.stroke {
stroke: var(--db-secondary);
}
}
::placeholder {
color: var(--foreground-secondary);
}
.no-match {
border-bottom: 1px solid var(--background-quaternary);
padding-block-end: var(--spacing-8);
}
.preview-list {
background-color: var(--background-tertiary);
border-radius: var(--spacing-8);
font-size: var(--font-size-xs);
margin-block-start: var(--spacing-8);
block-size: 215px;
overflow: auto;
padding: var(--spacing-8);
&.replace {
block-size: 175px;
}
& .preview-item {
align-items: center;
display: grid;
grid-template-columns: 33% 7% 60%;
}
::ng-deep .highlight {
background-color: var(--accent-tertiary);
color: var(--foreground-primary);
opacity: 70%;
}
& .original,
& .result {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result {
margin-inline-start: var(--spacing-4);
}
}
.preview {
margin-block-start: var(--spacing-24);
}
:host[data-theme='light'] {
.nav-tabs {
background-color: var(--lb-tertiary);
& .tab {
background-color: var(--lb-tertiary);
&.active {
background-color: var(--lb-primary);
}
}
}
.icon-btn {
.stroke {
stroke: var(--lb-primary);
}
}
}

View file

@ -1,26 +1,122 @@
<div class="form">
<p class="explanation body-s">Introduce the text to replace</p>
<div class="form-group">
<label class="input-label-hidden" for="current">Search</label>
<input
[(ngModel)]="textToReplace.current"
class="input"
type="text"
placeholder="Search"
id="current"
/>
<div>
<div role="tablist" class="nav-tabs">
<button
(click)="selectTab('add')"
[class.active]="tab === 'add'"
type="button"
role="tab"
class="tab"
>
Add text
</button>
<button
(click)="selectTab('replace')"
[class.active]="tab === 'replace'"
type="button"
role="tab"
class="tab"
>
Replace text
</button>
</div>
<div class="form-group">
<label class="input-label-hidden" for="replace">Replace</label>
<input
[(ngModel)]="textToReplace.new"
class="input"
type="text"
placeholder="Replace"
id="replace"
/>
<div *ngIf="tab === 'add'" class="form">
<p class="explanation body-s">
Select layers to rename (otherwise it will apply to all layers) and enter
the text you want to add.
</p>
<div class="form-group">
<label class="input-label-hidden" for="search">Add text</label>
<input
#addElement
[(ngModel)]="addText"
class="input"
type="text"
placeholder="[Original layer name]"
id="addText"
/>
</div>
<button
type="button"
data-appearance="primary"
[class.btn-feedback]="btnFeedback"
(click)="updateText()"
>
<span *ngIf="!btnFeedback" class="text-btn">Add</span>
<span *ngIf="btnFeedback" class="icon icon-btn icon-tick">
<svg width="16" height="16" fill="none">
<path d="M13.333 4 6 11.333 2.667 8" class="stroke" />
</svg>
</span>
</button>
<p class="body-s preview">Previsualization:</p>
<ul class="preview-list">
<li class="preview-item" *ngFor="let preview of previewList()">
<span class="original" [title]="preview.name">{{ preview.name }}</span>
<span class="icon icon-arrow-right-full"></span>
<span class="result" [title]="resultAddText(preview)">{{
resultAddText(preview)
}}</span>
</li>
</ul>
</div>
<div *ngIf="tab === 'replace'" class="form">
<p class="explanation body-s">
Select layers to rename (otherwise it will apply to all layers) and enter
the replacement text.
</p>
<div class="form-group">
<label class="input-label-hidden" for="search">Search</label>
<input
[(ngModel)]="textToReplace.search"
class="input"
type="text"
placeholder="Search"
id="search"
#searchElement
(keydown)="previewReplace()"
/>
</div>
<div class="form-group">
<label class="input-label-hidden" for="replace">Replace</label>
<input
[(ngModel)]="textToReplace.replace"
class="input"
type="text"
placeholder="Write the new text"
id="replace"
/>
</div>
<button
type="button"
data-appearance="primary"
(click)="updateText()"
[class.btn-feedback]="btnFeedback"
>
<span *ngIf="!btnFeedback" class="text-btn">Replace</span>
<span *ngIf="btnFeedback" class="icon icon-btn icon-tick">
<svg width="16" height="16" fill="none">
<path d="M13.333 4 6 11.333 2.667 8" class="stroke" />
</svg>
</span>
</button>
<p class="body-s preview">Previsualization:</p>
<ul class="preview-list replace">
<li *ngIf="previewList().length === 0" class="no-match">
No matches found
</li>
<li class="preview-item" *ngFor="let preview of previewList()">
<span
class="original"
[innerHTML]="highlightMatch(preview.name)"
[title]="preview.name"
></span>
<span class="icon icon-arrow-right-full"></span>
<span class="result" [title]="resultReplaceText(preview.name)">{{
resultReplaceText(preview.name)
}}</span>
</li>
</ul>
</div>
<button type="button" data-appearance="primary" (click)="updateText()">
Replace all
</button>
</div>

View file

@ -1,10 +1,15 @@
import { Component, inject } from '@angular/core';
import { Component, ElementRef, ViewChild, inject } from '@angular/core';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import type { PluginMessageEvent, ReplaceText } from '../app/model';
import type {
PluginMessageEvent,
ReplaceText,
ThemePluginEvent,
} from '../app/model';
import { filter, fromEvent, map, merge, take } from 'rxjs';
import { FormsModule } from '@angular/forms';
import { PenpotShape } from '@penpot/plugin-types';
@Component({
standalone: true,
@ -17,12 +22,22 @@ import { FormsModule } from '@angular/forms';
},
})
export class AppComponent {
@ViewChild('searchElement') public searchElement!: ElementRef;
@ViewChild('addElement') public addElement!: ElementRef;
route = inject(ActivatedRoute);
messages$ = fromEvent<MessageEvent<PluginMessageEvent>>(window, 'message');
public textToReplace: ReplaceText = {
current: '',
new: '',
search: '',
replace: '',
};
public addText = '[Original layer name]';
public tab: 'add' | 'replace' = 'add';
public btnFeedback = false;
constructor() {
this.sendMessage({ type: 'ready' });
}
initialTheme$ = this.route.queryParamMap.pipe(
map((params) => params.get('theme')),
@ -36,14 +51,95 @@ export class AppComponent {
this.messages$.pipe(
filter((event) => event.data.type === 'theme'),
map((event) => {
return event.data.content;
return (event.data as ThemePluginEvent).content;
})
)
)
);
previewList = toSignal(
this.messages$.pipe(
filter(
(event) => event.data.type === 'init' || event.data.type === 'selection'
),
map((event) => {
if (event.data.type === 'init') {
return event.data.content.selection;
} else if (event.data.type === 'selection') {
return event.data.content.selection;
}
return [];
})
),
{
initialValue: [],
}
);
public updateText() {
this.sendMessage({ type: 'replace-text', content: this.textToReplace });
if (this.tab === 'replace') {
this.sendMessage({ type: 'replace-text', content: this.textToReplace });
this.handleBtnFeedback();
this.searchElement.nativeElement.focus();
this.resetForm();
} else {
const elementsToUpdate = this.previewList().map((item) => {
return {
current: item.name,
new: this.resultAddText(item),
};
});
this.sendMessage({ type: 'add-text', content: elementsToUpdate });
this.handleBtnFeedback();
this.addElement.nativeElement.focus();
this.resetForm();
}
}
public previewReplace() {
this.sendMessage({
type: 'preview-replace-text',
content: this.textToReplace,
});
}
public resultReplaceText(text: string) {
return text.replace(this.textToReplace.search, this.textToReplace.replace);
}
public highlightMatch(text: string) {
if (this.textToReplace.search) {
return text.replace(
this.textToReplace.search,
`<span class="highlight">${this.textToReplace.search}</span>`
);
} else {
return text;
}
}
public selectTab(tab: 'add' | 'replace') {
this.tab = tab;
this.resetForm();
}
public resetForm() {
this.textToReplace.search = '';
this.textToReplace.replace = '';
this.addText = '[Original layer name]';
this.sendMessage({ type: 'ready' });
}
public handleBtnFeedback() {
this.btnFeedback = true;
setTimeout(() => {
this.btnFeedback = false;
}, 750);
}
public resultAddText(shape: PenpotShape) {
return this.addText.replace('[Original layer name]', shape.name);
}
private sendMessage(message: PluginMessageEvent): void {

View file

@ -1,7 +1,20 @@
import { PenpotShape } from '@penpot/plugin-types';
export interface ReadyPluginEvent {
type: 'ready';
}
export interface InitPluginEvent {
type: 'init';
content: {
theme: string;
selection: PenpotShape[];
};
}
export interface SelectionPluginEvent {
type: 'selection';
content: {
selection: PenpotShape[];
};
}
export interface ThemePluginEvent {
@ -14,12 +27,31 @@ export interface ReplaceTextPluginEvent {
content: ReplaceText;
}
export interface AddTextPluginEvent {
type: 'add-text';
content: AddText[];
}
export interface PreviewReplaceTextPluginEvent {
type: 'preview-replace-text';
content: ReplaceText;
}
export type PluginMessageEvent =
| ReadyPluginEvent
| InitPluginEvent
| SelectionPluginEvent
| ThemePluginEvent
| ReplaceTextPluginEvent;
| ReplaceTextPluginEvent
| AddTextPluginEvent
| PreviewReplaceTextPluginEvent;
export interface ReplaceText {
search: string;
replace: string;
}
export interface AddText {
current: string;
new: string;
}

View file

@ -0,0 +1 @@
<svg width="16" xmlns="http://www.w3.org/2000/svg" height="16" fill="none"><g data-testid="Icons / Actions / Tick M"><g class="fills"><rect rx="0" ry="0" width="16" height="16" class="frame-background"/></g><g data-testid="svg-path" class="frame-children"><path d="M13.333 4 6 11.333 2.667 8" class="fills"/><g class="strokes"><path d="M13.333 4 6 11.333 2.667 8" style="fill: none; stroke-width: 1; stroke: rgb(143, 157, 163); stroke-opacity: 1; stroke-linecap: round;" class="stroke-shape"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 511 B

View file

@ -1,25 +1,78 @@
import { PluginMessageEvent } from './app/model';
penpot.ui.open('Plugin rename layers', `?theme=${penpot.getTheme()}`, {
width: 235,
height: 245,
penpot.ui.open('RENAME LAYER PLUGIN', `?theme=${penpot.getTheme()}`, {
width: 290,
height: 550,
});
penpot.on('themechange', (theme) => {
penpot.ui.sendMessage({ type: 'theme', content: theme });
});
penpot.on('selectionchange', () => {
resetSelection();
});
penpot.ui.onMessage<PluginMessageEvent>((message) => {
if (message.type === 'replace-text') {
const shapes = penpot.getPage()?.findShapes();
const shapesToUpdate = shapes?.filter((shape) =>
shape.name.includes(message.content.current)
);
if (message.type === 'ready') {
resetSelection();
} else if (message.type === 'replace-text') {
const shapes = getShapes();
const shapesToUpdate = shapes?.filter((shape) => {
return shape.name.includes(message.content.search);
});
shapesToUpdate?.forEach((shape) => {
shape.name = shape.name.replace(
message.content.current,
message.content.new
// eslint-disable-next-line
message.content.search,
message.content.replace
);
});
updateReplaceTextPreview(message.content.search);
} else if (message.type === 'preview-replace-text') {
updateReplaceTextPreview(message.content.search);
} else if (message.type === 'add-text') {
const currentNames = message.content.map((shape) => shape.current);
const shapes = getShapes();
const shapesToUpdate = shapes?.filter((shape) =>
currentNames.includes(shape.name)
);
shapesToUpdate?.forEach((shape) => {
const newText = message.content.find((it) => it.current === shape.name);
return (shape.name = newText?.new ?? shape.name);
});
resetSelection();
}
});
function getShapes() {
return penpot.getSelectedShapes().length
? penpot.getSelectedShapes()
: penpot.getPage()?.findShapes();
}
function resetSelection() {
penpot.ui.sendMessage({
type: 'selection',
content: {
selection: getShapes(),
},
});
}
function updateReplaceTextPreview(search: string) {
if (search) {
const shapes = getShapes();
const shapesToUpdate = shapes?.filter((shape) => {
return shape.name.includes(search);
});
penpot.ui.sendMessage({
type: 'selection',
content: {
selection: shapesToUpdate,
},
});
} else {
resetSelection();
}
}

View file

@ -1,19 +1,19 @@
.icon {
display: flex;
align-items: center;
block-size: var(--spacing-16);
cursor: default;
font-family: var(--body-font);
font-size: var(--font-size-medium);
inline-size: var(--spacing-16);
justify-content: center;
user-select: none;
display: flex;
align-items: center;
block-size: var(--spacing-16);
cursor: default;
font-family: var(--body-font);
font-size: var(--font-size-medium);
inline-size: var(--spacing-16);
justify-content: center;
user-select: none;
}
.icon-arrow-bottom {
background-image: url('../icons/arrow-bottom.svg');
}
background-image: url('../icons/arrow-bottom.svg');
}
.icon-arrow-left {
background-image: url('../icons/arrow-left.svg');
}
@ -26,6 +26,10 @@
background-image: url('../icons/arrow-top.svg');
}
.icon-arrow-right-full {
background-image: url('../icons/arrow-right-full.svg');
}
.icon-close {
background-image: url('../icons/actions-close.svg');
}
@ -88,4 +92,4 @@
.icon-download {
background-image: url('../icons/app-download.svg');
}
}

View file

@ -72,3 +72,18 @@ ul {
background-color: var(--background-primary);
color: var(--foreground-secondary);
}
/* ===== Scrollbar CSS ===== */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px transparent;
border-radius: var(--spacing-8);
}
::-webkit-scrollbar-thumb {
border-radius: var(--spacing-8);
-webkit-box-shadow: inset 0 0 6px #aab5ba;
}

View file

@ -0,0 +1 @@
<svg width="15" xmlns="http://www.w3.org/2000/svg" height="15" fill="none"><g data-testid="arrow-right"><defs><clipPath id="a" class="frame-clip frame-clip-def"><rect rx="0" ry="0" width="15" height="15"/></clipPath></defs><g clip-path="url(#a)"><g class="fills"><rect rx="0" ry="0" width="24" height="24" class="frame-background"/></g><g class="frame-children"><g data-testid="svg-path"><path d="M3.125 7.5h8.75" style="fill: none;" class="fills"/><g stroke-linecap="round" stroke-linejoin="round" class="strokes"><path d="M3.125 7.5h8.75" style="fill: none; stroke-width: 1; stroke: rgb(143, 157, 163); stroke-opacity: 1;" class="stroke-shape"/></g></g><g data-testid="svg-path"><path d="M7.5 3.125 11.875 7.5 7.5 11.875" style="fill: none;" class="fills"/><g stroke-linecap="round" stroke-linejoin="round" class="strokes"><path d="M7.5 3.125 11.875 7.5 7.5 11.875" style="fill: none; stroke-width: 1; stroke: rgb(143, 157, 163); stroke-opacity: 1;" class="stroke-shape"/></g></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1,000 B