mirror of
https://github.com/estruyf/vscode-front-matter.git
synced 2026-08-07 01:13:08 +02:00
#128 - 🚀 multi image selection support
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
- [#124](https://github.com/estruyf/vscode-front-matter/issues/124): Add new `isPreviewImage` property to the content type field to specify custom preview images
|
||||
- [#126](https://github.com/estruyf/vscode-front-matter/issues/126): Create new content from the available content types
|
||||
- [#127](https://github.com/estruyf/vscode-front-matter/issues/127): Title bar action added to open the dashboard
|
||||
- [#128](https://github.com/estruyf/vscode-front-matter/issues/128): Support for multi-select on image fields added
|
||||
|
||||
### 🐞 Fixes
|
||||
|
||||
|
||||
@@ -557,12 +557,53 @@ input:checked + .field__toggle__slider:before {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.metadata_field__datetime > button:hover {
|
||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
|
||||
.metadata_field__multiple_images {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metadata_field__preview_image img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-height: 16rem;
|
||||
}
|
||||
|
||||
.metadata_field__preview_image__button {
|
||||
background-color: transparent;
|
||||
border: 2px dashed var(--vscode-button-background);
|
||||
padding: 1.5rem;
|
||||
filter: brightness(85%);
|
||||
}
|
||||
|
||||
.metadata_field__preview_image__button:hover {
|
||||
background-color: rgba(255, 255, 255, .1);
|
||||
filter: brightness(100%);
|
||||
}
|
||||
|
||||
.metadata_field__preview_image__button svg {
|
||||
color: var(--vscode-foreground);
|
||||
display: block;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.metadata_field__preview_image__button span {
|
||||
color: var(--vscode-foreground);
|
||||
display: inline-block;
|
||||
margin: 0 auto;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.metadata_field__preview_image__preview {
|
||||
background-color: var(--vscode-button-secondaryBackground);
|
||||
}
|
||||
|
||||
.metadata_field__preview_image__remove {
|
||||
background-color: var(--vscode-inputValidation-errorBackground);
|
||||
color: var(--vscode-inputValidation-errorForeground);
|
||||
|
||||
+1
-1
@@ -291,7 +291,7 @@
|
||||
"type": "boolean",
|
||||
"description": "Is a single line field"
|
||||
},
|
||||
"multiSelect": {
|
||||
"multiple": {
|
||||
"type": "boolean",
|
||||
"description": "Do you allow to select multiple values?"
|
||||
},
|
||||
|
||||
@@ -219,7 +219,7 @@ export class Dashboard {
|
||||
panel.getMediaSelection();
|
||||
} else {
|
||||
panel.getMediaSelection();
|
||||
panel.updateMetadata({field: data.fieldName, value: data.image});
|
||||
panel.updateMetadata({field: data.fieldName, value: data.image });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Messenger } from '@estruyf/vscode/dist/client';
|
||||
import { CheckCircleIcon, ClipboardCopyIcon, CodeIcon, PencilIcon, PhotographIcon, TrashIcon } from '@heroicons/react/outline';
|
||||
import { basename, dirname, parse } from 'path';
|
||||
import { basename, dirname } from 'path';
|
||||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
@@ -70,6 +70,8 @@ export const Item: React.FunctionComponent<IItemProps> = ({media}: React.PropsWi
|
||||
image: parseWinPath(relPath) || "",
|
||||
file: viewData?.data?.filePath,
|
||||
fieldName: viewData?.data?.fieldName,
|
||||
multiple: viewData?.data?.multiple,
|
||||
value: viewData?.data?.value,
|
||||
position: viewData?.data?.position || null,
|
||||
alt: alt || "",
|
||||
caption: caption || ""
|
||||
@@ -197,11 +199,11 @@ export const Item: React.FunctionComponent<IItemProps> = ({media}: React.PropsWi
|
||||
viewData?.data?.filePath ? (
|
||||
<>
|
||||
<button
|
||||
title={`Insert into your article`}
|
||||
title={`Insert into your content`}
|
||||
className={`hover:text-teal-900 focus:outline-none`}
|
||||
onClick={insertToArticle}>
|
||||
<CheckCircleIcon className={`h-5 w-5`} />
|
||||
<span className={`sr-only`}>Insert into your article</span>
|
||||
<span className={`sr-only`}>Insert into your content</span>
|
||||
</button>
|
||||
{
|
||||
(viewData?.data?.position && settings?.mediaSnippet && settings?.mediaSnippet.length > 0) && (
|
||||
|
||||
@@ -21,9 +21,8 @@ import { Preview } from '../commands/Preview';
|
||||
import { openFileInEditor } from '../helpers/openFileInEditor';
|
||||
import { WebviewHelper } from '@estruyf/vscode';
|
||||
import { Extension } from '../helpers/Extension';
|
||||
import { dirname, join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { Dashboard } from '../commands/Dashboard';
|
||||
import { ImageHelper } from '../helpers/ImageHelper';
|
||||
|
||||
const FILE_LIMIT = 10;
|
||||
|
||||
@@ -245,23 +244,28 @@ export class ExplorerView implements WebviewViewProvider, Disposable {
|
||||
const contentType = ArticleHelper.getContentType(updatedMetadata);
|
||||
if (contentType) {
|
||||
const imageFields = contentType.fields.filter((field) => field.type === "image");
|
||||
|
||||
for (const field of imageFields) {
|
||||
if (updatedMetadata[field.name]) {
|
||||
const staticPath = join(wsFolder.fsPath, staticFolder || "", updatedMetadata[field.name]);
|
||||
const contentFolderPath = filePath ? join(dirname(filePath), updatedMetadata[field.name]) : null;
|
||||
const imageData = ImageHelper.allRelToAbs(field, updatedMetadata[field.name])
|
||||
|
||||
let previewUri = null;
|
||||
if (existsSync(staticPath)) {
|
||||
previewUri = Uri.file(staticPath);
|
||||
} else if (contentFolderPath && existsSync(contentFolderPath)) {
|
||||
previewUri = Uri.file(contentFolderPath);
|
||||
}
|
||||
if (imageData) {
|
||||
if (field.multiple && imageData instanceof Array) {
|
||||
const preview = imageData.map(preview => preview && preview.absPath ? ({
|
||||
...preview,
|
||||
webviewUrl: this.panel?.webview.asWebviewUri(preview.absPath).toString()
|
||||
}) : null);
|
||||
|
||||
if (previewUri) {
|
||||
const preview = this.panel?.webview.asWebviewUri(previewUri);
|
||||
updatedMetadata[field.name]= preview?.toString() || "";
|
||||
updatedMetadata[field.name] = preview || [];
|
||||
} else if (!field.multiple && !Array.isArray(imageData) && imageData.absPath) {
|
||||
const preview = this.panel?.webview.asWebviewUri(imageData.absPath);
|
||||
updatedMetadata[field.name] = {
|
||||
...imageData,
|
||||
webviewUrl: preview ? preview.toString() : null
|
||||
};
|
||||
}
|
||||
} else {
|
||||
updatedMetadata[field.name] = "";
|
||||
updatedMetadata[field.name] = field.multiple ? [] : "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,7 +299,7 @@ export class ExplorerView implements WebviewViewProvider, Disposable {
|
||||
/**
|
||||
* Update the metadata of the article
|
||||
*/
|
||||
public async updateMetadata({field, value}: { field: string, value: string }) {
|
||||
public async updateMetadata({field, value }: { field: string, value: any, fieldData?: { multiple: boolean, value: string[] } }) {
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
@@ -312,14 +316,33 @@ export class ExplorerView implements WebviewViewProvider, Disposable {
|
||||
|
||||
const contentType = ArticleHelper.getContentType(article.data);
|
||||
const dateFields = contentType.fields.filter((field) => field.type === "datetime");
|
||||
const imageFields = contentType.fields.filter((field) => field.type === "image" && field.multiple);
|
||||
|
||||
for (const dateField of dateFields) {
|
||||
if ((field === dateField.name) && value) {
|
||||
article.data[field] = Article.formatDate(new Date(value));
|
||||
} else {
|
||||
} else if (!imageFields.find(f => f.name === field)) {
|
||||
// Only override the field data if it is not an multiselect image field
|
||||
article.data[field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const imageField of imageFields) {
|
||||
if (field === imageField.name) {
|
||||
// If value is an array, it means it comes from the explorer view itself (deletion)
|
||||
if (Array.isArray(value)) {
|
||||
article.data[field] = value || [];
|
||||
} else { // Otherwise it is coming from the media dashboard (addition)
|
||||
let fieldValue = article.data[field];
|
||||
if (fieldValue && !Array.isArray(fieldValue)) {
|
||||
fieldValue = [fieldValue];
|
||||
}
|
||||
const crntData = Object.assign([], fieldValue);
|
||||
const allRelPaths = [...(crntData || []), value];
|
||||
article.data[field] = [...new Set(allRelPaths)].filter(f => f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ArticleHelper.update(editor, article);
|
||||
this.pushMetadata(article.data);
|
||||
|
||||
@@ -14,7 +14,10 @@ import { DEFAULT_CONTENT_TYPE_NAME } from "../constants/ContentType";
|
||||
|
||||
export class ContentType {
|
||||
|
||||
|
||||
/**
|
||||
* Create content based on content types
|
||||
* @returns
|
||||
*/
|
||||
public static async createContent() {
|
||||
const selectedContentType = await Questions.SelectContentType();
|
||||
if (!selectedContentType) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Uri, window } from 'vscode';
|
||||
import { dirname, join } from "path";
|
||||
import { Field } from '../models';
|
||||
import { existsSync } from 'fs';
|
||||
import { Folders } from '../commands/Folders';
|
||||
import { Settings } from './SettingsHelper';
|
||||
import { SETTINGS_CONTENT_STATIC_FOLDERS } from '../constants';
|
||||
|
||||
export class ImageHelper {
|
||||
|
||||
/**
|
||||
* Parse all images to use absolute paths
|
||||
* @param field
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
public static allRelToAbs(field: Field, value: string | string[] | undefined) {
|
||||
const filePath = window.activeTextEditor?.document.uri.fsPath;
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
let previewUri = null;
|
||||
|
||||
if (field.multiple) {
|
||||
if (Array.isArray(value)) {
|
||||
previewUri = value.map(v => ({
|
||||
original: v,
|
||||
absPath: ImageHelper.relToAbs(filePath, v)
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
if (typeof value === "string") {
|
||||
return {
|
||||
original: value,
|
||||
absPath: ImageHelper.relToAbs(filePath, value)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return previewUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse relative path to absolute path
|
||||
* @param filePath
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
public static relToAbs(filePath: string, value: string) {
|
||||
const wsFolder = Folders.getWorkspaceFolder();
|
||||
const staticFolder = Settings.get<string>(SETTINGS_CONTENT_STATIC_FOLDERS);
|
||||
|
||||
const staticPath = join(wsFolder?.fsPath || "", staticFolder || "", value);
|
||||
const contentFolderPath = filePath ? join(dirname(filePath), value) : null;
|
||||
|
||||
if (existsSync(staticPath)) {
|
||||
return Uri.file(staticPath);
|
||||
} else if (contentFolderPath && existsSync(contentFolderPath)) {
|
||||
return Uri.file(contentFolderPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse absolute path to relative path
|
||||
* @param imgValue
|
||||
* @returns
|
||||
*/
|
||||
public static absToRel(imgValue: string) {
|
||||
const wsFolder = Folders.getWorkspaceFolder();
|
||||
const staticFolder = Settings.get<string>(SETTINGS_CONTENT_STATIC_FOLDERS);
|
||||
|
||||
let relPath = imgValue || "";
|
||||
if (imgValue) {
|
||||
relPath = imgValue.split(wsFolder?.fsPath || "").pop() || "";
|
||||
relPath = imgValue.split(staticFolder || "").pop() || "";
|
||||
}
|
||||
return relPath;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export interface Field {
|
||||
type: "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories";
|
||||
choices?: string[] | Choice[];
|
||||
single?: boolean;
|
||||
multiSelect?: boolean;
|
||||
multiple?: boolean;
|
||||
isPreviewImage?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { PreviewImageValue } from './PreviewImageField';
|
||||
|
||||
export interface IPreviewImageProps {
|
||||
value: PreviewImageValue;
|
||||
onRemove: (value: string) => void;
|
||||
}
|
||||
|
||||
export const PreviewImage: React.FunctionComponent<IPreviewImageProps> = ({ value, onRemove }: React.PropsWithChildren<IPreviewImageProps>) => {
|
||||
return (
|
||||
<div className={`metadata_field__preview_image__preview`}>
|
||||
<img src={value.webviewUrl} />
|
||||
|
||||
<button type="button" onClick={() => onRemove(value.original)} className={`metadata_field__preview_image__remove`}>Remove image</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,24 +3,38 @@ import * as React from 'react';
|
||||
import { MessageHelper } from '../../../helpers/MessageHelper';
|
||||
import { CommandToCode } from '../../CommandToCode';
|
||||
import { VsLabel } from '../VscodeComponents';
|
||||
import { PreviewImage } from './PreviewImage';
|
||||
|
||||
export interface PreviewImageValue {
|
||||
original: string;
|
||||
webviewUrl: string;
|
||||
}
|
||||
|
||||
export interface IPreviewImageFieldProps {
|
||||
label: string;
|
||||
fieldName: string;
|
||||
value: string | null;
|
||||
value: PreviewImageValue | PreviewImageValue[] | null;
|
||||
filePath: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
multiple?: boolean;
|
||||
onChange: (value: string | string[] | null) => void;
|
||||
}
|
||||
|
||||
export const PreviewImageField: React.FunctionComponent<IPreviewImageFieldProps> = ({label, fieldName, onChange, value, filePath}: React.PropsWithChildren<IPreviewImageFieldProps>) => {
|
||||
export const PreviewImageField: React.FunctionComponent<IPreviewImageFieldProps> = ({label, fieldName, onChange, value, filePath, multiple}: React.PropsWithChildren<IPreviewImageFieldProps>) => {
|
||||
|
||||
const selectImage = () => {
|
||||
MessageHelper.sendMessage(CommandToCode.selectImage, {
|
||||
filePath,
|
||||
fieldName
|
||||
filePath: filePath,
|
||||
fieldName,
|
||||
value,
|
||||
multiple
|
||||
});
|
||||
};
|
||||
|
||||
const onImageRemove = (imageToRemove: string) => {
|
||||
const newValue = value && Array.isArray(value) ? value.filter(image => image.original !== imageToRemove).map(i => i.original) : null;
|
||||
onChange(newValue);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`metadata_field`}>
|
||||
<VsLabel>
|
||||
@@ -29,16 +43,27 @@ export const PreviewImageField: React.FunctionComponent<IPreviewImageFieldProps>
|
||||
</div>
|
||||
</VsLabel>
|
||||
|
||||
<div className={`metadata_field__preview_image`}>
|
||||
<div className={`metadata_field__preview_image ${multiple && value && (value as PreviewImageValue[]).length > 0 ? `metadata_field__multiple_images` : ''}`}>
|
||||
{
|
||||
value ? (
|
||||
<div>
|
||||
<img src={value} />
|
||||
(!value || multiple) && (
|
||||
<button className={`metadata_field__preview_image__button`} title={`Add your ${label?.toLowerCase() || "image"}`} type="button" onClick={selectImage}>
|
||||
<PhotographIcon />
|
||||
<span className="mt-2 block text-sm font-medium text-gray-900">Add your {label?.toLowerCase() || "image"}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
<button onClick={() => onChange(null)} className={`metadata_field__preview_image__remove`}>Remove image</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={selectImage}>Select image</button>
|
||||
{
|
||||
value && !Array.isArray(value) && (
|
||||
<PreviewImage value={value} onRemove={() => onChange(null)} />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
multiple && value && Array.isArray(value) && (
|
||||
value.map(image => (
|
||||
<PreviewImage key={image.original} value={image} onRemove={onImageRemove} />
|
||||
))
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { parseJSON } from 'date-fns';
|
||||
import { DateTimeField } from './Fields/DateTimeField';
|
||||
import { TextField } from './Fields/TextField';
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import { PreviewImageField } from './Fields/PreviewImageField';
|
||||
import { PreviewImageField, PreviewImageValue } from './Fields/PreviewImageField';
|
||||
import { ListUnorderedIcon } from './Icons/ListUnorderedIcon';
|
||||
import { NumberField } from './Fields/NumberField';
|
||||
import { ChoiceField } from './Fields/ChoiceField';
|
||||
@@ -116,7 +116,8 @@ export const Metadata: React.FunctionComponent<IMetadataProps> = ({settings, met
|
||||
label={field.title || field.name}
|
||||
fieldName={field.name}
|
||||
filePath={metadata.filePath as string}
|
||||
value={metadata[field.name] as string}
|
||||
value={metadata[field.name] as PreviewImageValue | PreviewImageValue[] | null}
|
||||
multiple={field.multiple}
|
||||
onChange={(value => sendUpdate(field.name, value))} />
|
||||
);
|
||||
} else if (field.type === 'choice') {
|
||||
@@ -129,7 +130,7 @@ export const Metadata: React.FunctionComponent<IMetadataProps> = ({settings, met
|
||||
label={field.title || field.name}
|
||||
selected={choiceValue as string}
|
||||
choices={choices}
|
||||
multiSelect={field.multiSelect}
|
||||
multiSelect={field.multiple}
|
||||
onChange={(value => sendUpdate(field.name, value))} />
|
||||
);
|
||||
} else if (field.type === 'tags') {
|
||||
|
||||
Reference in New Issue
Block a user