/**
* This file creates functions for accessing the browser's local storage as if
* it were a collection of files. It also provides file open, save, and save as
* menu items that can be used not only for loading files from and saving files
* to the browser's local storage, but also uploading and downloading files or
* importing them from the web. It also provides a file menu item for deleting
* files from the browser's local storage.
*
* It also installs autosave features into the editor. Every few seconds, if
* the editor's content is dirty, it is saved into browser local storage. If
* the user saves the content elsewhere, it is removed from browser local
* storage. If the user opens Lurch and it finds that content is in the local
* storage autosave, it lets the user choose whether to load it or discard it.
*
* @module LocalStorageDrive
*/
import { LurchDocument } from './lurch-document.js'
import { Dialog, AlertItem, LongTextInputItem } from './dialog.js'
import { isValidURL, appURL, isEmbedded } from './utilities.js'
import { downloadFile } from './upload-download.js'
// Internal use only
// Prefix for distinguishing which LocalStorage keys are for Lurch files
const prefix = 'lurch-file-'
// Internal use only
// Get a list of all files in LocalStorage
const allFileNames = () => {
let result = [ ]
for ( let i = 0 ; i < window.localStorage.length ; i++ )
if ( window.localStorage.key( i ).startsWith( prefix ) )
result.push( window.localStorage.key( i ).substring( prefix.length ) )
return result
}
/**
* Test whether the given file currently exists in the browser's local storage.
*
* @param {string} name - the name of the file whose presence is to be tested
* @returns {boolean} whether a file with that name is currently stored in the
* browser's localStorage
*/
export const fileExists = name => allFileNames().includes( name )
/**
* Read the contents of a file from the browser's local storage.
*
* @param {string} name - the name of the file whose contents are to be read
* @returns {string} the contents of the file (or undefined if the file does not
* exist)
*/
export const readFile = name => window.localStorage.getItem( prefix + name )
/**
* Write new contents into a file in the browser's local storage.
*
* @param {string} name - the name of the file whose contents are to be written
* @param {string} content - the new contents to write
*/
export const writeFile = ( name, content ) =>
window.localStorage.setItem( prefix + name, content )
/**
* Delete a file from the browser's local storage.
*
* @param {string} name - the name of the file to be deleted
*/
export const deleteFile = name => window.localStorage.removeItem( prefix + name )
// Internal use only
// Tools similar to those above, but for autosave
const autosaveKey = 'lurch-autosave'
const autosaveFrequencyInSeconds = 5
const autosave = content =>
window.localStorage.setItem( autosaveKey, content )
const getAutosave = () => window.localStorage.getItem( autosaveKey )
const autosaveExists = () => {
for ( let i = 0 ; i < window.localStorage.length ; i++ )
if ( window.localStorage.key( i ) == autosaveKey ) return true
return false
}
const removeAutosave = () => window.localStorage.removeItem( autosaveKey )
/**
* Save the contents of the given editor into the given file. However, if a
* file with that name already exists, pop up a dialog prompting the user to
* decide if they really want to overwrite the file, and proceed only if they
* accept.
*
* @param {tinymce.Editor} editor - the editor whose contents are to be saved
* @param {string} filename - the filename into which to save those contents
*/
export const saveAs = ( editor, filename ) => {
if ( !fileExists( filename ) ) {
const LD = new LurchDocument( editor )
writeFile( filename, LD.getDocument() )
LD.setFileID( filename )
return Dialog.notify( editor, 'success', `Saved ${filename}.` )
}
Dialog.areYouSure( editor,
`A file named ${filename} already exists.
Continuing will overwrite that file. Proceed anyway?`
).then( sure => {
if ( !sure ) return
const LD = new LurchDocument( editor )
writeFile( filename, LD.getDocument() )
LD.setFileID( filename )
Dialog.notify( editor, 'success', `Saved over ${filename}.` )
} )
}
// Internal use only
// Checks whether the user minds discarding their recent work before proceeding.
const ensureWorkIsSaved = editor => new Promise( ( resolve, reject ) => {
if ( !editor.isDirty() )
return resolve( true )
Dialog.areYouSure(
editor,
'You will lose any unsaved work. Continue anyway?'
).then( resolve, reject )
} )
/**
* Silently (i.e., without asking the user anything in a dialog box) save the
* given new content into the existing file with the given name in the user's
* `LocalStorage`. If it succeeds, pop up a brief success notification. If it
* fails, show a failure notification containing the error and wait for the user
* to dismiss it. It also clears the editor's dirty flag.
*
* @param {tinymce.Editor} editor the editor to use for any notifications
* @param {string} filename the filename whose content should be updated
* @function
* @see {@link module:LocalStorageDrive.showSaveAsDialog showSaveAsDialog()}
*/
const silentFileSave = editor => {
// change the behavior if only saving to computer
const mode = editor.appOptions.fileSaveTabs
if ( mode.length === 1 && mode[0] === 'To your computer' ) {
downloadFile( editor )
} else {
const LD = new LurchDocument( editor )
const filename = LD.getFileID()
if ( filename.startsWith( 'file:///' ) ) {
downloadFile( editor )
} else if ( isValidURL( filename ) ) {
Dialog.notify( editor, 'error', `
You loaded this file from the web, so you cannot save it back there.
Instead, use File > Save As to choose where to save it.
` )
} else {
writeFile( filename, LD.getDocument() )
editor.setDirty( false )
removeAutosave()
}
}
}
/**
* Install into a TinyMCE editor instance five new menu items intended for the
* File menu: New, Open, Save, Save as..., and Delete a saved document. Also
* install autosave features that store the current document whenever it is
* dirty and unsaved, and offer to reload it if you close the tab and then
* re-open.
*
* @param {tinymce.Editor} editor the TinyMCE editor instance into which the new
* menu item should be installed
* @function
*/
export const install = editor => {
// First install the menu items:
editor.ui.registry.addMenuItem( 'newlurchdocument', {
text : 'New',
icon : 'new-document',
tooltip : 'New document',
shortcut : 'alt+N',
onAction : () => ensureWorkIsSaved( editor ).then( saved => {
if ( saved ) new LurchDocument( editor ).newDocument()
} )
} )
editor.ui.registry.addMenuItem( 'opendocument', {
text : 'Open',
tooltip : 'Open file',
shortcut : 'alt+O',
/// icon : 'upload', // do not modify or delete this entire comment line
onAction : () => ensureWorkIsSaved( editor ).then( saved => {
if ( saved ) Dialog.loadFile( editor, 'Open file' ).then( result => {
if ( result ) {
const LD = new LurchDocument( editor )
LD.setDocument( result.content )
LD.setFileID( result.filename )
Dialog.notify( editor, 'success', `Loaded ${result.filename}.` )
}
} )
} )
} )
editor.ui.registry.addMenuItem( 'savedocument', {
text : 'Save',
icon : 'save',
tooltip : 'Save or download file',
shortcut : 'alt+S',
onAction : () => {
// change the behavior if only saving to computer
const mode = editor.appOptions.fileSaveTabs
if (mode.length === 1 && mode[0] === 'To your computer') {
silentFileSave( editor )
} else {
if ( new LurchDocument( editor ).getFileID() )
silentFileSave( editor )
else
Dialog.saveFile( editor, 'Save file' ).then( saved => {
if ( saved ) {
editor.setDirty( false )
removeAutosave()
}
} )
}
}
} )
editor.ui.registry.addMenuItem( 'savedocumentas', {
text : 'Save as...',
tooltip : 'Save or download file',
shortcut : 'alt+shift+S',
onAction : () => Dialog.saveFile( editor, 'Save file' ).then( saved => {
if ( saved ) {
editor.setDirty( false )
removeAutosave()
}
} )
} )
editor.ui.registry.addMenuItem( 'embeddocument', {
text : 'Embed...',
tooltip : 'Embed document in a web page',
onAction : () => {
const html = new LurchDocument( editor ).getDocument()
const iframe = document.createElement( 'iframe' )
iframe.src = `${appURL()}?data=${encodeURIComponent( btoa( html ) )}`
iframe.style.width = '800px'
iframe.style.height = '400px'
const dialog = new Dialog( 'Embedding code', editor )
dialog.json.size = 'medium'
// We must put the styles in the element itself, to override
// TinyMCE's very aggressive CSS within dialogs:
dialog.addItem( new LongTextInputItem( 'code',
'Copy the following code into your web page' ) )
dialog.setInitialData( { code : iframe.outerHTML } )
dialog.removeButton( 'Cancel' )
dialog.setDefaultFocus( 'code' )
dialog.show()
const textarea = dialog.querySelector( 'textarea' )
textarea.select()
textarea.setAttribute( 'readonly', 'true' )
textarea.setAttribute( 'rows', 15 )
textarea.scrollTo( 0, 0 )
}
} )
editor.ui.registry.addMenuItem( 'deletesaved', {
text : 'Delete a saved document',
tooltip : 'Delete a file currently stored in browser storage',
onAction : () => {
const filenames = allFileNames()
if ( filenames.length == 0 )
return Dialog.notify( editor, 'error',
'There are not yet any files saved, so there are none to delete.' )
const dialog = new Dialog( 'Delete file from browser storage', editor )
dialog.addItem( new ChooseLocalFileItem( 'localFile' ) )
dialog.setDefaultFocus( 'localFile' )
dialog.show().then( userHitOK => {
if ( !userHitOK ) return
const filename = dialog.get( 'localFile' ).filename
deleteFile( filename )
Dialog.notify( editor, 'success', `Deleted ${filename}.` )
} )
}
} )
// When the editor is fully initialized, handle autosaving, but only if that
// feature is enabled in the app options:
if ( editor.appOptions.autoSaveEnabled ) {
editor.on( 'init', () => {
// First, if there's an autosave, offer to load it:
if ( autosaveExists() ) {
const dialog = new Dialog( 'Unsaved work exists', editor )
dialog.addItem( new AlertItem(
'warn',
'There is an unsaved document stored in your browser. '
+ 'This could be from another copy of Lurch running in another tab, '
+ 'or from a previous session in which you did not save your work.'
) )
dialog.setButtons(
{ text : 'Load it', type : 'submit', buttonType : 'primary' },
{ text : 'Delete it', type : 'cancel' }
)
dialog.show().then( choseToLoad => {
if ( choseToLoad )
new LurchDocument( editor ).setDocument( getAutosave() )
else
new LurchDocument( editor )
removeAutosave()
} )
} else {
new LurchDocument( editor )
}
// Next, set up the recurring timer for autosaving:
setInterval( () => {
if ( editor.isDirty() )
autosave( new LurchDocument( editor ).getDocument() )
}, autosaveFrequencyInSeconds * 1000 )
} )
}
}
/**
* An item that can be used in a {@link Dialog} to let the user choose a file
* stored in the browser's local storage. This is a simple drop-down list input
* control, populated with all the files currently in the browser's local
* storage.
*/
export class ChooseLocalFileItem {
/**
* Construct a new file open input control.
*
* @param {string} name - the key to use to identify this input control's
* content in the dialog's key-value mapping for all input controls
*/
constructor ( name ) {
this.name = name
}
// internal use only; creates the JSON to represent this object to TinyMCE
json () {
this.names = allFileNames()
return this.names.length == 0 ? [ {
type : 'htmlpanel',
html : 'No files are currently stored in the browser\'s local storage.'
} ] : [ {
type : 'selectbox',
name : this.name,
label : 'Choose a file:',
items : this.names.map( name => {
return { value : name, text : name }
} )
} ]
}
// internal use only; returns a filename-and-content object if requested by
// the dialog's get() function
get ( key, data ) {
if ( key == this.name && this.names && this.names.length > 0 ) return {
filename : data[key],
content : readFile( data[key] ),
source : 'browser storage'
}
}
}
export default { install }
source