diff --git a/assets/javascripts/.gitkeep b/assets/javascripts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/assets/javascripts/discourse/components/composer-valid-roll.gjs b/assets/javascripts/discourse/components/composer-valid-roll.gjs new file mode 100644 index 0000000..3c0aaf1 --- /dev/null +++ b/assets/javascripts/discourse/components/composer-valid-roll.gjs @@ -0,0 +1,134 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; +import { debounce } from "@ember/runloop"; +import icon from "discourse/helpers/d-icon"; +import loadscript from "discourse/lib/load-script"; +import { cook } from "discourse/lib/text"; +import { i18n } from "discourse-i18n"; +/* global rpgDiceRoller */ + +const ROLL_SELECTOR = ".bb-rollmaster[data-notation]"; +const NOTE_ATTR = "data-notation"; + +export default class ComposerValidRoll extends Component { + @tracked hasRolls = false; + @tracked loading = false; + @tracked errors = []; + + get title() { + if (this.loading) { + return i18n("rollmaster.validator.loading"); + } + if (!this.hasRolls) { + return ""; + } + if (this.errors.length) { + return i18n("rollmaster.validator.error"); + } else { + return i18n("rollmaster.validator.success"); + } + } + + /** + * @returns {string} + */ + get raw() { + return this.args.composer?.reply; + } + + get mayHaveRolls() { + if (!this.raw) { + return false; + } + const str = this.raw.toLowerCase(); + return str.includes("[roll]") && str.includes("[/roll]"); + } + + @action + async checkRolls(raw) { + this.loading = true; + const actualPreview = this.args.composer.getCookedHtml(); + if (actualPreview) { + this.validateRollsFromPost( + document.querySelector("#reply-control.show-preview .d-editor-preview") + ); + return; + } + + const cooked = await cook(raw); + const template = document.createElement("template"); + template.innerHTML = cooked; + this.validateRollsFromPost(template.content); + } + + @action + asyncCheckRolls() { + debounce(this, this.checkRolls, this.raw, 1000); + } + + /** + * @param {Element} post + */ + async validateRollsFromPost(post) { + await this.loadRpgDiceRoller(); + /** @type NodeList */ + const rollEls = post.querySelectorAll(ROLL_SELECTOR); + this.errors = []; + this.hasRolls = !!rollEls.length; + this.loading = false; + + if (!this.hasRolls) { + return; + } + + /** @type string[] */ + rollEls.forEach((el) => { + /** @type string */ + const notation = el.getAttribute(NOTE_ATTR); + const rolls = notation + .split("\n") + .map((r) => r.trim()) + .filter(Boolean); + rolls.forEach((roll) => { + try { + rpgDiceRoller.Parser.parse(roll); + } catch (err) { + this.errors.push(err); + } + }); + }); + } + + async loadRpgDiceRoller() { + await Promise.all([ + loadscript("/plugins/rollmaster/vendors/math.js"), + loadscript("/plugins/rollmaster/vendors/random-js.min.js"), + ]); + await loadscript("/plugins/rollmaster/vendors/rpg-dice-roller.min.js"); + } + + +} diff --git a/assets/javascripts/discourse/initializers/preview.js b/assets/javascripts/discourse/initializers/preview.js new file mode 100644 index 0000000..e4e4eaf --- /dev/null +++ b/assets/javascripts/discourse/initializers/preview.js @@ -0,0 +1,18 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import ComposerValidRoll from "../components/composer-valid-roll"; + +function initializeRollmasterPreview(api) { + const siteSettings = api.container.lookup("service:site-settings"); + if (!siteSettings.rollmaster_enabled) { + return; + } + + api.renderInOutlet("after-d-editor", ComposerValidRoll); +} + +export default { + name: "rollmaster-composer-preview", + initialize() { + withPluginApi("2.0.0", initializeRollmasterPreview); + }, +}; diff --git a/assets/javascripts/lib/discourse-markdown/bbcode.js b/assets/javascripts/lib/discourse-markdown/bbcode.js new file mode 100644 index 0000000..4afe472 --- /dev/null +++ b/assets/javascripts/lib/discourse-markdown/bbcode.js @@ -0,0 +1,53 @@ +import { i18n } from "discourse-i18n"; + +const ROLL_CLASS = "bb-rollmaster"; +const DATA_DICE = "data-notation"; + +function applyRollAttrs(state, token, attrs, content) { + token.attrs = [ + ["class", ROLL_CLASS], + [DATA_DICE, content], + ]; + + if (content) { + token = state.push("text", "", 0); + token.content = i18n("rollmaster.bbcode.placeholder") + content; + } +} + +const blockRule = { + tag: "roll", + replace(state, tagInfo, content) { + let token = state.push("roll_open", "div", 1); + + applyRollAttrs(state, token, tagInfo.attrs, content); + + state.push("roll_close", "div", -1); + return true; + }, +}; + +const inlineRule = { + tag: "roll", + replace(state, tagInfo, content) { + let token = state.push("roll_open", "span", 1); + + applyRollAttrs(state, token, tagInfo.attrs, content); + + state.push("roll_close", "span", -1); + return true; + }, +}; + +export function setup(helper) { + helper.allowList(["div.bb-rollmaster", "span.bb-rollmaster"]); + + helper.registerOptions((opts) => { + opts.features["rollmaster"] = true; + }); + + helper.registerPlugin((md) => { + md.inline.bbcode.ruler.push("inline-roll", inlineRule); + md.block.bbcode.ruler.push("block-roll", blockRule); + }); +} diff --git a/assets/stylesheets/.gitkeep b/assets/stylesheets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/assets/stylesheets/common/index.scss b/assets/stylesheets/common/index.scss new file mode 100644 index 0000000..999aa47 --- /dev/null +++ b/assets/stylesheets/common/index.scss @@ -0,0 +1,32 @@ +.rollmaster-valid-composer { + display: block; + position: absolute; + right: 0.125em; + bottom: 0em; + padding: 0.125em; + + svg.svg-roll { + --success-mild: color-mix( + in srgb, + var(--success-medium), + rgb(140, 140, 140) + ); + color: var(--success-mild, --success-medium); + &:has(~ .rollmaster-spinner) { + color: var(--primary-low-mid); + } + + &:has(+ .roll__invalid) { + color: var(--danger); + } + } + + svg.roll__invalid { + color: var(--danger); + } +} + +.rollmaster-spinner { + animation: rotate-forever 1s infinite linear; + color: var(--primary-low-mid); +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index e86bb19..d976371 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -6,4 +6,9 @@ en: rollmaster: "Rollmaster" js: rollmaster: - placeholder: placeholder + bbcode: + placeholder: "Rolling: " + validator: + loading: "validating roll notation..." + success: "all roll notations valid" + error: "invalid roll notation found" diff --git a/plugin.rb b/plugin.rb index 9a5e274..bad6c33 100644 --- a/plugin.rb +++ b/plugin.rb @@ -10,6 +10,8 @@ enabled_site_setting :rollmaster_enabled +register_asset "stylesheets/common/index.scss" + module ::Rollmaster PLUGIN_NAME = "rollmaster" end @@ -18,6 +20,9 @@ module ::Rollmaster after_initialize do # Code which should run after Rails has finished booting + + register_svg_icon "rollmaster-dices" + # I don't think this is needed, but it doesn't hurt to be safe ::Rollmaster::DiceEngine.reset_context diff --git a/svg-icons/sprites.svg b/svg-icons/sprites.svg new file mode 100644 index 0000000..152b2de --- /dev/null +++ b/svg-icons/sprites.svg @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/test/javascripts/.gitkeep b/test/javascripts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/test/javascripts/acceptance/composer-test.js b/test/javascripts/acceptance/composer-test.js new file mode 100644 index 0000000..7cb4ff2 --- /dev/null +++ b/test/javascripts/acceptance/composer-test.js @@ -0,0 +1,59 @@ +import { click, fillIn, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { i18n } from "discourse-i18n"; + +acceptance("Rollmaster - composer", function (needs) { + needs.user(); + needs.site({ can_tag_topics: true }); + needs.settings({ + bbcode_enabled: false, // overlap with other plugins. for local dev + rollmaster_enabled: true, + allow_uncategorized_topics: true, + }); + + test("bbcode [roll] is rendered", async function (assert) { + await visit("/"); + await click("#create-topic"); + + await fillIn(".d-editor-input", "hello world"); + + assert.dom(".d-editor-preview").hasText("hello world"); + + await fillIn(".d-editor-input", "[roll]test[/roll]"); + + assert + .dom(".d-editor-preview .bb-rollmaster") + .hasAttribute("data-notation", "test"); + + await fillIn(".d-editor-input", "[roll]2d20[/roll]"); + + assert + .dom(".d-editor-preview .bb-rollmaster") + .hasAttribute("data-notation", "2d20"); + }); + + test("[roll] notation is validated", async function (assert) { + await visit("/"); + await click("#create-topic"); + + await fillIn(".d-editor-input", "hello world"); + + assert.dom(".d-editor-preview").hasText("hello world"); + assert.dom(".rollmaster-valid-composer").doesNotExist(); + + await fillIn(".d-editor-input", "[roll]2d20[/roll]"); + + assert.dom(".rollmaster-valid-composer").exists(); + assert + .dom(".rollmaster-valid-composer") + .hasAttribute("title", i18n("rollmaster.validator.success")); + + await fillIn(".d-editor-input", "[roll]junk[/roll]"); + assert.dom(".rollmaster-valid-composer").exists(); + assert + .dom(".rollmaster-valid-composer") + .hasAttribute("title", i18n("rollmaster.validator.error")); + assert.dom(".rollmaster-valid-composer .roll__invalid").exists(); + }); +});