diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb4f8d3..31340439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,17 @@ # Change Log -## [6.2.0] - 2022-03-xx +## [7.0.0] - 2022-03-xx + +### ✨ Features + +- [#175](https://github.com/estruyf/vscode-front-matter/issues/175): New snippet support + dashboard ### 🎨 Enhancements - Light color theme enhancements to media cards - Light color theme enhancements to folder cards - [#272](https://github.com/estruyf/vscode-front-matter/issues/272): New slide over panel for showing details of media files +- [#276](https://github.com/estruyf/vscode-front-matter/issues/276): Add a Front Matter walkthrough for VS Code ## [6.1.0] - 2022-02-28 - [Release notes](https://beta.frontmatter.codes/updates/v6.1.0) diff --git a/assets/empty.svg b/assets/empty.svg new file mode 100644 index 00000000..8872d161 --- /dev/null +++ b/assets/empty.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/assets/icons/scissors-dark.svg b/assets/icons/scissors-dark.svg new file mode 100644 index 00000000..514110d3 --- /dev/null +++ b/assets/icons/scissors-dark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/scissors-light.svg b/assets/icons/scissors-light.svg new file mode 100644 index 00000000..780b674e --- /dev/null +++ b/assets/icons/scissors-light.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/walkthrough/documentation.md b/assets/walkthrough/documentation.md new file mode 100644 index 00000000..d5751e00 --- /dev/null +++ b/assets/walkthrough/documentation.md @@ -0,0 +1,3 @@ +## Documentation + +Our documentation can be found at: [https://frontmatter.codes/docs](https://frontmatter.codes/docs) \ No newline at end of file diff --git a/assets/walkthrough/get-started.md b/assets/walkthrough/get-started.md new file mode 100644 index 00000000..9cfcfb31 --- /dev/null +++ b/assets/walkthrough/get-started.md @@ -0,0 +1,11 @@ +## Getting started + +Thanks for installing Front Matter! + +To get started, open our dashboard which will guide you through the initialization process of your project. + +When you haven't initialized your project yet, you will see the Front Matter's welcome screen on which you will have to perform the following steps: + +- Project initialization +- Content folders registration +- Framework initialization diff --git a/assets/walkthrough/support-the-project.md b/assets/walkthrough/support-the-project.md new file mode 100644 index 00000000..5b9362cd --- /dev/null +++ b/assets/walkthrough/support-the-project.md @@ -0,0 +1,8 @@ +## Support the project + +Front Matter is an open source project and we are always looking for new contributors, supporters, and partners. If you are interested in backing the project, please consider supporting it by donating. You can donate at via the following links: + +- [GitHub Sponsors](https://github.com/sponsors/estruyf) +- [Open Collective](https://opencollective.com/frontmatter) + +> Each sponsor/backer will be mentioned on the [Front Matter](https://frontmatter.codes) website and on the [GitHub repository](https://github.com/estruyf/vscode-front-matter). \ No newline at end of file diff --git a/package.json b/package.json index db0eb1aa..c36b4970 100644 --- a/package.json +++ b/package.json @@ -1089,7 +1089,7 @@ }, { "command": "frontMatter.markup.blockquote", - "title": "Codeblock", + "title": "Blockquote", "category": "Front matter", "icon": { "light": "assets/icons/blockquote-light.svg", @@ -1180,6 +1180,15 @@ "light": "/assets/icons/media-light.svg" } }, + { + "command": "frontMatter.insertSnippet", + "title": "Insert snippet into your content", + "category": "Front matter", + "icon": { + "dark": "/assets/icons/scissors-dark.svg", + "light": "/assets/icons/scissors-light.svg" + } + }, { "command": "frontMatter.insertTags", "title": "Insert tags", @@ -1212,6 +1221,15 @@ "light": "/assets/icons/frontmatter-small-light.svg" } }, + { + "command": "frontMatter.dashboard.snippets", + "title": "Open snippets dashboard", + "category": "Front matter", + "icon": { + "dark": "/assets/icons/frontmatter-small-dark.svg", + "light": "/assets/icons/frontmatter-small-light.svg" + } + }, { "command": "frontMatter.dashboard.media", "title": "Open media dashboard", @@ -1287,32 +1305,32 @@ "editor/title": [ { "command": "frontMatter.markup.heading", - "group": "navigation@-132", + "group": "navigation@-133", "when": "resourceLangId == markdown && frontMatter:markdown:wysiwyg" }, { "command": "frontMatter.markup.bold", - "group": "navigation@-131", + "group": "navigation@-132", "when": "resourceLangId == markdown && frontMatter:markdown:wysiwyg" }, { "command": "frontMatter.markup.italic", - "group": "navigation@-130", + "group": "navigation@-131", "when": "resourceLangId == markdown && frontMatter:markdown:wysiwyg" }, { "command": "frontMatter.markup.strikethrough", - "group": "navigation@-129", + "group": "navigation@-130", "when": "resourceLangId == markdown && frontMatter:markdown:wysiwyg" }, { - "command": "frontMatter.markup.blockquote", - "group": "navigation@-128", - "when": "resourceLangId == markdown && frontMatter:markdown:wysiwyg" + "command": "frontMatter.insertSnippet", + "group": "navigation@-129", + "when": "resourceLangId == markdown" }, { "command": "frontMatter.insertImage", - "group": "navigation@-127", + "group": "navigation@-128", "when": "resourceLangId == markdown" }, { @@ -1345,6 +1363,11 @@ "group": "1_markup@5", "when": "resourceLangId == markdown && frontMatter:markdown:wysiwyg" }, + { + "command": "frontMatter.markup.blockquote", + "group": "1_markup@6", + "when": "resourceLangId == markdown && frontMatter:markdown:wysiwyg" + }, { "command": "frontMatter.dashboard", "group": "navigation@-98", @@ -1466,6 +1489,42 @@ "text.html.markdown" ] } + ], + "walkthroughs": [ + { + "id": "frontmatter.welcome", + "title": "Get started with Front Matter", + "description": "Discover the features of Front Matter and learn how to use the CMS for your SSG or static site.", + "steps": [ + { + "id": "frontmatter.welcome.init", + "title": "Get started", + "description": "Initial steps to get started.\n[Open dashboard](command:frontMatter.dashboard)", + "media": { + "markdown": "assets/walkthrough/get-started.md" + }, + "completionEvents": ["onContext:frontMatterInitialized"] + }, + { + "id": "frontmatter.welcome.documentation", + "title": "Documentation", + "description": "Check out the documentation for Front Matter.\n[View our documentation](https://frontmatter.codes/docs)", + "media": { + "markdown": "assets/walkthrough/documentation.md" + }, + "completionEvents": ["onLink:https://frontmatter.codes/docs"] + }, + { + "id": "frontmatter.welcome.supporter", + "title": "Support the project", + "description": "Become a supporter.\n[Support the project](https://github.com/sponsors/estruyf)", + "media": { + "markdown": "assets/walkthrough/support-the-project.md" + }, + "completionEvents": ["onLink:https://github.com/sponsors/estruyf"] + } + ] + } ] }, "scripts": { diff --git a/src/commands/Article.ts b/src/commands/Article.ts index cc55675e..a7afe679 100644 --- a/src/commands/Article.ts +++ b/src/commands/Article.ts @@ -13,6 +13,7 @@ import { parseWinPath } from '../helpers/parseWinPath'; import { Telemetry } from '../helpers/Telemetry'; import { ParsedFrontMatter } from '../parsers'; import { MediaListener } from '../listeners/panel'; +import { NavigationType } from '../dashboardWebView/models'; export class Article { @@ -340,6 +341,29 @@ export class Article { MediaListener.getMediaSelection(); } + /** + * Insert a snippet into the article + */ + public static async insertSnippet() { + let editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + const position = editor.selection.active; + const selectionText = editor.document.getText(editor.selection); + + await vscode.commands.executeCommand(COMMAND_NAME.dashboard, { + type: NavigationType.Snippets, + data: { + filePath: editor.document.uri.fsPath, + fieldName: basename(editor.document.uri.fsPath), + position, + selection: selectionText + } + } as DashboardData); + } + /** * Get the current article */ diff --git a/src/commands/Project.ts b/src/commands/Project.ts index 5595c207..0fbc2fb2 100644 --- a/src/commands/Project.ts +++ b/src/commands/Project.ts @@ -7,6 +7,7 @@ import { Template } from "./Template"; import { Folders } from "./Folders"; import { Settings } from "../helpers"; import { SETTING_CONTENT_DEFAULT_FILETYPE, TelemetryEvent } from "../constants"; +import { SettingsListener } from '../listeners/dashboard'; export class Project { @@ -50,6 +51,8 @@ categories: [] } Telemetry.send(TelemetryEvent.initialization) + + SettingsListener.getSettings(); } catch (err: any) { Notifications.error(`Sorry, something went wrong - ${err?.message || err}`); } diff --git a/src/commands/Template.ts b/src/commands/Template.ts index fa5659b0..d164ed0f 100644 --- a/src/commands/Template.ts +++ b/src/commands/Template.ts @@ -23,6 +23,10 @@ export class Template { public static async init() { const isInitialized = await Template.isInitialized(); await vscode.commands.executeCommand('setContext', CONTEXT.canInit, !isInitialized); + + if (isInitialized) { + await vscode.commands.executeCommand('setContext', CONTEXT.initialized, true); + } } /** diff --git a/src/commands/Wysiwyg.ts b/src/commands/Wysiwyg.ts index 70201ce0..657335d2 100644 --- a/src/commands/Wysiwyg.ts +++ b/src/commands/Wysiwyg.ts @@ -54,6 +54,7 @@ export class Wysiwyg { { label: "$(tasklist) Task list", detail: "Add a task list", alwaysShow: true }, { label: "$(code) Code", detail: "Add inline code snippet", alwaysShow: true }, { label: "$(symbol-namespace) Code block", detail: "Add a code block", alwaysShow: true }, + { label: "$(quote) Blockquote", detail: "Add a blockquote", alwaysShow: true }, ] const option = await window.showQuickPick([ ...qpItems ], { @@ -73,6 +74,8 @@ export class Wysiwyg { await this.addMarkup(MarkupType.code); } else if (option.label === qpItems[4].label) { await this.addMarkup(MarkupType.codeblock); + } else if (option.label === qpItems[5].label) { + await this.addMarkup(MarkupType.blockquote); } } })); diff --git a/src/constants/Extension.ts b/src/constants/Extension.ts index 561a0541..e0446325 100644 --- a/src/constants/Extension.ts +++ b/src/constants/Extension.ts @@ -29,13 +29,17 @@ export const COMMAND_NAME = { preview: getCommandName("preview"), dashboard: getCommandName("dashboard"), dashboardMedia: getCommandName("dashboard.media"), + dashboardSnippets: getCommandName("dashboard.snippets"), dashboardData: getCommandName("dashboard.data"), dashboardClose: getCommandName("dashboard.close"), promote: getCommandName("promoteSettings"), - insertImage: getCommandName("insertImage"), createFolder: getCommandName("createFolder"), diagnostics: getCommandName("diagnostics"), + // Insert dashboards + insertImage: getCommandName("insertImage"), + insertSnippet: getCommandName("insertSnippet"), + // WYSIWYG bold: getCommandName("markup.bold"), italic: getCommandName("markup.italic"), diff --git a/src/constants/TelemetryEvent.ts b/src/constants/TelemetryEvent.ts index dfca5182..56a0aae5 100644 --- a/src/constants/TelemetryEvent.ts +++ b/src/constants/TelemetryEvent.ts @@ -4,6 +4,7 @@ export const TelemetryEvent = { openContentDashboard: 'openContentDashboard', openMediaDashboard: 'openMediaDashboard', openDataDashboard: 'openDataDashboard', + openSnippetsDashboard: 'openSnippetsDashboard', closeDashboard: 'closeDashboard', generateSlug: 'generateSlug', createContentFromTemplate: 'createContentFromTemplate', diff --git a/src/constants/context.ts b/src/constants/context.ts index 8f029de2..6f5b7689 100644 --- a/src/constants/context.ts +++ b/src/constants/context.ts @@ -1,5 +1,6 @@ export const CONTEXT = { canInit: "frontMatterCanInit", + initialized: "frontMatterInitialized", canOpenPreview: "frontMatterCanOpenPreview", canOpenDashboard: "frontMatterCanOpenDashboard", isEnabled: "frontMatter:enabled", diff --git a/src/dashboardWebView/DashboardMessage.ts b/src/dashboardWebView/DashboardMessage.ts index ad5ccb61..305bbda4 100644 --- a/src/dashboardWebView/DashboardMessage.ts +++ b/src/dashboardWebView/DashboardMessage.ts @@ -26,4 +26,6 @@ export enum DashboardMessage { putDataEntries = 'putDataEntries', sendTelemetry = 'sendTelemetry', insertSnippet = 'insertSnippet', + addSnippet = 'addSnippet', + updateSnippet = 'updateSnippet', } \ No newline at end of file diff --git a/src/dashboardWebView/components/Contents/Item.tsx b/src/dashboardWebView/components/Contents/Item.tsx index 29d62e18..05b8b6d4 100644 --- a/src/dashboardWebView/components/Contents/Item.tsx +++ b/src/dashboardWebView/components/Contents/Item.tsx @@ -23,7 +23,7 @@ export const Item: React.FunctionComponent = ({ fmFilePath, date, ti if (view === DashboardViewType.Grid) { return ( - { diff --git a/src/dashboardWebView/components/Header/Header.tsx b/src/dashboardWebView/components/Header/Header.tsx index b665cd94..07cbe395 100644 --- a/src/dashboardWebView/components/Header/Header.tsx +++ b/src/dashboardWebView/components/Header/Header.tsx @@ -21,6 +21,7 @@ import { CustomScript } from '../../../models'; import { LightningBoltIcon, PlusIcon } from '@heroicons/react/outline'; export interface IHeaderProps { + header?: React.ReactNode; settings: Settings | null; // Navigation @@ -30,7 +31,7 @@ export interface IHeaderProps { folders?: string[]; } -export const Header: React.FunctionComponent = ({totalPages, folders, settings }: React.PropsWithChildren) => { +export const Header: React.FunctionComponent = ({header, totalPages, folders, settings }: React.PropsWithChildren) => { const [ crntTag, setCrntTag ] = useRecoilState(TagAtom); const [ crntCategory, setCrntCategory ] = useRecoilState(CategoryAtom); const [ view, setView ] = useRecoilState(DashboardViewAtom); @@ -148,6 +149,10 @@ export const Header: React.FunctionComponent = ({totalPages, folde > ) } + + { + header + } ); }; \ No newline at end of file diff --git a/src/dashboardWebView/components/Header/Tabs.tsx b/src/dashboardWebView/components/Header/Tabs.tsx index 6d15115b..fefe21a0 100644 --- a/src/dashboardWebView/components/Header/Tabs.tsx +++ b/src/dashboardWebView/components/Header/Tabs.tsx @@ -1,4 +1,4 @@ -import { CodeIcon, DatabaseIcon, PhotographIcon } from '@heroicons/react/outline'; +import { DatabaseIcon, PhotographIcon, ScissorsIcon } from '@heroicons/react/outline'; import * as React from 'react'; import { useRecoilValue } from 'recoil'; import { MarkdownIcon } from '../../../panelWebView/components/Icons/MarkdownIcon'; @@ -33,7 +33,7 @@ export const Tabs: React.FunctionComponent = ({ onNavigate }: React. - Snippets + Snippets { diff --git a/src/dashboardWebView/components/Layout/PageLayout.tsx b/src/dashboardWebView/components/Layout/PageLayout.tsx index c2083d48..634bf70e 100644 --- a/src/dashboardWebView/components/Layout/PageLayout.tsx +++ b/src/dashboardWebView/components/Layout/PageLayout.tsx @@ -4,16 +4,18 @@ import { SettingsSelector } from '../../state'; import { Header } from '../Header'; export interface IPageLayoutProps { + header?: React.ReactNode; folders?: string[] | undefined totalPages?: number | undefined } -export const PageLayout: React.FunctionComponent = ({ folders, totalPages, children }: React.PropsWithChildren) => { +export const PageLayout: React.FunctionComponent = ({ header, folders, totalPages, children }: React.PropsWithChildren) => { const settings = useRecoilValue(SettingsSelector); return ( diff --git a/src/dashboardWebView/components/Media/Item.tsx b/src/dashboardWebView/components/Media/Item.tsx index 4d46bc75..ba79099c 100644 --- a/src/dashboardWebView/components/Media/Item.tsx +++ b/src/dashboardWebView/components/Media/Item.tsx @@ -205,7 +205,7 @@ export const Item: React.FunctionComponent = ({media}: React.PropsWi return ( <> - + diff --git a/src/dashboardWebView/components/Modals/FormDialog.tsx b/src/dashboardWebView/components/Modals/FormDialog.tsx index 729e05a0..701dc06d 100644 --- a/src/dashboardWebView/components/Modals/FormDialog.tsx +++ b/src/dashboardWebView/components/Modals/FormDialog.tsx @@ -16,8 +16,7 @@ export interface IFormDialogProps { export const FormDialog: React.FunctionComponent = ({title, description, cancelBtnText, okBtnText, dismiss, isSaveDisabled, trigger, children}: React.PropsWithChildren) => { const cancelButtonRef = useRef(null); - - + return ( dismiss()}> @@ -67,7 +66,7 @@ export const FormDialog: React.FunctionComponent = ({title, de trigger()} disabled={isSaveDisabled} > @@ -75,7 +74,7 @@ export const FormDialog: React.FunctionComponent = ({title, de dismiss()} ref={cancelButtonRef} > diff --git a/src/dashboardWebView/components/SnippetsView/Item.tsx b/src/dashboardWebView/components/SnippetsView/Item.tsx index bc84673a..f6bbb2bc 100644 --- a/src/dashboardWebView/components/SnippetsView/Item.tsx +++ b/src/dashboardWebView/components/SnippetsView/Item.tsx @@ -1,13 +1,14 @@ import { Messenger } from '@estruyf/vscode/dist/client'; -import { DotsHorizontalIcon, PlusIcon } from '@heroicons/react/outline'; +import { CodeIcon, DotsHorizontalIcon, PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/outline'; import * as React from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { useRecoilValue } from 'recoil'; -import { Choice, Scanner, SnippetParser, TokenType, Variable, VariableResolver } from '../../../helpers/SnippetParser'; import { DashboardMessage } from '../../DashboardMessage'; -import { ViewDataSelector } from '../../state'; +import { SettingsSelector, ViewDataSelector } from '../../state'; +import { Alert } from '../Modals/Alert'; import { FormDialog } from '../Modals/FormDialog'; -import { SnippetForm } from './SnippetForm'; +import { NewForm } from './NewForm'; +import SnippetForm, { SnippetFormHandle } from './SnippetForm'; export interface IItemProps { title: string; @@ -16,21 +17,77 @@ export interface IItemProps { export const Item: React.FunctionComponent = ({ title, snippet }: React.PropsWithChildren) => { const viewData = useRecoilValue(ViewDataSelector); + const settings = useRecoilValue(SettingsSelector); const [ showInsertDialog, setShowInsertDialog ] = useState(false); + const [ showEditDialog, setShowEditDialog ] = useState(false); + const [ showAlert, setShowAlert ] = React.useState(false); - // Todo: On add, show dialog to insert the placeholders and content + const [ snippetTitle, setSnippetTitle ] = useState(''); + const [ snippetDescription, setSnippetDescription ] = useState(''); + const [ snippetOriginalBody, setSnippetOriginalBody ] = useState(''); + + const formRef = useRef(null); const insertToArticle = () => { - Messenger.send(DashboardMessage.insertSnippet, { - file: viewData?.data?.filePath, - snippet: snippet.body.join(`\n`) - }); + formRef.current?.onSave(); + setShowInsertDialog(false); }; + + const reset = () => { + setShowEditDialog(false); + setSnippetTitle(''); + setSnippetDescription(''); + setSnippetOriginalBody(''); + }; + + const onOpenEdit = useCallback(() => { + setSnippetTitle(title); + setSnippetDescription(snippet.description); + setSnippetOriginalBody(snippet.body.join(`\n`)); + setShowEditDialog(true); + }, [snippet]); + + const onSnippetUpdate = useCallback(() => { + if (!snippetTitle || !snippetOriginalBody) { + reset(); + return; + } + + const snippets = Object.assign({}, settings?.snippets || {}); + const snippetContents = { + description: snippetDescription || '', + body: snippetOriginalBody.split("\n") + }; + + if (title === snippetTitle) { + snippets[title] = snippetContents; + } else { + delete snippets[title]; + snippets[snippetTitle] = snippetContents; + } + + Messenger.send(DashboardMessage.updateSnippet, { snippets }); + + reset(); + }, [settings?.snippets, title, snippetTitle, snippetDescription, snippetOriginalBody]); + + const onDelete = useCallback(() => { + const snippets = Object.assign({}, settings?.snippets || {}); + delete snippets[title]; + + Messenger.send(DashboardMessage.updateSnippet, { snippets }); + + setShowAlert(false); + }, [settings?.snippets, title]); return ( <> - - {title} + + + + + + {title} @@ -40,22 +97,30 @@ export const Item: React.FunctionComponent = ({ title, snippet }: Re { - viewData?.data?.filePath ? ( + viewData?.data?.filePath && ( <> setShowInsertDialog(true)}> Insert snippet > - ) : ( - Edit ) } + + + + Edit snippet + + + setShowAlert(true)}> + + Delete snippet + - {snippet.description} + {snippet.description} { @@ -70,11 +135,48 @@ export const Item: React.FunctionComponent = ({ title, snippet }: Re cancelBtnText='Cancel'> + ref={formRef} + snippet={snippet} + selection={viewData?.data?.selection} /> ) } + + { + showEditDialog && ( + + + setSnippetTitle(value)} + onDescriptionUpdate={(value: string) => setSnippetDescription(value)} + onBodyUpdate={(value: string) => setSnippetOriginalBody(value)} /> + + + ) + } + + { + showAlert && ( + setShowAlert(false)} + trigger={onDelete} /> + ) + } > ); }; \ No newline at end of file diff --git a/src/dashboardWebView/components/SnippetsView/NewForm.tsx b/src/dashboardWebView/components/SnippetsView/NewForm.tsx new file mode 100644 index 00000000..fc319203 --- /dev/null +++ b/src/dashboardWebView/components/SnippetsView/NewForm.tsx @@ -0,0 +1,108 @@ +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/outline'; +import * as React from 'react'; + +export interface INewFormProps { + title: string; + description: string; + body: string; + + onTitleUpdate: (value: string) => void; + onDescriptionUpdate: (value: string) => void; + onBodyUpdate: (value: string) => void; +} + +export const NewForm: React.FunctionComponent = ({ title, description, body, onTitleUpdate, onDescriptionUpdate, onBodyUpdate }: React.PropsWithChildren) => { + const [ showDetails, setShowDetails ] = React.useState(false); + + return ( + + + + Title * + + + onTitleUpdate(e.currentTarget.value)} + /> + + + + + + Description + + + onDescriptionUpdate(e.currentTarget.value)} + /> + + + + + + Snippet * + + + onBodyUpdate(e.currentTarget.value)} + /> + + + + + + Placeholders guidelines + + { + showDetails ? ( + setShowDetails(false)}> + + + ) : ( + setShowDetails(true)}> + + + ) + } + + + + + + Insert selected text (can still be updated) + {`\${selection}`} + + + + Variable without default + {`\${variable}`} + + + + Variable with default + {`\${variable:default}`} + + + + Variable with choices + {`\${variable|choice 1,choice 2,choice 3|}`} + + + + + ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/SnippetsView/SnippetForm.tsx b/src/dashboardWebView/components/SnippetsView/SnippetForm.tsx index d57b76d9..1114c985 100644 --- a/src/dashboardWebView/components/SnippetsView/SnippetForm.tsx +++ b/src/dashboardWebView/components/SnippetsView/SnippetForm.tsx @@ -1,13 +1,68 @@ +import { Messenger } from '@estruyf/vscode/dist/client'; +import { ChevronDownIcon } from '@heroicons/react/outline'; import * as React from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'; +import { useRecoilValue } from 'recoil'; import { Choice, SnippetParser, Variable, VariableResolver } from '../../../helpers/SnippetParser'; +import { DashboardMessage } from '../../DashboardMessage'; +import { ViewDataSelector } from '../../state'; + export interface ISnippetFormProps { snippet: any; + selection: string | undefined; } -export const SnippetForm: React.FunctionComponent = ({ snippet }: React.PropsWithChildren) => { - const [ fields, setFields ] = useState([]); +export interface SnippetFormHandle { + onSave: () => void; +} + +interface SnippetField { + name: string; + value: string; + type: 'text' | 'select'; + tmString: string; + options?: string[]; +} + +const SnippetForm: React.ForwardRefRenderFunction = ({ snippet, selection }, ref) => { + const viewData = useRecoilValue(ViewDataSelector); + const [ fields, setFields ] = useState([]); + + const onTextChange = useCallback((field: SnippetField, value: string) => { + setFields(prevFields => prevFields.map(f => f.name === field.name ? { ...f, value } : f)); + }, [setFields]); + + const insertSelectionValue = useCallback((fieldName: string) => { + if (selection && fieldName === 'selection') { + return selection; + } + + return; + }, [selection]); + + const snippetBody = useMemo(() => { + let body = snippet.body.join(`\n`); + + for (const field of fields) { + body = body.replace(field.tmString, field.value); + } + + return body; + }, [fields, selection]); + + useImperativeHandle(ref, () => ({ + onSave() { + if (!snippetBody) { + return; + } + + Messenger.send(DashboardMessage.insertSnippet, { + file: viewData?.data?.filePath, + snippet: snippetBody + }); + } + })); useEffect(() => { const snippetParser = new SnippetParser(); @@ -20,30 +75,31 @@ export const SnippetForm: React.FunctionComponent = ({ snippe for (const placeholder of placeholders) { const tmString = placeholder.toTextmateString(); - console.log(`tmString`, placeholder); if (placeholder.children.length === 0) { allFields.push({ type: 'text', name: placeholder.index, - value: '', + value: insertSelectionValue(placeholder.index as string) || '', tmString }); } else { for (const child of placeholder.children as any[]) { if (child instanceof Choice) { + const options = child.options.map(o => o.value); + allFields.push({ type: 'select', name: placeholder.index, - value: (child as any).value, - options: child.options, + value: (child as any).value || options[0] || "", + options, tmString }); } else { allFields.push({ type: 'text', name: placeholder.index, - value: (child as any).value, + value: insertSelectionValue(placeholder.index as string) || (child as any).value || "", tmString }); } @@ -56,17 +112,51 @@ export const SnippetForm: React.FunctionComponent = ({ snippe return ( - {snippet.body.join(`\n`)} + + {snippetBody} + - + { - fields.map((field: any, index: number) => ( - - {field.name} - {field.value} - {(field.options || []).join(',')} - + fields.map((field: SnippetField, index: number) => ( + + + {field.name} + + + { + field.type === 'select' ? ( + + onTextChange(field, e.target.value)}> + { + field.options?.map((option: string, index: number) => ( + {option} + )) + } + + + + + ) : ( + onTextChange(field, e.currentTarget.value)} + /> + ) + } + + )) } - + ); -}; \ No newline at end of file +}; + +export default React.forwardRef(SnippetForm); \ No newline at end of file diff --git a/src/dashboardWebView/components/SnippetsView/Snippets.tsx b/src/dashboardWebView/components/SnippetsView/Snippets.tsx index ea579897..f794ca26 100644 --- a/src/dashboardWebView/components/SnippetsView/Snippets.tsx +++ b/src/dashboardWebView/components/SnippetsView/Snippets.tsx @@ -1,22 +1,69 @@ -import { CodeIcon } from '@heroicons/react/outline'; +import { Messenger } from '@estruyf/vscode/dist/client'; +import { CodeIcon, PlusSmIcon } from '@heroicons/react/outline'; import * as React from 'react'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useRecoilValue } from 'recoil'; +import { DashboardMessage } from '../../DashboardMessage'; import { SettingsSelector, ViewDataSelector } from '../../state'; import { PageLayout } from '../Layout/PageLayout'; +import { FormDialog } from '../Modals/FormDialog'; import { Item } from './Item'; +import { NewForm } from './NewForm'; export interface ISnippetsProps {} export const Snippets: React.FunctionComponent = (props: React.PropsWithChildren) => { const settings = useRecoilValue(SettingsSelector); const viewData = useRecoilValue(ViewDataSelector); + const [ snippetTitle, setSnippetTitle ] = useState(''); + const [ snippetDescription, setSnippetDescription ] = useState(''); + const [ snippetBody, setSnippetBody ] = useState(''); + const [ showCreateDialog, setShowCreateDialog ] = useState(false); const snippetKeys = useMemo(() => Object.keys(settings?.snippets) || [], [settings?.snippets]); const snippets = settings?.snippets || {}; + + const onSnippetAdd = useCallback(() => { + if (!snippetTitle || !snippetBody) { + reset(); + return; + } + + Messenger.send(DashboardMessage.addSnippet, { + title: snippetTitle, + description: snippetDescription || '', + body: snippetBody + }); + + reset(); + }, [snippetTitle, snippetDescription, snippetBody]); + + const reset = () => { + setShowCreateDialog(false); + setSnippetTitle(''); + setSnippetDescription(''); + setSnippetBody(''); + }; return ( - + + + setShowCreateDialog(true)}> + + Create new snippet + + + + )}> + { viewData?.data?.filePath && ( @@ -46,6 +93,29 @@ export const Snippets: React.FunctionComponent = (props: React.P ) } + + { + showCreateDialog && ( + + + setSnippetTitle(value)} + onDescriptionUpdate={(value: string) => setSnippetDescription(value)} + onBodyUpdate={(value: string) => setSnippetBody(value)} /> + + + ) + } ); }; \ No newline at end of file diff --git a/src/dashboardWebView/hooks/useMessages.tsx b/src/dashboardWebView/hooks/useMessages.tsx index fec94a32..32bbebdb 100644 --- a/src/dashboardWebView/hooks/useMessages.tsx +++ b/src/dashboardWebView/hooks/useMessages.tsx @@ -28,6 +28,8 @@ export default function useMessages() { setView(NavigationType.Contents); } else if (message.data.data?.type === NavigationType.Data) { setView(NavigationType.Data); + } else if (message.data.data?.type === NavigationType.Snippets) { + setView(NavigationType.Snippets); } break; case DashboardCommand.settings: diff --git a/src/extension.ts b/src/extension.ts index 76aac326..a17b6dac 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,6 +22,7 @@ import { Diagnostics } from './commands/Diagnostics'; import { PagesListener } from './listeners/dashboard'; import { Backers } from './commands/Backers'; import { DataListener, SettingsListener } from './listeners/panel'; +import { NavigationType } from './dashboardWebView/models'; let frontMatterStatusBar: vscode.StatusBarItem; let statusDebouncer: { (fnc: any, time: number): void; }; @@ -57,7 +58,7 @@ export async function activate(context: vscode.ExtensionContext) { subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.dashboard, (data?: DashboardData) => { Telemetry.send(TelemetryEvent.openContentDashboard); if (!data) { - Dashboard.open({ type: "contents" }); + Dashboard.open({ type: NavigationType.Contents }); } else { Dashboard.open(data); } @@ -65,12 +66,17 @@ export async function activate(context: vscode.ExtensionContext) { subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.dashboardMedia, (data?: DashboardData) => { Telemetry.send(TelemetryEvent.openMediaDashboard); - Dashboard.open({ type: "media" }); + Dashboard.open({ type: NavigationType.Media }); + })); + + subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.dashboardSnippets, (data?: DashboardData) => { + Telemetry.send(TelemetryEvent.openSnippetsDashboard); + Dashboard.open({ type: NavigationType.Snippets }); })); subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.dashboardData, (data?: DashboardData) => { Telemetry.send(TelemetryEvent.openDataDashboard); - Dashboard.open({ type: "data" }); + Dashboard.open({ type: NavigationType.Data }); })); subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.dashboardClose, (data?: DashboardData) => { @@ -206,6 +212,9 @@ export async function activate(context: vscode.ExtensionContext) { // Inserting an image in Markdown subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.insertImage, Article.insertImage)); + // Inserting a snippet in Markdown + subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.insertSnippet, Article.insertSnippet)); + // Create the editor experience for bulk scripts subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(ContentProvider.scheme, new ContentProvider())); diff --git a/src/helpers/SnippetParser.ts b/src/helpers/SnippetParser.ts index cf278388..417c7652 100644 --- a/src/helpers/SnippetParser.ts +++ b/src/helpers/SnippetParser.ts @@ -238,11 +238,13 @@ export class Placeholder extends TransformableMarker { toTextmateString(): string { let transformString = ''; + if (this.transform) { transformString = this.transform.toTextmateString(); } + if (this.children.length === 0 && !this.transform) { - return `\$${this.index}`; + return `\${${this.index}}`; } else if (this.children.length === 0) { return `\${${this.index}${transformString}}`; } else if (this.choice) { diff --git a/src/listeners/dashboard/SnippetListener.ts b/src/listeners/dashboard/SnippetListener.ts index 16e7ad69..a7b9b644 100644 --- a/src/listeners/dashboard/SnippetListener.ts +++ b/src/listeners/dashboard/SnippetListener.ts @@ -1,7 +1,9 @@ import { EditorHelper } from "@estruyf/vscode"; import { Position, window } from "vscode"; import { Dashboard } from "../../commands/Dashboard"; +import { SETTING_CONTENT_SNIPPETS } from "../../constants"; import { DashboardMessage } from "../../dashboardWebView/DashboardMessage"; +import { Notifications, Settings } from "../../helpers"; import { BaseListener } from "./BaseListener"; @@ -11,12 +13,51 @@ export class SnippetListener extends BaseListener { super.process(msg); switch(msg.command) { + case DashboardMessage.addSnippet: + this.addSnippet(msg.data); + break; + case DashboardMessage.updateSnippet: + this.updateSnippet(msg.data); + break; case DashboardMessage.insertSnippet: this.insertSnippet(msg.data); break; } } + private static async addSnippet(data: any) { + const { title, description, body } = data; + + if (!title || !body) { + Notifications.warning("Snippet missing title or body"); + return; + } + + const snippets = Settings.get(SETTING_CONTENT_SNIPPETS); + if (snippets && snippets[title]) { + Notifications.warning("Snippet with the same title already exists"); + return; + } + + snippets[title] = { + description, + body: body.split("\n") + }; + + Settings.update(SETTING_CONTENT_SNIPPETS, snippets, true); + } + + private static async updateSnippet(data: any) { + const { snippets } = data; + + if (!snippets) { + Notifications.warning("No snippets to update"); + return; + } + + Settings.update(SETTING_CONTENT_SNIPPETS, snippets, true); + } + private static async insertSnippet(data: any) { const { file, snippet } = data; diff --git a/src/models/DashboardData.ts b/src/models/DashboardData.ts index df0f8f93..2b60696a 100644 --- a/src/models/DashboardData.ts +++ b/src/models/DashboardData.ts @@ -1,4 +1,6 @@ +import { NavigationType } from '../dashboardWebView/models'; + export interface DashboardData { - type: "contents" | "media" | "data"; + type: NavigationType; data?: any; } \ No newline at end of file
{snippet.description}
{snippet.body.join(`\n`)}
+ {snippetBody} +
{field.name} - {field.value} - {(field.options || []).join(',')}