diff --git a/ImageEditor/1.0.0/ImageEditor.js b/ImageEditor/1.0.0/ImageEditor.js index 923346370..895815f8d 100644 --- a/ImageEditor/1.0.0/ImageEditor.js +++ b/ImageEditor/1.0.0/ImageEditor.js @@ -82,6 +82,7 @@ The Image Editor allows you to select any handout that contains
  • The Image Editor cannot be used to edit its own handout.
  • Only images in the notes field of a handout are visible to the editor. Images in gmnotes are not shown.
  • Style changes are written directly to the handout HTML. Always keep a backup of important handout content.
  • +
  • Large handouts: If the referenced handout contains a very large amount of content (many images, long text), keep it closed while making edits in the Image Editor. Having both handouts open simultaneously while saving changes can cause the browser to become unresponsive.
  • `; @@ -110,6 +111,8 @@ The Image Editor allows you to select any handout that contains // ================================================== const Config = { editorName: 'Image Editor', + + largeHandoutWarningSize: 100000, properties: { width: { type: 'size' }, @@ -655,6 +658,19 @@ The Image Editor allows you to select any handout that contains Utils.rebuildHandoutCache(() => { handout.get('notes', notes => { + + if (notes && notes.length > Config.largeHandoutWarningSize) { + Utils.whisper(msg.who, + `
    ` + + `
    ⚠ Large Handout Warning
    ` + + `
    The chosen handout is ${Math.round(notes.length/1000)}k characters. ` + + `If you keep it open while editing, your browser may become unresponsive. ` + + `It is recommended to close the referenced handout before making edits, ` + + `then reopen it to review changes.
    ` + + `
    ` + ); + } + const images = Parser.extractImages(notes); if (!images.length) { editor.set('notes', diff --git a/ImageEditor/1.0.1/ImageEditor.js b/ImageEditor/1.0.1/ImageEditor.js new file mode 100644 index 000000000..606fcec98 --- /dev/null +++ b/ImageEditor/1.0.1/ImageEditor.js @@ -0,0 +1,805 @@ +// Script: Image Editor +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis +const imageeditor = (() => { + +on("ready", () => { + 'use strict'; + + const version = '1.0.1'; + log('-=> Image Editor v' + version + ' is loaded. Use !imageeditor to create interface'); + // 1.0.0 Debut + // 1.0.1 Added warning for large referenced handouts, which can cause the browser to become unresponsive if open simultaneously with the editor. + + + // ================================================== + // HELP HANDOUT + // ================================================== + const HELP_NAME = 'Help: Image Editor'; + const HELP_AVATAR = 'https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147'; + const HELP_TEXT = ` +

    Image Editor Guide

    +

    +The Image Editor allows you to select any handout that contains images and modify the style, layout, and properties of those images without manually editing HTML. Changes are written directly back to the handout's notes. +

    + +

    Getting Started

    +

    Type !imageeditor in chat to open the Image Editor interface. The editor opens as a handout called Image Editor in your journal. Only handouts that contain at least one image will appear in the chooser.

    + +

    Choosing a Handout

    +

    Click the Choose Handout button in the top right of the editor. A dropdown will appear listing all handouts that contain images, in alphabetical order. Selecting one will load its images into the editor.

    +

    The name of the currently selected handout appears as a link in the header. Below it, if the currently selected image is preceded by a header in the handout, that header will appear as a secondary link which jumps directly to that section of the handout.

    + +

    Thumbnails Panel

    +

    The left panel shows thumbnails of every image found in the selected handout. Click any thumbnail to load that image into the preview panel and populate the properties panel with its current values. The title of the image, if set, appears below each thumbnail.

    + +

    Preview Panel

    +

    The center panel shows a large preview of the currently selected image. At the top of the preview panel is a navigation bar with left and right arrow buttons for moving to the previous or next image without scrolling the thumbnail list. The small thumbnails beside the arrows show the adjacent images and are also clickable.

    + +

    Properties Panel

    +

    The right panel shows the editable properties of the currently selected image. Click any value to open a prompt where you can enter a new value. Leaving the prompt blank will remove that property from the image entirely.

    + +

    title

    +

    Sets the title attribute of the image tag. This text appears as a tooltip when the user hovers over the image.

    + +

    url

    +

    Sets the src attribute, replacing the image with a different one at the given URL.

    + +

    layout

    +

    Applies a float-based layout preset to the image. Options are:

    + + +

    width / height

    +

    Sets the width or height of the image. Values must be in pixels (e.g. 200px) or percent (e.g. 50%).

    + +

    margin

    +

    Sets the margin around the image. Accepts 1 to 4 values in pixels or percent, space-separated, following standard CSS margin shorthand (e.g. 8px 16px).

    + +

    border-radius

    +

    Rounds the corners of the image. Value must be in pixels or percent (e.g. 8px).

    + +

    Presets

    +

    Below the properties are quick-apply preset buttons. Clicking a preset merges a predefined set of style values into the image's existing styles, preserving properties like border-radius that are not part of the preset.

    + + +

    Commands

    + + +

    Notes

    + +`; + + // ================================================== + // STATE + // ================================================== + const checkInstall = () => { + state.ImageEditor = state.ImageEditor || { + handoutId: null, + selectedIndex: 0, + // cachedHandoutOptions: array of "Name,id" strings for the picker. + // Rebuilt only on --choose and --refresh, not on every render. + cachedHandoutOptions: null + }; + // Remove legacy fields that should not be in persistent state. + if (state.ImageEditor.hasOwnProperty('currentImages')) { + delete state.ImageEditor.currentImages; + } + if (state.ImageEditor.hasOwnProperty('editorHandoutId')) { + delete state.ImageEditor.editorHandoutId; + } + }; + + // ================================================== + // CONFIG + // ================================================== + const Config = { + editorName: 'Image Editor', + + largeHandoutWarningSize: 100000, + + properties: { + width: { type: 'size' }, + 'max-width': { type: 'size' }, + height: { type: 'size' }, + 'max-height': { type: 'size' }, + 'border-radius': { type: 'size' }, + + layout: { + type: 'enum', + values: ['left', 'right', 'center', 'none'] + }, + + margin: { type: 'margin' }, + + title: { type: 'string', attribute: true }, + url: { type: 'string', attribute: true } + }, + + presets: [ + { label: 'Left 30%', styles: { width: '30%', float: 'left', display: 'inline', margin: '0 8px 8px 0' } }, + { label: 'Right 30%', styles: { width: '30%', float: 'right', display: 'inline', margin: '0 0 8px 8px' } }, + { label: 'Left 50%', styles: { width: '50%', float: 'left', display: 'inline', margin: '0 8px 8px 0' } }, + { label: 'Right 50%', styles: { width: '50%', float: 'right', display: 'inline', margin: '0 0 8px 8px' } }, + { label: 'Left 60%', styles: { width: '60%', float: 'left', display: 'inline', margin: '0 8px 8px 0' } }, + { label: 'Right 60%', styles: { width: '60%', float: 'right', display: 'inline', margin: '0 0 8px 8px' } }, + { label: 'Center', styles: { width: '100%', float: 'none', display: 'block', margin: '0 auto' } }, + { label: 'Clear', styles: null } + ] + }; + + // ================================================== + // CSS (CENTRALIZED) + // ================================================== + const CSS = { + header: 'font-weight:bold; font-size:18px; padding:8px; color:#ddd;', + headerRow: 'width:100%; background:#000; border:none; table-layout:fixed;', + headerCell: 'vertical-align:middle; border:none; background:#000; padding:0 8px;', + right: 'text-align:right;', + center: 'text-align:center; color:#ddd; font-weight:bold; font-size:18px;', + + table: 'width:100%; background:#000; color:#ccc; border:none; table-layout:fixed;', + + td: 'vertical-align:top; padding:10px;', + tdThumbnail: 'width:205px; vertical-align:top; padding:10px; text-align:center;', + tdPreview: 'vertical-align:top; padding:10px;', + tdControl: 'width:205px; vertical-align:top; padding:10px;', + + thumb: 'display:block; margin:8px auto 2px auto; width:125px; border:1px solid #444; border-radius:8px; background:transparent;', + preview: 'max-width:100%; max-height:300px; border:none; background:#111; padding:4px;', + + handoutButton: 'text-decoration:none; color:#6ca0ff; background:#222; border:1px solid #444; padding:4px 8px; border-radius:3px; cursor:pointer; font-size:14px;', + button: 'text-decoration:none; color:#6ca0ff; cursor:pointer;', + + label: 'font-weight:bold; color:#aaa;', + value: 'color:#6ca0ff;', + muted: 'color:#666;', + + panelHeader: 'font-weight:bold; color:#ddd; margin-bottom:8px; border-bottom:1px solid #444; padding-bottom:4px;', + + thumbnailPanel:'width:200px; max-height:500px; overflow-y:auto; background:#000; border:none; padding:8px; text-align:center;', + previewPanel: 'background:#000; border:none; padding:8px; text-align:center;', + controlPanel: 'width:200px; max-height:500px; overflow-y:auto; background:#000; border:none; padding:8px;', + previewHeader: 'width:100%; overflow:hidden; border-bottom:1px solid #444; padding-bottom:4px; margin-bottom:8px;', + navButton: 'text-decoration:none; color:#ddd; font-weight:bold; font-size:16px; padding:0 6px; background:#222; border:1px solid #444; border-radius:3px;', + navThumb: 'max-height:30px; border:1px solid #444; border-radius:3px; vertical-align:middle; margin:0 2px;', + + handoutLink: 'text-decoration:none; color:#fff; font-weight:bold; font-size:14px;', + headerLink: 'text-decoration:none; color:#aad4f5; font-weight:normal; font-size:11px; display:block; margin-top:3px;', + + // Chat launch message (sent when bare !imageeditor is typed) + launchBox: 'background:#111; border:1px solid #444; border-radius:4px; padding:10px 14px; font-family:sans-serif;', + launchTitle: 'font-size:15px; font-weight:bold; color:#ddd; margin-bottom:6px;', + launchLink: 'text-decoration:none; color:#6ca0ff; background:#222; border:1px solid #444; padding:5px 10px; border-radius:3px; font-size:13px; font-weight:bold;', + + // Help whisper box — same family as launchBox + helpLink: 'color:#9fd3ff; font-weight:bold; text-decoration:none;' + }; + + // ================================================== + // UTILITIES + // ================================================== + const Utils = { + + send: (msg) => sendChat('ImageEditor', msg), + whisper: (who, msg) => sendChat('ImageEditor', `/w "${who.replace(/ \(GM\)$/,'')}" ${msg}`), + + buildEnumQuery: (title, values, current) => { + const opts = values.map(v => `${v}${v === current ? '*' : ''}`); + return `?{${title}|${opts.join('|')}}`; + }, + + buildSizeQuery: (title, current) => `?{${title} (px or %)|${current || ''}}`, + buildMarginQuery: (current) => `?{Margin (1-4 values px or %)|${current || ''}}`, + + // ------------------------------------------------------------------ + // rebuildHandoutCache + // ------------------------------------------------------------------ + // Asynchronously scans all handouts for images and stores the result + // in state.ImageEditor.cachedHandoutOptions as an array of "Name,id" + // strings. Calls callback() when done (no arguments). + // + // Call this ONLY on --choose and --refresh. All other operations + // must use the cache directly — never call this on every render. + // ------------------------------------------------------------------ + rebuildHandoutCache: (callback) => { + const handouts = findObjs({ type: 'handout' }) + .sort((a, b) => a.get('name').localeCompare(b.get('name'))); + + if (!handouts.length) { + state.ImageEditor.cachedHandoutOptions = []; + callback(); + return; + } + + const results = []; + let remaining = handouts.length; + + handouts.forEach(h => { + h.get('notes', text => { + const hasImage = /]*>/i.test(text || ''); + if (hasImage) results.push(h); + remaining--; + if (remaining === 0) { + state.ImageEditor.cachedHandoutOptions = + results.map(r => `${r.get('name')},${r.id}`); + callback(); + } + }); + }); + }, + + // Return the cached picker options, or an empty array if not yet built. + getCachedOptions: () => state.ImageEditor.cachedHandoutOptions || [], + + // Always look up the editor handout by name. This means a rename or + // deletion is handled gracefully — the script never holds a stale ID. + // findObjs is an in-memory index lookup and is fast enough to call + // on every command without caching. + getEditorHandout: () => { + const existing = findObjs({ type: 'handout', name: Config.editorName })[0]; + if (existing) return existing; + + // Not found — create it fresh. + log(`[ImageEditor] "${Config.editorName}" handout not found — creating.`); + return createObj('handout', { + name: Config.editorName, + inplayerjournals: 'all' + }); + }, + + // ------------------------------------------------------------------ + // findPrecedingHeader + // ------------------------------------------------------------------ + // Scans the handout HTML for the nearest h1-h4 that appears before + // the Nth tag (0-based). Returns { text, level } or null. + // ------------------------------------------------------------------ + findPrecedingHeader: (html, imgIndex) => { + + // Step 1: find the character offset of the target tag. + const imgRe = /]*>/gi; + let count = 0; + let imgPos = -1; + let m; + + while ((m = imgRe.exec(html)) !== null) { + if (count === imgIndex) { imgPos = m.index; break; } + count++; + } + + if (imgPos === -1) return null; + + // Step 2: find the last h1-h4 whose opening tag starts before imgPos. + // Work on only the substring before the image for efficiency. + const before = html.slice(0, imgPos); + const headRe = /]*>([\s\S]*?)<\/h\1>/gi; + let lastHeader = null; + let hm; + + while ((hm = headRe.exec(before)) !== null) { + const text = hm[2].replace(/<[^>]+>/g, '').trim(); + if (text) lastHeader = { text, level: parseInt(hm[1], 10) }; + } + + return lastHeader; + } + }; + + // ================================================== + // COMMAND PARSER + // ================================================== + const parseArgs = (content) => { + const tokens = content.split(/\s+--/).slice(1); + const args = {}; + + tokens.forEach(t => { + const [key, ...rest] = t.split(/\s+/); + const body = rest.join(' '); + + if (key === 'set') { + const [prop, ...valParts] = body.split('|'); + const value = valParts.join('|').trim(); + args.set = args.set || []; + args.set.push({ property: prop.trim(), value }); + } else { + args[key] = body.trim(); + } + }); + + return args; + }; + + // ================================================== + // STYLE ENGINE + // ================================================== + const StyleEngine = { + + parse: (str) => { + const o = {}; + if (!str) return o; + str.split(';').forEach(p => { + const idx = p.indexOf(':'); + if (idx === -1) return; + const k = p.slice(0, idx).trim(); + const v = p.slice(idx + 1).trim(); + if (k && v) o[k] = v; + }); + return o; + }, + + serialize: (o) => + Object.entries(o).map(([k, v]) => `${k}:${v}`).join('; '), + + validateSize: (v) => + /^\d+(px|%)$/.test(v) ? v : null, + + validateMargin: (v) => { + v = v.trim().replace(/\s+/g, ' '); + return /^(\d+(px|%))( \d+(px|%)){0,3}$/.test(v) ? v : null; + }, + + applyLayout: (style, layout) => { + delete style.float; + delete style.display; + + if (layout === 'left') { style.float = 'left'; style.display = 'inline'; style.margin = '0'; } + if (layout === 'right') { style.float = 'right'; style.display = 'inline'; style.margin = '0'; } + + if (layout === 'center') { + style.float = 'none'; + style.display = 'block'; + if (style.margin) { + const p = style.margin.split(' '); + if (p.length === 1) style.margin = `${p[0]} auto`; + if (p.length === 2) style.margin = `${p[0]} auto`; + if (p.length === 3) style.margin = `${p[0]} auto ${p[2]}`; + if (p.length === 4) style.margin = `${p[0]} auto ${p[2]} auto`; + } else { + style.margin = '0 auto'; + } + } + return style; + } + }; + + // ================================================== + // IMAGE PARSER + // ================================================== + const Parser = { + + extractImages: (html) => { + const matches = html.match(/]*>/gi) || []; + return matches.map(tag => { + const get = (a) => { + const m = tag.match(new RegExp(`${a}="([^"]*)"`, 'i')); + return m ? m[1] : ''; + }; + return { tag, src: get('src'), style: get('style'), title: get('title') }; + }); + }, + + // Replace the first occurrence of oldTag in html using a literal + // string search (indexOf + slice) rather than .replace(), which would + // misinterpret regex special characters in image src URLs. + replaceImage: (html, oldTag, newTag) => { + const pos = html.indexOf(oldTag); + if (pos === -1) return html; + return html.slice(0, pos) + newTag + html.slice(pos + oldTag.length); + }, + + rebuildTag: (img, styleStr) => { + let t = img.tag + .replace(/\sstyle="[^"]*"/i, '') + .replace(/\stitle="[^"]*"/i, '') + .replace(/\ssrc="[^"]*"/i, ''); + return t.replace(' { + + const handoutId = state.ImageEditor.handoutId; + + let centreContent; + if (handoutName) { + const handoutUrl = `http://journal.roll20.net/handout/${handoutId}`; + const handoutLink = `${handoutName}`; + const deepLink = headerInfo + ? `${headerInfo.text}` + : ''; + centreContent = handoutLink + deepLink; + } else { + centreContent = `No Handout Selected`; + } + + const pickerQuery = (handoutOptions && handoutOptions.length) + ? `?{Select Handout|${handoutOptions.join('|')}}` + : '?{No handouts with images found|}'; + + return ` + + + + + + +
    +
    Image Editor
    +
    + ${centreContent} + + + Choose Handout + + + ? + +
    `; + }, + + thumbs: (images) => + images.map((i, idx) => ` + + + +
    ${i.title || ''}
    + `).join(''), + + controls: (img) => { + + const style = StyleEngine.parse(img.style); + + const control = (name, val, query) => + `
    + ${name}: + + ${val || '—'} + +
    `; + + const propOrder = ['title', 'url', 'layout', 'width', 'height', 'margin', 'border-radius']; + + const propertyControls = propOrder.map(p => { + const conf = Config.properties[p]; + if (!conf) return ''; + + let val = (p === 'title') ? img.title : style[p]; + val = val || '—'; + + let query = ''; + if (conf.type === 'enum') query = Utils.buildEnumQuery(p, conf.values, val === '—' ? '' : val); + if (conf.type === 'size') query = Utils.buildSizeQuery(p, val === '—' ? '' : val); + if (conf.type === 'margin') query = Utils.buildMarginQuery(val === '—' ? '' : val); + if (conf.type === 'string') query = `?{${p}|${val === '—' ? '' : val}}`; + + return control(p, val, query); + }).join(''); + + const presetButtons = Config.presets.map(p => + ` + ${p.label} + ` + ).join(' '); + + return propertyControls + ` +
    +
    Presets
    + ${presetButtons} +
    `; + }, + + render: (handout, images, handoutOptions, headerInfo) => { + + // Clamp idx to valid range — state may hold a stale index from a + // previous session with more images than the current array has. + const idx = images.length > 0 + ? Math.max(0, Math.min(state.ImageEditor.selectedIndex || 0, images.length - 1)) + : 0; + const img = images[idx] || null; + + return ` +
    + ${Renderer.header(handout.get('name'), handoutOptions, headerInfo)} + + + + + + +
    +
    +
    Thumbnails
    + ${Renderer.thumbs(images)} +
    +
    +
    +
    + + ${img && idx > 0 + ? `` + : ``} + + + ${img && idx < images.length - 1 + ? `` + : ``} + + Image Preview +
    + ${img + ? `` + : `
    No image selected
    `} +
    +
    +
    +
    Properties
    + ${img ? Renderer.controls(img) : '
    No image selected
    '} +
    +
    +
    `; + } + }; + + // ================================================== + // RENDER HELPERS + // ================================================== + // Central render call used by all command paths. + // Uses the cached handout options — never rebuilds the list. + const renderEditor = (handout, notes, images) => { + const editor = Utils.getEditorHandout(); + const options = Utils.getCachedOptions(); + const idx = state.ImageEditor.selectedIndex || 0; + const headerInfo = Utils.findPrecedingHeader(notes, idx); + editor.set('notes', Renderer.render(handout, images, options, headerInfo)); + }; + + // Render the empty-state panel (no handout chosen yet) into the editor + // handout. Called on script load and whenever the editor needs to show + // its initial UI so the GM has something to interact with. + const renderEmptyPanel = () => { + const editor = Utils.getEditorHandout(); + editor.set('notes', Renderer.render({ get: () => null }, [], Utils.getCachedOptions(), null)); + }; + + // ================================================== + // MAIN + // ================================================== + const handleInput = (msg) => { + + if (msg.type !== 'api') return; + if (!msg.content.startsWith('!imageeditor')) return; + + // ------------------------------------------------------------------ + // Bare command: !imageeditor (no sub-command) + // Ensure the editor handout exists, then whisper a styled clickable + // link to it so the GM can open it without hunting through the journal. + // ------------------------------------------------------------------ + const trimmed = msg.content.trim(); + if (trimmed === '!imageeditor') { + const editor = Utils.getEditorHandout(); + const url = `http://journal.roll20.net/handout/${editor.id}`; + Utils.whisper(msg.who, + `
    ` + + `
    Image Editor
    ` + + `Open Image Editor` + + `
    ` + ); + // Also ensure the handout has content — renders the empty-state + // panel if the handout was just created or was previously blank. + editor.get('notes', notes => { + if (!notes || !notes.trim()) renderEmptyPanel(); + }); + return; + } + + // ------------------------------------------------------------------ + // --help: create or update the Help: Image Editor handout, whisper link. + // ------------------------------------------------------------------ + if (trimmed === '!imageeditor --help') { + let helpHandout = findObjs({ type: 'handout', name: HELP_NAME })[0]; + if (!helpHandout) { + helpHandout = createObj('handout', { + name: HELP_NAME, + archived: false, + avatar: HELP_AVATAR + }); + } + helpHandout.set('avatar', HELP_AVATAR); + helpHandout.set('notes', HELP_TEXT); + const helpUrl = `http://journal.roll20.net/handout/${helpHandout.id}`; + const helpBox = + `
    ` + + `
    Image Editor Help
    ` + + `Open Help Handout` + + `
    `; + sendChat('ImageEditor', `/w gm ${helpBox}`, null, { noarchive: true }); + return; + } + + const args = parseArgs(msg.content); + const editor = Utils.getEditorHandout(); + + // ------------------------------------------------------------------ + // --choose: set new target handout, rebuild the picker cache, render. + // This is the ONLY path that calls rebuildHandoutCache. + // ------------------------------------------------------------------ + if (args.choose) { + state.ImageEditor.handoutId = args.choose; + state.ImageEditor.selectedIndex = 0; + + const handout = getObj('handout', args.choose); + if (!handout) { + editor.set('notes', Renderer.render({ get: () => null }, [], Utils.getCachedOptions(), null)); + return; + } + + Utils.rebuildHandoutCache(() => { + handout.get('notes', notes => { + + if (notes && notes.length > Config.largeHandoutWarningSize) { + Utils.whisper(msg.who, + `
    ` + + `
    ⚠ Large Handout Warning
    ` + + `
    The chosen handout is ${Math.round(notes.length/1000)}k characters. ` + + `If you keep it open while editing, your browser may become unresponsive. ` + + `It is recommended to close the referenced handout before making edits, ` + + `then reopen it to review changes.
    ` + + `
    ` + ); + } + + const images = Parser.extractImages(notes); + if (!images.length) { + editor.set('notes', + Renderer.header(handout.get('name'), Utils.getCachedOptions(), null) + + '

    No images found in this handout.

    ' + ); + return; + } + renderEditor(handout, notes, images); + }); + }); + return; + } + + // ------------------------------------------------------------------ + // All other commands operate on the already-chosen handout. + // They use the cached picker list and never scan all handouts. + // ------------------------------------------------------------------ + const handout = getObj('handout', state.ImageEditor.handoutId); + + if (!handout) { + editor.set('notes', Renderer.render({ get: () => null }, [], Utils.getCachedOptions(), null)); + return; + } + + if (handout.get('name') === Config.editorName) { + Utils.whisper(msg.who, 'Cannot edit images in the Image Editor handout.'); + return; + } + + handout.get('notes', notes => { + + let images = Parser.extractImages(notes); + + if (!images.length) { + editor.set('notes', + Renderer.header(handout.get('name'), Utils.getCachedOptions(), null) + + '

    No images found in this handout.

    ' + ); + return; + } + + // --select + if (args.select !== undefined) { + state.ImageEditor.selectedIndex = Math.max(0, + Math.min(images.length - 1, parseInt(args.select))); + } + + let img = images[state.ImageEditor.selectedIndex]; + let style = StyleEngine.parse(img.style); + + // --set + if (args.set) { + args.set.forEach(s => { + const conf = Config.properties[s.property]; + if (!conf) return; + const value = s.value.trim(); + + if (!value) { + if (conf.type === 'string') { img.title = ''; } + else { delete style[s.property]; } + if (s.property === 'layout') style = StyleEngine.applyLayout(style, 'none'); + return; + } + if (conf.type === 'size') { const v = StyleEngine.validateSize(value); if (v) style[s.property] = v; } + if (conf.type === 'margin') { const v = StyleEngine.validateMargin(value); if (v) style.margin = v; } + if (conf.type === 'enum') { + if (conf.values.includes(value) && s.property === 'layout') { + style = StyleEngine.applyLayout(style, value); + style.layout = value; + } + } + if (conf.type === 'string') { + if (s.property === 'url') img.src = value; + else img.title = value; + } + }); + + const newTag = Parser.rebuildTag(img, StyleEngine.serialize(style)); + notes = Parser.replaceImage(notes, img.tag, newTag); + handout.set('notes', notes); + images = Parser.extractImages(notes); + } + + // Re-fetch img after possible --set update + img = images[state.ImageEditor.selectedIndex]; + style = StyleEngine.parse(img.style); + + // --preset + if (args.preset) { + try { + const presetStyles = JSON.parse(decodeURIComponent(args.preset)); + if (presetStyles === null) { + style = {}; + } else { + Object.assign(style, presetStyles); + } + } catch(e) { + log('ImageEditor: failed to parse preset — ' + e); + } + const newTag = Parser.rebuildTag(img, StyleEngine.serialize(style)); + notes = Parser.replaceImage(notes, img.tag, newTag); + handout.set('notes', notes); + images = Parser.extractImages(notes); + } + + renderEditor(handout, notes, images); + }); + }; + + // ================================================== + checkInstall(); + on('chat:message', handleInput); + + // Prime the cache on script load, then render the empty-state panel so + // the handout has content immediately — even if it was just created. + Utils.rebuildHandoutCache(() => { + log('-=> Image Editor: handout cache primed (' + + (state.ImageEditor.cachedHandoutOptions || []).length + ' handouts with images).'); + // Only write the empty panel if the handout is genuinely blank — + // don't overwrite a valid session that survived a sandbox restart. + const editor = Utils.getEditorHandout(); + editor.get('notes', notes => { + if (!notes || !notes.trim()) renderEmptyPanel(); + }); + }); + +}); + +})(); + diff --git a/ImageEditor/ImageEditor.js b/ImageEditor/ImageEditor.js index 923346370..895815f8d 100644 --- a/ImageEditor/ImageEditor.js +++ b/ImageEditor/ImageEditor.js @@ -82,6 +82,7 @@ The Image Editor allows you to select any handout that contains
  • The Image Editor cannot be used to edit its own handout.
  • Only images in the notes field of a handout are visible to the editor. Images in gmnotes are not shown.
  • Style changes are written directly to the handout HTML. Always keep a backup of important handout content.
  • +
  • Large handouts: If the referenced handout contains a very large amount of content (many images, long text), keep it closed while making edits in the Image Editor. Having both handouts open simultaneously while saving changes can cause the browser to become unresponsive.
  • `; @@ -110,6 +111,8 @@ The Image Editor allows you to select any handout that contains // ================================================== const Config = { editorName: 'Image Editor', + + largeHandoutWarningSize: 100000, properties: { width: { type: 'size' }, @@ -655,6 +658,19 @@ The Image Editor allows you to select any handout that contains Utils.rebuildHandoutCache(() => { handout.get('notes', notes => { + + if (notes && notes.length > Config.largeHandoutWarningSize) { + Utils.whisper(msg.who, + `
    ` + + `
    ⚠ Large Handout Warning
    ` + + `
    The chosen handout is ${Math.round(notes.length/1000)}k characters. ` + + `If you keep it open while editing, your browser may become unresponsive. ` + + `It is recommended to close the referenced handout before making edits, ` + + `then reopen it to review changes.
    ` + + `
    ` + ); + } + const images = Parser.extractImages(notes); if (!images.length) { editor.set('notes', diff --git a/ImageEditor/script.json b/ImageEditor/script.json index 742a24622..486fa303a 100644 --- a/ImageEditor/script.json +++ b/ImageEditor/script.json @@ -1,7 +1,7 @@ { "name": "Image Editor", "script": "ImageEditor.js", - "version": "1.0.0", + "version": "1.0.1", "description": "Provides a graphical interface for editing images inside Roll20 handouts, including layout, styling, and attributes without manual HTML editing.", "authors": "Keith Curtis", "roll20userid": "162065", @@ -10,5 +10,5 @@ }, "dependencies": [], "conflicts": [], - "previousversions": ["1.0.0"] -} \ No newline at end of file + "previousversions": ["1.0.0","1.0.1"] +} diff --git a/Supernotes/0.2.5/Supernotes.js b/Supernotes/0.2.5/Supernotes.js index 08b4c15a1..5fe3c5100 100644 --- a/Supernotes/0.2.5/Supernotes.js +++ b/Supernotes/0.2.5/Supernotes.js @@ -1,14 +1,7 @@ -var API_Meta = API_Meta || {}; -API_Meta.Supernotes = { - offset: Number.MAX_SAFE_INTEGER, - lineCount: -1 -}; { - try { - throw new Error(''); - } catch (e) { - API_Meta.Supernotes.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (7)); - } -} +var API_Meta = API_Meta||{}; +API_Meta.Supernotes={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.Supernotes.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-3);}} + // Supernotes_Templates can be called by other scripts. At this point ScriptCards is the only One Click script that does this. let Supernotes_Templates = { @@ -40,6 +33,33 @@ let Supernotes_Templates = { footer: "" }, + dark55: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#e16363; font-weight:bold; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#e16363; font-weight:bolder; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`, + buttondivider: ' | ', + handoutbuttonstyle: `style='display:inline-block; color:#e16363; font-weight:bolder; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`, + whisperStyle: `'background-color:#none; color:#ccc; display:block; padding:5px; margin-top:20px; border-top: 1px solid #d72f2f; font-weight:normal;'`, + whisperbuttonstyle: `style='display:inline-block; color:#ccc; font-weight:bold; background-color: transparent;padding: 0px; border: none;`, + footer: "" + }, + + light55: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`, + buttondivider: ' | ', + handoutbuttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`, + whisperStyle: `'background-color:#F1ECE6; color:#292218; display:block; padding:5px; margin-top:20px; border-top: 1px solid #8E5620; font-weight:normal;'`, + whisperbuttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;padding: 0px; border: none;`, + footer: "" + }, roll20dark: { boxcode: `
    `, @@ -385,6 +405,324 @@ on('ready', function() { }); on('ready', () => { + + + /* ========================================================= + * Supernotes Help Handout Builder + * ========================================================= */ + +const buildSupernotesHelp = () => { + + const HANDOUT_NAME = "Help: Supernotes"; + const HANDOUT_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; // change if desired + +const helpHtml = ` +

    Supernotes

    +

    Documentation for v.${version}

    + +
    + +

    Overview

    + +

    +Supernotes pulls content from a token’s GM Notes field and from other character fields not normally accessible to macros. +If a token represents a character, you may retrieve: +

    + +
      +
    • Character GM Notes
    • +
    • Character Bio
    • +
    • Character Avatar
    • +
    • Bio images (single, indexed, or all)
    • +
    • Token tooltip
    • +
    • Token image
    • +
    + +

    +Notes may be whispered to the GM, sent to all players, whispered to the sender, or written directly to a named handout. +A footer button may optionally appear on GM whispers, allowing the note to be forwarded to players. +

    + +

    +Images, API command buttons, links, markdown image syntax [x](imageURL), and most special characters pass through correctly in both chat and handouts. +

    + + +

    Special Control Character for Inline GMnotes

    +-----

    +

    Five dashes placed in the gmnotes of a token indicate that any following content is trested as gm-only text when sent to chat. +

    + +
    + +

    Commands

    + +

    !gmnote +Whispers note to GM.

    + +

    !pcnote +Sends note to all players.

    + +

    !selfnote +Whispers note to the command sender.

    + +
    + +

    Parameters

    + +

    Sources

    +
      +
    • --token +Pull from selected token GM Notes (default). Token does not require a character.
    • + +
    • --charnote +Pull from represented character GM Notes.
    • + +
    • --bio +Pull from character Bio field.
    • + +
    • --avatar +Return character Avatar image.
    • + +
    • --image +Return first Bio image.
    • + +
    • --images +Return all Bio images.
    • + +
    • --image[number] +Return indexed Bio image (e.g. --image1, --image2).
    • + +
    • --tooltip +Return selected token tooltip.
    • + +
    • --tokenimage +Return selected token image.
    • + +
    • --card +Return token image and gmnotes in one report.
    • + +
    + +

    Options

    + +
      +
    • --notitle +Suppress title in chat output. May be added to any command in any order.
    • + +
    • --idTOKENID +Read notes from specific token ID. No space after --id. Example: !gmnote --id-1234567890abcdef
    • + +
    • --handout|Handout Name| +Send output to named handout instead of chat. +Creates the handout if it does not exist. +Content above the automatic horizontal rule remains persistent.
    • + + +
    • --help +Displays help.
    • + +
    • --config +Opens configuration dialog.
    • +
    + +
    + +

    Examples

    + +
    !pcnote --bio
    +

    Sends selected character Bio to all players.

    + +
    !gmnote --charnote
    +

    Whispers character GM Notes to GM.

    + +
    !pcnote --image --notitle
    +

    Sends first image without revealing title.

    + +
    + +

    Templates

    + +

    +Add a template using: +

    + +
    --template|templatename
    + +

    +Example: +

    + +
    !gmnote --template|crt
    +!pcnote --template|notebook --bio
    +!pcnote --template|faraway --tokenimage
    + +

    +All templates include inline buttons and support Send to Players and Make Handout. +Handouts use Roll20’s native styling for cross-platform reliability. +

    + +
    + +

    Available Templates

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    generic
    Just the facts, Ma'am. Nothing fancy here.
    dark
    As previous, but in reverse.
    crt
    Retro greenscreen for hacking and cyberpunk. Or for reports on that xenomorph hiding on your ship.
    notebook
    You know, for kids. Who like to ride bikes. Maybe they attend a school and solve mysteries.
    gothic
    Classic noire horror for contending with Universal monsters or maybe contending with elder gods.
    apoc
    Messages scrawled on a wall. Crumbling and ancient, like the world that was.
    scroll
    High fantasy. Or low fantasy—we don't judge.
    scroll2
    An alternative to scroll, thats even scrollier.
    lcars
    For opening hailing frequencies and to boldly split infinitives that no one has split before!
    faraway
    No animated title crawl, but still has that space wizard feel.
    steam
    Gears and brass have changed my life.
    western
    Return with us now to those thrilling days of yesteryear.
    dragon
    Three-fivey style
    wizard
    A fifth edition of templates.
    strange
    Other kids who ride bikes and play D&D.
    gate3
    For folks who like the GOTY based on D&D.
    choices
    A second gate-y style, suitable for for the same crowd.
    roll20light
    for when you want your notes to have the feeling of authority
    roll20dark
    As before, but.... dark
    news
    Extra! Extra! Read all about it! The ink bleeds through from the other side of the newsprint.
    treasure
    For listing all that loot.
    vault
    A comforting style for sheltered people.
    path
    A style that works well with PF2 Adventure Paths
    osrblue
    Gygax-approved. Maybe. The graph paper even has yellowed edges
    roman
    Hail Caesar!
    dark55
    A style to complement the D&D 5.5e (2024) Sheet dark mode
    light55
    A style to complement the D&D 5.5e (2024) Sheet light mode
    + +
    + +

    Configuration

    + +

    +On installation, Supernotes defaults to the Default roll template. +The configuration dialog allows you to: +

    + +
      +
    • Select a sheet roll template
    • +
    • Toggle the “Send to Players” footer button
    • +
    + +

    +Supported sheet templates include: +

    + +
      +
    • Default Template
    • +
    • D&D 5th Edition by Roll20
    • +
    • 5e Shaped
    • +
    • Pathfinder by Roll20
    • +
    • Pathfinder Community
    • +
    • Pathfinder 2e by Roll20
    • +
    • Starfinder
    • +
    + +
    + +

    Troubleshooting

    + +

    +If you experience template issues or configuration problems, you may use the buttons below to restore default behavior or re-open the configuration dialog. +

    + + + +

    +Restore Default Template resets Supernotes to the Default roll template.
    +Re-Run Configuration opens the configuration dialog to select a sheet template and toggle footer options. +

    + +`; + + + // Find existing handout + let handout = findObjs({ + _type: "handout", + name: HANDOUT_NAME + })[0]; + + // Create if missing + if (!handout) { + handout = createObj("handout", { + name: HANDOUT_NAME, + archived: false + }); + } + + // Always overwrite content + avatar + handout.set({ + notes: helpHtml, + avatar: HANDOUT_AVATAR + }); + + const link = `http://journal.roll20.net/handout/${handout.get("_id")}`; + + const box = + `
    ` + + `
    Supernotes Help
    ` + + `Open Help Handout` + + `
    `; + + sendChat("Supernotes", `/w gm ${box}`, null, { noarchive: true }); +}; + function parseMarkdown(markdownText) { const htmlText = markdownText @@ -405,14 +743,30 @@ function cleanText(text,buttonStyle){ text = ((undefined !== text) ? text.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1").replace(/

    /gm, "").replace(/<\/p>/gm, "
    ").replace("padding:5px'>

    ", "padding:5px'>") : ""); text = text.replace(' + .replace(/\r?\n+/g, "
    ") + // Normalize mixed
    ,
    ,
    variations to
    + .replace(/<\s*br\s*\/?\s*>/gi, "
    ") + // Remove accidental duplicate


    etc + .replace(/(
    \s*){2,}/g, "
    ") + .trim(); + return text; } + const decodeUnicode = (str) => str.replace(/%u[0-9a-fA-F]{2,4}/g, (m) => String.fromCharCode(parseInt(m.slice(2), 16))); - const version = '0.2.5'; + const version = '0.2.7'; log('Supernotes v' + version + ' is ready! --offset ' + API_Meta.Supernotes.offset + 'To set the template of choice or to toggle the send to players option, Use the command !gmnote --config'); +//Changelong +// 0.2.7 Added Templates for 2024 sheet, Dark and Light +// 0.2.6 Reworked and updated Help system to use handout. Fixed logic issue Card output. +// 0.2.5 fixed trailing space problem in command line, fixed linebreak issue. + + + on('chat:message', function(msg) { if ('api' === msg.type && msg.content.match(/^!(gm|pc|self)note\b/)) { @@ -465,7 +819,7 @@ sendChat ("notes","success. Virtual token id is " + virtualTokenID); } let secondOption = ''; - let args = msg.content.split(/\s+--/); + let args = msg.content.trim().split(/\s+--/); let customTemplate = ''; let option = ''; @@ -575,6 +929,12 @@ sendChat ("notes","success. Virtual token id is " + virtualTokenID); case "notebook": chosenTemplate = templates.notebook; break; + case "dark55": + chosenTemplate = templates.dark55; + break; + case "light55": + chosenTemplate = templates.light55; + break; case "bob": break; default: @@ -634,7 +994,6 @@ whisper= whisper.replace(/<\/span>
    /i,"") handoutButton = ((undefined !== handoutButton) ? handoutButton.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1
    ") : ""); whisper = ((whisper.length>0) ? "
    " + whisper + "
    " : ""); //log ("whisper = " + whisper); - return sendChat(whom, messagePrefix + '&{template:' + template + '}{{' + title + '=' + whom + '}} {{' + theText + '=' + message + whisper + playerButton + handoutButton + '}}'); } @@ -661,7 +1020,7 @@ whisper = ((whisper.length>0) ? "
    " + whisper + playerButton = '
    Send to Players in Chat'; if (makeHandout) { - handoutButton = ((playerButton) ? ' | ' : '
    ') + 'Make Handout'; + handoutButton = ((playerButton) ? ' | ' : '
    ') + 'Make Handout'; } message = message.replace(/\[.*?\]\((.*?\.(jpg|jpeg|png|gif))\)/g, ``); message = message.replace(/\[(.*?)\]\((.*?)\)/g, '$1'); @@ -871,22 +1230,26 @@ message = message.replace(/201px/,newHeight+'px'); } } else { if (option !== undefined && option.includes('help')) { - message = 'Supernotes pulls the contents from a token's GM Notes field. If the token represents a character, you can optionally pull in the Bio or GM notes from the character, as well as the avatar, or extract just the image from the bio field. The user can decide whether to whisper the notes to the GM or broadcast them to all players. Finally, there is the option to add a footer to notes whispered to the GM. This footer creates a chat button to give the option of sending the notes on to the players.
    This script as written is optimized for the D&D 5th Edition by Roll20 sheet, but can be adapted easily suing the Configuration section below.

    Commands:
    !gmnote whispers the note to the GM
    !pcnote sends the note to all players

    Paramaters
    --token Pulls notes from the selected token's gm notes field. This is optional. If it is missing, the script assumes --token
    --charnote Pulls notes from the gm notes field of the character assigned to a token.
    --bio Pulls notes from the bio field of the character assigned to a token.
    --avatar Pulls the image from the avatar field of the character assigned to a token.
    --image Pulls first image from the bio field of the character assigned to a token, if any exists. Otherwise returns notice that no artwork is available
    --images Pulls all images from the bio field of the character assigned to a token, if any exist. Otherwise returns notice that no artwork is available
    --image[number] Pulls indexed image from the bio field of the character assigned to a token, if any exist. --image1 will pull the first image, --image2 the second and so on. Otherwise returns first image if available. If no images are available, returns notice that no artwork is available.
    --template[templatename] Instead of using the configured sheet roll template, you can choose from between more than 10 custom templates that cover most common genres. Add the template command directly after the main prompt, followed by any of the regular parameters above. The current choices are:
    generic. Just the facts, ma'am. Nothing fancy here.
    dark. As above, but in reverse.
    crt. Retro greenscreen for hacking and cyberpunk. Or for reports on that xenomorph hiding on your ship.
    notebook. You know, for kids. Who like to ride bikes. Maybe they attend a school and fight vampires or rescue lost extraterrestrials
    gothic. Classic noire horror for contending with Universal monsters or maybe contending with elder gods.
    apoc. Messages scrawled on a wall. Crumbling and ancient, like the world that was.
    scroll. High fantasy. Or low fantasy—we don't judge.
    lcars. For opening hailing frequencies and to boldly split infinitives that no one has split before!
    faraway. No animated title crawl, but still has that space wizard feel.
    steam. Gears and brass have changed my life.
    western. Return with us now to those thrilling days of yesteryear!

    --help Displays help.
    --config Returns a configuration dialog box that allows you to set which sheet's roll template to use, and to toggle the "Send to Players" footer.


    Configuration
    When first installed, Supernotes is configured for the default roll template. It will display a config dialog box at startup that will allow you to choose a roll template based on your character sheet of choice, as well as the option to toggle whether you want the "Send to Players" footer button to appear.
    You will need to edit the code of the script to create a custom configuration. The pre-installed sheets are:
    Default Template
    D&D 5th Edition by Roll20
    5e Shaped
    Pathfinder by Roll20
    Pathfinder Community
    Pathfinder 2e by Roll20
    Starfinder
    Call of Cthulhu 7th Edition by Roll20
    '; - sendMessage('Supernotes', messagePrefix, template, title, theText, message, false); - + buildSupernotesHelp(); + return; } else { - if (!(option + '').match(/^(bio|charnote|tokenimage|tooltip|avatar|imag(e|es|e[1-9]))/)) { + if (!(option + '').match(/^(card|bio|charnote|tokenimage|tooltip|avatar|imag(e|es|e[1-9]))/)) { option = 'token'; } let playerButton = ''; if (sendToPlayers && (command === '!gmnote' || command === '!selfnote')) { - playerButton = '\n[Send to Players](' + msg.content.replace(/!(gm|self)/, "!pc") + ')'; + + + + + + playerButton = '\n[Send to Players](' + msg.content.replace(/!(gm|self)/, "!pc") + ' --id' + tokenID + ')'; } let handoutButton = ''; if (makeHandout && (command.includes('gmnote') || command.includes('selfnote'))) { - handoutButton = ((playerButton) ? ' | ' : '
    ') + '[Make Handout](' + msg.content.replace(/!(pc|self)/, "!gm") + ' --handout|NamePlaceholder|)'; + handoutButton = ((playerButton) ? ' | ' : '
    ') + '[Make Handout](' + msg.content.replace(/!(pc|self)/, "!gm") + ' --id' + tokenID + ' --handout|NamePlaceholder|)'; } else { //handoutButton = '\n[Make Handout](' + msg.content.replace(/!(pc|self)/, "!gm") +')'; @@ -902,7 +1265,73 @@ message = message.replace(/201px/,newHeight+'px'); - if (option === 'tooltip') { +if (option === 'card') { + + (theToken || []).forEach(sel => { + + const o = getObj('graphic', sel._id); + if (!o) return; + + const tokenID = o.id; + const tokenName = o.get('name') || ''; + const rawGM = o.get('gmnotes') || ''; + + // Always assign whom deterministically + whom = tokenName; + + // Decode GM notes safely + let decodedGM = rawGM ? unescape(decodeUnicode(rawGM)) : ''; + + // Apply regex filtering if present + if (decodedGM && regex) { + decodedGM = _.filter( + decodedGM.split(/(?:[\n\r]+|)/), + l => regex.test(l) + ).join('\r'); + } + + message = decodedGM || ''; + + // Crop GM-only content for player/self notes + if (command === '!pcnote' || command === '!selfnote') { + if (message.includes("-----")) { + message = message.split('-----')[0]; + } + } + + // Apply notitle + if (notitle) { + whom = ''; + } + + // Inject token image if message isn't an image URL + if (!/\.(png|jpg|jpeg|gif)/i.test(message)) { + + let styledTokenImage = ``; + + if (!message) { + message = `

    `; + } + + message = styledTokenImage + message; + } + + sendMessage( + whom, + messagePrefix, + template, + title, + theText, + message, + tokenID, + playerButton, + handoutButton + ); + + }); + + } else { + if (option === 'tooltip') { (theToken || []) .map(o => getObj('graphic', o._id)) .filter(g => undefined !== g) @@ -1064,7 +1493,10 @@ message = message.replace(/201px/,newHeight+'px'); (theToken || []) .map(o => getObj('graphic', o._id)) .filter(g => undefined !== g) - .filter((o) => o.get('gmnotes').length > 0) + .filter((o) => { + const gm = (o && o.get) ? o.get('gmnotes') : ''; + return !!(gm && gm.length > 0); +}) .forEach(o => { if (regex) { message = _.filter(unescape(decodeUnicode(o.get('gmnotes'))).split(/(?:[\n\r]+|)/), (l) => regex.test(l)).join('\r'); @@ -1101,6 +1533,7 @@ message = message.replace(/201px/,newHeight+'px'); ].forEach(m => log(m)); */ } + } } } } diff --git a/Supernotes/0.2.7/Supernotes.js b/Supernotes/0.2.7/Supernotes.js new file mode 100644 index 000000000..5fe3c5100 --- /dev/null +++ b/Supernotes/0.2.7/Supernotes.js @@ -0,0 +1,1546 @@ +var API_Meta = API_Meta||{}; +API_Meta.Supernotes={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.Supernotes.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-3);}} + + +// Supernotes_Templates can be called by other scripts. At this point ScriptCards is the only One Click script that does this. +let Supernotes_Templates = { + generic: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#ce0f69 !important; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#ce0f69; background-color: transparent;padding: 0px; border: none;'`, + buttondivider: ' | ', + handoutbuttonstyle: `style='display:inline-block; color:#ce0f69; background-color: transparent;padding: 0px; border: none;'`, + whisperStyle: `'background-color:#2b2130; color:#fbfcf0; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'`, + whisperbuttonstyle: `style='display:inline-block; color:#bbb; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + dark: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#a980bd; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#a980bd; background-color: transparent;padding: 0px; border: none;'`, + buttondivider: ' | ', + handoutbuttonstyle: `style='display:inline-block; color:#a980bd; background-color: transparent;padding: 0px; border: none;'`, + whisperStyle: `'background-color:#2b2130; color:#fbfcf0; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'`, + whisperbuttonstyle: `style='display:inline-block; color:#bbb; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + dark55: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#e16363; font-weight:bold; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#e16363; font-weight:bolder; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`, + buttondivider: ' | ', + handoutbuttonstyle: `style='display:inline-block; color:#e16363; font-weight:bolder; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`, + whisperStyle: `'background-color:#none; color:#ccc; display:block; padding:5px; margin-top:20px; border-top: 1px solid #d72f2f; font-weight:normal;'`, + whisperbuttonstyle: `style='display:inline-block; color:#ccc; font-weight:bold; background-color: transparent;padding: 0px; border: none;`, + footer: "" + }, + + light55: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`, + buttondivider: ' | ', + handoutbuttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`, + whisperStyle: `'background-color:#F1ECE6; color:#292218; display:block; padding:5px; margin-top:20px; border-top: 1px solid #8E5620; font-weight:normal;'`, + whisperbuttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;padding: 0px; border: none;`, + footer: "" + }, + + roll20dark: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#a980bd; font-weight:bold; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#fff; font-weight:bolder; background-color: #e7339d;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif; ;'`, + buttondivider: '', + handoutbuttonstyle: `style='display:inline-block; color:#fff; font-weight:bolder; background-color: #e7339d;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none;font-family:"nunito black", nunito;'`, + whisperStyle: `'background-color:#f9cce7; color:#111; display:block; padding:5px; margin-top:20px;'`, + whisperbuttonstyle: `style='display:inline-block; color:#702c91; font-weight:bold; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + roll20light: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#702c91; font-weight:bold; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#fff; font-weight:bolder; background-color: #e7339d;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif; ;'`, + buttondivider: '', + handoutbuttonstyle: `style='display:inline-block; color:#fff; font-weight:bolder; background-color: #e7339d;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"Nunito Black", nunito;'`, + whisperStyle: `'background-color:#f9cce7; color:#111; display:block; padding:5px; margin-top:20px;'`, + whisperbuttonstyle: `style='display:inline-block; color:#702c91; font-weight:bold; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + + lcars: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#cc6060; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; border:none; color:black; background-color: #cc6060; border-radius: 10px 0px 0px 10px; padding: 2px 4px 2px 4px;margin-top: 12px; font-size: 10px; font-family: Tahoma, sans-serif; font-stretch: condensed !important; text-transform: uppercase;'`, + buttondivider: '', + handoutbuttonstyle: `style='display:inline-block; border:none; color:black; background-color: #cc6060; border-radius: 0px 10px 10px 0px; padding: 2px 4px 2px 4px;margin-top: 12px; margin-left:4px; font-size: 10px; font-family: Tahoma, sans-serif; font-stretch: condensed !important; text-transform: uppercase;'`, + whisperStyle: `'border-radius: 10px 0px 0px 10px; color:#ffae21; border-color: #ffae21; display:block; border-width: 0px 0px 5px 15px; border-style: solid; padding:5px'`, + whisperbuttonstyle: `style='display:inline-block; color:#cc6060; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + faraway: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#13f2fc; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#13f2fc; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`, + buttondivider: ``, + handoutbuttonstyle: `style='display:inline-block; color:#13f2fc; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`, + whisperStyle: `'background-color:transparent; color:#feda4a; display:block; border-width: 8px; border-style: solid; border-radius:5px; border-color:#feda4a; padding:15px; margin-top:10px;'`, + whisperbuttonstyle: `style='display:inline-block; color:#13f2fc; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + strange: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#ff1515; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#ff1515; font-family: "Goblin One"; font-weight:normal; font-size: 10px; background-color: transparent;padding: 0px; border: none;'`, + buttondivider: ``, + handoutbuttonstyle: `style='display:inline-block; color:#ff1515; font-family: "Goblin One"; font-weight:normal; font-size: 10px; background-color: transparent; padding: 0px; border: none;'`, + whisperStyle: `'background-color:##4f0606; color:#ff1515; display:block; border: 1px solid #000; box-shadow: 0 0 5px #ff1515; padding:5px; margin-top:10px'`, + whisperbuttonstyle: `style='display:inline-block; color:#bbb; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + gothic: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#ccc; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#ccc; font-size:12px; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`, + buttondivider: ``, + handoutbuttonstyle: `style='display:inline-block; color:#ccc; font-size:12px; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`, + whisperStyle: `'background-color:#2b2130; color:#ddd; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'`, + whisperbuttonstyle: `style='display:inline-block; color:#aaa; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + western: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#000; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#7e2d40; background-color: transparent;padding: 0px; border: none'`, + buttondivider: ``, + handoutbuttonstyle: `style='display:inline-block; color:#7e2d40; background-color: transparent;padding: 0px; border: none'`, + whisperStyle: `'background-color:#382d1d; color:#ebcfa9; font-style: italic; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px; margin-top:5px'`, + whisperbuttonstyle: `style='display:inline-block; color:#fabe69; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + dragon: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#0e3365; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color: #0e3365; font-size:14px; background-color: transparent;padding: 0px; border: none'`, + buttondivider: "  •  ", //``, + handoutbuttonstyle: `style='display:inline-block; color: #0e3365; font-size:14px; background-color: transparent;padding: 0px; border: none'`, + whisperStyle: `'display:block; border-width: 5px 0px 5px 0px; border-style: solid; border-color:#58170D; padding:5px; margin-top:9px;'`, + whisperbuttonstyle: `style='display:inline-block; color:#0e3365; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + + + wizard: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#58170D; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color: #000; font-size:12px; background-color: transparent;padding: 0px; border: none'`, + buttondivider: "  •  ", //``, + handoutbuttonstyle: `style='display:inline-block; color: #000; font-size:12px; background-color: transparent;padding: 0px; border: none'`, + whisperStyle: `'background-color:#E0E5C1; color:#000; display:block; border-width: 1px; border-width: 1px 0px 1px 0px; border-style: solid; border-color:#58170D; padding:5px'`, + whisperbuttonstyle: `style='display:inline-block; color:#58170D; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + +path: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#5e0000; font-weight:bold; background-color: transparent; padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color: #eee; font-size:12px; background-color: #5e0000; padding: 0px 4px 0px 4px; border-style:solid; border-width: 2px 4px 2px 4px; border-color: #d9c484; text-transformation: all-caps; font-family: "gin", impact, "Arial Bold Condensed", sans-serif;'`, + buttondivider: "    ", //``, + handoutbuttonstyle: `style='display:inline-block; color: #eee; font-size:12px; background-color: #5e0000; padding: 0px 4px 0px 4px; border-style:solid; border-width: 2px 4px 2px 4px; border-color: #d9c484; text-transformation: all-caps; font-family: "gin", impact, "Arial Bold Condensed", sans-serif;'`, + whisperStyle: `'background-color:#dbd1bc; color:#000; display:block; border-width: 1px; margin-top:15px; padding:5px; font-size: 15px; font-family: "Good OT", arial, sans-serif;'`, + whisperbuttonstyle: `style='display:inline-block; color:#58170D; background-color: transparent; font-weight:bold; padding: 0px; border: none'`, + footer: "" +}, + +apoc: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#555; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#000; font-size:14px; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`, + buttondivider: " / ", + handoutbuttonstyle: `style='display:inline-block; color:#000; font-size:14px; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`, + whisperStyle: `'background-color:#403f3d; color:#ddd; display:block; padding:5px !important; margin:5px; font-family: "Shadows Into Light", Monaco,"Courier New", monospace !important; '`, + whisperbuttonstyle: `style='display:inline-block; color:#bbb; background-color: transparent;padding: 0px; border: none'`, + footer: `` + }, + + roman: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#7c6f39; font-weight: bold; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#000; font-size:12px; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`, + buttondivider: " | ", + handoutbuttonstyle: `style='display:inline-block; color:#000; font-size:12px; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`, + whisperStyle: `'background-image: url(https://files.d20.io/images/459209597/cdZeKGAy2_NKcU1Wjkjeew/original.jpg); background-repeat: no-repeat; background-size: 100% 100%; background-color:#403f3d; color:#ddd; display:block; padding:8px !important; margin:5px 0px; text-shadow: none; line-height:16px;'`, + whisperbuttonstyle: `style='display:inline-block; color:#bbaa55; font-weight: bolder !important; background-color: transparent;padding: 0px; border: none'`, + footer: `` + }, + + notebook: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color: red; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:red; font-size:10px; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`, + buttondivider: `/`, + handoutbuttonstyle: `style='display:inline-block; color:red; font-size:10px; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`, + whisperStyle: `'color:red; display:block; padding-top:7px; font-family: "Patrick Hand", Monaco,"Courier New", monospace; line-height: 16px;'`, + whisperbuttonstyle: `style='display:inline-block; color:#333; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + steam: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#056b20; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#056b20; font-size:12px; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`, + buttondivider: ``, + handoutbuttonstyle: `style='display:inline-block; color:#056b20; font-size:12px; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`, + whisperStyle: `'background-color:#2b2130; color:#fbfcf0; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'`, + whisperbuttonstyle: `style='display:inline-block; color:#fff; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + treasure: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#8a4100; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#634401; font-size:14px; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`, + buttondivider: ``, + handoutbuttonstyle: `style='display:inline-block; color:#401e00; font-size:14px; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`, + whisperStyle: `'background-color:#401e00; color:#eee; font-family: Tahoma, serif; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; margin-top:10px;padding:5px'`, + whisperbuttonstyle: `style='display:inline-block; color:#e3b76f; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + +choices: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `

    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#eee; hover: yellow; background-color: transparent;padding: 0px; border: none; '`, + playerbuttonstyle: `style='display:inline-block; color: #eee; font-size:16px; font-family: "Minion", "Minion Pro", serif; background-color: transparent;padding: 0px; border: none'`, + buttondivider: "  ◼  ", //``, + handoutbuttonstyle: `style='display:inline-block; color: #eee; font-size:16px; font-family: "Minion", "Minion Pro", serif; background-color: transparent;padding: 0px; border: none'`, + whisperStyle: `'background-image: linear-gradient(to bottom,#4b443d,#3f3732,#4b443d); background-color: transparent; color:#f8e8a6; display:block; border-width: 1px; border: 1px solid #4f4841; margin: 20px, -12px, 15px, -12px; padding:10px, 10px'`, + whisperbuttonstyle: `style='display:inline-block; color:#eee; background-color: transparent;padding: 0px; border: none'`, + footer: "" +}, +gate3: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#eada8d; background-color: transparent;padding: 0px; border: none; '`, + playerbuttonstyle: `style='display:inline-block; color: #eee; font-size:16px; font-family: "Minion", "Minion Pro", serif; background-color: transparent;padding: 0px; border: none'`, + buttondivider: "  ◼  ", //``, + handoutbuttonstyle: `style='display:inline-block; color: #eee; font-size:16px; font-family: "Minion", "Minion Pro", serif; background-color: transparent;padding: 0px; border: none'`, + whisperStyle: `'background-image: linear-gradient(to bottom,#4b443d,#3f3732,#4b443d); background-color: transparent; color:#f8e8a6; display:block; border-width: 1px; border: 1px solid #4f4841; margin: 20px, -12px, 15px, -12px; padding:10px, 10px'`, + whisperbuttonstyle: `style='display:inline-block; color:#eee; background-color: transparent;padding: 0px; border: none'`, + footer: "" +}, + + + crt: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#fff; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block;font-weight:bold; color:white; background-color: transparent;padding: 0px; border: none;font-size: 12px'`, + buttondivider: '|', + handoutbuttonstyle: `style='display:inline-block;font-weight:bold; color:white; background-color: transparent;padding: 0px; border: none;font-size: 12px'`, + whisperStyle: `'background-color:#2b2130; color:#fbfcf0; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'`, + whisperbuttonstyle: `style='display:inline-block; color:#fff; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + news: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#222; text-decoration:underline; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block;float:right; margin-top:5px; font-weight:bold; color:#444; background-color: transparent;padding: 0px; border: none;font-size: 12px'`, + buttondivider: ' ', + handoutbuttonstyle: `style='display:inline-block;float:left; margin-top:5px; font-weight:bold; color:#444; background-color: transparent;padding: 0px; border: none;font-size: 12px'`, + whisperStyle: `'background-color: rgba(0, 0, 0, 0.1); color:#444; font-size: 14px;font-family: arial, helvetica, sans-serif; padding:8px; display:block; border: 1px solid #444;'`, + whisperbuttonstyle: `style='display:inline-block; color:#444; text-decoration:underline; background-color: transparent; padding: 0px; border: none'`, + footer: "" + }, + + scroll: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#7e2d40; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#7e2d40; background-color: transparent;padding: 0px; border: none'`, + buttondivider: ' | ', + handoutbuttonstyle: `style='display:inline-block; color:#7e2d40; background-color: transparent;padding: 0px; border: none'`, + whisperStyle: `'background-color:#58170d; color:#d9bf93; display:block; padding:5px'`, + whisperbuttonstyle: `style='display:inline-block; color:#fce5bb; background-color: transparent;padding: 0px; border: none'`, + footer: "" + }, + + scroll2: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#58170D; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; font-size: 14px !important; color:#58170D; background-color: transparent;padding: 0px; border: none'`, + buttondivider: ' | ', + handoutbuttonstyle: `style='display:inline-block; font-size: 14px !important; color:#58170D; background-color: transparent;padding: 0px; border: none'`, + whisperStyle: `'background-color:#241605; color:#eee; box-shadow: 0px 0px 5px 5px #241605; display:block; border-radius:15px; padding:5px; margin: 15px 5px 10px 5px'`, + whisperbuttonstyle: `style='display:inline-block; color:#fcdd6d; background-color: transparent;padding: 0px; border: none'`, + footer: `` + }, + + vault: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#111; text-decoration: underline; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; font-size: 15px !important; color:#fef265; text-shadow: 2px 2px 2px #111; background-color: transparent;padding: 0px; border: none'`, + buttondivider: `   `, + handoutbuttonstyle: `style='display:inline-block; font-size: 15px !important; color:#fef265; text-shadow: 2px 2px 2px #111;background-color: transparent;padding: 0px; border: none'`, + whisperStyle: `'background-color: #transparent; background-image: url(https://files.d20.io/images/459209469/UA2E7Vyf-kncA8k1jUuyAg/original.png; color:#111; display:block; text-shadow: none; text-align:center; font-family: "Contrail One"; border-radius:3px; padding:5px; margin: 15px -20px 10px -20px'`, + whisperbuttonstyle: `style='display:inline-block; color:#284a73; background-color: transparent;padding: 0px; border: none'`, + footer: `` + }, + + osrblue: { + boxcode: `
    `, + titlecode: `
    `, + textcode: `
    `, + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block !important; color:#333; text-decoration: underline; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; font-size: 14px !important; color:#333; text-decoration: underline; background-color: transparent;padding: 0px; border: none'`, + buttondivider: `|`, + handoutbuttonstyle: `style='display:inline-block; font-size: 14px !important; color:#333; text-decoration: underline; background-color: transparent;padding: 0px; border: none'`, + whisperStyle: `'background-color: #729aa5; color:#eee; display:block; text-align:center; font-family: "Arial"; padding:5px; margin: 15px -20px 10px -20px'`, + whisperbuttonstyle: `style='display:inline-block; color:#eee; text-decoration: underline; background-color: transparent;padding: 0px; border: none'`, + footer: `` + } + +}; + +on('ready', function() { + if (!_.has(state, 'Supernotes')) { + state.Supernotes = { + sheet: 'Default', + template: 'default', + title: 'name', + theText: '', + sendToPlayers: true, + makeHandout: true, + darkMode: false + }; + message = 'Welcome to Supernotes! If this is your first time running it, the script is set to use the Default Roll Template. You can choose a different sheet template below, as well as decide whether you want the script to display a "Send to Players" footer at the end of every GM message. It is currently set to true.

    [Default Template - any sheet](!gmnote --config|default)
    [D&D 5th Edition by Roll20](!gmnote --config|dnd5e)
    [DnD 5e Shaped](!gmnote --config|5eshaped)
    [Pathfinder by Roll20](!gmnote --config|pfofficial)
    [Pathfinder Community](!gmnote --config|pfcommunity)
    [Pathfinder 2e by Roll20](!gmnote --config|pf2e)
    [Starfinder by Roll20](!gmnote --config|starfinder)
    [Call of Cthulhu 7th Edition by Roll20](!gmnote --config|callofcthulhu)

    [Toggle Send to Players](!gmnote --config|sendtoPlayers)'; + sendChat('Supernotes', '/w gm &{template:' + state.Supernotes.template + '}{{' + state.Supernotes.title + '=' + 'Config' + '}} {{' + state.Supernotes.theText + '=' + message + '}}'); + } +}); + +on('ready', () => { + + + /* ========================================================= + * Supernotes Help Handout Builder + * ========================================================= */ + +const buildSupernotesHelp = () => { + + const HANDOUT_NAME = "Help: Supernotes"; + const HANDOUT_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; // change if desired + +const helpHtml = ` +

    Supernotes

    +

    Documentation for v.${version}

    + +
    + +

    Overview

    + +

    +Supernotes pulls content from a token’s GM Notes field and from other character fields not normally accessible to macros. +If a token represents a character, you may retrieve: +

    + +
      +
    • Character GM Notes
    • +
    • Character Bio
    • +
    • Character Avatar
    • +
    • Bio images (single, indexed, or all)
    • +
    • Token tooltip
    • +
    • Token image
    • +
    + +

    +Notes may be whispered to the GM, sent to all players, whispered to the sender, or written directly to a named handout. +A footer button may optionally appear on GM whispers, allowing the note to be forwarded to players. +

    + +

    +Images, API command buttons, links, markdown image syntax [x](imageURL), and most special characters pass through correctly in both chat and handouts. +

    + + +

    Special Control Character for Inline GMnotes

    +-----

    +

    Five dashes placed in the gmnotes of a token indicate that any following content is trested as gm-only text when sent to chat. +

    + +
    + +

    Commands

    + +

    !gmnote +Whispers note to GM.

    + +

    !pcnote +Sends note to all players.

    + +

    !selfnote +Whispers note to the command sender.

    + +
    + +

    Parameters

    + +

    Sources

    +
      +
    • --token +Pull from selected token GM Notes (default). Token does not require a character.
    • + +
    • --charnote +Pull from represented character GM Notes.
    • + +
    • --bio +Pull from character Bio field.
    • + +
    • --avatar +Return character Avatar image.
    • + +
    • --image +Return first Bio image.
    • + +
    • --images +Return all Bio images.
    • + +
    • --image[number] +Return indexed Bio image (e.g. --image1, --image2).
    • + +
    • --tooltip +Return selected token tooltip.
    • + +
    • --tokenimage +Return selected token image.
    • + +
    • --card +Return token image and gmnotes in one report.
    • + +
    + +

    Options

    + +
      +
    • --notitle +Suppress title in chat output. May be added to any command in any order.
    • + +
    • --idTOKENID +Read notes from specific token ID. No space after --id. Example: !gmnote --id-1234567890abcdef
    • + +
    • --handout|Handout Name| +Send output to named handout instead of chat. +Creates the handout if it does not exist. +Content above the automatic horizontal rule remains persistent.
    • + + +
    • --help +Displays help.
    • + +
    • --config +Opens configuration dialog.
    • +
    + +
    + +

    Examples

    + +
    !pcnote --bio
    +

    Sends selected character Bio to all players.

    + +
    !gmnote --charnote
    +

    Whispers character GM Notes to GM.

    + +
    !pcnote --image --notitle
    +

    Sends first image without revealing title.

    + +
    + +

    Templates

    + +

    +Add a template using: +

    + +
    --template|templatename
    + +

    +Example: +

    + +
    !gmnote --template|crt
    +!pcnote --template|notebook --bio
    +!pcnote --template|faraway --tokenimage
    + +

    +All templates include inline buttons and support Send to Players and Make Handout. +Handouts use Roll20’s native styling for cross-platform reliability. +

    + +
    + +

    Available Templates

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    generic
    Just the facts, Ma'am. Nothing fancy here.
    dark
    As previous, but in reverse.
    crt
    Retro greenscreen for hacking and cyberpunk. Or for reports on that xenomorph hiding on your ship.
    notebook
    You know, for kids. Who like to ride bikes. Maybe they attend a school and solve mysteries.
    gothic
    Classic noire horror for contending with Universal monsters or maybe contending with elder gods.
    apoc
    Messages scrawled on a wall. Crumbling and ancient, like the world that was.
    scroll
    High fantasy. Or low fantasy—we don't judge.
    scroll2
    An alternative to scroll, thats even scrollier.
    lcars
    For opening hailing frequencies and to boldly split infinitives that no one has split before!
    faraway
    No animated title crawl, but still has that space wizard feel.
    steam
    Gears and brass have changed my life.
    western
    Return with us now to those thrilling days of yesteryear.
    dragon
    Three-fivey style
    wizard
    A fifth edition of templates.
    strange
    Other kids who ride bikes and play D&D.
    gate3
    For folks who like the GOTY based on D&D.
    choices
    A second gate-y style, suitable for for the same crowd.
    roll20light
    for when you want your notes to have the feeling of authority
    roll20dark
    As before, but.... dark
    news
    Extra! Extra! Read all about it! The ink bleeds through from the other side of the newsprint.
    treasure
    For listing all that loot.
    vault
    A comforting style for sheltered people.
    path
    A style that works well with PF2 Adventure Paths
    osrblue
    Gygax-approved. Maybe. The graph paper even has yellowed edges
    roman
    Hail Caesar!
    dark55
    A style to complement the D&D 5.5e (2024) Sheet dark mode
    light55
    A style to complement the D&D 5.5e (2024) Sheet light mode
    + +
    + +

    Configuration

    + +

    +On installation, Supernotes defaults to the Default roll template. +The configuration dialog allows you to: +

    + +
      +
    • Select a sheet roll template
    • +
    • Toggle the “Send to Players” footer button
    • +
    + +

    +Supported sheet templates include: +

    + +
      +
    • Default Template
    • +
    • D&D 5th Edition by Roll20
    • +
    • 5e Shaped
    • +
    • Pathfinder by Roll20
    • +
    • Pathfinder Community
    • +
    • Pathfinder 2e by Roll20
    • +
    • Starfinder
    • +
    + +
    + +

    Troubleshooting

    + +

    +If you experience template issues or configuration problems, you may use the buttons below to restore default behavior or re-open the configuration dialog. +

    + + + +

    +Restore Default Template resets Supernotes to the Default roll template.
    +Re-Run Configuration opens the configuration dialog to select a sheet template and toggle footer options. +

    + +`; + + + // Find existing handout + let handout = findObjs({ + _type: "handout", + name: HANDOUT_NAME + })[0]; + + // Create if missing + if (!handout) { + handout = createObj("handout", { + name: HANDOUT_NAME, + archived: false + }); + } + + // Always overwrite content + avatar + handout.set({ + notes: helpHtml, + avatar: HANDOUT_AVATAR + }); + + const link = `http://journal.roll20.net/handout/${handout.get("_id")}`; + + const box = + `
    ` + + `
    Supernotes Help
    ` + + `Open Help Handout` + + `
    `; + + sendChat("Supernotes", `/w gm ${box}`, null, { noarchive: true }); +}; + + + function parseMarkdown(markdownText) { + const htmlText = markdownText + .replace(/^### (.*$)/gim, '

    $1

    ') + .replace(/^## (.*$)/gim, '

    $1

    ') + .replace(/^# (.*$)/gim, '

    $1

    ') + .replace(/^\> (.*$)/gim, '
    $1
    ') + .replace(/\*\*(.*)\*\*/gim, '$1') + .replace(/\*(.*)\*/gim, '$1') + .replace(/!\[(.*?)\]\((.*?)\)/gim, "$1") + .replace(/\[(.*?)\]\((.*?)\)/gim, "$1") + .replace(/\n$/gim, '
    ') + + return htmlText.trim() + } + +function cleanText(text,buttonStyle){ + text = ((undefined !== text) ? text.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1").replace(/

    /gm, "").replace(/<\/p>/gm, "
    ").replace("padding:5px'>

    ", "padding:5px'>") : ""); + text = text.replace(' + .replace(/\r?\n+/g, "
    ") + // Normalize mixed
    ,
    ,
    variations to
    + .replace(/<\s*br\s*\/?\s*>/gi, "
    ") + // Remove accidental duplicate


    etc + .replace(/(
    \s*){2,}/g, "
    ") + .trim(); + +return text; +} + + + + const decodeUnicode = (str) => str.replace(/%u[0-9a-fA-F]{2,4}/g, (m) => String.fromCharCode(parseInt(m.slice(2), 16))); + + const version = '0.2.7'; + log('Supernotes v' + version + ' is ready! --offset ' + API_Meta.Supernotes.offset + 'To set the template of choice or to toggle the send to players option, Use the command !gmnote --config'); +//Changelong +// 0.2.7 Added Templates for 2024 sheet, Dark and Light +// 0.2.6 Reworked and updated Help system to use handout. Fixed logic issue Card output. +// 0.2.5 fixed trailing space problem in command line, fixed linebreak issue. + + + + + on('chat:message', function(msg) { + if ('api' === msg.type && msg.content.match(/^!(gm|pc|self)note\b/)) { + let match = msg.content.match(/^!gmnote-(.*)$/); +let selectedObject = msg.selected; + +//################## EXPERIMENTAL TO GET TOKEN ID FROM SUPPLIED VALUE +if(msg.content.includes("--token|")){ + virtualTokenID = msg.content.split(/--token\|/)[1].split(/\s/)[0]; +sendChat ("notes","success. Virtual token id is " + virtualTokenID); + if (virtualTokenID.length !== 20 && virtualTokenID.charAt(0) !== "-"){ + sendChat ("notes","this is not a token id :" + virtualTokenID); + sendChat ("notes","player page id :" + Campaign().get("playerpageid")); + + selectedObject = findObjs({ + _type: "graphic", + _id: virtualTokenID, + }); + log ("selectedObject is " + selectedObject); + // selectedObject = theToken[0]; + } + if (selectedObject){ + sendChat ("notes", "number of 'selected' objects is " +selectedObject.length); + } else{ + sendChat ("notes", "no passed value"); + } +//sendChat ("notes","virtual ID is " + selectedObject[0].get("_id")); +} +//################## EXPERIMENTAL TO GET TOKEN ID FROM SUPPLIED VALUE + + + + + + + + //define command + let command = msg.content.split(/\s+--/)[0]; + let sender = msg.who; + let senderID = msg.playerid; + + let isGM = playerIsGM(senderID); + let messagePrefix = '/w gm '; + if (command === '!pcnote') { + messagePrefix = ''; + } + + if (command === '!selfnote') { + messagePrefix = '/w ' + sender + ' '; + } + + let secondOption = ''; + let args = msg.content.trim().split(/\s+--/); + + let customTemplate = ''; + let option = ''; + let notitle = false; + let id = ''; + let tokenImage = ''; + let tooltip = ''; + let tokenName = ''; + let trueToken = []; + let tokenID = ''; + let handoutTitle = ''; + let whisper = ''; + + let templates = Supernotes_Templates; + + + + + function sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton) { + handoutButton = ((handoutButton) ? handoutButton.replace(/NamePlaceholder/, whom) : handoutButton); + + if (message === "" && option.match(/^(bio|charnote|token|tooltip)/)) { + message = `The information does not exist for the ${option} option` + } + + if (handoutTitle === '') { + //Crops out GM info on player messages + if (isGM) { + //message = (message.includes("-----") ? message.split('-----')[0] + "
    " + message.split('-----')[1] + "
    " : message); + whisper = (message.includes("-----") ? message.split('-----')[1] : ""); + message = (message.includes("-----") ? message.split('-----')[0] : message); + + } + + if (customTemplate.length > 0) { + let chosenTemplate = templates.generic; + switch (customTemplate) { + case "crt": + chosenTemplate = templates.crt; + break; + case "dark": + chosenTemplate = templates.dark; + break; + case "roll20light": + chosenTemplate = templates.roll20light; + break; + case "roll20dark": + chosenTemplate = templates.roll20dark; + break; + case "scroll": + chosenTemplate = templates.scroll; + break; + case "scroll2": + chosenTemplate = templates.scroll2; + break; + case "vault": + chosenTemplate = templates.vault; + break; + case "osrblue": + chosenTemplate = templates.osrblue; + break; + case "lcars": + chosenTemplate = templates.lcars; + break; + case "faraway": + chosenTemplate = templates.faraway; + break; + case "strange": + chosenTemplate = templates.strange; + break; + case "gothic": + chosenTemplate = templates.gothic; + break; + case "western": + chosenTemplate = templates.western; + break; + case "dragon": + chosenTemplate = templates.dragon; + break; + case "wizard": + chosenTemplate = templates.wizard; + break; + case "path": + chosenTemplate = templates.path; + break; + case "treasure": + chosenTemplate = templates.treasure; + break; + case "steam": + chosenTemplate = templates.steam; + break; + case "gate3": + chosenTemplate = templates.gate3; + break; + case "choices": + chosenTemplate = templates.choices; + break; + case "apoc": + chosenTemplate = templates.apoc; + break; + case "news": + chosenTemplate = templates.news; + break; + case "roman": + chosenTemplate = templates.roman; + break; + case "notebook": + chosenTemplate = templates.notebook; + break; + case "dark55": + chosenTemplate = templates.dark55; + break; + case "light55": + chosenTemplate = templates.light55; + break; + case "bob": + break; + default: + chosenTemplate = templates.generic; + // code block + } + + + + + playerButton = playerButton.split('\n')[1]; + + playerButton = ((undefined !== playerButton) ? playerButton.replace(/\[(.*?)\]\((.*?)\)/gim, "
    $1") : ""); + handoutButton = ((undefined !== handoutButton) ? handoutButton.replace(/\[(.*?)\]\((.*?)\)/gim, "$1").replace(" | 0) ? "
    " + whisper + "
    " : ""); + + +message = cleanText(message,chosenTemplate.buttonstyle); +//the following lines attempt to account for numerous Roll20 CSS and HTML oddities. +whisper = cleanText(whisper,chosenTemplate.whisperbuttonstyle); +whisper= whisper.replace(/<\/span>
    /i,"") +.replace(/
    /i,'') +.replace(/

    /i,'

    ') +.replace(/(

    |<\/p>)/,'') +.replace(/>
    /i,'>'); + + + + + + +// message = ((undefined !== message) ? message.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1").replace(/

    /gm, "").replace(/<\/p>/gm, "
    ").replace("padding:5px'>

    ", "padding:5px'>") : ""); +// message = message.replace('\n

    $1") : ""); + handoutButton = ((undefined !== handoutButton) ? handoutButton.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1") : ""); +whisper = ((whisper.length>0) ? "
    " + whisper + "
    " : ""); +//log ("whisper = " + whisper); + return sendChat(whom, messagePrefix + '&{template:' + template + '}{{' + title + '=' + whom + '}} {{' + theText + '=' + message + whisper + playerButton + handoutButton + '}}'); + } + + } else { + let noteHandout = findObjs({ + type: 'handout', + name: handoutTitle + }); + noteHandout = noteHandout ? noteHandout[0] : undefined; + + if (!noteHandout) { + noteHandout = createObj('handout', { + name: handoutTitle, + archived: false, + inplayerjournals: "", + controlledby: "" + }); + let noteHandoutid = noteHandout.get("_id"); + sendChat('Supernotes', `/w gm Supernotes has created a handout named ${handoutTitle}.
    Click here to open.`, null, { + noarchive: true + }); + } + if (noteHandout) { + + playerButton = '
    Send to Players in Chat'; + if (makeHandout) { + handoutButton = ((playerButton) ? ' | ' : '
    ') + 'Make Handout'; + } + message = message.replace(/\[.*?\]\((.*?\.(jpg|jpeg|png|gif))\)/g, ``); + message = message.replace(/\[(.*?)\]\((.*?)\)/g, '$1'); + message = message.replace(//g, `\(\d*\)/)) { + let reportCount= notes.match(/(?<=\()\d+/);; +//log ("reportCount = " + reportCount); + +let newHeight = reportCount * 20; +if (newHeight > 500){newHeight = 500}; +if (newHeight < 200){newHeight = 200}; +//log ("newHeight = " + newHeight); +message = message.replace(/201px/,newHeight+'px'); + + } +//##############TEST FOR VARIABLE IMAGE HEIGHT BASED ON HEIGHT OF REPORT################################################### + + + if (notes.includes('')) { + if (notes.includes('!report')) { + notes = notes.split('')[0] + ''; + } else { + notes = notes.split(/
    /i)[0] + ''; + } + } else { + playerButton = ''; + handoutButton = ''; + notes = ''; //'; + } + /*if (notes.includes('     ')) { + notes = notes.split('     ')[0] + '     ' + } else { + notes = '     ' + }*/ + //message = '
    ' + message +'
    '; + + noteHandout.set("gmnotes", gmnote); + noteHandout.set("notes", notes + "

    " + whom + "

    " + message + playerButton + handoutButton) + //THIS NEEDS A TOGGLE + //if(!tokenImage.includes("marketplace")){noteHandout.set("avatar", tokenImage+"?12345678")} + }) + } else { + sendChat('Supernotes', whom + `No handout named ${handoutTitle} was found.`, null, { + noarchive: true + }, ) + } + + } + + } + + let theToken = selectedObject; + + args.forEach(a => { + if (a === 'notitle') { + notitle = true + } + if (a.includes('id-')) { + id = a.split(/id/)[1] + } + if (a.match(/handout\|.*?\|/)) { + handoutTitle = a.match(/handout\|.*?\|/).toString().split('|')[1] + } + if (a !== command && !(a.includes('id-')) && !(a.includes('handout|')) && a !== 'notitle') { + option = a + } + if (a.includes('template|')) { + customTemplate = a.split(/\|/)[1] + } + + }); + + ((id) ? theToken = [{ + "_id": id, + "type": "graphic" + }] : theToken = selectedObject); + + + if (undefined !== theToken) { + trueToken = getObj('graphic', theToken[0]._id); + tokenImage = trueToken.get('imgsrc'); + tokenTooltip = trueToken.get('tooltip'); + tokenName = trueToken.get('name'); + tokenID = trueToken.get('_id'); + } + + + + const template = state.Supernotes.template; + const title = state.Supernotes.title; + const theText = state.Supernotes.theText; + const sendToPlayers = state.Supernotes.sendToPlayers; + const makeHandout = state.Supernotes.makeHandout || false; + const darkMode = state.Supernotes.darkMode || false; + const whisperStyle = ((darkMode) ? `'background-color:#2b2130; color:#fbfcf0; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'` : `'background-color:#fff; color:#000; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'`); + + const whisperColor = ((darkMode) ? "#2b2130" : "#fbfcf0"); + const whisperTextColor = ((darkMode) ? "#fff" : "#000"); + const buttonstyle = ((darkMode) ? `style='display:inline-block; color:#a980bd; font-size: 0.9em; background-color: transparent;padding: 0px; border: none'` : `style='display:inline-block; color:#ce0f69; font-size: 0.9em; background-color: transparent;padding: 0px; border: none'`); + + + + + if (option !== undefined && option.includes('config')) { + let templateChoice = option.split('|')[1] + + if (templateChoice === undefined) { + message = 'Current sheet template:
    ' + state.Supernotes.sheet + '
    Send to Players:
    ' + state.Supernotes.sendToPlayers + '

    Choose a template for Supernotes to use.

    [Default Template - any sheet](!gmnote --config|default)
    [D&D 5th Edition by Roll20](!gmnote --config|dnd5e)
    [DnD 5e Shaped](!gmnote --config|5eshaped)
    [Pathfinder Community](!gmnote --config|pfcommunity)
    [Pathfinder by Roll20](!gmnote --config|pfofficial)
    [Pathfinder 2e by Roll20](!gmnote --config|pf2e)
    [Starfinder by Roll20](!gmnote --config|starfinder)
    [Call of Cthulhu 7th Edition by Roll20](!gmnote --config|callofcthulhu)

    [Toggle Send to Players](!gmnote --config|sendtoPlayers)
    [Toggle Make Handout button](!gmnote --config|makeHandout)
    [Toggle Darkmode](!gmnote --config|darkMode)' + sendChat('Supernotes', messagePrefix + '&{template:' + template + '}{{' + title + '=' + 'Config' + '}} {{' + theText + '=' + message + '}}'); + } + + + switch (templateChoice) { + case 'default': + state.Supernotes.sheet = 'Default'; + state.Supernotes.template = 'default'; + state.Supernotes.title = 'name'; + state.Supernotes.theText = ''; + sendChat('Supernotes', '/w gm Supernotes set to Default roll template'); + break; + case 'dnd5e': + state.Supernotes.sheet = 'D&D 5th Edition by Roll20'; + state.Supernotes.template = 'npcaction'; + state.Supernotes.title = 'rname'; + state.Supernotes.theText = 'description'; + sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet); + break; + case '5eshaped': + state.Supernotes.sheet = 'DnD 5e Shaped'; + state.Supernotes.template = '5e-shaped'; + state.Supernotes.title = 'title'; + state.Supernotes.theText = 'text_big'; + sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet); + break; + case 'pfcommunity': + state.Supernotes.sheet = 'Pathfinder Community'; + state.Supernotes.template = 'pf_generic'; + state.Supernotes.title = 'name'; + state.Supernotes.theText = 'description'; + sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet); + break; + case 'pfofficial': + state.Supernotes.sheet = 'Pathfinder by Roll20'; + state.Supernotes.template = 'npc'; + state.Supernotes.title = 'name'; + state.Supernotes.theText = 'descflag=1}} {{desc'; + sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet); + break; + case 'pf2e': + state.Supernotes.sheet = 'Pathefinder 2e'; + state.Supernotes.template = 'rolls'; + state.Supernotes.title = 'header'; + state.Supernotes.theText = 'notes_show=[[1]]}} {{notes'; + sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet); + break; + case 'starfinder': + state.Supernotes.sheet = 'Starfinder'; + state.Supernotes.template = 'sf_generic'; + state.Supernotes.title = 'title'; + state.Supernotes.theText = 'buttons0'; + sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet); + break; + case 'callofcthulhu': + state.Supernotes.sheet = 'Call of Cthulhu 7th Edition by Roll20'; + state.Supernotes.template = 'callofcthulhu'; + state.Supernotes.title = 'title'; + state.Supernotes.theText = 'roll_bonus'; + sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet); + break; + case 'sendtoPlayers': + if (state.Supernotes.sendToPlayers) { + state.Supernotes.sendToPlayers = false + } else { + state.Supernotes.sendToPlayers = true + }; + sendChat('Supernotes', '/w gm Send to Players set to ' + state.Supernotes.sendToPlayers); + break; + case 'makeHandout': + if (state.Supernotes.makeHandout) { + state.Supernotes.makeHandout = false + } else { + state.Supernotes.makeHandout = true + }; + sendChat('Supernotes', '/w gm Make Handout button set to ' + state.Supernotes.makeHandout); + break; + case 'darkMode': + if (state.Supernotes.darkMode) { + state.Supernotes.darkMode = false + } else { + state.Supernotes.darkMode = true + }; + sendChat('Supernotes', '/w gm darkMode set to ' + state.Supernotes.darkMode); + break; + } + } else { + if (option !== undefined && option.includes('help')) { + buildSupernotesHelp(); + return; + } else { + if (!(option + '').match(/^(card|bio|charnote|tokenimage|tooltip|avatar|imag(e|es|e[1-9]))/)) { + option = 'token'; + } + + let playerButton = ''; + if (sendToPlayers && (command === '!gmnote' || command === '!selfnote')) { + + + + + + playerButton = '\n[Send to Players](' + msg.content.replace(/!(gm|self)/, "!pc") + ' --id' + tokenID + ')'; + } + + let handoutButton = ''; + if (makeHandout && (command.includes('gmnote') || command.includes('selfnote'))) { + handoutButton = ((playerButton) ? ' | ' : '
    ') + '[Make Handout](' + msg.content.replace(/!(pc|self)/, "!gm") + ' --id' + tokenID + ' --handout|NamePlaceholder|)'; + } else { + //handoutButton = '\n[Make Handout](' + msg.content.replace(/!(pc|self)/, "!gm") +')'; + + } + + let regex; + if (match && match[1]) { + regex = new RegExp(`^${match[1]}`, 'i'); + } + + let message = ''; + let whom = ''; + + + +if (option === 'card') { + + (theToken || []).forEach(sel => { + + const o = getObj('graphic', sel._id); + if (!o) return; + + const tokenID = o.id; + const tokenName = o.get('name') || ''; + const rawGM = o.get('gmnotes') || ''; + + // Always assign whom deterministically + whom = tokenName; + + // Decode GM notes safely + let decodedGM = rawGM ? unescape(decodeUnicode(rawGM)) : ''; + + // Apply regex filtering if present + if (decodedGM && regex) { + decodedGM = _.filter( + decodedGM.split(/(?:[\n\r]+|)/), + l => regex.test(l) + ).join('\r'); + } + + message = decodedGM || ''; + + // Crop GM-only content for player/self notes + if (command === '!pcnote' || command === '!selfnote') { + if (message.includes("-----")) { + message = message.split('-----')[0]; + } + } + + // Apply notitle + if (notitle) { + whom = ''; + } + + // Inject token image if message isn't an image URL + if (!/\.(png|jpg|jpeg|gif)/i.test(message)) { + + let styledTokenImage = ``; + + if (!message) { + message = `

    `; + } + + message = styledTokenImage + message; + } + + sendMessage( + whom, + messagePrefix, + template, + title, + theText, + message, + tokenID, + playerButton, + handoutButton + ); + + }); + + } else { + if (option === 'tooltip') { + (theToken || []) + .map(o => getObj('graphic', o._id)) + .filter(g => undefined !== g) + .map(t => getObj('character', t.get('represents'))) + .filter(c => undefined !== c) + .forEach(c => { + message = tokenTooltip; + whom = tokenName; + if (notitle) { + whom = ''; + } + sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton); + }); + } else { + if (option === 'tokenimage') { + (theToken || []) + .map(o => getObj('graphic', o._id)) + .filter(g => undefined !== g) + /* .map(t => getObj('character', t.get('represents')))*/ + .filter(c => undefined !== c) + .forEach(c => { + message = ""; + whom = tokenName; + if (notitle) { + whom = ''; + } + sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton); + }); + } else { + if (option === 'avatar') { + (theToken || []) + .map(o => getObj('graphic', o._id)) + .filter(g => undefined !== g) + .map(t => getObj('character', t.get('represents'))) + .filter(c => undefined !== c) + .forEach(c => { + message = ""; + whom = c.get('name'); + if (notitle) { + whom = ''; + } + sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton); + }); + } else { + + if (option.match(/^imag(e|es|e[1-9])/)) { + + + (theToken || []) + .map(o => getObj('graphic', o._id)) + .filter(g => undefined !== g) + .map(t => getObj('character', t.get('represents'))) + .filter(c => undefined !== c) + .forEach(c => c.get('bio', (val) => { + if (null !== val && 'null' !== val && val.length > 0) { + if (regex) { + message = _.filter( + decodeUnicode(val).split(/(?:[\n\r]+|)/), + (l) => regex.test(l.replace(/<[^>]*>/g, '')) + ).join('\r'); + message = message.replace("/g); + if (artwork === null) { + artwork = 'No artwork exists for this character. Consider specifiying avatar.' + }; + + } else { + artwork = message.match(/\<.* src.*?\>/g); + artwork = String(artwork); + if (artwork === null) { + artwork = 'No artwork exists for this character. Consider specifiying avatar.' + }; + + + imageIndex = option.match(/\d+/g); + + + if (isNaN(imageIndex) || !imageIndex) { + imageIndex = 1 + } + + if (imageIndex > (artwork.split(",")).length) { + imageIndex = 1 + } + + imageIndex = imageIndex - 1; //corrects from human readable + + artwork = artwork.split(",")[imageIndex]; + + } + if (('' + artwork).length > 3) { + message = artwork; + } else { + message = 'No artwork exists for this character.'; + } + if (artwork === "null" || message === "null") { + message = 'No artwork exists for this character. Consider specifiying avatar.' + }; + + whom = c.get('name'); + + //Sends the final message + if (notitle) { + whom = ''; + } + sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton); + + } + })); + } else { + + + + if ((option === 'bio') || (option === 'charnote')) { + let suboption = (option === 'charnote') ? 'gmnotes' : 'bio'; + + (theToken || []) + .map(o => getObj('graphic', o._id)) + .filter(g => undefined !== g) + .map(t => getObj('character', t.get('represents'))) + .filter(c => undefined !== c) + .forEach(c => c.get(suboption, (val) => { + if (null !== val && 'null' !== val && val.length > 0) { + if (regex) { + message = _.filter( + decodeUnicode(val).split(/(?:[\n\r]+|)/), + (l) => regex.test(l.replace(/<[^>]*>/g, '')) + ).join('\r'); + } else { + message = decodeUnicode(val); + } + whom = c.get('name'); + //Crops out GM info on player messages + if (command === '!pcnote' || command === '!selfnote') { + message = (message.includes("-----") ? message.split('-----')[0] : message); + } + //Sends the final message + if (notitle) { + whom = ''; + } + sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton); + + } else { + if (notitle) { + whom = '' + } + message = `The information does not exist for the ${option} option`; + sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton); + + } + })); + } else { + (theToken || []) + .map(o => getObj('graphic', o._id)) + .filter(g => undefined !== g) + .filter((o) => { + const gm = (o && o.get) ? o.get('gmnotes') : ''; + return !!(gm && gm.length > 0); +}) + .forEach(o => { + if (regex) { + message = _.filter(unescape(decodeUnicode(o.get('gmnotes'))).split(/(?:[\n\r]+|)/), (l) => regex.test(l)).join('\r'); + } else { + message = unescape(decodeUnicode(o.get('gmnotes'))); + } + whom = o.get('name'); + + }); + + //Crops out GM info on player messages + if (command === '!pcnote' || command === '!selfnote') { + message = (message.includes("-----") ? message.split('-----')[0] : message); + } + + //Sends the final message + if (notitle) { + whom = ''; + } + sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton); + + } + + /* Log Block. Turn on for debugging + [ + `### REPORT###`, + `THE MESSAGE =${message}`, + `command = ${command}`, + // `option = ${option}`, + `secondOption = ${secondOption}`, + `messagePrefix = ${messagePrefix}`, + `whom = ${whom}`, + `message =${message}` + ].forEach(m => log(m)); + */ + } + } + } + } + } + } + } + } + }); +}); + +{ try { throw new Error(''); } catch (e) { API_Meta.Supernotes.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.Supernotes.offset); } } diff --git a/Supernotes/Supernotes.js b/Supernotes/Supernotes.js index 3b8bcec52..5fe3c5100 100644 --- a/Supernotes/Supernotes.js +++ b/Supernotes/Supernotes.js @@ -33,6 +33,33 @@ let Supernotes_Templates = { footer: "" }, + dark55: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#e16363; font-weight:bold; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#e16363; font-weight:bolder; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`, + buttondivider: ' | ', + handoutbuttonstyle: `style='display:inline-block; color:#e16363; font-weight:bolder; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`, + whisperStyle: `'background-color:#none; color:#ccc; display:block; padding:5px; margin-top:20px; border-top: 1px solid #d72f2f; font-weight:normal;'`, + whisperbuttonstyle: `style='display:inline-block; color:#ccc; font-weight:bold; background-color: transparent;padding: 0px; border: none;`, + footer: "" + }, + + light55: { + boxcode: `
    `, + titlecode: `
    `, + textcode: "
    ", + buttonwrapper: `
    `, + buttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;padding: 0px; border: none'`, + playerbuttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`, + buttondivider: ' | ', + handoutbuttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`, + whisperStyle: `'background-color:#F1ECE6; color:#292218; display:block; padding:5px; margin-top:20px; border-top: 1px solid #8E5620; font-weight:normal;'`, + whisperbuttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;padding: 0px; border: none;`, + footer: "" + }, roll20dark: { boxcode: `
    `, @@ -590,8 +617,8 @@ Handouts use Roll20’s native styling for cross-platform reliability. roman
    Hail Caesar!
    - - +dark55
    A style to complement the D&D 5.5e (2024) Sheet dark mode
    +light55
    A style to complement the D&D 5.5e (2024) Sheet light mode
    @@ -731,9 +758,10 @@ return text; const decodeUnicode = (str) => str.replace(/%u[0-9a-fA-F]{2,4}/g, (m) => String.fromCharCode(parseInt(m.slice(2), 16))); - const version = '0.2.6'; + const version = '0.2.7'; log('Supernotes v' + version + ' is ready! --offset ' + API_Meta.Supernotes.offset + 'To set the template of choice or to toggle the send to players option, Use the command !gmnote --config'); //Changelong +// 0.2.7 Added Templates for 2024 sheet, Dark and Light // 0.2.6 Reworked and updated Help system to use handout. Fixed logic issue Card output. // 0.2.5 fixed trailing space problem in command line, fixed linebreak issue. @@ -901,6 +929,12 @@ sendChat ("notes","success. Virtual token id is " + virtualTokenID); case "notebook": chosenTemplate = templates.notebook; break; + case "dark55": + chosenTemplate = templates.dark55; + break; + case "light55": + chosenTemplate = templates.light55; + break; case "bob": break; default: @@ -986,7 +1020,7 @@ whisper = ((whisper.length>0) ? "
    " + whisper + playerButton = '
    Send to Players in Chat'; if (makeHandout) { - handoutButton = ((playerButton) ? ' | ' : '
    ') + 'Make Handout'; + handoutButton = ((playerButton) ? ' | ' : '
    ') + 'Make Handout'; } message = message.replace(/\[.*?\]\((.*?\.(jpg|jpeg|png|gif))\)/g, ``); message = message.replace(/\[(.*?)\]\((.*?)\)/g, '$1'); @@ -1205,12 +1239,17 @@ message = message.replace(/201px/,newHeight+'px'); let playerButton = ''; if (sendToPlayers && (command === '!gmnote' || command === '!selfnote')) { - playerButton = '\n[Send to Players](' + msg.content.replace(/!(gm|self)/, "!pc") + ')'; + + + + + + playerButton = '\n[Send to Players](' + msg.content.replace(/!(gm|self)/, "!pc") + ' --id' + tokenID + ')'; } let handoutButton = ''; if (makeHandout && (command.includes('gmnote') || command.includes('selfnote'))) { - handoutButton = ((playerButton) ? ' | ' : '
    ') + '[Make Handout](' + msg.content.replace(/!(pc|self)/, "!gm") + ' --handout|NamePlaceholder|)'; + handoutButton = ((playerButton) ? ' | ' : '
    ') + '[Make Handout](' + msg.content.replace(/!(pc|self)/, "!gm") + ' --id' + tokenID + ' --handout|NamePlaceholder|)'; } else { //handoutButton = '\n[Make Handout](' + msg.content.replace(/!(pc|self)/, "!gm") +')'; diff --git a/Supernotes/script.json b/Supernotes/script.json index 4ec70f8e3..0e83945f2 100644 --- a/Supernotes/script.json +++ b/Supernotes/script.json @@ -1,7 +1,7 @@ { "name": "Supernotes", "script": "Supernotes.js", - "version": "0.2.6", + "version": "0.2.7", "description": "# Supernotes\r*by keithcurtis, expanded from code written by the Aaron.*\r\rThis script pulls the contents from a token's GM Notes field and sends them to chat, based on a user-selectable roll template. If the token represents a character, you can optionally pull in the Bio or GM notes from the character. The user can decide whether to whisper the notes to the GM or broadcast them to all players. Finally, there is the option to add a footer to notes whispered to the GM. This footer creates a chat button to give the option of sending the notes on to the players.\r\rThis script as written is optimized for the D&D 5th Edition by Roll20 sheet, but can be adapted easily suing the Configuration section below.\r\r* [SuperNotes forum thread](https://app.roll20.net/forum/post/8293909/script-supernotes)\r\r\r## Commands:\r\r**!gmnote** whispers the note to the GM\r\r**!pcnote** sends the note to all players\r\r**!selfnote** whispers the note to to the sender\r\r\r## Paramaters\r\r*--token* Pulls notes from the selected token's gm notes field. This is optional. If it is missing, the script assumes --token\r\r*--charnote* Pulls notes from the gm notes field of the character assigned to a token.\r\r*--bio* Pulls notes from the bio field of the character assigned to a token.\r\r*--avatar* Pulls the image from the avatar field of the character assigned to a token.\r\r--image Pulls first image from the bio field of the character assigned to a token, if any exists. Otherwise returns notice that no artwork is available\r\r*--images* Pulls all images from the bio field of the character assigned to a token, if any exist. Otherwise returns notice that no artwork is available\r\r*--image[number]* Pulls indexed image from the bio field of the character assigned to a token, if any exist. *--image1* will pull the first image, *--image2* the second and so on. Otherwise returns first image if available. If no images are available, returns notice that no artwork is available.\r\r*--notitle* This option suppresses the title in the chat output. It is useful for times when the GM might wish to show an image or note to the player without clueing them in wha the note is about. For instance, they may wish to reveal an image of a monster without revealing its name.\r\r*--id* supply this with a token id, and the script will attempt to read the notes associated with a specific token, or the character associate with that token. There is no space between --id and the token id. Only one token id may be passed.\r\r*--handout|Handoutname|* If this is present in the arguments, the note will be sent to a handout instead of chat. This can allow a note to remain usable without scrolling through the chat. It can also be used as a sort of floating palette. Notes in handouts can be updated. Running the macro again will regenerate the note. The string in between pipes will be used as the name of the note handout. If no handout by that name exists, Supernotes will create one and post a link in chat to open it. The title must be placed between two pipes. handout|My Handout| will work. handout|My Handout will break.\rA note handout automatically creates a horizontal rule at the top of the handout. Anything typed manually above that rule will be persistent. Supernotes will not overwrite this portion. You can use this area to create Journal Command Buttons to generate new notes or to give some context to the existing note. All updates are live.\r\r--template[templatename] Instead of using the configured sheet roll template, you can choose from between more than 10 custom templates that cover most common genres. Add the template command directly after the main prompt, followed by any of the regular parameters above. The current choices are:\r**template|generic.** Just the facts, ma'am. Nothing fancy here.\r**template|dark.** As above, but in reverse.\r**template|crt.** Retro greenscreen for hacking and cyberpunk. Or for reports on that xenomorph hiding on your ship.\r**template|notebook.** You know, for kids. Who like to ride bikes. Maybe they attend a school and fight vampires or rescue lost extraterrestrials\r**template|gothic.** Classic noire horror for contending with Universal monsters or maybe contending with elder gods.\r**template|apoc.** Messages scrawled on a wall. Crumbling and ancient, like the world that was.\r**template|scroll.** High fantasy. Or low fantasy—we don't judge.\r**template|lcars.** For opening hailing frequencies and to boldly split infinitives that no one has split before!\r**template|faraway.** No animated title crawl, but still has that space wizard feel.\r**template|steam.** Gears and brass have changed my life.\r**template|western.** Return with us now to those thrilling days of yesteryear!\r**template|wizard.** Like those ones that live on the coast\r**template|dragon.** Third Edition goodness!\r\r*--help* Displays help.\r\r*--config* Returns a configuration dialog box that allows you to set which sheet's roll template to use, and to toggle the '\r Players' footer.\r\r\r## Configuration\r\rWhen first installed, Supernotes is configured for the default roll template. It will display a config dialog box at startup that will allow you to choose a roll template based on your character sheet of choice, as well as the option to toggle whether you want the '\r Players' footer button to appear.\r\rYou will need to edit the code of the script if you wish to create a custom configuration, or contact keithcurtis on the Roll20 forum and request an addition. The pre-installed sheets are:\r\rDefault Template, D&D 5th Edition by Roll20, 5e Shaped, Pathfinder by Roll20, Pathfinder Community, Pathfinder 2e by Roll20, Starfinder, Starfinder, Call of Cthulhu 7th Edition by Roll20", "authors": "Keith Curtis", "roll20userid": "162065", @@ -12,5 +12,5 @@ "character.represents": "read" }, "conflicts": [], - "previousversions": ["0.0.4","0.0.5","0.0.6","0.0.7","0.0.8","0.0.9","0.0.91","0.1.0","0.1.1","0.1.2","0.1.3","0.1.4","0.2.0","0.2.1","0.2.2","0.2.3","0.2.4","0.2.5","0.2.6"] + "previousversions": ["0.0.4","0.0.5","0.0.6","0.0.7","0.0.8","0.0.9","0.0.91","0.1.0","0.1.1","0.1.2","0.1.3","0.1.4","0.2.0","0.2.1","0.2.2","0.2.3","0.2.4","0.2.5","0.2.6","0.2.7"] } diff --git a/TokenHome/1.0.1/TokenHome.js b/TokenHome/1.0.1/TokenHome.js new file mode 100644 index 000000000..12dae3629 --- /dev/null +++ b/TokenHome/1.0.1/TokenHome.js @@ -0,0 +1,588 @@ +// Script: TokenHome +// By: Keith Curtis, based on a script by the Aaron +// Contact: https://app.roll20.net/users/162065/keithcurtis + +on('ready', () => { + + 'use strict'; + + const version = '1.0.1'; + log('-=> Token Home v' + version + ' is loaded. Use !home --help for documentation'); + // 1.0.1 Added Macro generation + // 1.0.0 Debut + + + /************************* + * CONFIG + *************************/ + const STORAGE_ATTR = 'gmnotes'; + const DEFAULT_LOC = 'L1'; + const VALID_LAYERS = ['objects', 'map', 'gmlayer', 'walls']; + const DEFAULT_RADIUS = 300; + + /************************* + * REGEX + *************************/ + const HOME_BLOCK_REGEX = + /
    \s*TOKENHOME([\s\S]*?)<\/div>/i; + + const HOME_LINE_REGEX = + /^\s*(L\d+)\s*:\s*(-?\d+(?:\.\d*)?)\s*,\s*(-?\d+(?:\.\d*)?)\s*,\s*(\w+)\s*$/gim; + + const LEGACY_BLOCK_REGEX = + /
    ]*data-tokenhomes\s*=\s*"(?:true|1)"[^>]*>([\s\S]*?)<\/div>/i; + + + /************************* + * Help System + *************************/ + + const HOME_HELP_NAME = "Help: Token Home"; +const HOME_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; + +const HOME_HELP_TEXT = ` +

    Token Home Script Help

    + +

    +The Token Home script allows tokens to store and recall multiple +named locations on the current page. Each location records an X/Y position and +the token’s layer. Tokens can be sent back to saved locations, queried, or +summoned to a selected anchor point based on proximity. +

    + +

    +This script is a blow-up and glow-up of a script written for me by the Aaron, +years ago. Anything about it that is broken is mine. :) +

    + +
      +
    • Store multiple locations per token (L1, L2, L3, …)
    • +
    • Recall tokens to stored locations
    • +
    • Preserve token layer when moving
    • +
    • Summon tokens to a selected map object based on distance
    • +
    • Compatible with tokens placed outside page bounds
    • +
    + +

    Base Command: !home

    + +
    + +

    Primary Commands

    + +
      +
    • --set — Store the selected token’s current position as a location.
    • +
    • --L# — Recall the selected token to a stored location. That's an L (upper or lower case), followed by an integer.
    • +
    • --summon — Pull tokens to a selected anchor based on proximity.
    • +
    • --clear — Remove stored location data from selected tokens.
    • +
    • --help — Open this help handout.
    • +
    • --macro — Creates a generic chat menu macro that you can modify — Coming in the next merge.
    • +
    + +
    + +

    Location Storage

    + +

    +Locations are identified by numbered slots: +L1, L2, L3, and higher. +There is no fixed upper limit. This is how I use them, but you can use whatever +location logic works for you; +

    + +
      +
    • L1 — Typically used as the token’s default location
    • +
    • L2 — Commonly used for Residence
    • +
    • L3 — Commonly used for Work
    • +
    • L4 — Commonly used for Encounter
    • +
    + +

    Each stored location records:

    + +
      +
    • X position (pixels)
    • +
    • Y position (pixels)
    • +
    • Token layer
    • +
    + +
    + +

    Set Command

    + +

    Format:

    +
    +!home --set --L#
    +
    + +

    +Stores the selected token’s current position and layer into location L N. +

    + +

    Rules

    + +
      +
    • Any number of tokens can be selected
    • +
    • Existing data for that location is overwritten
    • +
    • Page ID is not stored
    • +
    + +

    Examples

    + +
      +
    • !home --set --L1Set default location
    • +
    • !home --set --L2Set residence
    • +
    • !home --set --L5Set custom location
    • +
    + +
    + +

    Recall Command

    + +

    Format:

    +
    +!home --L#
    +
    + +

    +Moves the selected token to the stored location L N. +

    + +

    Rules

    + +
      +
    • Any number of tokens can be selected
    • +
    • If the location does not exist, the command aborts
    • +
    • The token’s layer is restored
    • +
    + +

    Examples

    + +
      +
    • !home --L1
    • +
    • !home --L3
    • +
    + +
    + +

    Summon Command

    + +

    +The summon command pulls tokens toward a selected anchor object +based on proximity to their stored locations. +

    + +

    Format:

    +
    +!home --summon [--L#] [--r pixels or grid squares]
    +
    + +

    +If no value is given, pixels are assumed. Use g for grid squares. +

    + +

    Examples of valid radius values:

    + +
      +
    • --r300 = 300 pixels
    • +
    • --r5g = 5 grid squares
    • +
    + +

    Anchor Selection

    + +

    Exactly one object must be selected. That object can be a:

    + +
      +
    • Token (graphic)
    • +
    • Text object (text)
    • +
    • Map pin (pin)
    • +
    + +

    +The selected object’s X/Y position is used as the summon target. +

    + +

    Optional Arguments

    + +
      +
    • --L# — Restrict the summon to a specific stored location
    • +
    • --r|pixels — Maximum distance from the anchor (default: 70)
    • +
    + +

    Behavior

    + +
      +
    • If --L# is supplied, only that location is tested
    • +
    • If omitted, all stored locations are considered
    • +
    • The closest matching location is used per token
    • +
    • Distance is measured from the stored location, not current token position
    • +
    • Tokens outside the radius are ignored
    • +
    + +

    Examples

    + +
      +
    • !home --summon
    • +
    • !home --summon --r|210
    • +
    • !home --summon --L2
    • +
    • !home --summon --L4 --r|140
    • +
    + +
    + +

    Clear Command

    + +

    Format:

    +
    +!home --clear [--L#]
    +
    + +
      +
    • If --L# is supplied, only that location is removed
    • +
    • If omitted, all stored locations are removed
    • +
    + +
    + +

    Chat Menu Macro

    + +

    +I use a macro for most of these commands, which has buttons for up to 4 locations. +The macro labels these as default, residence, work, and encounter, but you can +name these however you wish. +

    + +

    You can create this sample macro with the command !home --macro:

    + +
    +/w gm &{template:default} {{name=Token Home}}{{Default=[Set](!home --set --l1) [Go](!home --l1) [Near](!home --summon --l1) [Radius](!home --summon --l1 --radius|?{Input number of pixels})}}{{Residence=[Set](!home --set --l2) [Go](!home --l2) [Near](!home --summon --l2) [Radius](!home --summon --l2 --radius|?{Input number of pixels})}}{{Work=[Set](!home --set --l3) [Go](!home --l3) [Near](!home --summon --l3) [Radius](!home --summon --l3 --radius|?{Input number of pixels})}}{{Encounter=[Set](!home --set --l4) [Go](!home --l4) [Near](!home --summon --l4) [Radius](!home --summon --l4 --radius|?{Input number of pixels})}}{{Summon Any=[Near](!home --summon) [Within X Pixels](!home --summon --radius|?{Input number of pixels})}}
    +
    + + +`; + + /************************* + * HELP HANDOUT + *************************/ +const showHomeHelp = () => { + let handout = findObjs({ + type: 'handout', + name: HOME_HELP_NAME + })[0]; + + if (!handout) { + handout = createObj('handout', { + name: HOME_HELP_NAME, + avatar: HOME_HELP_AVATAR, + notes: HOME_HELP_TEXT, + inplayerjournals: 'gm', + controlledby: 'gm' + }); + } else { + // Ensure content stays current + handout.set({ + avatar: HOME_HELP_AVATAR, + notes: HOME_HELP_TEXT + }); + } + +sendChat( + 'TokenHome', `/w gm
    Token Home Help
    ${HOME_HELP_NAME}
    ` +); + +}; + + + + + /************************* + * LOW-LEVEL HELPERS + *************************/ + const readNotes = (token) => + unescape(token.get(STORAGE_ATTR) || ''); + + const writeNotes = (token, text) => + token.set(STORAGE_ATTR, escape(text)); + + const distance = (a, b) => + Math.hypot(a.left - b.left, a.top - b.top); + + /************************* + * STORAGE + *************************/ + const getHomes = (token) => { + let notes = readNotes(token); + + // 🔁 Auto-upgrade legacy once, silently + if (!HOME_BLOCK_REGEX.test(notes) && LEGACY_BLOCK_REGEX.test(notes)) { + convertLegacyHomes(token); + notes = readNotes(token); + } + + const match = notes.match(HOME_BLOCK_REGEX); + const homes = {}; + if (!match) return homes; + + HOME_LINE_REGEX.lastIndex = 0; + let m; + while ((m = HOME_LINE_REGEX.exec(match[1])) !== null) { + const [, loc, left, top, layer] = m; + homes[loc.toUpperCase()] = { + left: Number(left), + top: Number(top), + layer: VALID_LAYERS.includes(layer) ? layer : 'objects' + }; + } + return homes; + }; + + const saveHomes = (token, homes) => { + let notes = readNotes(token).replace(HOME_BLOCK_REGEX, ''); + + const lines = Object.entries(homes) + .map(([loc, h]) => `${loc}:${h.left},${h.top},${h.layer}`) + .join('\n'); + + if (!lines.trim()) { + writeNotes(token, notes); + return; + } + + const block = +`
    +TOKENHOME +${lines} +
    `; + + writeNotes(token, notes + block); + }; + + const setHome = (token, loc) => { + const homes = getHomes(token); + homes[loc] = { + left: token.get('left'), + top: token.get('top'), + layer: VALID_LAYERS.includes(token.get('layer')) + ? token.get('layer') + : 'objects' + }; + saveHomes(token, homes); + }; + + const clearHome = (token, loc) => { + const homes = getHomes(token); + if (loc) delete homes[loc]; + else Object.keys(homes).forEach(k => delete homes[k]); + saveHomes(token, homes); + }; + + /************************* + * LEGACY CONVERSION + *************************/ + const convertLegacyHomes = (token) => { + const notes = readNotes(token); + if (HOME_BLOCK_REGEX.test(notes)) return { skipped: true }; + + const legacyMatch = notes.match(LEGACY_BLOCK_REGEX); + if (!legacyMatch) return { skipped: true }; + + let raw = legacyMatch[1]; + try { + if (/%[0-9A-Fa-f]{2}/.test(raw)) raw = decodeURIComponent(raw); + const legacy = JSON.parse(raw); + + const homes = {}; + Object.keys(legacy).forEach(k => { + const h = legacy[k]; + if (typeof h.left !== 'number' || typeof h.top !== 'number') return; + const loc = /^L\d+$/i.test(k) ? k.toUpperCase() : 'L1'; + homes[loc] = { + left: h.left, + top: h.top, + layer: VALID_LAYERS.includes(h.layer) + ? h.layer + : token.get('layer') + }; + }); + + writeNotes(token, notes.replace(LEGACY_BLOCK_REGEX, '')); + saveHomes(token, homes); + return { converted: true }; + } catch { + return { failed: true }; + } + }; + + /************************* + * ANCHORS + *************************/ + const getAnchorFromSelection = (sel) => { + if (!sel || sel.length !== 1) return null; + const o = sel[0]; + const obj = getObj(o._type, o._id); + if (!obj) return null; + + if (o._type === 'graphic' || o._type === 'text') + return { left: obj.get('left'), top: obj.get('top'), pageid: obj.get('pageid') }; + + if (o._type === 'pin') + return { left: obj.get('x'), top: obj.get('y'), pageid: obj.get('pageid') }; + + if (o._type === 'path') { + const pts = JSON.parse(obj.get('path')); + const a = pts[0], b = pts[pts.length - 1]; + return { + left: (a[1] + b[1]) / 2 + obj.get('left'), + top: (a[2] + b[2]) / 2 + obj.get('top'), + pageid: obj.get('pageid') + }; + } + return null; + }; + + const findClosestHome = (homes, anchor, limitLoc) => { + let best = null; + Object.entries(homes).forEach(([loc, h]) => { + if (limitLoc && loc !== limitLoc) return; + const d = distance(h, anchor); + if (!best || d < best.dist) best = { home: h, dist: d }; + }); + return best; + }; + + /************************* + * CHAT HANDLER + *************************/ + on('chat:message', (msg) => { + if (msg.type !== 'api' || !/^!home\b/i.test(msg.content)) return; + if (!playerIsGM(msg.playerid)) return; + + const args = msg.content.split(/\s+--/).slice(1); + const flags = args.map(a => a.toLowerCase()); + + let location = null; + flags.forEach(f => { if (/^l\d+$/.test(f)) location = f.toUpperCase(); }); + + let mode = 'recall'; + if (flags.includes('set')) mode = 'set'; + else if (flags.includes('all')) mode = 'all'; + else if (flags.includes('summon')) mode = 'summon'; + else if (flags.includes('convert')) mode = 'convert'; + else if (flags.includes('clear')) mode = 'clear'; + else if (flags.includes('help')) mode = 'help'; + else if (flags.includes('macro')) mode = 'macro'; + + let radius = DEFAULT_RADIUS; + flags.forEach(f => { + if (f.startsWith('radius|')) { + const v = f.split('|')[1]; + if (v.endsWith('g')) { + radius = Number(v.slice(0, -1)) * 70; + } else { + radius = Number(v); + } + } + }); + +if (mode === 'help') { + showHomeHelp(); + return; +} + +if (mode === 'macro') { + + const MACRO_NAME = "Token Home"; + + const MACRO_ACTION = `/w gm &{template:default} {{name=Token Home}}{{Default=[Set](!home --set --l1) [Go](!home --l1) [Near](!home --summon --l1) [Radius](!home --summon --l1 --radius|?{Input number of pixels})}}{{Residence=[Set](!home --set --l2) [Go](!home --l2) [Near](!home --summon --l2) [Radius](!home --summon --l2 --radius|?{Input number of pixels})}}{{Work=[Set](!home --set --l3) [Go](!home --l3) [Near](!home --summon --l3) [Radius](!home --summon --l3 --radius|?{Input number of pixels})}}{{Encounter=[Set](!home --set --l4) [Go](!home --l4) [Near](!home --summon --l4) [Radius](!home --summon --l4 --radius|?{Input number of pixels})}}{{Summon Any=[Near](!home --summon) [Within X Pixels](!home --summon --radius|?{Input number of pixels})}}`; + + let macro = findObjs({ + type: 'macro', + name: MACRO_NAME + })[0]; + + if (!macro) { + macro = createObj('macro', { + name: MACRO_NAME, + action: MACRO_ACTION, + visibleto: 'gm', + playerid: msg.playerid + }); + } else { + macro.set({ + action: MACRO_ACTION, + visibleto: 'gm' + }); + } + + sendChat('TokenHome', + `/w gm
    Token Home
    Macro created/updated.
    ` + ); + + return; +} + + + let targets = []; + const byName = args.find(a => a.startsWith('by-name ')); + if (byName) { + const name = byName.slice(8); + targets = findObjs({ type: 'graphic' }) + .filter(t => (t.get('name') || '').toLowerCase().includes(name)); + } else { + targets = (msg.selected || []) + .map(o => getObj('graphic', o._id)) + .filter(Boolean); + } + + if (mode === 'convert') { + let c = 0, s = 0; + targets.forEach(t => { + const r = convertLegacyHomes(t); + if (r?.converted) c++; else s++; + }); + sendChat('TokenHome', `/w gm Converted: ${c}, Skipped: ${s}`); + return; + } + + if (mode === 'clear') { + targets.forEach(t => clearHome(t, location)); + return; + } + + if (mode === 'set') { + targets.forEach(t => setHome(t, location || DEFAULT_LOC)); + return; + } + +if (mode === 'summon') { + const anchor = getAnchorFromSelection(msg.selected); + if (!anchor) return; + + const pageid = anchor.pageid; + + findObjs({ type: 'graphic', pageid }).forEach(t => { + const homes = getHomes(t); + const closest = findClosestHome(homes, anchor, location); + if (!closest) return; + + if (closest.dist <= radius) { + t.set({ + left: closest.home.left, + top: closest.home.top, + layer: closest.home.layer + }); + } + }); + return; +} + + // default recall + targets.forEach(t => { + const h = getHomes(t)[location || DEFAULT_LOC]; + if (!h) return; + t.set({ + left: h.left, + top: h.top, + layer: h.layer + }); + }); + }); +}); diff --git a/TokenHome/TokenHome.js b/TokenHome/TokenHome.js index 0f27ae4bd..12dae3629 100644 --- a/TokenHome/TokenHome.js +++ b/TokenHome/TokenHome.js @@ -1,12 +1,17 @@ // Script: TokenHome // By: Keith Curtis, based on a script by the Aaron // Contact: https://app.roll20.net/users/162065/keithcurtis -var API_Meta = API_Meta || {}; //eslint-disable-line no-var -API_Meta.TokenHome = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 }; -{ try { throw new Error(''); } catch (e) { API_Meta.TokenHome.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - 6); } } on('ready', () => { + 'use strict'; + + const version = '1.0.1'; + log('-=> Token Home v' + version + ' is loaded. Use !home --help for documentation'); + // 1.0.1 Added Macro generation + // 1.0.0 Debut + + /************************* * CONFIG *************************/ @@ -40,13 +45,14 @@ const HOME_HELP_TEXT = `

    The Token Home script allows tokens to store and recall multiple -named locations on the current page. -Each location records an X/Y position and the token’s layer. +named locations on the current page. Each location records an X/Y position and +the token’s layer. Tokens can be sent back to saved locations, queried, or +summoned to a selected anchor point based on proximity.

    -Tokens can be sent back to saved locations, queried, or summoned to a selected -anchor point based on proximity. +This script is a blow-up and glow-up of a script written for me by the Aaron, +years ago. Anything about it that is broken is mine. :)

      @@ -65,10 +71,11 @@ anchor point based on proximity.
      • --set — Store the selected token’s current position as a location.
      • -
      • --lN — Recall the selected token to a stored location.
      • +
      • --L# — Recall the selected token to a stored location. That's an L (upper or lower case), followed by an integer.
      • --summon — Pull tokens to a selected anchor based on proximity.
      • --clear — Remove stored location data from selected tokens.
      • --help — Open this help handout.
      • +
      • --macro — Creates a generic chat menu macro that you can modify — Coming in the next merge.

      @@ -78,7 +85,8 @@ anchor point based on proximity.

      Locations are identified by numbered slots: L1, L2, L3, and higher. -There is no fixed upper limit. +There is no fixed upper limit. This is how I use them, but you can use whatever +location logic works for you;

        @@ -88,9 +96,7 @@ There is no fixed upper limit.
      • L4 — Commonly used for Encounter
      -

      -Each stored location records: -

      +

      Each stored location records:

      • X position (pixels)
      • @@ -104,7 +110,7 @@ Each stored location records:

        Format:

        -!home --set --lN
        +!home --set --L#
         

        @@ -114,7 +120,7 @@ Stores the selected token’s current position and layer into location L N

        Rules

          -
        • Exactly one token must be selected
        • +
        • Any number of tokens can be selected
        • Existing data for that location is overwritten
        • Page ID is not stored
        @@ -122,9 +128,9 @@ Stores the selected token’s current position and layer into location L N

        Examples

          -
        • !home --set --l1 — Set default location
        • -
        • !home --set --l2 — Set residence
        • -
        • !home --set --l5 — Set custom location
        • +
        • !home --set --L1Set default location
        • +
        • !home --set --L2Set residence
        • +
        • !home --set --L5Set custom location

        @@ -133,7 +139,7 @@ Stores the selected token’s current position and layer into location L N

        Format:

        -!home --lN
        +!home --L#
         

        @@ -143,7 +149,7 @@ Moves the selected token to the stored location L N.

        Rules

          -
        • Exactly one token must be selected
        • +
        • Any number of tokens can be selected
        • If the location does not exist, the command aborts
        • The token’s layer is restored
        @@ -151,8 +157,8 @@ Moves the selected token to the stored location L N.

        Examples

          -
        • !home --l1
        • -
        • !home --l3
        • +
        • !home --L1
        • +
        • !home --L3

        @@ -166,17 +172,23 @@ based on proximity to their stored locations.

        Format:

        -!home --summon [--lN] [--r pixels or grid squares]
        +!home --summon [--L#] [--r pixels or grid squares]
         
        +

        -if no value is given, then pixels are assumed. Use 'g' for grid squares.

        --r300
        = 300 pixels,
        --r5g
        = 5 grid squares. +If no value is given, pixels are assumed. Use g for grid squares.

        +

        Examples of valid radius values:

        + +
          +
        • --r300 = 300 pixels
        • +
        • --r5g = 5 grid squares
        • +
        +

        Anchor Selection

        -

        -Exactly one object must be selected: -

        +

        Exactly one object must be selected. That object can be a:

        • Token (graphic)
        • @@ -191,21 +203,14 @@ The selected object’s X/Y position is used as the summon target.

          Optional Arguments

            -
          • - --lN
            - Restrict the summon to a specific stored location. -
          • -
          • - --r pixels
            - Maximum distance from the anchor. - Default: 70. -
          • +
          • --L# — Restrict the summon to a specific stored location
          • +
          • --r|pixels — Maximum distance from the anchor (default: 70)

          Behavior

            -
          • If --lN is supplied, only that location is tested
          • +
          • If --L# is supplied, only that location is tested
          • If omitted, all stored locations are considered
          • The closest matching location is used per token
          • Distance is measured from the stored location, not current token position
          • @@ -216,9 +221,9 @@ The selected object’s X/Y position is used as the summon target.
            • !home --summon
            • -
            • !home --summon --r 210
            • -
            • !home --summon --l2
            • -
            • !home --summon --l4 --r 140
            • +
            • !home --summon --r|210
            • +
            • !home --summon --L2
            • +
            • !home --summon --L4 --r|140

            @@ -227,24 +232,31 @@ The selected object’s X/Y position is used as the summon target.

            Format:

            -!home --clear [--lN]
            +!home --clear [--L#]
             
              -
            • If --lN is supplied, only that location is removed
            • +
            • If --L# is supplied, only that location is removed
            • If omitted, all stored locations are removed

            -

            General Rules

            +

            Chat Menu Macro

            + +

            +I use a macro for most of these commands, which has buttons for up to 4 locations. +The macro labels these as default, residence, work, and encounter, but you can +name these however you wish. +

            + +

            You can create this sample macro with the command !home --macro:

            + +
            +/w gm &{template:default} {{name=Token Home}}{{Default=[Set](!home --set --l1) [Go](!home --l1) [Near](!home --summon --l1) [Radius](!home --summon --l1 --radius|?{Input number of pixels})}}{{Residence=[Set](!home --set --l2) [Go](!home --l2) [Near](!home --summon --l2) [Radius](!home --summon --l2 --radius|?{Input number of pixels})}}{{Work=[Set](!home --set --l3) [Go](!home --l3) [Near](!home --summon --l3) [Radius](!home --summon --l3 --radius|?{Input number of pixels})}}{{Encounter=[Set](!home --set --l4) [Go](!home --l4) [Near](!home --summon --l4) [Radius](!home --summon --l4 --radius|?{Input number of pixels})}}{{Summon Any=[Near](!home --summon) [Within X Pixels](!home --summon --radius|?{Input number of pixels})}}
            +
            + -
              -
            • All commands are GM-only
            • -
            • Commands operate only on the current page
            • -
            • Tokens may be placed outside page bounds
            • -
            • Invalid arguments abort the command
            • -
            `; /************************* @@ -456,6 +468,7 @@ ${lines} else if (flags.includes('convert')) mode = 'convert'; else if (flags.includes('clear')) mode = 'clear'; else if (flags.includes('help')) mode = 'help'; + else if (flags.includes('macro')) mode = 'macro'; let radius = DEFAULT_RADIUS; flags.forEach(f => { @@ -474,6 +487,38 @@ if (mode === 'help') { return; } +if (mode === 'macro') { + + const MACRO_NAME = "Token Home"; + + const MACRO_ACTION = `/w gm &{template:default} {{name=Token Home}}{{Default=[Set](!home --set --l1) [Go](!home --l1) [Near](!home --summon --l1) [Radius](!home --summon --l1 --radius|?{Input number of pixels})}}{{Residence=[Set](!home --set --l2) [Go](!home --l2) [Near](!home --summon --l2) [Radius](!home --summon --l2 --radius|?{Input number of pixels})}}{{Work=[Set](!home --set --l3) [Go](!home --l3) [Near](!home --summon --l3) [Radius](!home --summon --l3 --radius|?{Input number of pixels})}}{{Encounter=[Set](!home --set --l4) [Go](!home --l4) [Near](!home --summon --l4) [Radius](!home --summon --l4 --radius|?{Input number of pixels})}}{{Summon Any=[Near](!home --summon) [Within X Pixels](!home --summon --radius|?{Input number of pixels})}}`; + + let macro = findObjs({ + type: 'macro', + name: MACRO_NAME + })[0]; + + if (!macro) { + macro = createObj('macro', { + name: MACRO_NAME, + action: MACRO_ACTION, + visibleto: 'gm', + playerid: msg.playerid + }); + } else { + macro.set({ + action: MACRO_ACTION, + visibleto: 'gm' + }); + } + + sendChat('TokenHome', + `/w gm
            Token Home
            Macro created/updated.
            ` + ); + + return; +} + let targets = []; const byName = args.find(a => a.startsWith('by-name ')); @@ -541,6 +586,3 @@ if (mode === 'summon') { }); }); }); - - -{ try { throw new Error(''); } catch (e) { API_Meta.TokenHome.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.TokenHome.offset); } } diff --git a/TokenHome/script.json b/TokenHome/script.json index b80695f22..1c0b47912 100644 --- a/TokenHome/script.json +++ b/TokenHome/script.json @@ -1,7 +1,7 @@ { "name": "TokenHome", "script": "TokenHome.js", - "version": "1.0.0", + "version": "1.0.1", "description": "# TokenHome\n\nTokenHome is a GM-only Roll20 API script that allows tokens to store, recall, and manage multiple named \"home\" locations on the current page. Each location records pixel-precise X/Y coordinates and the token’s layer, enabling reliable repositioning, staging, and summoning workflows.\n\n---\n\n## Core Capabilities\n\n- Store multiple locations per token (L1, L2, L3, …)\n- Recall tokens to saved locations with layer restoration\n- Summon tokens toward a selected anchor based on proximity\n- Preserve compatibility with tokens outside page bounds\n- Automatically migrate legacy single-home token data\n- Clear individual locations or all stored data per token\n\n**Base Command:** `!home`\n\n---\n\n## Primary Commands\n\n```\n!home --set --L#\n!home --L#\n!home --summon [--L#] [--radius|#]\n!home --clear [--L#]\n!home --help\n```\n\n- `--set` stores the selected token’s current position and layer.\n- `--L#` recalls a token to a stored location.\n- `--summon` pulls tokens toward a selected anchor based on distance.\n- `--clear` removes stored location data from selected tokens.\n- `--help` opens the script’s help handout.\n\n---\n\n## Highlights\n\n- Unlimited numbered locations per token (case-insensitive).\n- Distance-based summoning can target a specific location or choose the closest.\n- Radius supports both pixel values and grid units.\n- Layer is restored on recall and summon.\n- Storage is embedded safely in GM Notes using a hidden JSON block.\n\nDesigned for GMs who want fast, reliable control over token positioning, staging, and recall without page locking or teleport hacks.", "authors": "Keith Curtis, based on a Script by the Aaron", "roll20userid": "162065", @@ -10,5 +10,5 @@ "graphic": "write" }, "conflicts": [], - "previousversions": ["1.0.0"] -} + "previousversions": ["1.0.0","1.0.1"] +} \ No newline at end of file