Changes for new panel view

This commit is contained in:
Elio Struyf
2020-12-02 17:37:14 +01:00
parent 4e65302a47
commit 3d75d5dcdf
37 changed files with 2054 additions and 167 deletions

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@ node_modules
.vscode-test/
*.vsix
.DS_Store
dist
dist
todo.md

2
.vscode/launch.json vendored
View File

@@ -16,7 +16,7 @@
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "npm: webpack"
"preLaunchTask": "npm: build:ext"
},
{
"name": "Extension Tests",

2
.vscode/tasks.json vendored
View File

@@ -18,7 +18,7 @@
},
{
"type": "npm",
"script": "webpack",
"script": "build:ext",
"group": {
"kind": "build",
"isDefault": true

17
assets/frontmatter.svg Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 28 28" style="enable-background:new 0 0 28 28;" xml:space="preserve">
<style type="text/css">
.st0{font-family:'Futura-CondensedExtraBold';}
.st1{font-size:11.3081px;}
.st2{fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:10;}
.st3{font-family:'Futura-CondensedMedium';}
.st4{font-size:10px;}
.st5{fill:none;}
</style>
<text transform="matrix(0.7959 0 0 1 1.6978 11.391)" class="st0 st1">FRONT</text>
<rect class="st2" width="28" height="28"/>
<text transform="matrix(1 0 0 1 1.9325 24.496)" class="st3 st4">MATTER</text>
<rect x="-33.5" y="14" class="st5" width="8.6" height="14"/>
</svg>

After

Width:  |  Height:  |  Size: 880 B

23
assets/media/main.js Normal file
View File

@@ -0,0 +1,23 @@
(function () {
const vscode = acquireVsCodeApi();
window.addEventListener('message', event => {
const message = event.data;
switch (message.type) {
case 'addColor':
addColor();
break;
case 'clearColors':
colors = [];
updateColorList(colors);
break;
}
});
window.onload = function() {
vscode.postMessage({ command: 'get-data' });
console.log('Ready to accept data.');
};
}());

30
assets/media/reset.css Normal file
View File

@@ -0,0 +1,30 @@
html {
box-sizing: border-box;
font-size: 13px;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ol,
ul {
margin: 0;
padding: 0;
font-weight: normal;
}
img {
max-width: 100%;
height: auto;
}

132
assets/media/styles.css Normal file
View File

@@ -0,0 +1,132 @@
/* Styling: https://code.visualstudio.com/api/references/theme-color */
@-webkit-keyframes load7 {
0%,
80%,
100% {
box-shadow: 0 2.5em 0 -1.3em;
}
40% {
box-shadow: 0 2.5em 0 0;
}
}
@keyframes load7 {
0%,
80%,
100% {
box-shadow: 0 2.5em 0 -1.3em;
}
40% {
box-shadow: 0 2.5em 0 0;
}
}
.spinner,
.spinner:before,
.spinner:after {
border-radius: 50%;
width: 2em;
height: 2em;
animation-fill-mode: both;
animation: load7 1.8s infinite ease-in-out;
}
.spinner {
color: var(--vscode-panelSectionHeader-foreground);
font-size: 10px;
margin: 80px auto;
position: relative;
text-indent: -9999em;
transform: translateZ(0);
animation-delay: -0.16s;
}
.spinner:before,
.spinner:after {
content: '';
position: absolute;
top: 0;
}
.spinner:before {
left: -3.5em;
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
.spinner:after {
left: 3.5em;
}
.frontmatter h3 {
margin-bottom: 1rem;
}
.frontmatter p,
.frontmatter h4,
.frontmatter ul {
margin-bottom: .5rem;
}
.seo__status__details {
margin-bottom: 2rem;
}
.not-valid {
color: var(--vscode-errorForeground);
}
.article__actions {
margin-bottom: 2rem;
}
.article__action {
margin-bottom: 1rem;
}
.article__tags {
margin-bottom: 1rem;
}
.article__tags ul {
color: var(--vscode-dropdown-foreground);
background-color: var(--vscode-dropdown-background);
}
.article__tags li {
padding: var(--input-padding-vertical) var(--input-padding-horizontal);
}
.article__tags li:active {
color: var(--vscode-button-foreground);
background-color: var(--vscode-button-background);
}
.article__tags li[data-focus="true"] {
background-color: var(--vscode-button-hoverBackground);
}
.article__tags li[aria-disabled="true"] {
display: none;
}
.article__tags__items {
margin-top: 1rem;
}
.article__tags__items__btn {
display: inline-block;
margin-bottom: .5rem;
margin-right: .5rem;
width: auto;
}
.article__tags__items__btn span {
margin-left: .5rem;
}
.article__tags__items__pill_notexists {
color: var(--vscode-inputValidation-errorForeground);
background-color: var(--vscode-inputValidation-errorBackground);
}

101
assets/media/vscode.css Normal file
View File

@@ -0,0 +1,101 @@
:root {
--container-paddding: 20px;
--input-padding-vertical: 6px;
--input-padding-horizontal: 4px;
--input-margin-vertical: 4px;
--input-margin-horizontal: 0;
}
body {
padding: 0 var(--container-paddding);
color: var(--vscode-foreground);
font-size: var(--vscode-font-size);
font-weight: var(--vscode-font-weight);
font-family: var(--vscode-font-family);
background-color: var(--vscode-editor-background);
}
ol,
ul {
padding-left: var(--container-paddding);
}
body > *,
form > * {
margin-block-start: var(--input-margin-vertical);
margin-block-end: var(--input-margin-vertical);
}
*:focus {
outline-color: var(--vscode-focusBorder) !important;
}
a {
color: var(--vscode-textLink-foreground);
}
a:hover,
a:active {
color: var(--vscode-textLink-activeForeground);
}
code {
font-size: var(--vscode-editor-font-size);
font-family: var(--vscode-editor-font-family);
}
button {
border: none;
padding: var(--input-padding-vertical) var(--input-padding-horizontal);
width: 100%;
text-align: center;
outline: 1px solid transparent;
outline-offset: 2px !important;
color: var(--vscode-button-foreground);
background: var(--vscode-button-background);
}
button:hover {
cursor: pointer;
background: var(--vscode-button-hoverBackground);
}
button:focus {
outline-color: var(--vscode-focusBorder);
}
button:disabled {
background: var(--vscode-button-background);
filter: brightness(40%);
}
button.secondary {
color: var(--vscode-button-secondaryForeground);
background: var(--vscode-button-secondaryBackground);
}
button.secondary:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
button.secondary:disabled {
background: var(--vscode-button-secondaryBackground);
filter: brightness(40%);
}
input:not([type='checkbox']),
textarea {
display: block;
width: 100%;
border: none;
font-family: var(--vscode-font-family);
padding: var(--input-padding-vertical) var(--input-padding-horizontal);
color: var(--vscode-input-foreground);
outline-color: var(--vscode-input-border);
background-color: var(--vscode-input-background);
}
input::placeholder,
textarea::placeholder {
color: var(--vscode-input-placeholderForeground);
}

957
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,10 +47,31 @@
"onCommand:frontMatter.setDate",
"onCommand:frontMatter.setLastModifiedDate",
"onCommand:frontMatter.generateSlug",
"onCommand:frontMatter.createFromTemplate"
"onCommand:frontMatter.createFromTemplate",
"onView:frontMatter.explorer"
],
"main": "./dist/extension",
"contributes": {
"viewsContainers": {
"activitybar": [
{
"id": "frontmatter-explorer",
"title": "FrontMatter",
"icon": "assets/frontmatter.svg"
}
]
},
"views": {
"frontmatter-explorer": [
{
"id": "frontMatter.explorer",
"name": "FrontMatter",
"icon": "assets/frontmatter.svg",
"contextualTitle": "FrontMatter",
"type": "webview"
}
]
},
"configuration": {
"title": "Front Matter: Configuration",
"properties": {
@@ -191,8 +212,8 @@
},
"scripts": {
"vscode:prepublish": "webpack --mode production",
"webpack": "webpack --mode development",
"webpack-dev": "webpack --mode development --watch",
"build:ext": "webpack --mode development",
"dev:ext": "webpack --mode development --watch",
"test-compile": "tsc -p ./"
},
"devDependencies": {
@@ -200,19 +221,27 @@
"@types/js-yaml": "3.12.1",
"@types/mocha": "^5.2.6",
"@types/node": "^10.12.21",
"@types/vscode": "^1.37.0",
"@types/react": "17.0.0",
"@types/react-dom": "17.0.0",
"@types/vscode": "1.51.0",
"date-fns": "2.0.1",
"glob": "^7.1.4",
"gray-matter": "4.0.2",
"html-loader": "1.3.2",
"html-webpack-plugin": "4.5.0",
"mocha": "^6.1.4",
"ts-loader": "8.0.3",
"tslint": "^5.12.1",
"typescript": "4.0.2",
"vscode-test": "^1.0.2",
"webpack": "4.44.1",
"webpack": "4.44.2",
"webpack-cli": "3.3.12"
},
"dependencies": {
"@iarna/toml": "2.2.3"
"@iarna/toml": "2.2.3",
"@material-ui/core": "4.11.1",
"@material-ui/lab": "4.0.0-alpha.56",
"react": "17.0.1",
"react-dom": "17.0.1"
}
}

View File

@@ -3,7 +3,7 @@ import * as vscode from 'vscode';
import { TaxonomyType } from "../models";
import { CONFIG_KEY, SETTING_DATE_FORMAT, EXTENSION_NAME, SETTING_SLUG_PREFIX, SETTING_SLUG_SUFFIX, SETTING_DATE_FIELD } from "../constants/settings";
import { format } from "date-fns";
import { ArticleHelper, SettingsHelper } from '../helpers';
import { ArticleHelper, SettingsHelper, SlugHelper } from '../helpers';
import matter = require('gray-matter');
@@ -161,7 +161,7 @@ export class Article {
}
const articleTitle: string = article.data["title"];
const slug = ArticleHelper.createSlug(articleTitle);
const slug = SlugHelper.createSlug(articleTitle);
if (slug) {
article.data["slug"] = `${prefix}${slug}${suffix}`;
ArticleHelper.update(editor, article);

View File

@@ -2,6 +2,7 @@ import { SETTING_SEO_DESCRIPTION_LENGTH, SETTING_SEO_TITLE_LENGTH } from './../c
import * as vscode from 'vscode';
import { CONFIG_KEY } from '../constants';
import { ArticleHelper, SeoHelper } from '../helpers';
import { ExplorerView } from '../webview/ExplorerView';
export class StatusListener {
@@ -38,8 +39,8 @@ export class StatusListener {
// Retrieve the SEO config properties
const config = vscode.workspace.getConfiguration(CONFIG_KEY);
let titleLength = config.get(SETTING_SEO_TITLE_LENGTH) as number || -1;
let descLength = config.get(SETTING_SEO_DESCRIPTION_LENGTH) as number || -1;
const titleLength = config.get(SETTING_SEO_TITLE_LENGTH) as number || -1;
const descLength = config.get(SETTING_SEO_DESCRIPTION_LENGTH) as number || -1;
if (article.data.title && titleLength > -1) {
SeoHelper.checkLength(editor, collection, article, "title", titleLength);
@@ -49,6 +50,12 @@ export class StatusListener {
SeoHelper.checkLength(editor, collection, article, "description", descLength);
}
}
const panel = ExplorerView.getInstance();
if (panel && panel.visible) {
panel.pushMetadata(article!.data);
}
return;
} catch (e) {
// Nothing to do

View File

@@ -2,14 +2,22 @@ import * as vscode from 'vscode';
import { Article, Settings, StatusListener } from './commands';
import { Template } from './commands/Template';
import { TaxonomyType } from './models';
import { ExplorerView } from './webview/ExplorerView';
let frontMatterStatusBar: vscode.StatusBarItem;
let debouncer: { (fnc: any, time: number): void; };
let collection: vscode.DiagnosticCollection;
export function activate({ subscriptions }: vscode.ExtensionContext) {
export function activate({ subscriptions, extensionUri }: vscode.ExtensionContext) {
collection = vscode.languages.createDiagnosticCollection('frontMatter');
const explorerSidebar = ExplorerView.getInstance(extensionUri);
let explorerView = vscode.window.registerWebviewViewProvider(ExplorerView.viewType, explorerSidebar, {
webviewOptions: {
retainContextWhenHidden: true
}
});
let insertTags = vscode.commands.registerCommand('frontMatter.insertTags', () => {
Article.insert(TaxonomyType.Tag);
});
@@ -75,6 +83,7 @@ export function activate({ subscriptions }: vscode.ExtensionContext) {
// Subscribe all commands
subscriptions.push(insertTags);
subscriptions.push(explorerView);
subscriptions.push(insertCategories);
subscriptions.push(createTag);
subscriptions.push(createCategory);

View File

@@ -1,8 +1,6 @@
import * as vscode from 'vscode';
import * as matter from "gray-matter";
import * as fs from "fs";
import { stopWords } from '../constants/stopwords-en';
import { charMap } from '../constants/charMap';
import { CONFIG_KEY, SETTING_INDENT_ARRAY, SETTING_REMOVE_QUOTES } from '../constants';
import { DumpOptions } from 'js-yaml';
import { TomlEngine, getFmLanguage, getFormatOpts } from './TomlEngine';
@@ -98,62 +96,4 @@ export class ArticleHelper {
indent: spaces || 2
} as DumpOptions as any));
}
/**
* Generate the slug
*
* @param articleTitle
*/
public static createSlug(articleTitle: string): string | null {
if (!articleTitle) {
return null;
}
// Remove punctuation from input string, and split it into words.
let cleanTitle = this.removePunctuation(articleTitle);
cleanTitle = cleanTitle.toLowerCase();
// Split into words
let words = cleanTitle.split(/\s/);
// Removing stop words
words = this.removeStopWords(words);
cleanTitle = words.join("-");
cleanTitle = this.replaceCharacters(cleanTitle);
return cleanTitle;
}
/**
* Remove links, periods, commas, semi-colons, etc.
*
* @param value
*/
private static removePunctuation(value: string): string {
const punctuationless = value.replace(/[\.,-\/#!$@%\^&\*;:{}=\-_`'"~()+\?<>]/g, " ");
// Remove double spaces
return punctuationless.replace(/\s{2,}/g," ");
}
/**
* Remove stop words
*
* @param words
*/
private static removeStopWords(words: string[]) {
const validWords: string[] = [];
for (const word of words) {
if (stopWords.indexOf(word.toLowerCase()) === -1) {
validWords.push(word);
}
}
return validWords;
}
/**
* Replace characters from title
*
* @param value
*/
private static replaceCharacters(value: string) {
const characters = [...value];
return characters.map(c => charMap[c] || c).join("");
}
}

63
src/helpers/SlugHelper.ts Normal file
View File

@@ -0,0 +1,63 @@
import { stopWords } from '../constants/stopwords-en';
import { charMap } from '../constants/charMap';
export class SlugHelper {
/**
* Generate the slug
*
* @param articleTitle
*/
public static createSlug(articleTitle: string): string | null {
if (!articleTitle) {
return null;
}
// Remove punctuation from input string, and split it into words.
let cleanTitle = this.removePunctuation(articleTitle);
cleanTitle = cleanTitle.toLowerCase();
// Split into words
let words = cleanTitle.split(/\s/);
// Removing stop words
words = this.removeStopWords(words);
cleanTitle = words.join("-");
cleanTitle = this.replaceCharacters(cleanTitle);
return cleanTitle;
}
/**
* Remove links, periods, commas, semi-colons, etc.
*
* @param value
*/
private static removePunctuation(value: string): string {
const punctuationless = value.replace(/[\.,-\/#!$@%\^&\*;:{}=\-_`'"~()+\?<>]/g, " ");
// Remove double spaces
return punctuationless.replace(/\s{2,}/g," ");
}
/**
* Remove stop words
*
* @param words
*/
private static removeStopWords(words: string[]) {
const validWords: string[] = [];
for (const word of words) {
if (stopWords.indexOf(word.toLowerCase()) === -1) {
validWords.push(word);
}
}
return validWords;
}
/**
* Replace characters from title
*
* @param value
*/
private static replaceCharacters(value: string) {
const characters = [...value];
return characters.map(c => charMap[c] || c).join("");
}
}

View File

@@ -1,6 +1,8 @@
export * from './ArticleHelper';
export * from './FilesHelper';
export * from './Sanitize';
export * from './SeoHelper';
export * from './SettingsHelper';
export * from './SlugHelper';
export * from './StringHelpers';
export * from './TomlEngine';

View File

@@ -0,0 +1,17 @@
export interface PanelSettings {
seo: SEO;
slug: Slug;
tags: string[];
categories: string[];
}
export interface SEO {
title: number;
description: number;
}
export interface Slug {
prefix: number;
suffix: number;
}

5
src/viewpanel/Command.ts Normal file
View File

@@ -0,0 +1,5 @@
export enum Command {
loading = "loading",
metadata = "metadata",
settings = "settings"
}

View File

@@ -0,0 +1,8 @@
export enum CommandToCode {
getData = "get-data",
updateSlug = 'update-slug',
updateDate = 'update-date',
publish = 'publish',
updateTags = "update-tags",
updateCategories = "update-categories"
}

4
src/viewpanel/TagType.ts Normal file
View File

@@ -0,0 +1,4 @@
export enum TagType {
tags = "Tags",
categories = "Categories"
}

View File

@@ -0,0 +1,45 @@
import * as React from 'react';
import { Actions } from './components/Actions';
import { SeoStatus } from './components/SeoStatus';
import { Spinner } from './components/Spinner';
import { TagPicker } from './components/TagPicker';
import useMessages from './hooks/useMessages';
import { TagType } from './TagType';
export interface IViewPanelProps {
}
export const ViewPanel: React.FunctionComponent<IViewPanelProps> = (props: React.PropsWithChildren<IViewPanelProps>) => {
const { loading, metadata, settings } = useMessages();
if (loading) {
return (
<Spinner />
);
}
if (!metadata || Object.keys(metadata).length === 0) {
return (
<div className="frontmatter">
<p>Current view/file is not supported by FrontMatter.</p>
</div>
);
}
return (
<div className="frontmatter">
{
settings && settings.seo && <SeoStatus seo={settings.seo} data={metadata} />
}
{
settings && metadata && <Actions metadata={metadata} settings={settings} />
}
{
(settings && settings.tags && settings.tags.length > 0) && <TagPicker type={TagType.tags} crntSelected={metadata.tags || []} options={settings.tags} />
}
{
(settings && settings.categories && settings.categories.length > 0) && <TagPicker type={TagType.categories} crntSelected={metadata.categories || []} options={settings.categories} />
}
</div>
);
};

View File

@@ -0,0 +1,30 @@
import * as React from 'react';
import { PanelSettings } from '../../models/PanelSettings';
import { DateAction } from './DateAction';
import { PublishAction } from './PublishAction';
import { SlugAction } from './SlugAction';
export interface IActionsProps {
metadata: any;
settings: PanelSettings;
}
export const Actions: React.FunctionComponent<IActionsProps> = (props: React.PropsWithChildren<IActionsProps>) => {
const { metadata, settings } = props;
if (!metadata || Object.keys(metadata).length === 0 || !settings) {
return null;
}
return (
<div className={`article__actions`}>
<h3>Actions</h3>
{ metadata && metadata.title && <SlugAction value={metadata.title} crntValue={metadata.slug} slugOpts={settings.slug} /> }
<DateAction />
{ metadata && typeof metadata.draft !== undefined && <PublishAction draft={metadata.draft} />}
</div>
);
};

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import { CommandToCode } from '../CommandToCode';
import useMessages from '../hooks/useMessages';
export interface IDateActionProps {}
export const DateAction: React.FunctionComponent<IDateActionProps> = (props: React.PropsWithChildren<IDateActionProps>) => {
const { sendMessage } = useMessages();
const setDate = () => {
sendMessage(CommandToCode.updateDate);
};
return (
<div className={`article__action`}>
<button onClick={setDate}>Set current date</button>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import * as React from 'react';
import { CommandToCode } from '../CommandToCode';
import useMessages from '../hooks/useMessages';
export interface IPublishActionProps {
draft: boolean;
}
export const PublishAction: React.FunctionComponent<IPublishActionProps> = (props: React.PropsWithChildren<IPublishActionProps>) => {
const { sendMessage } = useMessages();
const { draft } = props;
const publish = () => {
sendMessage(CommandToCode.publish);
};
return (
<div className={`article__action`}>
<button onClick={publish} className={`${draft ? "" : "secondary"}`}>{draft ? "Publish" : "Revert to draft"}</button>
</div>
);
};

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
export interface ISeoDetailsProps {
allowedLength: number;
title: string;
value: string;
}
export const SeoDetails: React.FunctionComponent<ISeoDetailsProps> = (props: React.PropsWithChildren<ISeoDetailsProps>) => {
const { allowedLength, title, value } = props;
return (
<div className={`seo__status__details ${value.length <= allowedLength ? "valid" : "not-valid"}`}>
<h4><strong>{title}</strong></h4>
<ul>
<li><b>Length</b>: {value.length}</li>
<li><b>Allowed length</b>: {allowedLength}</li>
</ul>
</div>
);
};

View File

@@ -0,0 +1,25 @@
import * as React from 'react';
import { SEO } from '../../models/PanelSettings';
import { SeoDetails } from './SeoDetails';
export interface ISeoStatusProps {
seo: SEO;
data: any;
}
export const SeoStatus: React.FunctionComponent<ISeoStatusProps> = (props: React.PropsWithChildren<ISeoStatusProps>) => {
const { data, seo } = props;
const { title, description } = data;
if (!title && !description) {
return null;
}
return (
<div className="seo__status">
<h3>SEO Status</h3>
{ (title && seo.title > 0) && <SeoDetails title="Title" allowedLength={seo.title} value={title} /> }
{ (description && seo.description > 0) && <SeoDetails title="Description" allowedLength={seo.description} value={description} /> }
</div>
);
};

View File

@@ -0,0 +1,29 @@
import * as React from 'react';
import { SlugHelper } from '../../helpers/SlugHelper';
import { Slug } from '../../models/PanelSettings';
import { CommandToCode } from '../CommandToCode';
import useMessages from '../hooks/useMessages';
export interface ISlugActionProps {
value: string;
crntValue: string;
slugOpts: Slug;
}
export const SlugAction: React.FunctionComponent<ISlugActionProps> = (props: React.PropsWithChildren<ISlugActionProps>) => {
const { value, crntValue, slugOpts } = props;
const { sendMessage } = useMessages();
let slug = SlugHelper.createSlug(value);
slug = `${slugOpts.prefix}${slug}${slugOpts.suffix}`;
const optimize = () => {
sendMessage(CommandToCode.updateSlug);
};
return (
<div className={`article__action`}>
<button onClick={optimize} disabled={crntValue === slug}>Optimize slug</button>
</div>
);
};

View File

@@ -0,0 +1,9 @@
import * as React from 'react';
export interface ISpinnerProps {}
export const Spinner: React.FunctionComponent<ISpinnerProps> = (props: React.PropsWithChildren<ISpinnerProps>) => {
return (
<div className="spinner">Loading...</div>
);
};

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
export interface ITagProps {
className: string;
value: string;
title: string;
onRemove: (tags: string) => void;
}
export const Tag: React.FunctionComponent<ITagProps> = (props: React.PropsWithChildren<ITagProps>) => {
const { value, className, title, onRemove } = props;
return (
<button title={title} className={`article__tags__items__btn ${className}`} onClick={() => onRemove(value)}>{value} <span>x</span></button>
);
};

View File

@@ -0,0 +1,97 @@
import * as React from 'react';
import useAutocomplete from '@material-ui/lab/useAutocomplete';
import { makeStyles, createStyles } from '@material-ui/core/styles';
import { Tags } from './Tags';
import { usePrevious } from '../hooks/usePrevious';
import useMessages from '../hooks/useMessages';
import { CommandToCode } from '../CommandToCode';
import { TagType } from '../TagType';
export interface ITagPickerProps {
type: string;
crntSelected: string[];
options: string[];
}
const useStyles = makeStyles(() =>
createStyles({
label: {
display: 'block',
},
input: {
width: 200,
},
listbox: {
width: 200,
margin: 0,
padding: 0,
zIndex: 1,
position: 'absolute',
listStyle: 'none',
overflow: 'auto',
maxHeight: 200,
border: '1px solid rgba(0,0,0,.8)'
},
}),
);
export const TagPicker: React.FunctionComponent<ITagPickerProps> = (props: React.PropsWithChildren<ITagPickerProps>) => {
const { type, crntSelected, options } = props;
const [ selected, setSelected ] = React.useState<string[]>([]);
const prevSelected = usePrevious(crntSelected);
const { sendMessage } = useMessages();
const classes = useStyles();
const { getRootProps, getInputProps, getListboxProps, getOptionProps, groupedOptions } = useAutocomplete({
id: 'use-autocomplete',
options: options,
multiple: true,
autoComplete: true,
value: crntSelected,
getOptionDisabled: (option) => selected.includes(option),
onChange: (e, values: string[]) => {
const uniqValues = Array.from(new Set([...selected, ...values]));
setSelected(uniqValues);
sendUpdate(uniqValues);
}
});
const onRemove = (tag: string) => {
const newSelection = selected.filter(s => s !== tag);
setSelected(newSelection);
sendUpdate(newSelection);
};
const sendUpdate = (values: string[]) => {
const cmdType = type === TagType.tags ? CommandToCode.updateTags : CommandToCode.updateCategories;
sendMessage(cmdType, values);
};
React.useEffect(() => {
if (prevSelected !== crntSelected) {
setSelected(crntSelected);
}
}, [crntSelected]);
return (
<div className={`article__tags`}>
<h3>{type}</h3>
<div {...getRootProps()}>
<input className={classes.input} {...getInputProps()} placeholder={`Pick your ${type.toLowerCase()}`}/>
</div>
{
groupedOptions.length > 0 ? (
<ul className={classes.listbox} {...getListboxProps()}>
{groupedOptions.map((option, index) => (
<li key={index} {...getOptionProps({ option, index })}>{option}</li>
))}
</ul>
) : null
}
<Tags values={selected} onRemove={onRemove} options={options} />
</div>
);
};

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
import { Tag } from './Tag';
export interface ITagsProps {
values: string[];
options: string[];
onRemove: (tags: string) => void;
}
export const Tags: React.FunctionComponent<ITagsProps> = (props: React.PropsWithChildren<ITagsProps>) => {
const { values, options, onRemove } = props;
return (
<div className={`article__tags__items`}>
{
values.map(t => (
<Tag key={t.replace(/ /g, "_")} value={t} className={`${options.includes(t) ? 'article__tags__items__pill_exists' : 'article__tags__items__pill_notexists'}`} onRemove={onRemove} title={`${options.includes(t) ? `Remove ${t}` : `Be aware, this tag "${t}" is not saved in your settings.`}`} />
))
}
</div>
);
};

View File

@@ -0,0 +1,54 @@
import { useState, useEffect } from 'react';
import { PanelSettings } from '../../models/PanelSettings';
import { Command } from '../Command';
import { CommandToCode } from '../CommandToCode';
declare const acquireVsCodeApi: <T = unknown>() => {
getState: () => T;
setState: (data: T) => void;
postMessage: (msg: unknown) => void;
};
const vscode = acquireVsCodeApi();
export default function useMessages() {
const [metadata, setMetadata] = useState<any>({});
const [settings, setSettings] = useState<PanelSettings>();
const [loading, setLoading] = useState<boolean>(false);
window.addEventListener('message', event => {
const message = event.data;
switch (message.command) {
case Command.metadata:
setMetadata(message.data);
setLoading(false);
break;
case Command.settings:
setSettings(message.data);
setLoading(false);
break;
case Command.loading:
setLoading(message.data);
break;
}
});
useEffect(() => {
setLoading(true);
vscode.postMessage({ command: CommandToCode.getData });
}, ['']);
return {
metadata,
settings,
loading,
sendMessage: (command: CommandToCode, data?: any) => {
if (data) {
vscode.postMessage({ command, data });
} else {
vscode.postMessage({ command });
}
}
};
}

View File

@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}

6
src/viewpanel/index.tsx Normal file
View File

@@ -0,0 +1,6 @@
import * as React from "react";
import { render } from "react-dom";
import { ViewPanel } from "./ViewPanel";
const elm = document.querySelector("#app");
render(<ViewPanel />, elm);

226
src/webview/ExplorerView.ts Normal file
View File

@@ -0,0 +1,226 @@
import { PanelSettings } from './../models/PanelSettings';
import { CancellationToken, Disposable, Uri, Webview, WebviewView, WebviewViewProvider, WebviewViewResolveContext, window, workspace } from "vscode";
import { CONFIG_KEY, SETTING_SEO_DESCRIPTION_LENGTH, SETTING_SEO_TITLE_LENGTH, SETTING_SLUG_PREFIX, SETTING_SLUG_SUFFIX, SETTING_TAXONOMY_CATEGORIES, SETTING_TAXONOMY_TAGS } from "../constants";
import { ArticleHelper } from "../helpers";
import { Command } from "../viewpanel/Command";
import { CommandToCode } from '../viewpanel/CommandToCode';
import { Article } from '../commands';
import { TagType } from '../viewpanel/TagType';
export class ExplorerView implements WebviewViewProvider, Disposable {
public static readonly viewType = "frontMatter.explorer";
private static instance: ExplorerView;
private panel: WebviewView | null = null;
private disposable: Disposable | null = null;
private constructor(private readonly extPath: Uri) {}
/**
* Creates the singleton instance for the panel
* @param extPath
*/
public static getInstance(extPath?: Uri): ExplorerView {
if (!ExplorerView.instance) {
ExplorerView.instance = new ExplorerView(extPath as Uri);
}
return ExplorerView.instance;
}
/**
* Retrieve the visibility of the webview
*/
get visible() {
return this.panel ? this.panel.visible : false;
}
/**
* Webview panel dispose
*/
public dispose() {
if (this.disposable) {
this.disposable.dispose();
}
}
/**
* Default resolve webview panel
* @param webviewView
* @param context
* @param token
*/
public async resolveWebviewView(webviewView: WebviewView, context: WebviewViewResolveContext, token: CancellationToken): Promise<void> {
this.panel = webviewView;
webviewView.webview.options = {
enableScripts: true,
enableCommandUris: true,
localResourceRoots: [this.extPath]
};
webviewView.webview.html = this.getWebviewContent(webviewView.webview);
this.disposable = Disposable.from(
webviewView.onDidDispose(() => { webviewView.webview.html = ""; }, this),
// window.onDidChangeWindowState(() => { console.log(`onDidChangeWindowState visible`, this.visible); }, this)
);
webviewView.webview.onDidReceiveMessage(msg => {
switch(msg.command) {
case CommandToCode.getData:
this.getSettings();
this.getFileData();
break;
case CommandToCode.updateSlug:
Article.generateSlug();
break;
case CommandToCode.updateDate:
Article.setDate();
break;
case CommandToCode.publish:
Article.toggleDraft();
break;
case CommandToCode.updateTags:
this.updateTags(TagType.tags, msg.data || []);
break;
case CommandToCode.updateCategories:
this.updateTags(TagType.categories, msg.data || []);
break;
}
});
webviewView.onDidChangeVisibility(() => {
if (this.visible) {
this.getFileData();
}
});
window.onDidChangeActiveTextEditor(() => {
this.postWebviewMessage({ command: Command.loading, data: true });
if (this.visible) {
this.getFileData();
}
}, this);
workspace.onDidChangeConfiguration(() => {
this.getSettings();
});
}
/**
* Triggers a metadata change in the panel
* @param metadata
*/
public pushMetadata(metadata: any) {
this.postWebviewMessage({ command: Command.metadata, data: metadata });
}
/**
* Retrieve the extension settings
*/
private getSettings() {
const config = workspace.getConfiguration(CONFIG_KEY);
this.postWebviewMessage({
command: Command.settings,
data: {
seo: {
title: config.get(SETTING_SEO_TITLE_LENGTH) as number || -1,
description: config.get(SETTING_SEO_DESCRIPTION_LENGTH) as number || -1
},
slug: {
prefix: config.get(SETTING_SLUG_PREFIX) || "",
suffix: config.get(SETTING_SLUG_SUFFIX) || ""
},
tags: config.get(SETTING_TAXONOMY_TAGS) || [],
categories: config.get(SETTING_TAXONOMY_CATEGORIES) || []
} as PanelSettings
});
}
/**
* Retrieve the file its front matter
*/
private getFileData() {
const editor = window.activeTextEditor;
if (!editor) {
return "";
}
const article = ArticleHelper.getFrontMatter(editor);
this.postWebviewMessage({ command: Command.metadata, data: article!.data });
}
/**
* Update the tags in the current document
* @param tagType
* @param values
*/
private updateTags(tagType: TagType, values: string[]) {
const editor = window.activeTextEditor;
if (!editor) {
return "";
}
const article = ArticleHelper.getFrontMatter(editor);
if (article && article.data) {
article.data[tagType.toLowerCase()] = values || [];
ArticleHelper.update(editor, article);
this.postWebviewMessage({ command: Command.metadata, data: article.data });
}
}
/**
* Post data to the panel
* @param msg
*/
private postWebviewMessage(msg: { command: Command, data: any }) {
this.panel!.webview.postMessage(msg);
}
private getNonce() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 32; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
/**
* Retrieve the webview HTML contents
* @param webView
*/
private getWebviewContent(webView: Webview): string {
const styleVSCodeUri = webView.asWebviewUri(Uri.joinPath(this.extPath, 'assets/media', 'vscode.css'));
const styleResetUri = webView.asWebviewUri(Uri.joinPath(this.extPath, 'assets/media', 'reset.css'));
const stylesUri = webView.asWebviewUri(Uri.joinPath(this.extPath, 'assets/media', 'styles.css'));
const scriptUri = webView.asWebviewUri(Uri.joinPath(this.extPath, 'dist', 'bundle.js'));
const nonce = this.getNonce();
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webView.cspSource} 'self' 'unsafe-inline'; script-src 'nonce-${nonce}'; style-src ${webView.cspSource} 'self' 'unsafe-inline'; font-src ${webView.cspSource}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="${styleResetUri}" rel="stylesheet">
<link href="${styleVSCodeUri}" rel="stylesheet">
<link href="${stylesUri}" rel="stylesheet">
<title>FrontMatter</title>
</head>
<body>
<div id="app"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>
`;
}
}

View File

@@ -4,15 +4,13 @@
"target": "es6",
"outDir": "out",
"lib": [
"es6"
"es6",
"DOM"
],
"sourceMap": true,
"rootDir": "src",
"strict": true /* enable all strict type-checking options */
/* Additional Checks */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
"strict": true,
"jsx": "react"
},
"exclude": [
"node_modules",

View File

@@ -4,38 +4,54 @@
const path = require('path');
/**@type {import('webpack').Configuration}*/
const config = {
target: 'node', // vscode extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/
entry: './src/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/
output: {
// the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/
path: path.resolve(__dirname, 'dist'),
filename: 'extension.js',
libraryTarget: 'commonjs2',
devtoolModuleFilenameTemplate: '../[resource-path]'
},
devtool: 'source-map',
externals: {
vscode: 'commonjs vscode' // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/
},
resolve: {
// support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader
extensions: ['.ts', '.js']
},
module: {
rules: [
{
module.exports = [
{
name: 'extension',
target: 'node',
entry: './src/extension.ts',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'extension.js',
libraryTarget: 'commonjs2',
devtoolModuleFilenameTemplate: '../[resource-path]'
},
devtool: 'source-map',
externals: {
vscode: 'commonjs vscode'
},
resolve: {
extensions: ['.ts', '.js', '.tsx', '.jsx']
},
module: {
rules: [{
test: /\.ts$/,
exclude: /node_modules/,
use: [
{
loader: 'ts-loader'
}
]
}
]
use: [{
loader: 'ts-loader'
}]
}]
}
},
{
name: 'viewpanel',
target: 'web',
entry: './src/viewpanel/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
devtool: 'source-map',
resolve: {
extensions: ['.ts', '.js', '.tsx', '.jsx']
},
module: {
rules: [{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: [{
loader: 'ts-loader'
}]
}]
}
}
};
module.exports = config;
];