From 6154164b4b7b124e4c6837639e7f9f8a022f309d Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Mon, 4 Oct 2021 11:32:52 +0200 Subject: [PATCH] =?UTF-8?q?#128=20-=20=F0=9F=9A=80=20multi=20image=20selec?= =?UTF-8?q?tion=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + assets/media/styles.css | 41 ++++++++++ package.json | 2 +- src/commands/Dashboard.ts | 2 +- .../components/Media/Item.tsx | 8 +- src/explorerView/ExplorerView.ts | 55 +++++++++---- src/helpers/ContentType.ts | 5 +- src/helpers/ImageHelper.ts | 80 +++++++++++++++++++ src/models/PanelSettings.ts | 2 +- .../components/Fields/PreviewImage.tsx | 17 ++++ .../components/Fields/PreviewImageField.tsx | 51 +++++++++--- src/panelWebView/components/Metadata.tsx | 7 +- 12 files changed, 232 insertions(+), 39 deletions(-) create mode 100644 src/helpers/ImageHelper.ts create mode 100644 src/panelWebView/components/Fields/PreviewImage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c28037a..8566dcf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/assets/media/styles.css b/assets/media/styles.css index 4356b2ed..153fe8bc 100644 --- a/assets/media/styles.css +++ b/assets/media/styles.css @@ -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); diff --git a/package.json b/package.json index 6d8497d1..e49df3e0 100644 --- a/package.json +++ b/package.json @@ -291,7 +291,7 @@ "type": "boolean", "description": "Is a single line field" }, - "multiSelect": { + "multiple": { "type": "boolean", "description": "Do you allow to select multiple values?" }, diff --git a/src/commands/Dashboard.ts b/src/commands/Dashboard.ts index 5665e286..ebfdcb6a 100644 --- a/src/commands/Dashboard.ts +++ b/src/commands/Dashboard.ts @@ -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 }); } } } diff --git a/src/dashboardWebView/components/Media/Item.tsx b/src/dashboardWebView/components/Media/Item.tsx index 81874481..c7c567b4 100644 --- a/src/dashboardWebView/components/Media/Item.tsx +++ b/src/dashboardWebView/components/Media/Item.tsx @@ -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 = ({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 = ({media}: React.PropsWi viewData?.data?.filePath ? ( <> { (viewData?.data?.position && settings?.mediaSnippet && settings?.mediaSnippet.length > 0) && ( diff --git a/src/explorerView/ExplorerView.ts b/src/explorerView/ExplorerView.ts index 7fc485b6..9eab2b09 100644 --- a/src/explorerView/ExplorerView.ts +++ b/src/explorerView/ExplorerView.ts @@ -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); diff --git a/src/helpers/ContentType.ts b/src/helpers/ContentType.ts index 2748198f..11924f68 100644 --- a/src/helpers/ContentType.ts +++ b/src/helpers/ContentType.ts @@ -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) { diff --git a/src/helpers/ImageHelper.ts b/src/helpers/ImageHelper.ts new file mode 100644 index 00000000..fa1e78b6 --- /dev/null +++ b/src/helpers/ImageHelper.ts @@ -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(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(SETTINGS_CONTENT_STATIC_FOLDERS); + + let relPath = imgValue || ""; + if (imgValue) { + relPath = imgValue.split(wsFolder?.fsPath || "").pop() || ""; + relPath = imgValue.split(staticFolder || "").pop() || ""; + } + return relPath; + } +} \ No newline at end of file diff --git a/src/models/PanelSettings.ts b/src/models/PanelSettings.ts index 7693e5bb..ec0eb774 100644 --- a/src/models/PanelSettings.ts +++ b/src/models/PanelSettings.ts @@ -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; } diff --git a/src/panelWebView/components/Fields/PreviewImage.tsx b/src/panelWebView/components/Fields/PreviewImage.tsx new file mode 100644 index 00000000..d564fb4e --- /dev/null +++ b/src/panelWebView/components/Fields/PreviewImage.tsx @@ -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 = ({ value, onRemove }: React.PropsWithChildren) => { + return ( +
+ + + +
+ ); +}; \ No newline at end of file diff --git a/src/panelWebView/components/Fields/PreviewImageField.tsx b/src/panelWebView/components/Fields/PreviewImageField.tsx index b70f472f..d65718f9 100644 --- a/src/panelWebView/components/Fields/PreviewImageField.tsx +++ b/src/panelWebView/components/Fields/PreviewImageField.tsx @@ -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 = ({label, fieldName, onChange, value, filePath}: React.PropsWithChildren) => { +export const PreviewImageField: React.FunctionComponent = ({label, fieldName, onChange, value, filePath, multiple}: React.PropsWithChildren) => { 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 (
@@ -29,16 +43,27 @@ export const PreviewImageField: React.FunctionComponent
-
+
0 ? `metadata_field__multiple_images` : ''}`}> { - value ? ( -
- + (!value || multiple) && ( + + ) + } - -
- ) : ( - + { + value && !Array.isArray(value) && ( + onChange(null)} /> + ) + } + + { + multiple && value && Array.isArray(value) && ( + value.map(image => ( + + )) ) }
diff --git a/src/panelWebView/components/Metadata.tsx b/src/panelWebView/components/Metadata.tsx index a7d7be57..dec0da9e 100644 --- a/src/panelWebView/components/Metadata.tsx +++ b/src/panelWebView/components/Metadata.tsx @@ -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 = ({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 = ({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') {