whiteboard-app
Version:
Whiteboard - a slide-based activity presentation system
666 lines (576 loc) • 20 kB
JavaScript
const fs = require('fs');
const { ModuleBase } = require('elmoed');
const schemaconf = require('schemaconf');
const pathlib = require('path');
const Slide = require('./Slide');
const { glyphIcon, flattenMenuTemplate, makeGlobalMenuTemplate } = require('../utils');
const dialogs = require('../utils/dialogs.js');
const { refreshBackground } = require('../utils/backgrounds.js');
const { checkIfDeckIsTestDeck } = require('../utils/utils.js');
const { getStore } = require('../utils/prefs.js');
const NOOP = () => {};
const SLIDE_MOUNT_POINT = '#current_slide';
const AUTOSAVE_RATE = 10 * 1000; // every 10 seconds
const WB_FORMAT = dialogs.WB_FORMAT;
const VERSION_ERROR = `
Warning: Unrecognized Whiteboard file version. Your version of Whiteboard
might be outdated, or the file may have an error.
`.replace(/[\s\n]+/g, ' ');
let _currentUID = 0;
function _uid(str = null) {
_currentUID += 1;
if (str) {
return `${str}-${_currentUID}`;
}
return _currentUID;
}
class Deck extends ModuleBase {
constructor(...args) {
super(...args);
// Init the background according to preferences
const { browserWindow } = this.windowInfo;
refreshBackground(browserWindow);
this.setupEvents();
this.setupWindowEvents();
this.presentationMode = false;
// File format meta information
this.fileVersion = null;
// Misc cached menu stuff
this.globalMenu = null;
this.contextMenu = null;
// Set up data structures to contained slide data
this.slideIDs = []; // ordering for slides
this.slideData = {}; // data as it gets updated
this.slideEditors = {}; // as editors get created, put here
this.activeSlideID = null;
// Used to determine if has unsaved data
this._lastWrittenData = '';
this._autosaveInterval = null;
// Set up initial menu and context menu
this.setMenu();
this.setContextMenu();
}
addSlide() {
const slideID = _uid('slide');
const info = Slide.newSlideInfo();
this.slideIDs.unshift(slideID);
this.slideData[slideID] = info;
this.activateSlide(slideID);
this._updateSlideDataFromEditors();
}
_deleteSlide(slideID) {
const editor = this.slideEditors[slideID];
const { editors } = this.manager;
// Trigger clean up events, then delete references to editor
if (editor) {
// editor.destroy(); // TODO fix editor clean up
editors.callMethodRecursively(editor, 'onWindowClosed');
editors.destroyEditor(editor);
}
delete this.slideData[slideID];
delete this.slideEditors[slideID];
if (this.activeSlideID === slideID) {
this.activeSlideID = null;
}
}
togglePresentationMode() {
this.setPresentationMode(!this.presentationMode);
}
/*
Either turns on or off presentation mode
*/
setPresentationMode(presentationMode) {
this.presentationMode = presentationMode;
const { browserWindow } = this.windowInfo;
browserWindow.setMenuBarVisibility(!this.presentationMode);
browserWindow.setFullScreen(this.presentationMode);
}
deleteCurrentSlide() {
const newSlidesIDs = this.slideIDs
.filter(slideID => slideID !== this.activeSlideID);
this.setFewerSlides(newSlidesIDs);
}
setFewerSlides(newSlideIDs) {
const newSlides = new Set(newSlideIDs);
for (const slideID of this.slideIDs) {
if (!newSlides.has(slideID)) {
// Found deletion
this._deleteSlide(slideID);
}
}
this.slideIDs = newSlideIDs;
const deletedCurrentSlide = this.activeSlideID === null;
if (this.slideIDs.length === 0) {
// Check for deleting all slides
this._initSlides({ slide: [] });
}
if (deletedCurrentSlide) {
// Check for deleting active slide
this.activateSlide(this.slideIDs[0]);
} else {
this.update();
}
}
load(callback, options) {
const { creating } = (options || {});
fs.open(this.path, 'r', (err, fd) => {
// check if can't open, if so, assume it doesn't exist
if (err) {
if (!creating) {
console.error(err);
console.error(`Cannot open ${this.path}`);
throw err;
}
this._initSlides({ slide: [] });
this._initMeta({ format: [{ version: WB_FORMAT }] });
callback();
return;
}
// otherwise read data from file
fs.readFile(fd, 'utf-8', (fileErr, contents) => {
const data = schemaconf.format.parse(contents);
this._initSlides(data);
this._initMeta(data);
callback();
});
});
}
/*
Generate savable data from the current status of the deck
*/
getData() {
const data = {
slide: this.slideIDs.map(slideID => this.slideData[slideID]),
};
const version = this.fileVersion;
if (version) {
data.format = [{ version }];
}
const css = this.globalCss;
if (css && css.length > 0) {
data.deck = [{ css }];
}
return data;
}
save(callback = NOOP) {
// console.log('saving!');
this._updateSlideDataFromEditors();
const data = this.getData();
const string = schemaconf.format.stringify(data);
fs.writeFile(this.path, string, (err) => {
if (err) {
console.error('cannot write to path: ', this.path);
throw err;
}
this._lastWrittenData = JSON.stringify(data);
callback();
});
}
autosave() {
// Cheaper just to automatically save, without a "edited" check
// if (this.isSavedToDisk()) {
// }
this.save();
}
_updateSlideDataFromEditors() {
for (const slideID of this.slideIDs) {
const editor = this.slideEditors[slideID];
if (editor) {
this.slideData[slideID] = editor.slideData;
}
}
}
/*
Given the data object, extract version information
*/
_initMeta(data) {
this.readonly = false;
this.fileVersion = null; // default to unknown version
this.globalCss = null; // defaults to no extra CSS
if (data.deck) {
const deck = data.deck[0];
// Read CSS from file, if applicable
if (deck.cssfile) {
const dirPath = pathlib.dirname(this.path);
const path = pathlib.resolve(dirPath, deck.cssfile);
try {
this.globalCss = fs.readFileSync(path, 'utf-8');
} catch (e) {
console.error('Error reading Global CSS file: ', e);
}
}
// Add in custom deck CSS, if applicable
if (deck.css) {
if (!this.globalCss) {
this.globalCss = '';
}
this.globalCss += deck.css;
}
// Add in custom deck CSS, if applicable
if (deck.readonly) {
this.readonly = deck.readonly === 'true';
}
}
// Load CSS from globalCss
if (this.globalCss) {
this._initCss(this.globalCss);
}
if (!data.format) {
return;
}
if (data.format.length !== 1) {
console.error('Format info error');
return;
}
const format = data.format[0];
if (format.version !== WB_FORMAT) {
console.error('Unrecognized version: ', format);
dialogs.showErrorMessage(this, VERSION_ERROR);
return;
}
this.fileVersion = format.version;
}
_initSlides(data) {
let slides = data.slide;
if (slides.length === 0) {
// Zero state, init with an empty slide
slides = [Slide.newSlideInfo()];
}
// Loop through slides in the file populating the data structures,
// generating a Slide ID for each one. IDs are ephemeral / not stored,
// only used to keep track of ordering.
for (const slide of slides) {
const slideID = _uid('slide');
this.slideData[slideID] = slide;
this.slideIDs.push(slideID);
}
if (this.activeSlideID === null) {
// Nothing yet active, activate first slide
this.activeSlideID = this.slideIDs[0];
}
// Save a string to be used to check for changes
const finalData = this.getData();
this._lastWrittenData = JSON.stringify(finalData);
// Initialize autosave status
this.setAutosave(this.isAutosaveOn(), true);
}
_initCss(cssString) {
const { browserWindow } = this.windowInfo;
browserWindow.webContents.insertCSS(cssString);
}
makeNavigationMenu(includeSeparator = false) {
return [
{
label: 'Next',
accelerator: 'CommandOrControl+Right',
click: () => this.nextSlide(),
icon: glyphIcon('arrow-thin-right'),
},
{
label: 'Previous',
accelerator: 'CommandOrControl+Left',
click: () => this.previousSlide(),
icon: glyphIcon('arrow-thin-left'),
},
{
label: 'All slides',
accelerator: 'F2',
click: () => this.send('toggle_deck'),
icon: glyphIcon('picture-copy'),
},
...(includeSeparator ? [{ type: 'separator' }] : []),
{
label: 'Fullscreen Presentation',
accelerator: 'F5',
icon: glyphIcon('easal'),
click: () => this.togglePresentationMode(),
// Looks better w/o the checkbox
// type: 'checkbox',
// checked: !!this.presentationMode,
},
{
label: 'Cut',
accelerator: 'CmdOrCtrl+X',
role: 'cut',
}, {
label: 'Copy',
accelerator: 'CmdOrCtrl+C',
role: 'copy',
}, {
label: 'Paste',
accelerator: 'CmdOrCtrl+V',
role: 'paste',
}, {
label: 'Select All',
accelerator: 'CmdOrCtrl+A',
role: 'selectall',
},
];
}
/*
Set up the right click context with an optional appended menu fragment
*/
setContextMenu(menuToAppend = []) {
if (menuToAppend.length > 0) {
// Add a separator after
menuToAppend.unshift({ type: 'separator' });
}
const template = [
...this.makeNavigationMenu(),
...menuToAppend,
];
// once we're loaded, setup a nice menu
const { Menu } = this.manager.electron;
this.contextMenu = Menu.buildFromTemplate(template);
}
makeFileMenu() {
if (this.readonly) {
return [
{
label: 'Save disabled',
enabled: false,
icon: glyphIcon('lock'),
},
];
}
const isAutosave = this.isAutosaveOn();
const isAutosaveAvailable = this.fileVersion !== null;
return [
{
label: 'Autosave',
click: () => this.setAutosave(!isAutosave), // toggle
checked: isAutosave,
type: 'checkbox',
enabled: isAutosaveAvailable,
// icon: glyphIcon('refresh'),
},
// Conditionally include Save, only if not autosave included
...isAutosave ? [] : [
{
label: 'Save',
click: () => this.save(),
icon: glyphIcon('floppy-disk'),
},
],
{
label: 'Save as...',
click: () => this.showSaveAsDialog(),
icon: glyphIcon('floppy-disk'),
},
];
}
makeTopLevelMenu(slideMenu) {
const extraSlideOperations = [
{
label: 'Add slide',
click: () => this.addSlide(),
icon: glyphIcon('plus'),
},
{
label: 'Delete slide',
click: () => this.deleteCurrentSlide(),
icon: glyphIcon('trash'),
},
];
return [
{
label: 'Navigation',
submenu: this.makeNavigationMenu(true),
},
{
label: 'Slide',
submenu: slideMenu && slideMenu.length ? [
...extraSlideOperations,
{ type: 'separator' },
slideMenu[0],
...slideMenu.slice(1),
] : [],
},
];
}
makeHelpMenu() {
return [
{
label: 'Shortcut help',
icon: glyphIcon('keyboard'),
accelerator: 'F1',
click: () => this.send('toggle_help', this.helpInfo),
},
];
}
/*
Sets the status of autosave
*/
setAutosave(isOn, skipSetting) {
if (!skipSetting) {
getStore().set('deckAutosave', isOn);
}
if (this._autosaveInterval) {
clearInterval(this._autosaveInterval);
}
if (isOn) {
this._autosaveInterval = setInterval(
() => this.autosave(), AUTOSAVE_RATE);
} else {
this._autosaveInterval = null;
}
}
onWindowFocused() {
if (!this.globalMenu) {
return;
}
// Set this as the global menu bar, and ensure is visible (if in
// presentation mode)
const { Menu } = this.manager.electron;
const { browserWindow } = this.windowInfo;
Menu.setApplicationMenu(this.globalMenu);
browserWindow.setMenu(this.globalMenu);
}
/*
Set up the global menu with an optional slide menu fragment
*/
setMenu(slideMenu = []) {
const template = makeGlobalMenuTemplate(
this.windowInfo,
this.manager,
pathlib.dirname(this.path),
this.makeTopLevelMenu(slideMenu),
this.makeFileMenu(),
this.makeHelpMenu(),
);
// once we're loaded, setup a nice menu
const { Menu } = this.manager.electron;
this.globalMenu = Menu.buildFromTemplate(template);
// Generate info about the current context for help purposes
this.helpInfo = flattenMenuTemplate(template,
'accelerator', 'label', 'icon').filter(item => item.accelerator);
// Set this as the global menu bar, and ensure is visible (if in
// presentation mode)
Menu.setApplicationMenu(this.globalMenu);
const { browserWindow } = this.windowInfo;
// only not in presentation mode
browserWindow.setMenuBarVisibility(!this.presentationMode);
browserWindow.setMenu(this.globalMenu);
}
// Given a slide id, activate that slide for editing.
activateSlide(slideID) {
this.activeSlideID = slideID;
this.update(); // updates the sidebar
// Now get the relevant slide
const info = this.slideData[slideID];
const slidePath = this.getSubPath(slideID, 'slide');
const slideEditor = this.subMount(
slidePath, SLIDE_MOUNT_POINT, NOOP, info);
this.slideEditors[slideID] = slideEditor;
}
setupWindowEvents() {
dialogs.setupShowConfirmQuit(this);
}
isAutosaveOn() {
if (checkIfDeckIsTestDeck(this)) {
// Annoying hack: Check if we are in an end to end test, if so
// ALWAYS disable autosave
// TODO: Remove me once Issue #59 is fixed
return false;
}
if (this.fileVersion === null) {
// File version is unknown, don't do autosave
return false;
}
if (this.readonly) {
// If readonly, always skip autosave
return false;
}
return getStore().get('deckAutosave');
}
isSavedToDisk() {
// Updates slide and compares with what was last saved to disk
this._updateSlideDataFromEditors();
const data = this.getData();
return this._lastWrittenData === JSON.stringify(data);
}
setupEvents() {
this.on('activate', (event, slideID) => {
this.activateSlide(slideID);
});
this.on('add_slide', () => {
// Create new slide then activate it
this.addSlide();
});
this.on('reorder', (event, slideIDs) => {
if (slideIDs.length === 0) {
// TODO figure this one out...
console.error('Empty reorder attempt');
return;
}
if (slideIDs.length !== this.slideIDs.length) {
// Uh oh, should only be a reorder event
throw new Error('Invalid slide reorder!');
}
this.slideIDs = slideIDs;
this.update();
});
this.on('set_fewer_slides', (event, newSlideIDs) => {
this.setFewerSlides(newSlideIDs);
});
this.on('ready', () => {
// open the top thing
this.activateSlide(this.slideIDs[0]);
});
this.on('next_slide', () => this.nextSlide());
this.on('previous_slide', () => this.previousSlide());
this.on('show_context_menu', (event, x, y) => {
this.contextMenu.popup(undefined, { x, y });
});
}
nextSlide() {
const next = this.offsetSlideID(this.activeSlideID, 1);
this.activateSlide(next);
}
previousSlide() {
const prev = this.offsetSlideID(this.activeSlideID, -1);
this.activateSlide(prev);
}
offsetSlideID(slideID, offset) {
let index = this.slideIDs.indexOf(slideID) + offset;
// bound index by array length
index = Math.min(this.slideIDs.length - 1, Math.max(0, index));
return this.slideIDs[index];
}
showSaveAsDialog() {
const { dialog } = this.manager.electron;
dialog.showSaveDialog({
defaultPath: this.path,
filters: [
{ name: 'Whiteboard', extensions: ['whiteboard'] },
],
}, (newPath) => {
if (!newPath) {
return; // canceled
}
this.path = newPath;
this.save();
});
}
static layoutDeckPreview(manager, slides) {
return slides.map(slide => ({
panerows: Slide.layoutPanePreviews(manager, slide),
}));
}
getProps() {
const slides = this.slideIDs.map((id) => {
const slide = this.slideData[id] || { title: 'Unnamed' };
const { title } = slide;
return {
id,
title,
is_active: this.activeSlideID === id,
panerows: Slide.layoutPanePreviews(this.manager, slide),
};
});
return {
slides,
};
}
}
module.exports = Deck;