From 55a14b3fbefa7c58c3944ee34a7f27cf04cdb51d Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Wed, 20 Apr 2022 11:02:32 +0200 Subject: [PATCH] #308 - Implementation of the file field --- assets/media/styles.css | 4 + package.json | 30 +++++- src/commands/Article.ts | 2 +- src/constants/Extension.ts | 2 +- src/constants/TelemetryEvent.ts | 1 + src/dashboardWebView/DashboardMessage.ts | 1 + .../components/Media/Item.tsx | 41 ++++--- .../components/Media/Media.tsx | 17 ++- src/extension.ts | 2 +- src/helpers/MediaHelpers.ts | 21 ++-- src/listeners/dashboard/MediaListener.ts | 4 + src/listeners/panel/DataListener.ts | 10 +- src/listeners/panel/MediaListener.ts | 3 + src/models/DashboardData.ts | 4 + src/models/PanelSettings.ts | 3 +- src/panelWebView/CommandToCode.ts | 1 + .../components/Fields/FileField.tsx | 102 ++++++++++++++++++ .../components/Fields/PreviewImageField.tsx | 5 +- .../components/Fields/WrapperField.tsx | 16 +++ src/panelWebView/styles.css | 74 +++++++++++++ 20 files changed, 306 insertions(+), 37 deletions(-) create mode 100644 src/panelWebView/components/Fields/FileField.tsx diff --git a/assets/media/styles.css b/assets/media/styles.css index fc387807..b349f165 100644 --- a/assets/media/styles.css +++ b/assets/media/styles.css @@ -639,6 +639,7 @@ input:checked + .field__toggle__slider:before { max-height: 16rem; } +.metadata_field__file__button, .metadata_field__preview_image__button { background-color: transparent; border: 1px dashed var(--vscode-button-background); @@ -646,11 +647,13 @@ input:checked + .field__toggle__slider:before { filter: brightness(85%); } +.metadata_field__file__button:hover, .metadata_field__preview_image__button:hover { background-color: rgba(255, 255, 255, .1); filter: brightness(100%); } +.metadata_field__file__button svg, .metadata_field__preview_image__button svg { color: var(--vscode-foreground); display: block; @@ -659,6 +662,7 @@ input:checked + .field__toggle__slider:before { margin: 0 auto; } +.metadata_field__file__button span, .metadata_field__preview_image__button span { color: var(--vscode-foreground); display: inline-block; diff --git a/package.json b/package.json index a706be5d..9d6f5ff1 100644 --- a/package.json +++ b/package.json @@ -744,6 +744,7 @@ "datetime", "boolean", "image", + "file", "choice", "taxonomy", "tags", @@ -827,6 +828,13 @@ "default": "", "description": "The ID of your taxonomy field" }, + "fileExtensions": { + "type": "array", + "description": "Specify the file extensions to allow for the file picker", + "items": { + "type": "string" + } + }, "fields": { "$ref": "#contenttypefield" }, @@ -874,6 +882,20 @@ "name" ], "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "file" + } + } + }, + "then": { + "required": [ + "fileExtensions" + ] + } + }, { "if": { "properties": { @@ -1305,8 +1327,8 @@ "category": "Front matter" }, { - "command": "frontMatter.insertImage", - "title": "Insert image into your content", + "command": "frontMatter.insertMedia", + "title": "Insert media into your content", "category": "Front matter", "icon": { "dark": "/assets/icons/media-dark.svg", @@ -1468,7 +1490,7 @@ "when": "frontMatter:file:isValid == true && frontMatter:dashboard:snippets:enabled" }, { - "command": "frontMatter.insertImage", + "command": "frontMatter.insertMedia", "group": "navigation@-128", "when": "frontMatter:file:isValid == true" }, @@ -1625,7 +1647,7 @@ "when": "frontMatter:file:isValid == true && frontMatter:dashboard:snippets:enabled" }, { - "command": "frontMatter.insertImage", + "command": "frontMatter.insertMedia", "when": "frontMatter:file:isValid == true" }, { diff --git a/src/commands/Article.ts b/src/commands/Article.ts index aedf16af..4015443b 100644 --- a/src/commands/Article.ts +++ b/src/commands/Article.ts @@ -324,7 +324,7 @@ export class Article { /** * Insert an image from the media dashboard into the article */ - public static async insertImage() { + public static async insertMedia() { let editor = vscode.window.activeTextEditor; if (!editor) { return; diff --git a/src/constants/Extension.ts b/src/constants/Extension.ts index 8a77b662..c3331a7f 100644 --- a/src/constants/Extension.ts +++ b/src/constants/Extension.ts @@ -38,7 +38,7 @@ export const COMMAND_NAME = { modeSwitch: getCommandName("mode.switch"), // Insert dashboards - insertImage: getCommandName("insertImage"), + insertMedia: getCommandName("insertMedia"), insertSnippet: getCommandName("insertSnippet"), // WYSIWYG diff --git a/src/constants/TelemetryEvent.ts b/src/constants/TelemetryEvent.ts index a2627123..b04b0232 100644 --- a/src/constants/TelemetryEvent.ts +++ b/src/constants/TelemetryEvent.ts @@ -22,6 +22,7 @@ export const TelemetryEvent = { refreshMedia: 'refreshMedia', deleteMedia: 'deleteMedia', insertMediaToContent: 'insertMediaToContent', + insertFileToContent: 'insertFileToContent', updateMediaMetadata: 'updateMediaMetadata', openExplorerView: 'openExplorerView', diff --git a/src/dashboardWebView/DashboardMessage.ts b/src/dashboardWebView/DashboardMessage.ts index f8a38334..8fb42fbc 100644 --- a/src/dashboardWebView/DashboardMessage.ts +++ b/src/dashboardWebView/DashboardMessage.ts @@ -25,6 +25,7 @@ export enum DashboardMessage { insertPreviewImage = 'insertPreviewImage', updateMediaMetadata = 'updateMediaMetadata', createMediaFolder = 'createMediaFolder', + insertFile = 'insertFile', // Data dashboard getDataEntries = 'getDataEntries', diff --git a/src/dashboardWebView/components/Media/Item.tsx b/src/dashboardWebView/components/Media/Item.tsx index f8856d4f..af468957 100644 --- a/src/dashboardWebView/components/Media/Item.tsx +++ b/src/dashboardWebView/components/Media/Item.tsx @@ -9,7 +9,6 @@ import { CustomScript } from '../../../helpers/CustomScript'; import { parseWinPath } from '../../../helpers/parseWinPath'; import { ScriptType } from '../../../models'; import { MediaInfo } from '../../../models/MediaPaths'; -import { FileIcon } from '../../../panelWebView/components/Icons/FileIcon'; import { DashboardMessage } from '../../DashboardMessage'; import { LightboxAtom, SelectedMediaFolderSelector, SettingsSelector, ViewDataSelector } from '../../state'; import { MenuItem, MenuItems } from '../Menu'; @@ -74,18 +73,32 @@ export const Item: React.FunctionComponent = ({media}: React.PropsWi const insertToArticle = () => { const relPath = getRelPath(); - Messenger.send(DashboardMessage.insertPreviewImage, { - image: parseWinPath(relPath) || "", - file: viewData?.data?.filePath, - fieldName: viewData?.data?.fieldName, - parents: viewData?.data?.parents, - multiple: viewData?.data?.multiple, - value: viewData?.data?.value, - position: viewData?.data?.position || null, - blockData: typeof viewData?.data?.blockData !== "undefined" ? viewData?.data?.blockData : undefined, - alt: alt || "", - caption: caption || "" - }); + + if (viewData?.data?.type === "file") { + Messenger.send(DashboardMessage.insertFile, { + relPath: parseWinPath(relPath) || "", + file: viewData?.data?.filePath, + fieldName: viewData?.data?.fieldName, + parents: viewData?.data?.parents, + multiple: viewData?.data?.multiple, + value: viewData?.data?.value, + position: viewData?.data?.position || null, + blockData: typeof viewData?.data?.blockData !== "undefined" ? viewData?.data?.blockData : undefined + }); + } else { + Messenger.send(DashboardMessage.insertPreviewImage, { + relPath: parseWinPath(relPath) || "", + file: viewData?.data?.filePath, + fieldName: viewData?.data?.fieldName, + parents: viewData?.data?.parents, + multiple: viewData?.data?.multiple, + value: viewData?.data?.value, + position: viewData?.data?.position || null, + blockData: typeof viewData?.data?.blockData !== "undefined" ? viewData?.data?.blockData : undefined, + alt: alt || "", + caption: caption || "" + }); + } }; const insertSnippet = () => { @@ -100,7 +113,7 @@ export const Item: React.FunctionComponent = ({media}: React.PropsWi snippet = snippet?.replace("{mediaHeight}", media?.dimensions?.height?.toString() || ""); Messenger.send(DashboardMessage.insertPreviewImage, { - image: parseWinPath(relPath) || "", + relPath: parseWinPath(relPath) || "", file: viewData?.data?.filePath, fieldName: viewData?.data?.fieldName, position: viewData?.data?.position || null, diff --git a/src/dashboardWebView/components/Media/Media.tsx b/src/dashboardWebView/components/Media/Media.tsx index 24274318..2c62e883 100644 --- a/src/dashboardWebView/components/Media/Media.tsx +++ b/src/dashboardWebView/components/Media/Media.tsx @@ -17,7 +17,7 @@ import useMedia from '../../hooks/useMedia'; import { TelemetryEvent } from '../../../constants'; import { PageLayout } from '../Layout/PageLayout'; import { parseWinPath } from '../../../helpers/parseWinPath'; -import { join } from 'path'; +import { extname, join } from 'path'; export interface IMediaProps {} @@ -40,12 +40,23 @@ export const Media: React.FunctionComponent = (props: React.PropsWi }, [folders, viewData, settings?.staticFolder]); const allMedia = React.useMemo(() => { + let mediaFiles = media; // Check if content allows page bundle if (viewData && viewData.data && typeof viewData.data.pageBundle !== "undefined" && !viewData.data.pageBundle) { - return media.filter(m => parseWinPath(m.fsPath).includes(join('/', settings?.staticFolder || '', '/'))); + mediaFiles = media.filter(m => parseWinPath(m.fsPath).includes(join('/', settings?.staticFolder || '', '/'))); } - return media; + if (viewData && viewData.data && viewData.data.type === "file" && viewData.data.fileExtensions && viewData.data.fileExtensions.length > 0) { + const supportedExtensions = viewData.data.fileExtensions; + mediaFiles = mediaFiles.filter(m => { + const ext = extname(m.fsPath); + // Remove the dot from the extension + const extWithoutDot = ext.substring(1); + return supportedExtensions.includes(extWithoutDot); + }); + } + + return mediaFiles; }, [media, viewData, settings?.staticFolder]); const onDrop = useCallback((acceptedFiles: File[]) => { diff --git a/src/extension.ts b/src/extension.ts index acc16587..25cc9ccb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -211,7 +211,7 @@ export async function activate(context: vscode.ExtensionContext) { subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.preview, () => Preview.open(extensionPath) )); // Inserting an image in Markdown - subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.insertImage, Article.insertImage)); + subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.insertMedia, Article.insertMedia)); // Inserting a snippet in Markdown subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.insertSnippet, Article.insertSnippet)); diff --git a/src/helpers/MediaHelpers.ts b/src/helpers/MediaHelpers.ts index 42df21d9..3f45dc82 100644 --- a/src/helpers/MediaHelpers.ts +++ b/src/helpers/MediaHelpers.ts @@ -270,7 +270,7 @@ export class MediaHelpers { * @param data */ public static async insertMediaToMarkdown(data: any) { - if (data?.file && data?.image) { + if (data?.file && data?.relPath) { if (!data?.position) { await commands.executeCommand(`workbench.view.extension.frontmatter-explorer`); } @@ -281,12 +281,12 @@ export class MediaHelpers { const editor = window.activeTextEditor; const wsFolder = Folders.getWorkspaceFolder(); const filePath = data.file; - let imgPath = data.image; + let relPath = data.relPath; const article = editor ? ArticleHelper.getFrontMatter(editor) : null; const articleCt = article && article.data ? ArticleHelper.getContentType(article.data) : DEFAULT_CONTENT_TYPE; - const absImgPath = join(parseWinPath(wsFolder?.fsPath || ""), imgPath); + const absImgPath = join(parseWinPath(wsFolder?.fsPath || ""), relPath); const fileDir = parseWinPath(dirname(filePath)); const imgDir = parseWinPath(dirname(absImgPath)); const contentFolders = Folders.get(); @@ -303,11 +303,11 @@ export class MediaHelpers { if (existsInContent) { const relImgPath = relative(fileDir, imgDir); - imgPath = join(relImgPath, basename(imgPath)); + relPath = join(relImgPath, basename(relPath)); // Snippets are already parsed, so update the URL of the image if (data.snippet) { - data.snippet = data.snippet.replace(data.image, imgPath); + data.snippet = data.snippet.replace(data.relPath, relPath); } } } @@ -319,7 +319,14 @@ export class MediaHelpers { if (line) { const selection = editor?.selection; await editor?.edit(builder => { - const snippet = data.snippet || `![${data.alt || data.caption || ""}](${imgPath})`; + const mimeType = lookup(relPath) + + let isFile = true; + if (mimeType) { + isFile = !mimeType.startsWith('image'); + } + + const snippet = data.snippet || `${isFile ? "" : "!"}[${data.alt || data.caption || ""}](${relPath})`; if (selection !== undefined) { builder.replace(selection, snippet); } else { @@ -333,7 +340,7 @@ export class MediaHelpers { DataListener.updateMetadata({ field: data.fieldName, - value: imgPath, + value: relPath, parents: data.parents, blockData: data.blockData }); diff --git a/src/listeners/dashboard/MediaListener.ts b/src/listeners/dashboard/MediaListener.ts index 21ad392c..f4037d24 100644 --- a/src/listeners/dashboard/MediaListener.ts +++ b/src/listeners/dashboard/MediaListener.ts @@ -40,6 +40,10 @@ export class MediaListener extends BaseListener { Telemetry.send(TelemetryEvent.insertMediaToContent); MediaHelpers.insertMediaToMarkdown(msg?.data); break; + case DashboardMessage.insertFile: + Telemetry.send(TelemetryEvent.insertFileToContent); + MediaHelpers.insertMediaToMarkdown(msg?.data); + break; case DashboardMessage.updateMediaMetadata: Telemetry.send(TelemetryEvent.updateMediaMetadata); this.update(msg.data); diff --git a/src/listeners/panel/DataListener.ts b/src/listeners/panel/DataListener.ts index 9056380a..faf5ba59 100644 --- a/src/listeners/panel/DataListener.ts +++ b/src/listeners/panel/DataListener.ts @@ -147,12 +147,14 @@ export class DataListener extends BaseListener { const contentType = ArticleHelper.getContentType(article.data); const dateFields = contentType.fields.filter((f) => f.type === "datetime"); const imageFields = contentType.fields.filter((f) => f.type === "image" && f.multiple); + const fileFields = contentType.fields.filter((f) => f.type === "file" && f.multiple); // Support multi-level fields const parentObj = DataListener.getParentObject(article.data, article, parents, blockData); const isDateField = dateFields.some(f => f.name === field); const isMultiImageField = imageFields.some(f => f.name === field); + const isMultiFileField = fileFields.some(f => f.name === field); if (isDateField) { for (const dateField of dateFields) { @@ -160,9 +162,11 @@ export class DataListener extends BaseListener { parentObj[field] = Article.formatDate(new Date(value)); } } - } else if (isMultiImageField) { - for (const imageField of imageFields) { - if (field === imageField.name) { + } else if (isMultiImageField || isMultiFileField) { + const fields = isMultiImageField ? imageFields : fileFields; + + for (const crntField of fields) { + if (field === crntField.name) { // If value is an array, it means it comes from the explorer view itself (deletion) if (Array.isArray(value)) { parentObj[field] = value || []; diff --git a/src/listeners/panel/MediaListener.ts b/src/listeners/panel/MediaListener.ts index 7df4cae0..0ebd764f 100644 --- a/src/listeners/panel/MediaListener.ts +++ b/src/listeners/panel/MediaListener.ts @@ -22,6 +22,9 @@ export class MediaListener extends BaseListener { case CommandToCode.selectImage: this.selectMedia(msg); break; + case CommandToCode.selectFile: + this.selectMedia(msg); + break; case CommandToCode.getImageUrl: this.generateUrl(msg.data); break; diff --git a/src/models/DashboardData.ts b/src/models/DashboardData.ts index b33b2385..19fcc5a5 100644 --- a/src/models/DashboardData.ts +++ b/src/models/DashboardData.ts @@ -19,4 +19,8 @@ export interface ViewData { parents?: string[]; multiple?: string[]; value?: string; + + // File fields + type: "file" | "media"; + fileExtensions?: string[]; } \ No newline at end of file diff --git a/src/models/PanelSettings.ts b/src/models/PanelSettings.ts index 77696342..6273514a 100644 --- a/src/models/PanelSettings.ts +++ b/src/models/PanelSettings.ts @@ -48,7 +48,7 @@ export interface ContentType { pageBundle?: boolean; } -export type FieldType = "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories" | "draft" | "taxonomy" | "fields" | "json" | "block"; +export type FieldType = "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories" | "draft" | "taxonomy" | "fields" | "json" | "block" | "file"; export interface Field { title?: string; @@ -66,6 +66,7 @@ export interface Field { fieldGroup?: string | string[]; dataType?: string | string[]; taxonomyLimit?: number; + fileExtensions?: string[]; // Date fields isPublishDate?: boolean; diff --git a/src/panelWebView/CommandToCode.ts b/src/panelWebView/CommandToCode.ts index 84fd242b..c0946944 100644 --- a/src/panelWebView/CommandToCode.ts +++ b/src/panelWebView/CommandToCode.ts @@ -26,6 +26,7 @@ export enum CommandToCode { updateMetadata = "update-metadata", openDashboard = "open-dashboard", selectImage = "select-image", + selectFile = "select-file", updateCustomTaxonomy = "updateCustomTaxonomy", addToCustomTaxonomy = "addToCustomTaxonomy", frameworkCommand = "framework-command", diff --git a/src/panelWebView/components/Fields/FileField.tsx b/src/panelWebView/components/Fields/FileField.tsx new file mode 100644 index 00000000..30885c34 --- /dev/null +++ b/src/panelWebView/components/Fields/FileField.tsx @@ -0,0 +1,102 @@ +import { DocumentIcon, PaperClipIcon, TrashIcon } from '@heroicons/react/outline'; +import { basename } from 'path'; +import * as React from 'react'; +import { useCallback, useMemo } from 'react'; +import { MessageHelper } from '../../../helpers/MessageHelper'; +import { BlockFieldData } from '../../../models'; +import { CommandToCode } from '../../CommandToCode'; +import { VsLabel } from '../VscodeComponents'; + +export interface IFileFieldProps { + label: string; + fieldName: string; + filePath: string; + multiple?: boolean; + value: string | string[] | null; + fileExtensions?: string[]; + parents?: string[]; + blockData?: BlockFieldData; + onChange: (value: string | string[] | null) => void; +} + +const File = ({ value, onRemove }: { value: string, onRemove: (value: string) => void }) => { + return ( +
+
+ +
+ {basename(value)} + + +
+ ) +} + +export const FileField: React.FunctionComponent = ({ label, multiple, filePath, fileExtensions, fieldName, value, parents, blockData, onChange }: React.PropsWithChildren) => { + + const selectFile = useCallback(() => { + MessageHelper.sendMessage(CommandToCode.selectFile, { + filePath, + fieldName, + value, + multiple, + parents, + blockData, + fileExtensions, + type: "file" + }); + }, [filePath, fieldName, value, multiple, parents]); + + const onRemove = useCallback((crntValue) => { + const newValue = value && Array.isArray(value) ? value.filter(v => v !== crntValue) : null; + onChange(newValue); + } , [value]); + + const isEmpty = useMemo(() => { + return !value || (Array.isArray(value) && value.length === 0); + }, [value]); + + return ( +
+ +
+ {label} +
+
+ +
+ { + (isEmpty || multiple) && ( + + ) + } + + { + value && !Array.isArray(value) && ( +
+ +
+ ) + } + + { + multiple && value && Array.isArray(value) && ( +
+ { + value.map((v, idx) => ( + + )) + } +
+ ) + } +
+
+ ); +}; \ No newline at end of file diff --git a/src/panelWebView/components/Fields/PreviewImageField.tsx b/src/panelWebView/components/Fields/PreviewImageField.tsx index b8aefc6f..733233b7 100644 --- a/src/panelWebView/components/Fields/PreviewImageField.tsx +++ b/src/panelWebView/components/Fields/PreviewImageField.tsx @@ -42,7 +42,8 @@ export const PreviewImageField: React.FunctionComponent multiple, metadataInsert: true, parents, - blockData + blockData, + type: "media" }); }, [filePath, fieldName, value, multiple, parents]); @@ -64,7 +65,7 @@ export const PreviewImageField: React.FunctionComponent (!value || multiple) && ( ) } diff --git a/src/panelWebView/components/Fields/WrapperField.tsx b/src/panelWebView/components/Fields/WrapperField.tsx index b90d777f..a4af9138 100644 --- a/src/panelWebView/components/Fields/WrapperField.tsx +++ b/src/panelWebView/components/Fields/WrapperField.tsx @@ -19,6 +19,7 @@ import { VsLabel } from '../VscodeComponents'; import { ChoiceField } from './ChoiceField'; import { DateTimeField } from './DateTimeField'; import { DraftField } from './DraftField'; +import { FileField } from './FileField'; import { NumberField } from './NumberField'; import { PreviewImageField, PreviewImageValue } from './PreviewImageField'; import { TextField } from './TextField'; @@ -188,6 +189,21 @@ export const WrapperField: React.FunctionComponent = ({ onChange={(value) => onSendUpdate(field.name, value, parentFields)} /> ); + } else if (field.type === 'file') { + return ( + + onSendUpdate(field.name, value, parentFields)} /> + + ); } else if (field.type === 'choice') { const choices = field.choices || []; diff --git a/src/panelWebView/styles.css b/src/panelWebView/styles.css index d00e536f..4144d90d 100644 --- a/src/panelWebView/styles.css +++ b/src/panelWebView/styles.css @@ -253,6 +253,80 @@ display: none; } +/* File field */ +.metadata_field__file__button.not_empty { + display: flex; + align-items: center; + justify-content: center; +} + +.metadata_field__file__button.not_empty svg { + height: 16px; + width: 16px; + display: inline-block; + margin: inherit; +} + +.metadata_field__file__button.not_empty span { + margin-top: 0; + display: inline-block; + margin: inherit; + margin-left: .5rem; +} + +.metadata_field__file__list.multiple { + margin-top: 1rem; +} + +.metadata_field__file__list__item { + display: flex; + align-items: center; + padding: .5rem .25rem; + overflow: hidden; +} + +.metadata_field__file__list__item:nth-child(odd) { + background-color: rgba(255, 255, 255, .05); + filter: brightness(100%); +} + +.metadata_field__file__list__item:hover { + background-color: rgba(255, 255, 255, .1); + filter: brightness(100%); +} + +.metadata_field__file__item__icon { + margin-right: .5rem; + display: flex; + align-items: center; +} + +.metadata_field__file__item__text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-grow: 1; +} + +.metadata_field__file__item__remove { + display: none; + background: none; + width: auto; + color: var(--vscode-inputValidation-errorForeground); + padding: 0; + align-items: center; +} + +.metadata_field__file__item__remove:hover { + background: var(--vscode-inputValidation-errorBackground); +} + +.metadata_field__file__list__item:hover .metadata_field__file__item__remove { + display: flex; + margin: -.5rem -.25rem -.5rem 0; + padding: .5rem; +} + /* Quill changes */ .ql-toolbar.ql-snow, .ql-container.ql-snow {