@blinkk/editor
Version:
Structured content editor with live previews.
529 lines • 22.8 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SitePart = void 0;
const selective_edit_1 = require("@blinkk/selective-edit");
const modal_1 = require("../../ui/modal");
const filter_1 = require("../../../utility/filter");
const index_1 = require("./index");
const events_1 = require("../../events");
const lodash_merge_1 = __importDefault(require("lodash.merge"));
const selective_edit_2 = require("@blinkk/selective-edit");
const template_1 = require("../../template");
const DEFAULT_SITE_FILTER = {
includes: [/\.(yaml|yml|html|md)$/],
excludes: [/\/[_.]/],
};
const MODAL_KEY_COPY = 'menu_file_copy';
const MODAL_KEY_DELETE = 'menu_file_delete';
const MODAL_KEY_NEW = 'menu_file_new';
const STORAGE_FILE_EXPANDED = 'live.menu.site.expandedDirs';
class SitePart extends index_1.MenuSectionPart {
constructor(config) {
super(config);
// Recreate the file structure whenever the files are reloaded.
this.config.state.addListener('getFiles', () => {
this.fileStructure = undefined;
this.render();
});
}
classesForPart() {
const classes = super.classesForPart();
classes.le__part__menu__site = true;
return classes;
}
getOrCreateModalCopy(editor) {
if (!editor.parts.modals.modals[MODAL_KEY_COPY]) {
const selectiveConfig = lodash_merge_1.default({}, editor.config.selectiveConfig);
const modal = new modal_1.FormDialogModal({
title: 'Copy file',
selectiveConfig: selectiveConfig,
});
modal.templateModal = this.templateFileCopy.bind(this);
modal.actions.push({
label: 'Copy file',
level: modal_1.DialogActionLevel.Primary,
isDisabledFunc: () => {
return modal.isProcessing || !modal.selective.isValid;
},
isSubmit: true,
onClick: () => {
const value = modal.selective.value;
modal.startProcessing();
this.config.state.copyFile(value.originalPath, value.path, (newFile) => {
// Log the success to the notifications.
editor.parts.notifications.addInfo({
message: `New '${newFile.path}' file successfully created.`,
actions: [
{
label: 'Load file',
customEvent: events_1.EVENT_FILE_LOAD,
details: newFile,
},
],
});
// Reset the data for the next time the form is shown.
modal.data = new selective_edit_1.DeepObject();
modal.stopProcessing(true);
}, (error) => {
// Log the error to the notifications.
editor.parts.notifications.addError(error, true);
modal.error = error;
modal.stopProcessing();
});
},
});
modal.addCancelAction();
editor.parts.modals.modals[MODAL_KEY_COPY] = modal;
}
return editor.parts.modals.modals[MODAL_KEY_COPY];
}
getOrCreateModalDelete(editor) {
if (!editor.parts.modals.modals[MODAL_KEY_DELETE]) {
const selectiveConfig = lodash_merge_1.default({}, editor.config.selectiveConfig);
const modal = new modal_1.FormDialogModal({
title: 'Delete file',
selectiveConfig: selectiveConfig,
});
modal.templateModal = this.templateFileDelete.bind(this);
modal.actions.push({
label: 'Delete file',
level: modal_1.DialogActionLevel.Extreme,
isDisabledFunc: () => false,
isSubmit: true,
onClick: () => {
const path = modal.data.get('path');
modal.startProcessing();
this.config.state.deleteFile({
path: path,
}, () => {
// Log the success to the notifications.
editor.parts.notifications.addInfo({
message: `Deleted '${path}' file successfully.`,
});
// Reset the data for the next time the form is shown.
modal.data = new selective_edit_1.DeepObject();
modal.stopProcessing(true);
}, (error) => {
// Log the error to the notifications.
editor.parts.notifications.addError(error, true);
modal.error = error;
modal.stopProcessing();
});
},
});
modal.addCancelAction();
editor.parts.modals.modals[MODAL_KEY_DELETE] = modal;
}
return editor.parts.modals.modals[MODAL_KEY_DELETE];
}
getOrCreateModalNew(editor) {
if (!editor.parts.modals.modals[MODAL_KEY_NEW]) {
const selectiveConfig = lodash_merge_1.default({}, editor.config.selectiveConfig);
const modal = new modal_1.FormDialogModal({
title: 'New file',
selectiveConfig: selectiveConfig,
});
modal.templateModal = this.templateFileNew.bind(this);
modal.actions.push({
label: 'Create file',
level: modal_1.DialogActionLevel.Primary,
isDisabledFunc: () => {
return modal.isProcessing || !modal.selective.isValid;
},
isSubmit: true,
onClick: () => {
const value = modal.selective.value;
modal.startProcessing();
this.config.state.createFile(`${value.directory}${value.path}`, (newFile) => {
// Log the success to the notifications.
editor.parts.notifications.addInfo({
message: `New '${newFile.path}' file successfully created.`,
actions: [
{
label: 'Load file',
customEvent: events_1.EVENT_FILE_LOAD,
details: newFile,
},
],
});
// Reset the data for the next time the form is shown.
modal.data = new selective_edit_1.DeepObject();
modal.stopProcessing(true);
}, (error) => {
// Log the error to the notifications.
editor.parts.notifications.addError(error, true);
modal.error = error;
modal.stopProcessing();
});
},
});
modal.addCancelAction();
editor.parts.modals.modals[MODAL_KEY_NEW] = modal;
}
return editor.parts.modals.modals[MODAL_KEY_NEW];
}
loadFiles() {
this.config.state.getFiles(() => {
this.fileStructure = undefined;
this.render();
});
}
loadProject() {
this.config.state.getProject();
}
templateContent(editor) {
const project = this.config.state.project;
const files = this.config.state.files;
// Lazy load the project.
if (!project) {
this.loadProject();
}
// Lazy load the files.
if (files === undefined) {
this.loadFiles();
}
if (!project || files === undefined) {
return template_1.templateLoading(editor, {
pad: true,
});
}
if (files.length === 0) {
return selective_edit_1.html `<div class="le__part__menu__section__content">
<div class="le__list">
<div class="le__list__item">
<div class="le__list__item__label">No files found.</div>
</div>
</div>
</div>`;
}
if (!this.fileStructure) {
const eventHandlers = {
fileCopy: (evt, file) => {
evt.stopPropagation();
const modal = this.getOrCreateModalCopy(editor);
modal.data.set('originalPath', file.path);
// TODO: Modify the new path so it is not automatically an error.
modal.data.set('path', file.path);
// Make the form field custom to the file being copied.
modal.selective.resetFields();
modal.selective.fields.addField({
type: 'text',
key: 'path',
label: 'File path',
help: `Copy '${file.path}' file to this new file.`,
validation: [
{
type: 'require',
message: 'File name is required.',
},
{
type: 'pattern',
pattern: '^[a-z0-9-_./]*$',
message: 'File name can only contain lowercase alpha-numeric characters, . (period), _ (underscore), / (forward slash), and - (dash).',
},
{
type: 'pattern',
pattern: '/[a-z0-9]+[a-z0-9-_./]*$',
message: 'File name in the sub directory needs to start with alpha-numeric characters.',
},
{
type: 'pattern',
pattern: '^/content/[a-z0-9]+/',
message: 'File name needs to be in a collection (ex: /content/pages/).',
},
// TODO: Extension matching.
// {
// type: 'pattern',
// pattern: `^.*\.(${originalExt})$`,
// message: `File name needs to end with ".${originalExt}" to match the original file.`,
// },
{
type: 'match',
excluded: {
values: [file.path],
message: 'Cannot copy to the same file.',
},
},
// TODO: Existing file match checking.
// {
// type: 'match',
// level: 'warning',
// excluded: {
// values: otherPodPaths,
// message:
// 'File name already exists. Copying will overwrite the existing file.',
// },
// },
],
});
modal.show();
},
fileDelete: (evt, file) => {
evt.stopPropagation();
const modal = this.getOrCreateModalDelete(editor);
modal.data.set('path', file.path);
modal.show();
},
fileLoad: (evt, file) => {
evt.stopPropagation();
document.dispatchEvent(new CustomEvent(events_1.EVENT_FILE_LOAD, {
detail: file,
}));
},
fileNew: (evt, directory) => {
evt.stopPropagation();
const modal = this.getOrCreateModalNew(editor);
modal.data.set('directory', directory);
modal.selective.resetFields();
modal.selective.fields.addField({
type: 'text',
key: 'path',
label: 'File name',
help: `Creating new file in the '${directory}' directory. File name may also be used in the url.`,
validation: [
{
type: 'require',
message: 'File name is required.',
},
{
type: 'pattern',
pattern: '^[a-z0-9-_./]*$',
message: 'File name can only contain lowercase alpha-numeric characters, . (period), _ (underscore), / (forward slash), and - (dash).',
},
{
type: 'pattern',
pattern: '^[a-z0-9]+',
message: 'File name needs to start with an alpha-numeric character.',
},
],
});
modal.show();
},
render: this.render.bind(this),
};
// Determine what file filtering to use for the file list.
let filterConfig = DEFAULT_SITE_FILTER;
if (project.site?.files?.filter) {
filterConfig = project.site.files.filter;
// TODO: Allow for service specific default (ex: grow).
}
const filesFilter = new filter_1.IncludeExcludeFilter(filterConfig);
// Create the directory structure using the filtered files.
this.fileStructure = new DirectoryStructure(files.filter(file => filesFilter.matches(file.path)), eventHandlers, this.config.storage);
// If there is a file loaded, expand the directory out to show it.
if (this.config.state.file) {
this.fileStructure.expandToFile(this.config.state.file);
}
}
return selective_edit_1.html `<div class="le__part__menu__section__content">
<div class="le__list le__list--indent">
<div class="le__list__item le__list__item--heading">
<div class="le__list__item__icon">
<span class="material-icons">folder</span>
</div>
<div class="le__list__item__label">
${editor.config.labels?.files || 'Files'}
</div>
</div>
${this.fileStructure.template(editor)}
</div>
</div>`;
}
templateFileCopy(editor) {
const modal = this.getOrCreateModalCopy(editor);
const isValid = modal.selective.isValid;
try {
return modal.selective.template(modal.selective, modal.data);
}
finally {
if (isValid !== modal.selective.isValid) {
this.render();
}
}
}
templateFileDelete(editor) {
const modal = this.getOrCreateModalDelete(editor);
return selective_edit_1.html `<div class="le__modal__content__template__padded">
Do you want to delete the <code>${modal.data.get('path')}</code> file?
</div>`;
}
templateFileNew(editor) {
const modal = this.getOrCreateModalNew(editor);
const isValid = modal.selective.isValid;
try {
return modal.selective.template(modal.selective, modal.data);
}
finally {
if (isValid !== modal.selective.isValid) {
this.render();
}
}
}
templateTitle(editor) {
return selective_edit_1.html `<div class="le__part__menu__section__title">
${editor.config.labels?.menuSite || this.title}
</div>`;
}
get title() {
return 'Site';
}
}
exports.SitePart = SitePart;
class DirectoryStructure {
constructor(rootFiles, eventHandlers, storage, root = '/') {
this.rootFiles = rootFiles;
this.root = root;
this.storage = storage;
this.eventHandlers = eventHandlers;
this.directories = {};
this.files = [];
if (this.root === '/') {
this.isExpanded = true;
}
const currentExpandedPaths = this.storage.getItemArray(STORAGE_FILE_EXPANDED);
if (currentExpandedPaths.includes(this.root)) {
this.isExpanded = true;
}
for (const fileData of this.rootFiles) {
const relativePath = fileData.path.slice(this.root.length);
const pathParts = relativePath.split('/');
// Directories have more segments.
// First segment is empty string since it starts with /.
if (pathParts.length > 1) {
const directoryName = pathParts[0];
if (!this.directories[directoryName]) {
const subDirectoryRoot = `${this.root}${directoryName}/`;
const subFiles = this.rootFiles.filter(fileData => {
return fileData.path.startsWith(subDirectoryRoot);
});
this.directories[directoryName] = new DirectoryStructure(subFiles, this.eventHandlers, this.storage, subDirectoryRoot);
}
}
else {
this.files.push(fileData);
}
}
}
get base() {
const trimmedRoot = this.root.replace(/^\/+/, '').replace(/\/+$/, '');
const rootParts = trimmedRoot.split('/');
return rootParts[rootParts.length - 1];
}
baseFromFilePath(file) {
const pathParts = file.path.split('/');
const fileParts = pathParts[pathParts.length - 1].split('.');
return fileParts.slice(0, -1).join('.');
}
expandToFile(file) {
// As long as the directory starts with the same path, expand it.
if (file.file.path.startsWith(this.root)) {
this.isExpanded = true;
for (const key of Object.keys(this.directories)) {
this.directories[key].expandToFile(file);
}
}
}
handleExpandCollapse() {
this.isExpanded = !this.isExpanded;
const currentExpandedPaths = this.storage.getItemArray(STORAGE_FILE_EXPANDED);
if (this.isExpanded) {
// Add to the storage.
currentExpandedPaths.push(this.root);
}
else {
// Remove from the storage.
for (let i = 0; i < currentExpandedPaths.length; i++) {
if (currentExpandedPaths[i] === this.root) {
currentExpandedPaths.splice(i, 1);
break;
}
}
}
this.storage.setItemArray(STORAGE_FILE_EXPANDED, currentExpandedPaths);
this.eventHandlers.render();
}
template(editor) {
if (!this.isExpanded) {
return selective_edit_1.html ``;
}
return selective_edit_1.html `${this.templateDirectories(editor)}
${this.templateFiles(editor)}`;
}
templateDirectories(editor) {
if (!this.directories) {
return selective_edit_1.html ``;
}
return selective_edit_1.html `<div class="le__list">
${selective_edit_2.repeat(Object.keys(this.directories), (key) => key, (key) => selective_edit_1.html `<div
class="le__list__item le__list__item--secondary le__clickable"
=${this.directories[key].handleExpandCollapse.bind(this.directories[key])}
>
<div class="le__list__item__icon">
<span class="material-icons"
>${this.directories[key].isExpanded
? 'expand_more'
: 'chevron_right'}</span
>
</div>
<div class="le__list__item__label">
${this.directories[key].base}
</div>
</div>
${this.directories[key].template(editor)}`)}
</div>`;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
templateFiles(editor) {
if (!this.files || !this.files.length) {
return selective_edit_1.html ``;
}
return selective_edit_1.html `<div class="le__list">
<div
class="le__list__item le__list__item--primary le__clickable"
=${(evt) => this.eventHandlers.fileNew(evt, this.root)}
>
<div class="le__list__item__icon">
<span class="material-icons">add_circle</span>
</div>
<div class="le__list__item__label">
${editor.config.labels?.fileNew || 'New file'}
</div>
</div>
${selective_edit_2.repeat(this.files, (file) => file.path, (file) => selective_edit_1.html `<div
class=${selective_edit_1.classMap({
le__clickable: true,
le__list__item: true,
'le__list__item--selected': editor.state.file?.file.path === file.path,
})}
=${(evt) => this.eventHandlers.fileLoad(evt, file)}
>
<div class="le__list__item__icon">
<span class="material-icons">notes</span>
</div>
<div class="le__list__item__label">
${this.baseFromFilePath(file)}
</div>
<div class="le__actions le__actions--slim">
<div
class="le__actions__action le__clickable le__tooltip--top"
=${(evt) => this.eventHandlers.fileCopy(evt, file)}
data-tip="Duplicate file"
>
<span class="material-icons">file_copy</span>
</div>
<div
class="le__actions__action le__actions__action--extreme le__clickable le__tooltip--top-left"
=${(evt) => this.eventHandlers.fileDelete(evt, file)}
data-tip="Delete file"
>
<span class="material-icons">remove_circle</span>
</div>
</div>
</div>`)}
</div>`;
}
}
//# sourceMappingURL=site.js.map