Initial
This commit is contained in:
613
resources/app/client/apps/forms/journal-page-sheet.js
Normal file
613
resources/app/client/apps/forms/journal-page-sheet.js
Normal file
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single JournalEntryPage document.
|
||||
* @extends {DocumentSheet}
|
||||
* @param {JournalEntryPage} object The JournalEntryPage instance which is being edited.
|
||||
* @param {DocumentSheetOptions} [options] Application options.
|
||||
*/
|
||||
class JournalPageSheet extends DocumentSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["sheet", "journal-sheet", "journal-entry-page"],
|
||||
viewClasses: [],
|
||||
width: 600,
|
||||
height: 680,
|
||||
resizable: true,
|
||||
closeOnSubmit: false,
|
||||
submitOnClose: true,
|
||||
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
|
||||
includeTOC: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get template() {
|
||||
return `templates/journal/page-${this.document.type}-${this.isEditable ? "edit" : "view"}.html`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get title() {
|
||||
return this.object.permission ? this.object.name : "";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The table of contents for this JournalTextPageSheet.
|
||||
* @type {Record<string, JournalEntryPageHeading>}
|
||||
*/
|
||||
toc = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
return foundry.utils.mergeObject(super.getData(options), {
|
||||
headingLevels: Object.fromEntries(Array.fromRange(3, 1).map(level => {
|
||||
return [level, game.i18n.format("JOURNALENTRYPAGE.Level", {level})];
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _renderInner(...args) {
|
||||
await loadTemplates({
|
||||
journalEntryPageHeader: "templates/journal/parts/page-header.html",
|
||||
journalEntryPageFooter: "templates/journal/parts/page-footer.html"
|
||||
});
|
||||
const html = await super._renderInner(...args);
|
||||
if ( this.options.includeTOC ) this.toc = JournalEntryPage.implementation.buildTOC(html.get());
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A method called by the journal sheet when the view mode of the page sheet is closed.
|
||||
* @internal
|
||||
*/
|
||||
_closeView() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Text Secrets Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getSecretContent(secret) {
|
||||
return this.object.text.content;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_updateSecret(secret, content) {
|
||||
return this.object.update({"text.content": content});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Text Editor Integration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async activateEditor(name, options={}, initialContent="") {
|
||||
options.fitToSize = true;
|
||||
options.relativeLinks = true;
|
||||
const editor = await super.activateEditor(name, options, initialContent);
|
||||
this.form.querySelector('[role="application"]')?.style.removeProperty("height");
|
||||
return editor;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the parent sheet if it is open when the server autosaves the contents of this editor.
|
||||
* @param {string} html The updated editor contents.
|
||||
*/
|
||||
onAutosave(html) {
|
||||
this.object.parent?.sheet?.render(false);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the UI appropriately when receiving new steps from another client.
|
||||
*/
|
||||
onNewSteps() {
|
||||
this.form.querySelectorAll('[data-action="save-html"]').forEach(el => el.disabled = true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single JournalEntryPage text document.
|
||||
* @extends {JournalPageSheet}
|
||||
*/
|
||||
class JournalTextPageSheet extends JournalPageSheet {
|
||||
/**
|
||||
* Bi-directional HTML <-> Markdown converter.
|
||||
* @type {showdown.Converter}
|
||||
* @protected
|
||||
*/
|
||||
static _converter = (() => {
|
||||
Object.entries(CONST.SHOWDOWN_OPTIONS).forEach(([k, v]) => showdown.setOption(k, v));
|
||||
return new showdown.Converter();
|
||||
})();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Declare the format that we edit text content in for this sheet so we can perform conversions as necessary.
|
||||
* @type {number}
|
||||
*/
|
||||
static get format() {
|
||||
return CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.classes.push("text");
|
||||
options.secrets.push({parentSelector: "section.journal-page-content"});
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
const data = super.getData(options);
|
||||
this._convertFormats(data);
|
||||
data.editor = {
|
||||
engine: "prosemirror",
|
||||
collaborate: true,
|
||||
content: await TextEditor.enrichHTML(data.document.text.content, {
|
||||
relativeTo: this.object,
|
||||
secrets: this.object.isOwner
|
||||
})
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async close(options={}) {
|
||||
Object.values(this.editors).forEach(ed => {
|
||||
if ( ed.instance ) ed.instance.destroy();
|
||||
});
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, options) {
|
||||
if ( !this.#canRender(options.resync) ) return this.maximize().then(() => this.bringToTop());
|
||||
return super._render(force, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Suppress re-rendering the sheet in cases where an active editor has unsaved work.
|
||||
* In such cases we rely upon collaborative editing to save changes and re-render.
|
||||
* @param {boolean} [resync] Was the application instructed to re-sync?
|
||||
* @returns {boolean} Should a render operation be allowed?
|
||||
*/
|
||||
#canRender(resync) {
|
||||
if ( resync || (this._state !== Application.RENDER_STATES.RENDERED) || !this.isEditable ) return true;
|
||||
return !this.isEditorDirty();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine if any editors are dirty.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEditorDirty() {
|
||||
for ( const editor of Object.values(this.editors) ) {
|
||||
if ( editor.active && editor.instance?.isDirty() ) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _updateObject(event, formData) {
|
||||
if ( (this.constructor.format === CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML) && this.isEditorDirty() ) {
|
||||
// Clear any stored markdown so it can be re-converted.
|
||||
formData["text.markdown"] = "";
|
||||
formData["text.format"] = CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
|
||||
}
|
||||
return super._updateObject(event, formData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async saveEditor(name, { preventRender=true, ...options }={}) {
|
||||
return super.saveEditor(name, { ...options, preventRender });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Lazily convert text formats if we detect the document being saved in a different format.
|
||||
* @param {object} renderData Render data.
|
||||
* @protected
|
||||
*/
|
||||
_convertFormats(renderData) {
|
||||
const formats = CONST.JOURNAL_ENTRY_PAGE_FORMATS;
|
||||
const text = this.object.text;
|
||||
if ( (this.constructor.format === formats.MARKDOWN) && text.content?.length && !text.markdown?.length ) {
|
||||
// We've opened an HTML document in a markdown editor, so we need to convert the HTML to markdown for editing.
|
||||
renderData.data.text.markdown = this.constructor._converter.makeMarkdown(text.content.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single JournalEntryPage image document.
|
||||
* @extends {JournalPageSheet}
|
||||
*/
|
||||
class JournalImagePageSheet extends JournalPageSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.classes.push("image");
|
||||
options.height = "auto";
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single JournalEntryPage video document.
|
||||
* @extends {JournalPageSheet}
|
||||
*/
|
||||
class JournalVideoPageSheet extends JournalPageSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.classes.push("video");
|
||||
options.height = "auto";
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
return foundry.utils.mergeObject(super.getData(options), {
|
||||
flexRatio: !this.object.video.width && !this.object.video.height,
|
||||
isYouTube: game.video.isYouTubeURL(this.object.src),
|
||||
timestamp: this._timestampToTimeComponents(this.object.video.timestamp),
|
||||
yt: {
|
||||
id: `youtube-${foundry.utils.randomID()}`,
|
||||
url: game.video.getYouTubeEmbedURL(this.object.src, this._getYouTubeVars())
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
if ( this.isEditable ) return;
|
||||
// The below listeners are only for when the video page is being viewed, not edited.
|
||||
const iframe = html.find("iframe")[0];
|
||||
if ( iframe ) game.video.getYouTubePlayer(iframe.id, {
|
||||
events: {
|
||||
onStateChange: event => {
|
||||
if ( event.data === YT.PlayerState.PLAYING ) event.target.setVolume(this.object.video.volume * 100);
|
||||
}
|
||||
}
|
||||
}).then(player => {
|
||||
if ( this.object.video.timestamp ) player.seekTo(this.object.video.timestamp, true);
|
||||
});
|
||||
const video = html.parent().find("video")[0];
|
||||
if ( video ) {
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
video.volume = this.object.video.volume;
|
||||
if ( this.object.video.timestamp ) video.currentTime = this.object.video.timestamp;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the YouTube player parameters depending on whether the sheet is being viewed or edited.
|
||||
* @returns {object}
|
||||
* @protected
|
||||
*/
|
||||
_getYouTubeVars() {
|
||||
const vars = {playsinline: 1, modestbranding: 1};
|
||||
if ( !this.isEditable ) {
|
||||
vars.controls = this.object.video.controls ? 1 : 0;
|
||||
vars.autoplay = this.object.video.autoplay ? 1 : 0;
|
||||
vars.loop = this.object.video.loop ? 1 : 0;
|
||||
if ( this.object.video.timestamp ) vars.start = this.object.video.timestamp;
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getSubmitData(updateData={}) {
|
||||
const data = super._getSubmitData(updateData);
|
||||
data["video.timestamp"] = this._timeComponentsToTimestamp(foundry.utils.expandObject(data).timestamp);
|
||||
["h", "m", "s"].forEach(c => delete data[`timestamp.${c}`]);
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert time components to a timestamp in seconds.
|
||||
* @param {{[h]: number, [m]: number, [s]: number}} components The time components.
|
||||
* @returns {number} The timestamp, in seconds.
|
||||
* @protected
|
||||
*/
|
||||
_timeComponentsToTimestamp({h=0, m=0, s=0}={}) {
|
||||
return (h * 3600) + (m * 60) + s;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert a timestamp in seconds into separate time components.
|
||||
* @param {number} timestamp The timestamp, in seconds.
|
||||
* @returns {{[h]: number, [m]: number, [s]: number}} The individual time components.
|
||||
* @protected
|
||||
*/
|
||||
_timestampToTimeComponents(timestamp) {
|
||||
if ( !timestamp ) return {};
|
||||
const components = {};
|
||||
const h = Math.floor(timestamp / 3600);
|
||||
if ( h ) components.h = h;
|
||||
const m = Math.floor((timestamp % 3600) / 60);
|
||||
if ( m ) components.m = m;
|
||||
components.s = timestamp - (h * 3600) - (m * 60);
|
||||
return components;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single JournalEntryPage PDF document.
|
||||
* @extends {JournalPageSheet}
|
||||
*/
|
||||
class JournalPDFPageSheet extends JournalPageSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.classes.push("pdf");
|
||||
options.height = "auto";
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintain a cache of PDF sizes to avoid making HEAD requests every render.
|
||||
* @type {Record<string, number>}
|
||||
* @protected
|
||||
*/
|
||||
static _sizes = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find("> button").on("click", this._onLoadPDF.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
return foundry.utils.mergeObject(super.getData(options), {
|
||||
params: this._getViewerParams()
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _renderInner(...args) {
|
||||
const html = await super._renderInner(...args);
|
||||
const pdfLoader = html.closest(".load-pdf")[0];
|
||||
if ( this.isEditable || !pdfLoader ) return html;
|
||||
let size = this.constructor._sizes[this.object.src];
|
||||
if ( size === undefined ) {
|
||||
const res = await fetch(this.object.src, {method: "HEAD"}).catch(() => {});
|
||||
this.constructor._sizes[this.object.src] = size = Number(res?.headers.get("content-length"));
|
||||
}
|
||||
if ( !isNaN(size) ) {
|
||||
const mb = (size / 1024 / 1024).toFixed(2);
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("hint");
|
||||
span.textContent = ` (${mb} MB)`;
|
||||
pdfLoader.querySelector("button").appendChild(span);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a request to load a PDF.
|
||||
* @param {MouseEvent} event The triggering event.
|
||||
* @protected
|
||||
*/
|
||||
_onLoadPDF(event) {
|
||||
const target = event.currentTarget.parentElement;
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = `scripts/pdfjs/web/viewer.html?${this._getViewerParams()}`;
|
||||
target.replaceWith(frame);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve parameters to pass to the PDF viewer.
|
||||
* @returns {URLSearchParams}
|
||||
* @protected
|
||||
*/
|
||||
_getViewerParams() {
|
||||
const params = new URLSearchParams();
|
||||
if ( this.object.src ) {
|
||||
const src = URL.parseSafe(this.object.src) ? this.object.src : foundry.utils.getRoute(this.object.src);
|
||||
params.append("file", src);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A subclass of {@link JournalTextPageSheet} that implements a markdown editor for editing the text content.
|
||||
* @extends {JournalTextPageSheet}
|
||||
*/
|
||||
class MarkdownJournalPageSheet extends JournalTextPageSheet {
|
||||
/**
|
||||
* Store the dirty flag for this editor.
|
||||
* @type {boolean}
|
||||
* @protected
|
||||
*/
|
||||
_isDirty = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get format() {
|
||||
return CONST.JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.dragDrop = [{dropSelector: "textarea"}];
|
||||
options.classes.push("markdown");
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get template() {
|
||||
if ( this.isEditable ) return "templates/journal/page-markdown-edit.html";
|
||||
return super.template;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
const data = await super.getData(options);
|
||||
data.markdownFormat = CONST.JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find("textarea").on("keypress paste", () => this._isDirty = true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
isEditorDirty() {
|
||||
return this._isDirty;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _updateObject(event, formData) {
|
||||
// Do not persist the markdown conversion if the contents have not been edited.
|
||||
if ( !this.isEditorDirty() ) {
|
||||
delete formData["text.markdown"];
|
||||
delete formData["text.format"];
|
||||
}
|
||||
return super._updateObject(event, formData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDrop(event) {
|
||||
event.preventDefault();
|
||||
const eventData = TextEditor.getDragEventData(event);
|
||||
return this._onDropContentLink(eventData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle dropping a content link onto the editor.
|
||||
* @param {object} eventData The parsed event data.
|
||||
* @protected
|
||||
*/
|
||||
async _onDropContentLink(eventData) {
|
||||
const link = await TextEditor.getContentLink(eventData, {relativeTo: this.object});
|
||||
if ( !link ) return;
|
||||
const editor = this.form.elements["text.markdown"];
|
||||
const content = editor.value;
|
||||
editor.value = content.substring(0, editor.selectionStart) + link + content.substring(editor.selectionStart);
|
||||
this._isDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A subclass of {@link JournalTextPageSheet} that implements a TinyMCE editor.
|
||||
* @extends {JournalTextPageSheet}
|
||||
*/
|
||||
class JournalTextTinyMCESheet extends JournalTextPageSheet {
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
const data = await super.getData(options);
|
||||
data.editor.engine = "tinymce";
|
||||
data.editor.collaborate = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async close(options = {}) {
|
||||
return JournalPageSheet.prototype.close.call(this, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, options) {
|
||||
return JournalPageSheet.prototype._render.call(this, force, options);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user