This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View 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);
}
}