mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
Merge branch 'main' into feature_pm_strikethrough
This commit is contained in:
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -68,6 +68,8 @@ updates:
|
||||
- dependency-name: "moment-timezone"
|
||||
- dependency-name: "@discourse/moment-timezone-names-translations"
|
||||
- dependency-name: "squoosh"
|
||||
- dependency-name: "@glint/*" # Using unstable version - don't auto-upgrade to stable
|
||||
- dependency-name: "typescript" # Very sensitive to glint/volar version
|
||||
groups:
|
||||
babel:
|
||||
patterns:
|
||||
|
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -88,4 +88,4 @@ jobs:
|
||||
|
||||
- name: Glint
|
||||
if: ${{ !cancelled() }}
|
||||
run: pnpm glint -p jsconfig.json
|
||||
run: pnpm glint -p jsconfig.json --noEmit
|
||||
|
2
Gemfile
2
Gemfile
@@ -99,7 +99,7 @@ gem "sidekiq"
|
||||
gem "mini_scheduler"
|
||||
|
||||
gem "execjs", require: false
|
||||
gem "mini_racer", "0.17.pre12"
|
||||
gem "mini_racer", "0.17.pre13"
|
||||
|
||||
gem "highline", require: false
|
||||
|
||||
|
12
Gemfile.lock
12
Gemfile.lock
@@ -203,7 +203,7 @@ GEM
|
||||
json-schema (5.1.1)
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (~> 3.1)
|
||||
json_schemer (2.3.0)
|
||||
json_schemer (2.4.0)
|
||||
bigdecimal
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
@@ -247,7 +247,7 @@ GEM
|
||||
mini_racer (>= 0.6.3)
|
||||
method_source (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_racer (0.17.0.pre12)
|
||||
mini_racer (0.17.0.pre13)
|
||||
libv8-node (~> 22.7.0.4)
|
||||
mini_scheduler (0.18.0)
|
||||
sidekiq (>= 6.5, < 8.0)
|
||||
@@ -458,21 +458,21 @@ GEM
|
||||
rspec-core (>= 2.14)
|
||||
rtlcss (0.2.1)
|
||||
mini_racer (>= 0.6.3)
|
||||
rubocop (1.71.0)
|
||||
rubocop (1.71.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.0)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-capybara (2.21.0)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-discourse (3.9.2)
|
||||
rubocop-discourse (3.9.3)
|
||||
activesupport (>= 6.1)
|
||||
rubocop (>= 1.59.0)
|
||||
rubocop-capybara (>= 2.0.0)
|
||||
@@ -671,7 +671,7 @@ DEPENDENCIES
|
||||
message_bus
|
||||
messageformat-wrapper
|
||||
mini_mime
|
||||
mini_racer (= 0.17.pre12)
|
||||
mini_racer (= 0.17.pre13)
|
||||
mini_scheduler
|
||||
mini_sql
|
||||
mini_suffix
|
||||
|
38
README.md
38
README.md
@@ -2,23 +2,38 @@
|
||||
<img src="images/discourse-readme-logo.png" width="300px">
|
||||
</a>
|
||||
|
||||
Discourse is the online home for your community. We offer a 100% open source community platform to those who want complete control over how and where their site is run.
|
||||
The online home for your community.
|
||||
|
||||
Our platform has been battle-tested for over a decade and continues to evolve to meet users’ needs for a powerful community platform. Discourse allows you to create discussion topics and connect using real-time chat, as well as access an ever-growing number of official and community themes. In addition, we offer a wide variety of plugins for features ranging from chatbots powered by [Discourse AI](https://meta.discourse.org/t/discourse-ai/259214) to functionalities like SQL analysis using the [Data Explorer](https://meta.discourse.org/t/discourse-data-explorer/32566) plugin.
|
||||

|
||||
|
||||
To learn more, visit [**discourse.org**](https://www.discourse.org) and join our support community at [meta.discourse.org](https://meta.discourse.org).
|
||||
|
||||
## Screenshots
|
||||
> You can self-host Discourse on your own infrastructure. But if you'd rather skip the setup, maintenance, and server management, we offer official Discourse hosting.
|
||||
>
|
||||
> 👉 Learn more about [Discourse hosting](https://discourse.org/pricing)
|
||||
|
||||
<a href="https://blog.discourse.org/2023/08/discourse-3-1-is-here/"><img alt="Discourse 3.1" src="https://github-production-user-asset-6210df.s3.amazonaws.com/5862206/261215898-ae95f963-5ab4-4509-b87a-f9f6e9a109bf.png" width="720px"></a>
|
||||
Discourse is a 100% open-source community platform for those who want complete control over how and where their site is run.
|
||||
|
||||
<a href="https://forum.homeexchange.com"><img alt="HomeExchange" src="https://github.com/user-attachments/assets/42fbbd25-502f-4047-a348-c93fb99d7986" width="720px"></a>
|
||||
Our platform has been battle-tested for over a decade and continues to evolve to meet users’ needs for a powerful community platform.
|
||||
|
||||
<a href="https://twittercommunity.com/"><img alt="X Community" src="https://github.com/discourse/discourse/assets/2790986/ebb63eee-1927-4060-ada1-cf1bc774084c.png" width="720px"></a>
|
||||
**With Discourse, you can:**
|
||||
|
||||
<img width="414" alt="Mobile Preview" src="https://github.com/user-attachments/assets/6e209258-258a-48f9-8a31-1b7d9eee4b77">
|
||||
* 💬 **Create discussion topics** to foster meaningful conversations.
|
||||
|
||||
* ⚡️ **Connect in real-time** with built-in chat.
|
||||
|
||||
* 🎨 **Customize your experience** with an ever-growing selection of official and community themes.
|
||||
|
||||
* 🤖 **Enhance your community** with plugins, from chatbots powered by [Discourse AI](https://meta.discourse.org/t/discourse-ai/259214) to advanced tools like SQL analysis with the [Data Explorer](https://meta.discourse.org/t/discourse-data-explorer/32566) plugin.
|
||||
|
||||
To learn more, visit [discourse.org](https://www.discourse.org/) and join our support community at [meta.discourse.org](https://meta.discourse.org/).
|
||||
|
||||
|
||||
Here are just a few of the incredible communities using Discourse:
|
||||
|
||||

|
||||
|
||||
👉 [Discover more communities using Discourse](https://discover.discourse.org/)
|
||||
|
||||
Browse [lots more notable Discourse instances](https://www.discourse.org/customers).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -115,3 +130,8 @@ To guide our ongoing effort to build accessible software we follow the [W3C’s
|
||||
## Dedication
|
||||
|
||||
Discourse is built with [love, Internet style.](https://www.youtube.com/watch?v=Xe1TZaElTAs)
|
||||
|
||||
For over a decade, our [amazing community](https://meta.discourse.org/) has helped shape Discourse into what it is today. Your support, feedback, and contributions have been invaluable in making Discourse a powerful and versatile platform.
|
||||
|
||||
We’re deeply grateful for every feature request, bug report, and discussion that has driven Discourse forward. Thank you for being a part of this journey—we couldn’t have done it without you!
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
||||
import { service } from "@ember/service";
|
||||
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
|
||||
@@ -28,6 +29,11 @@ export default class AdminAreaSettings extends Component {
|
||||
return !this.loading && this.settings.length > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
async reloadSettings() {
|
||||
await this.#loadSettings();
|
||||
}
|
||||
|
||||
@bind
|
||||
async #loadSettings() {
|
||||
this.loading = true;
|
||||
@@ -69,6 +75,7 @@ export default class AdminAreaSettings extends Component {
|
||||
|
||||
<div
|
||||
class="content-body admin-config-area__settings admin-detail pull-left"
|
||||
{{didUpdate this.reloadSettings @plugin}}
|
||||
>
|
||||
{{#if this.showSettings}}
|
||||
<AdminFilteredSiteSettings
|
||||
|
@@ -13,7 +13,6 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import ApiKeyUrlsModal from "admin/components/modal/api-key-urls";
|
||||
import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser";
|
||||
import DTooltip from "float-kit/components/d-tooltip";
|
||||
|
||||
export default class AdminConfigAreasApiKeysNew extends Component {
|
||||
@service router;
|
||||
@@ -239,7 +238,6 @@ export default class AdminConfigAreasApiKeysNew extends Component {
|
||||
<table class="scopes-table grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>{{i18n "admin.api.scopes.allowed_urls"}}</td>
|
||||
<td>{{i18n
|
||||
@@ -265,28 +263,18 @@ export default class AdminConfigAreasApiKeysNew extends Component {
|
||||
<topicsCollection.Field
|
||||
@name="enabled"
|
||||
@title={{collectionData.key}}
|
||||
@showTitle={{false}}
|
||||
as |field|
|
||||
>
|
||||
<field.Checkbox />
|
||||
</topicsCollection.Field>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
class="scope-name"
|
||||
>{{collectionData.name}}</div>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{i18n
|
||||
@tooltip={{i18n
|
||||
(concat
|
||||
"admin.api.scopes.descriptions."
|
||||
scopeName
|
||||
"."
|
||||
collectionData.key
|
||||
)
|
||||
class="scope-tooltip"
|
||||
}}
|
||||
/>
|
||||
as |field|
|
||||
>
|
||||
<field.Checkbox />
|
||||
</topicsCollection.Field>
|
||||
</td>
|
||||
<td>
|
||||
<DButton
|
||||
|
@@ -257,13 +257,11 @@ export default class AdminConfigAreasWebhookForm extends Component {
|
||||
</field.Custom>
|
||||
</form.Field>
|
||||
|
||||
<span>
|
||||
<PluginOutlet
|
||||
@name="web-hook-fields"
|
||||
@connectorTagName="div"
|
||||
@outletArgs={{hash model=this.webhook}}
|
||||
/>
|
||||
</span>
|
||||
<PluginOutlet
|
||||
@name="web-hook-fields"
|
||||
@connectorTagName="div"
|
||||
@outletArgs={{hash model=this.webhook}}
|
||||
/>
|
||||
|
||||
<form.Field
|
||||
@name="verify_certificate"
|
||||
|
@@ -6,6 +6,7 @@ import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import autoFocus from "discourse/modifiers/auto-focus";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class AdminSiteSettingsFilterControls extends Component {
|
||||
@@ -71,6 +72,7 @@ export default class AdminSiteSettingsFilterControls extends Component {
|
||||
{{/if}}
|
||||
<input
|
||||
{{on "input" this.onChangeFilterInput}}
|
||||
{{autoFocus}}
|
||||
id="setting-filter"
|
||||
class="no-blur admin-site-settings-filter-controls__input"
|
||||
placeholder={{i18n "type_to_filter"}}
|
||||
|
@@ -9,12 +9,14 @@ import SiteSettingFilter from "discourse/lib/site-setting-filter";
|
||||
|
||||
export default class AdminSiteSettingsController extends Controller {
|
||||
@service router;
|
||||
@service currentUser;
|
||||
|
||||
@alias("model") allSiteSettings;
|
||||
|
||||
filter = "";
|
||||
visibleSiteSettings = null;
|
||||
siteSettingFilter = null;
|
||||
showSettingCategorySidebar = !this.currentUser.use_admin_sidebar;
|
||||
|
||||
filterContentNow(filterData, category) {
|
||||
this.siteSettingFilter ??= new SiteSettingFilter(this.allSiteSettings);
|
||||
@@ -23,12 +25,16 @@ export default class AdminSiteSettingsController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty(filterData.filter) && !filterData.onlyOverridden) {
|
||||
this.set("visibleSiteSettings", this.allSiteSettings);
|
||||
if (this.categoryNameKey === "all_results") {
|
||||
this.router.transitionTo("adminSiteSettings");
|
||||
// We want to land on All by default if admin sidebar is shown, in this
|
||||
// case we are hiding the inner site setting category sidebar.
|
||||
if (this.showSettingCategorySidebar) {
|
||||
if (isEmpty(filterData.filter) && !filterData.onlyOverridden) {
|
||||
this.set("visibleSiteSettings", this.allSiteSettings);
|
||||
if (this.categoryNameKey === "all_results") {
|
||||
this.router.transitionTo("adminSiteSettings");
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.set("filter", filterData.filter);
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { cached, tracked } from "@glimmer/tracking";
|
||||
import { capitalize, dasherize } from "@ember/string";
|
||||
import { dasherize } from "@ember/string";
|
||||
import { snakeCaseToCamelCase } from "discourse/lib/case-converter";
|
||||
import I18n, { i18n } from "discourse-i18n";
|
||||
|
||||
@@ -50,29 +50,7 @@ export default class AdminPlugin {
|
||||
|
||||
@cached
|
||||
get nameTitleized() {
|
||||
// The category name is better in a lot of cases, as it's a human-inputted
|
||||
// translation, and we can handle things like SAML instead of showing them
|
||||
// as Saml from discourse-saml. We can fall back to the programmatic version
|
||||
// though if needed.
|
||||
let name;
|
||||
if (this.translatedCategoryName) {
|
||||
name = this.translatedCategoryName;
|
||||
} else {
|
||||
name = this.name
|
||||
.split(/[-_]/)
|
||||
.map((word) => {
|
||||
return capitalize(word);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// Cuts down on repetition.
|
||||
const discoursePrefix = "Discourse ";
|
||||
if (name.startsWith(discoursePrefix)) {
|
||||
name = name.slice(discoursePrefix.length);
|
||||
}
|
||||
|
||||
return name;
|
||||
return this.translatedCategoryName || this.humanizedName;
|
||||
}
|
||||
|
||||
@cached
|
||||
|
@@ -7,12 +7,15 @@ import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class AdminSiteSettingsIndexRoute extends DiscourseRoute {
|
||||
@service router;
|
||||
@service currentUser;
|
||||
|
||||
beforeModel() {
|
||||
this.router.replaceWith(
|
||||
"adminSiteSettingsCategory",
|
||||
this.controllerFor("adminSiteSettings").get("visibleSiteSettings")[0]
|
||||
.nameKey
|
||||
);
|
||||
if (!this.currentUser.use_admin_sidebar) {
|
||||
this.router.replaceWith(
|
||||
"adminSiteSettingsCategory",
|
||||
this.controllerFor("adminSiteSettings").get("visibleSiteSettings")[0]
|
||||
.nameKey
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -7,10 +7,17 @@
|
||||
/>
|
||||
{{/each}}
|
||||
{{#if this.category.hasMore}}
|
||||
<p class="warning">{{i18n
|
||||
"admin.site_settings.more_site_setting_results"
|
||||
count=this.category.maxResults
|
||||
}}</p>
|
||||
{{#if this.currentUser.use_admin_sidebar}}
|
||||
<p class="warning">{{i18n
|
||||
"admin.site_settings.more_site_setting_results_no_category_sidebar"
|
||||
count=this.category.maxResults
|
||||
}}</p>
|
||||
{{else}}
|
||||
<p class="warning">{{i18n
|
||||
"admin.site_settings.more_site_setting_results"
|
||||
count=this.category.maxResults
|
||||
}}</p>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</section>
|
||||
{{else}}
|
||||
|
@@ -19,32 +19,39 @@
|
||||
@onToggleMenu={{this.toggleMenu}}
|
||||
/>
|
||||
|
||||
<div class="admin-nav admin-site-settings-category-nav pull-left">
|
||||
<ul class="nav nav-stacked">
|
||||
{{#each this.visibleSiteSettings as |category|}}
|
||||
<li
|
||||
class={{concat-class
|
||||
"admin-site-settings-category-nav__item"
|
||||
category.nameKey
|
||||
}}
|
||||
>
|
||||
<LinkTo
|
||||
@route="adminSiteSettingsCategory"
|
||||
@model={{category.nameKey}}
|
||||
class={{category.nameKey}}
|
||||
title={{category.name}}
|
||||
{{#if this.showSettingCategorySidebar}}
|
||||
<div class="admin-nav admin-site-settings-category-nav pull-left">
|
||||
<ul class="nav nav-stacked">
|
||||
{{#each this.visibleSiteSettings as |category|}}
|
||||
<li
|
||||
class={{concat-class
|
||||
"admin-site-settings-category-nav__item"
|
||||
category.nameKey
|
||||
}}
|
||||
>
|
||||
{{category.name}}
|
||||
{{#if category.count}}
|
||||
<span class="count">({{category.count}})</span>
|
||||
{{/if}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
<LinkTo
|
||||
@route="adminSiteSettingsCategory"
|
||||
@model={{category.nameKey}}
|
||||
class={{category.nameKey}}
|
||||
title={{category.name}}
|
||||
>
|
||||
{{category.name}}
|
||||
{{#if category.count}}
|
||||
<span class="count">({{category.count}})</span>
|
||||
{{/if}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="admin-detail pull-left mobile-closed">
|
||||
<div
|
||||
class={{concat-class
|
||||
"admin-detail pull-left mobile-closed"
|
||||
(unless this.showSettingCategorySidebar "-without-inner-sidebar")
|
||||
}}
|
||||
>
|
||||
{{outlet}}
|
||||
</div>
|
||||
|
||||
|
@@ -18,7 +18,7 @@
|
||||
"@ember/string": "^4.0.0",
|
||||
"ember-cli-babel": "^8.2.0",
|
||||
"ember-cli-htmlbars": "^6.3.0",
|
||||
"ember-template-imports": "^4.2.0"
|
||||
"ember-template-imports": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ember/optional-features": "^2.2.0",
|
||||
@@ -44,7 +44,7 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
},
|
||||
"ember": {
|
||||
"edition": "default"
|
||||
|
@@ -28,6 +28,6 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
}
|
||||
}
|
||||
|
@@ -10,6 +10,6 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
}
|
||||
}
|
||||
|
@@ -12,7 +12,7 @@
|
||||
"ember-auto-import": "^2.10.0",
|
||||
"ember-cli-babel": "^8.2.0",
|
||||
"ember-cli-htmlbars": "^6.3.0",
|
||||
"ember-template-imports": "^4.2.0",
|
||||
"ember-template-imports": "^4.3.0",
|
||||
"truth-helpers": "workspace:1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -25,6 +25,6 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
}
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
},
|
||||
"exports": {
|
||||
"./raw-handlebars-compiler": "./raw-handlebars-compiler.js"
|
||||
|
@@ -24,7 +24,7 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
},
|
||||
"ember": {
|
||||
"edition": "octane"
|
||||
|
@@ -36,7 +36,7 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
},
|
||||
"ember": {
|
||||
"edition": "octane"
|
||||
|
@@ -14,7 +14,7 @@
|
||||
"discourse-widget-hbs": "workspace:1.0.0",
|
||||
"ember-cli-babel": "^8.2.0",
|
||||
"ember-cli-htmlbars": "^6.3.0",
|
||||
"ember-template-imports": "^4.2.0",
|
||||
"ember-template-imports": "^4.3.0",
|
||||
"ember-this-fallback": "^0.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -25,7 +25,7 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
},
|
||||
"ember": {
|
||||
"edition": "default"
|
||||
|
@@ -42,7 +42,7 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
},
|
||||
"ember": {
|
||||
"edition": "default"
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<ComposerBody
|
||||
@composer={{this.composer.model}}
|
||||
@showPreview={{this.composer.showPreview}}
|
||||
@showPreview={{this.composer.isPreviewVisible}}
|
||||
@openIfDraft={{this.composer.openIfDraft}}
|
||||
@typed={{this.composer.typed}}
|
||||
@cancelled={{this.composer.cancelled}}
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<div class="grippie"></div>
|
||||
{{#if this.composer.visible}}
|
||||
{{html-class (if this.composer.showPreview "composer-has-preview")}}
|
||||
{{html-class (if this.composer.isPreviewVisible "composer-has-preview")}}
|
||||
<ComposerMessages
|
||||
@composer={{this.composer.model}}
|
||||
@messageCount={{this.composer.messageCount}}
|
||||
@@ -137,7 +137,7 @@
|
||||
|
||||
<div
|
||||
class="title-and-category
|
||||
{{if this.composer.showPreview 'with-preview'}}"
|
||||
{{if this.composer.isPreviewVisible 'with-preview'}}"
|
||||
>
|
||||
<ComposerTitle
|
||||
@composer={{this.composer.model}}
|
||||
@@ -205,7 +205,7 @@
|
||||
@connectorTagName="div"
|
||||
@outletArgs={{hash
|
||||
model=this.composer.model
|
||||
showPreview=this.composer.showPreview
|
||||
showPreview=this.composer.isPreviewVisible
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
@@ -239,25 +239,21 @@
|
||||
/>
|
||||
|
||||
{{#if this.site.mobileView}}
|
||||
<a
|
||||
href
|
||||
{{on "click" this.composer.cancel}}
|
||||
title={{i18n "cancel"}}
|
||||
class="cancel"
|
||||
>
|
||||
{{#if this.composer.canEdit}}
|
||||
{{d-icon "xmark"}}
|
||||
{{else}}
|
||||
{{d-icon "trash-can"}}
|
||||
{{/if}}
|
||||
</a>
|
||||
<DButton
|
||||
@action={{this.composer.cancel}}
|
||||
class="cancel btn-transparent"
|
||||
@icon={{if this.composer.canEdit "xmark" "trash-can"}}
|
||||
@preventFocus={{true}}
|
||||
@title="close"
|
||||
/>
|
||||
{{else}}
|
||||
<a
|
||||
href
|
||||
{{on "click" this.composer.cancel}}
|
||||
class="cancel"
|
||||
role="button"
|
||||
>{{i18n "close"}}</a>
|
||||
<DButton
|
||||
@action={{this.composer.cancel}}
|
||||
class="cancel btn-transparent"
|
||||
@preventFocus={{true}}
|
||||
@title="close"
|
||||
@label="close"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.site.mobileView}}
|
||||
@@ -299,17 +295,19 @@
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
<a
|
||||
href
|
||||
class="btn btn-default no-text mobile-preview"
|
||||
title={{i18n "composer.show_preview"}}
|
||||
{{on "click" this.composer.togglePreview}}
|
||||
aria-label={{i18n "composer.show_preview"}}
|
||||
>
|
||||
{{d-icon "desktop"}}
|
||||
</a>
|
||||
{{#if this.composer.allowPreview}}
|
||||
<a
|
||||
href
|
||||
class="btn btn-default no-text mobile-preview"
|
||||
title={{i18n "composer.show_preview"}}
|
||||
{{on "click" this.composer.togglePreview}}
|
||||
aria-label={{i18n "composer.show_preview"}}
|
||||
>
|
||||
{{d-icon "desktop"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.composer.showPreview}}
|
||||
{{#if this.composer.isPreviewVisible}}
|
||||
<DButton
|
||||
@action={{this.composer.togglePreview}}
|
||||
@title="composer.hide_preview"
|
||||
@@ -370,14 +368,14 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if this.site.desktopView}}
|
||||
{{#if (and this.composer.allowPreview this.site.desktopView)}}
|
||||
<DButton
|
||||
@action={{this.composer.togglePreview}}
|
||||
@translatedTitle={{this.composer.toggleText}}
|
||||
@icon="angles-left"
|
||||
class={{concat-class
|
||||
"btn-transparent btn-mini-toggle toggle-preview"
|
||||
(unless this.composer.showPreview "active")
|
||||
(unless this.composer.isPreviewVisible "active")
|
||||
}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
@@ -30,8 +30,7 @@
|
||||
@previewUpdated={{action "previewUpdated"}}
|
||||
@markdownOptions={{this.markdownOptions}}
|
||||
@extraButtons={{action "extraButtons"}}
|
||||
@importQuote={{this.composer.importQuote}}
|
||||
@processPreview={{this.composer.showPreview}}
|
||||
@processPreview={{this.composer.isPreviewVisible}}
|
||||
@validation={{this.validation}}
|
||||
@loading={{this.composer.loading}}
|
||||
@forcePreview={{this.forcePreview}}
|
||||
|
@@ -194,17 +194,29 @@ export default class ComposerEditor extends Component {
|
||||
this.appEvents.trigger(`${this.composerEventPrefix}:will-open`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the editor with the given text manipulation instance
|
||||
*
|
||||
* @param {TextManipulation} textManipulation The text manipulation instance
|
||||
* @returns {(() => void)} destructor function
|
||||
*/
|
||||
@bind
|
||||
setupEditor(textManipulation) {
|
||||
this.textManipulation = textManipulation;
|
||||
this.uppyComposerUpload.textManipulation = textManipulation;
|
||||
this.uppyComposerUpload.placeholderHandler = textManipulation.placeholder;
|
||||
|
||||
const input = this.element.querySelector(".d-editor-input");
|
||||
|
||||
input.addEventListener("scroll", this._throttledSyncEditorAndPreviewScroll);
|
||||
|
||||
// Focus on the body unless we have a title
|
||||
if (!this.get("composer.model.canEditTitle")) {
|
||||
this.composer.set("allowPreview", this.textManipulation.allowPreview);
|
||||
|
||||
if (
|
||||
// Focus on the editor unless we have a title
|
||||
!this.get("composer.model.canEditTitle") ||
|
||||
// Or focus is in the body (e.g. when the editor is destroyed)
|
||||
document.activeElement.tagName === "BODY"
|
||||
) {
|
||||
this.textManipulation.putCursorAtEnd();
|
||||
}
|
||||
|
||||
@@ -859,15 +871,6 @@ export default class ComposerEditor extends Component {
|
||||
|
||||
@action
|
||||
extraButtons(toolbar) {
|
||||
toolbar.addButton({
|
||||
id: "quote",
|
||||
group: "fontStyles",
|
||||
icon: "far-comment",
|
||||
sendAction: this.composer.importQuote,
|
||||
title: "composer.quote_post_title",
|
||||
unshift: true,
|
||||
});
|
||||
|
||||
if (
|
||||
this.composer.allowUpload &&
|
||||
this.composer.uploadIcon &&
|
||||
|
@@ -110,7 +110,7 @@ export default class TextareaEditor extends Component {
|
||||
@input={{@change}}
|
||||
@focusIn={{@focusIn}}
|
||||
@focusOut={{@focusOut}}
|
||||
class="d-editor-input"
|
||||
class={{@class}}
|
||||
@id={{@id}}
|
||||
{{this.registerTextarea}}
|
||||
/>
|
||||
|
@@ -0,0 +1,47 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
|
||||
export default class ComposerToggleSwitch extends Component {
|
||||
@action
|
||||
mouseDown(event) {
|
||||
if (this.args.preventFocus) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-redundant-role }}
|
||||
<button
|
||||
class={{concatClass
|
||||
"composer-toggle-switch"
|
||||
(if @state "--rte" "--markdown")
|
||||
}}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={{if @state "true" "false"}}
|
||||
{{! template-lint-disable no-pointer-down-event-binding }}
|
||||
{{on "mousedown" this.mouseDown}}
|
||||
...attributes
|
||||
>
|
||||
<span class="composer-toggle-switch__slider">
|
||||
<span
|
||||
class={{concatClass
|
||||
"composer-toggle-switch__left-icon"
|
||||
(unless @state "--active")
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>{{icon "fab-markdown"}}</span>
|
||||
<span
|
||||
class={{concatClass
|
||||
"composer-toggle-switch__right-icon"
|
||||
(if @state "--active")
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>{{icon "a"}}</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
}
|
@@ -8,6 +8,14 @@
|
||||
{{if this.isEditorFocused 'in-focus'}}"
|
||||
>
|
||||
<div class="d-editor-button-bar" role="toolbar">
|
||||
{{#if this.siteSettings.rich_editor}}
|
||||
<Composer::ToggleSwitch
|
||||
@preventFocus={{true}}
|
||||
@state={{this.isRichEditorEnabled}}
|
||||
{{on "click" this.toggleRichEditor}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#each this.toolbar.groups as |group|}}
|
||||
{{#each group.buttons as |b|}}
|
||||
{{#if (b.condition this)}}
|
||||
@@ -40,15 +48,18 @@
|
||||
|
||||
<ConditionalLoadingSpinner @condition={{this.loading}} />
|
||||
<this.editorComponent
|
||||
@class="d-editor-input"
|
||||
@onSetup={{this.setupEditor}}
|
||||
@markdownOptions={{this.markdownOptions}}
|
||||
@keymap={{this.keymap}}
|
||||
@value={{this.value}}
|
||||
@placeholder={{this.placeholderTranslated}}
|
||||
@disabled={{this.disabled}}
|
||||
@change={{this.change}}
|
||||
@change={{this.onChange}}
|
||||
@focusIn={{this.handleFocusIn}}
|
||||
@focusOut={{this.handleFocusOut}}
|
||||
@categoryId={{@categoryId}}
|
||||
@topicId={{@topicId}}
|
||||
@id={{this.textAreaId}}
|
||||
/>
|
||||
<PopupInputTip @validation={{this.validation}} />
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import Component from "@ember/component";
|
||||
import { action } from "@ember/object";
|
||||
import { getOwner } from "@ember/owner";
|
||||
@@ -26,6 +27,7 @@ import { wantsNewWindow } from "discourse/lib/intercept-click";
|
||||
import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts";
|
||||
import { linkSeenMentions } from "discourse/lib/link-mentions";
|
||||
import { loadOneboxes } from "discourse/lib/load-oneboxes";
|
||||
import loadRichEditor from "discourse/lib/load-rich-editor";
|
||||
import { findRawTemplate } from "discourse/lib/raw-templates";
|
||||
import { emojiUrlFor, generateCookFunction } from "discourse/lib/text";
|
||||
import userSearch from "discourse/lib/user-search";
|
||||
@@ -59,8 +61,9 @@ export default class DEditor extends Component {
|
||||
@service modal;
|
||||
@service menu;
|
||||
|
||||
editorComponent = TextareaEditor;
|
||||
textManipulation;
|
||||
@tracked editorComponent;
|
||||
/** @type {TextManipulation} */
|
||||
@tracked textManipulation;
|
||||
|
||||
ready = false;
|
||||
lastSel = null;
|
||||
@@ -74,10 +77,19 @@ export default class DEditor extends Component {
|
||||
},
|
||||
};
|
||||
|
||||
init() {
|
||||
async init() {
|
||||
super.init(...arguments);
|
||||
|
||||
this.register = getRegister(this);
|
||||
|
||||
if (
|
||||
this.siteSettings.rich_editor &&
|
||||
this.keyValueStore.get("d-editor-prefers-rich-editor") === "true"
|
||||
) {
|
||||
this.editorComponent = await loadRichEditor();
|
||||
} else {
|
||||
this.editorComponent = TextareaEditor;
|
||||
}
|
||||
}
|
||||
|
||||
@discourseComputed("placeholder")
|
||||
@@ -630,9 +642,15 @@ export default class DEditor extends Component {
|
||||
this.set("isEditorFocused", false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the editor with the given text manipulation instance
|
||||
*
|
||||
* @param {TextManipulation} textManipulation The text manipulation instance
|
||||
* @returns {(() => void)} destructor function
|
||||
*/
|
||||
@action
|
||||
setupEditor(textManipulation) {
|
||||
this.set("textManipulation", textManipulation);
|
||||
this.textManipulation = textManipulation;
|
||||
|
||||
const destroyEvents = this.setupEvents();
|
||||
|
||||
@@ -657,6 +675,28 @@ export default class DEditor extends Component {
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
async toggleRichEditor() {
|
||||
this.editorComponent = this.isRichEditorEnabled
|
||||
? TextareaEditor
|
||||
: await loadRichEditor();
|
||||
|
||||
this.keyValueStore.set({
|
||||
key: "d-editor-prefers-rich-editor",
|
||||
value: this.isRichEditorEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onChange(event) {
|
||||
this.set("value", event?.target?.value);
|
||||
this.change?.(event);
|
||||
}
|
||||
|
||||
get isRichEditorEnabled() {
|
||||
return this.editorComponent !== TextareaEditor;
|
||||
}
|
||||
|
||||
setupEvents() {
|
||||
const textManipulation = this.textManipulation;
|
||||
|
||||
|
@@ -86,6 +86,8 @@ export default class PostList extends Component {
|
||||
@additionalItemClasses={{@additionalItemClasses}}
|
||||
@titleAriaLabel={{@titleAriaLabel}}
|
||||
@showUserInfo={{@showUserInfo}}
|
||||
@resumeDraft={{@resumeDraft}}
|
||||
@removeDraft={{@removeDraft}}
|
||||
>
|
||||
<:abovePostItemHeader>
|
||||
{{yield post to="abovePostItemHeader"}}
|
||||
|
@@ -1,8 +1,13 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { hash } from "@ember/helper";
|
||||
import { fn, hash } from "@ember/helper";
|
||||
import { or } from "truth-helpers";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import PluginOutlet from "discourse/components/plugin-outlet";
|
||||
import TopicStatus from "discourse/components/topic-status";
|
||||
import avatar from "discourse/helpers/avatar";
|
||||
import categoryLink from "discourse/helpers/category-link";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import formatDate from "discourse/helpers/format-date";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import { prioritizeNameInUx } from "discourse/lib/settings";
|
||||
import { i18n } from "discourse-i18n";
|
||||
@@ -28,6 +33,10 @@ export default class PostListItemDetails extends Component {
|
||||
: this.args.post.title;
|
||||
}
|
||||
|
||||
get draftTitle() {
|
||||
return this.args.post.title ?? this.args.post.data.title;
|
||||
}
|
||||
|
||||
get titleAriaLabel() {
|
||||
if (this.args.titleAriaLabel) {
|
||||
return this.args.titleAriaLabel;
|
||||
@@ -58,14 +67,42 @@ export default class PostListItemDetails extends Component {
|
||||
href={{getURL this.url}}
|
||||
aria-label={{this.titleAriaLabel}}
|
||||
>{{this.topicTitle}}</a>
|
||||
{{else if @isDraft}}
|
||||
<DButton
|
||||
@action={{fn @resumeDraft @post}}
|
||||
class="btn-transparent draft-title"
|
||||
>
|
||||
{{or this.draftTitle (i18n "drafts.dropdown.untitled")}}
|
||||
</DButton>
|
||||
{{else}}
|
||||
{{this.topicTitle}}
|
||||
{{/if}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="category stream-post-category">
|
||||
{{categoryLink @post.category}}
|
||||
<div class="post-list-item__metadata">
|
||||
{{#if @post.category}}
|
||||
<span class="category stream-post-category">
|
||||
{{categoryLink @post.category}}
|
||||
</span>
|
||||
{{/if}}
|
||||
|
||||
<span class="time">
|
||||
{{formatDate @post.created_at leaveAgo="true"}}
|
||||
</span>
|
||||
|
||||
{{#if @post.deleted_by}}
|
||||
<span class="delete-info">
|
||||
{{icon "trash-can"}}
|
||||
{{avatar
|
||||
@post.deleted_by
|
||||
imageSize="tiny"
|
||||
extraClasses="actor"
|
||||
ignoreTitle="true"
|
||||
}}
|
||||
{{formatDate @item.deleted_at leaveAgo="true"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if this.showUserInfo}}
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { fn } from "@ember/helper";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import ExpandPost from "discourse/components/expand-post";
|
||||
import PostListItemDetails from "discourse/components/post-list/item/details";
|
||||
import avatar from "discourse/helpers/avatar";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import formatDate from "discourse/helpers/format-date";
|
||||
import { userPath } from "discourse/lib/url";
|
||||
|
||||
export default class PostListItem extends Component {
|
||||
@@ -53,6 +54,22 @@ export default class PostListItem extends Component {
|
||||
: this.args.post.id;
|
||||
}
|
||||
|
||||
get isDraft() {
|
||||
return this.args.post.constructor.name === "UserDraft";
|
||||
}
|
||||
|
||||
get draftIcon() {
|
||||
const key = this.args.post.draft_key;
|
||||
|
||||
if (key.startsWith("new_private_message")) {
|
||||
return "envelope";
|
||||
} else if (key.startsWith("new_topic")) {
|
||||
return "layer-group";
|
||||
} else {
|
||||
return "reply";
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="post-list-item
|
||||
@@ -66,20 +83,26 @@ export default class PostListItem extends Component {
|
||||
{{yield to="abovePostItemHeader"}}
|
||||
|
||||
<div class="post-list-item__header info">
|
||||
<a
|
||||
href={{userPath this.user.username}}
|
||||
data-user-card={{this.user.username}}
|
||||
class="avatar-link"
|
||||
>
|
||||
<div class="avatar-wrapper">
|
||||
{{avatar
|
||||
this.user
|
||||
imageSize="large"
|
||||
extraClasses="actor"
|
||||
ignoreTitle="true"
|
||||
}}
|
||||
{{#if this.isDraft}}
|
||||
<div class="draft-icon">
|
||||
{{icon this.draftIcon class="icon"}}
|
||||
</div>
|
||||
</a>
|
||||
{{else}}
|
||||
<a
|
||||
href={{userPath this.user.username}}
|
||||
data-user-card={{this.user.username}}
|
||||
class="avatar-link"
|
||||
>
|
||||
<div class="avatar-wrapper">
|
||||
{{avatar
|
||||
this.user
|
||||
imageSize="large"
|
||||
extraClasses="actor"
|
||||
ignoreTitle="true"
|
||||
}}
|
||||
</div>
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
<PostListItemDetails
|
||||
@post={{@post}}
|
||||
@@ -88,33 +111,31 @@ export default class PostListItem extends Component {
|
||||
@urlPath={{@urlPath}}
|
||||
@user={{this.user}}
|
||||
@showUserInfo={{@showUserInfo}}
|
||||
@isDraft={{this.isDraft}}
|
||||
@resumeDraft={{@resumeDraft}}
|
||||
/>
|
||||
|
||||
{{#if @post.draftType}}
|
||||
<span class="draft-type">{{@post.draftType}}</span>
|
||||
{{else}}
|
||||
{{#unless @post.draftType}}
|
||||
<ExpandPost @item={{@post}} />
|
||||
{{/unless}}
|
||||
|
||||
{{#if @post.editableDraft}}
|
||||
<div class="user-stream-item-draft-actions">
|
||||
<DButton
|
||||
@action={{fn @resumeDraft @post}}
|
||||
@icon="pencil"
|
||||
@title="drafts.resume"
|
||||
class="btn-default resume-draft"
|
||||
/>
|
||||
<DButton
|
||||
@action={{fn @removeDraft @post}}
|
||||
@icon="trash-can"
|
||||
@title="drafts.remove"
|
||||
class="btn-danger remove-draft"
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="post-list-item__metadata">
|
||||
<span class="time">
|
||||
{{formatDate @post.created_at leaveAgo="true"}}
|
||||
</span>
|
||||
|
||||
{{#if @post.deleted_by}}
|
||||
<span class="delete-info">
|
||||
{{icon "trash-can"}}
|
||||
{{avatar
|
||||
@post.deleted_by
|
||||
imageSize="tiny"
|
||||
extraClasses="actor"
|
||||
ignoreTitle="true"
|
||||
}}
|
||||
{{formatDate @item.deleted_at leaveAgo="true"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{yield to="belowPostItemMetadata"}}
|
||||
</div>
|
||||
|
||||
|
@@ -65,7 +65,7 @@ export default class PostMenuLikeButton extends Component {
|
||||
"toggle-like"
|
||||
"btn-icon"
|
||||
(if this.isAnimated "heart-animation")
|
||||
(if @post.liked "has-like fade-out" "like")
|
||||
(if @post.liked "has-like" "like")
|
||||
}}
|
||||
...attributes
|
||||
data-post-id={{@post.id}}
|
||||
|
@@ -58,7 +58,7 @@ export default class Item extends Component {
|
||||
let expandPinned;
|
||||
if (
|
||||
!this.args.topic.pinned ||
|
||||
(this.site.mobileView && !this.siteSettings.show_pinned_excerpt_mobile) ||
|
||||
(this.useMobileLayout && !this.siteSettings.show_pinned_excerpt_mobile) ||
|
||||
(this.site.desktopView && !this.siteSettings.show_pinned_excerpt_desktop)
|
||||
) {
|
||||
expandPinned = false;
|
||||
@@ -71,7 +71,7 @@ export default class Item extends Component {
|
||||
return applyValueTransformer(
|
||||
"topic-list-item-expand-pinned",
|
||||
expandPinned,
|
||||
{ topic: this.args.topic, mobileView: this.site.mobileView }
|
||||
{ topic: this.args.topic, mobileView: this.useMobileLayout }
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -8,7 +8,10 @@ import Header from "discourse/components/topic-list/header";
|
||||
import Item from "discourse/components/topic-list/item";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import DAG from "discourse/lib/dag";
|
||||
import { applyValueTransformer } from "discourse/lib/transformer";
|
||||
import {
|
||||
applyMutableValueTransformer,
|
||||
applyValueTransformer,
|
||||
} from "discourse/lib/transformer";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import HeaderActivityCell from "./header/activity-cell";
|
||||
import HeaderBulkSelectCell from "./header/bulk-select-cell";
|
||||
@@ -98,7 +101,7 @@ export default class TopicList extends Component {
|
||||
},
|
||||
};
|
||||
|
||||
return applyValueTransformer(
|
||||
return applyMutableValueTransformer(
|
||||
"topic-list-columns",
|
||||
defaultColumns,
|
||||
context
|
||||
|
@@ -16,9 +16,9 @@
|
||||
<TopicStatus @topic={{@item}} @disableActions={{true}} />
|
||||
<span class="title">
|
||||
{{#if @item.postUrl}}
|
||||
<a href={{@item.postUrl}}>{{html-safe @item.title}}</a>
|
||||
<a href={{@item.postUrl}}>{{replace-emoji @item.title}}</a>
|
||||
{{else}}
|
||||
{{html-safe @item.title}}
|
||||
{{replace-emoji @item.title}}
|
||||
{{/if}}
|
||||
</span>
|
||||
</div>
|
||||
|
@@ -1,13 +1,12 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { fn, hash } from "@ember/helper";
|
||||
import { hash } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { getOwner } from "@ember/owner";
|
||||
import { later } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { modifier } from "ember-modifier";
|
||||
import $ from "jquery";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import PluginOutlet from "discourse/components/plugin-outlet";
|
||||
import PostActionDescription from "discourse/components/post-action-description";
|
||||
import PostList from "discourse/components/post-list";
|
||||
@@ -164,6 +163,8 @@ export default class UserStreamComponent extends Component {
|
||||
@titlePath="titleHtml"
|
||||
@additionalItemClasses="user-stream-item"
|
||||
@showUserInfo={{false}}
|
||||
@resumeDraft={{this.resumeDraft}}
|
||||
@removeDraft={{this.removeDraft}}
|
||||
class={{concatClass "user-stream" this.filterClassName}}
|
||||
{{this.eventListeners @stream}}
|
||||
>
|
||||
@@ -216,23 +217,6 @@ export default class UserStreamComponent extends Component {
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
{{#if post.editableDraft}}
|
||||
<div class="user-stream-item-draft-actions">
|
||||
<DButton
|
||||
@action={{fn this.resumeDraft post}}
|
||||
@icon="pencil"
|
||||
@label="drafts.resume"
|
||||
class="btn-default resume-draft"
|
||||
/>
|
||||
<DButton
|
||||
@action={{fn this.removeDraft post}}
|
||||
@icon="trash-can"
|
||||
@title="drafts.remove"
|
||||
class="btn-danger remove-draft"
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</:abovePostItemExcerpt>
|
||||
|
||||
<:belowPostItem as |post|>
|
||||
|
@@ -15,17 +15,16 @@ import discourseDebounce from "discourse/lib/debounce";
|
||||
import discourseComputed, { bind } from "discourse/lib/decorators";
|
||||
import NameValidationHelper from "discourse/lib/name-validation-helper";
|
||||
import { userPath } from "discourse/lib/url";
|
||||
import UsernameValidationHelper from "discourse/lib/username-validation-helper";
|
||||
import { emailValid } from "discourse/lib/utilities";
|
||||
import PasswordValidation from "discourse/mixins/password-validation";
|
||||
import UserFieldsValidation from "discourse/mixins/user-fields-validation";
|
||||
import UsernameValidation from "discourse/mixins/username-validation";
|
||||
import { findAll } from "discourse/models/login-method";
|
||||
import User from "discourse/models/user";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class SignupPageController extends Controller.extend(
|
||||
PasswordValidation,
|
||||
UsernameValidation,
|
||||
UserFieldsValidation
|
||||
) {
|
||||
@service site;
|
||||
@@ -44,6 +43,7 @@ export default class SignupPageController extends Controller.extend(
|
||||
passwordValidationVisible = false;
|
||||
emailValidationVisible = false;
|
||||
nameValidationHelper = new NameValidationHelper(this);
|
||||
usernameValidationHelper = new UsernameValidationHelper(this);
|
||||
|
||||
@notEmpty("authOptions") hasAuthOptions;
|
||||
@setting("enable_local_logins") canCreateLocal;
|
||||
@@ -59,6 +59,11 @@ export default class SignupPageController extends Controller.extend(
|
||||
this.fetchConfirmationValue();
|
||||
}
|
||||
|
||||
@dependentKeyCompat
|
||||
get usernameValidation() {
|
||||
return this.usernameValidationHelper.usernameValidation;
|
||||
}
|
||||
|
||||
get nameTitle() {
|
||||
return this.nameValidationHelper.nameTitle;
|
||||
}
|
||||
@@ -356,7 +361,11 @@ export default class SignupPageController extends Controller.extend(
|
||||
// If email is valid and username has not been entered yet,
|
||||
// or email and username were filled automatically by 3rd party auth,
|
||||
// then look for a registered username that matches the email.
|
||||
discourseDebounce(this, this.fetchExistingUsername, 500);
|
||||
discourseDebounce(
|
||||
this,
|
||||
this.usernameValidationHelper.fetchExistingUsername,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,31 +1,28 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { concat, fn } from "@ember/helper";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||||
import { eq } from "truth-helpers";
|
||||
import FKLabel from "discourse/form-kit/components/fk/label";
|
||||
import FKMeta from "discourse/form-kit/components/fk/meta";
|
||||
import FKOptional from "discourse/form-kit/components/fk/optional";
|
||||
import FKText from "discourse/form-kit/components/fk/text";
|
||||
import FKTooltip from "discourse/form-kit/components/fk/tooltip";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import DTooltip from "float-kit/components/d-tooltip";
|
||||
|
||||
export default class FKControlWrapper extends Component {
|
||||
get controlType() {
|
||||
if (this.args.component.controlType === "input") {
|
||||
return this.args.component.controlType + "-" + (this.args.type || "text");
|
||||
return this.args.field.type + "-" + (this.args.type || "text");
|
||||
}
|
||||
|
||||
return this.args.component.controlType;
|
||||
return this.args.field.type;
|
||||
}
|
||||
|
||||
get error() {
|
||||
return (this.args.errors ?? {})[this.args.field.name];
|
||||
}
|
||||
|
||||
get isComponentTooltip() {
|
||||
return typeof this.args.field.tooltip === "object";
|
||||
}
|
||||
|
||||
get titleFormat() {
|
||||
return this.args.field.titleFormat || this.args.field.format;
|
||||
}
|
||||
@@ -52,42 +49,34 @@ export default class FKControlWrapper extends Component {
|
||||
data-disabled={{@field.disabled}}
|
||||
data-name={{@field.name}}
|
||||
data-control-type={{this.controlType}}
|
||||
{{didInsert (fn @registerField @field.name @field)}}
|
||||
{{willDestroy (fn @unregisterField @field.name)}}
|
||||
>
|
||||
{{#if @field.showTitle}}
|
||||
<FKLabel
|
||||
class={{concatClass
|
||||
"form-kit__container-title"
|
||||
(if this.titleFormat (concat "--" this.titleFormat))
|
||||
}}
|
||||
@fieldId={{@field.id}}
|
||||
>
|
||||
<span>{{@field.title}}</span>
|
||||
{{#unless (eq @field.type "checkbox")}}
|
||||
{{#if @field.showTitle}}
|
||||
<FKLabel
|
||||
class={{concatClass
|
||||
"form-kit__container-title"
|
||||
(if this.titleFormat (concat "--" this.titleFormat))
|
||||
}}
|
||||
@fieldId={{@field.id}}
|
||||
>
|
||||
<span>{{@field.title}}</span>
|
||||
|
||||
{{#unless @field.required}}
|
||||
<span class="form-kit__container-optional">({{i18n
|
||||
"form_kit.optional"
|
||||
}})</span>
|
||||
{{/unless}}
|
||||
<FKOptional @field={{@field}} />
|
||||
<FKTooltip @field={{@field}} />
|
||||
</FKLabel>
|
||||
{{/if}}
|
||||
|
||||
{{#if @field.tooltip}}
|
||||
{{#if this.isComponentTooltip}}
|
||||
<@field.tooltip />
|
||||
{{else}}
|
||||
<DTooltip @icon="circle-question" @content={{@field.tooltip}} />
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</FKLabel>
|
||||
{{/if}}
|
||||
|
||||
{{#if @field.description}}
|
||||
<FKText
|
||||
class={{concatClass
|
||||
"form-kit__container-description"
|
||||
(if this.descriptionFormat (concat "--" this.descriptionFormat))
|
||||
}}
|
||||
>{{@field.description}}</FKText>
|
||||
{{/if}}
|
||||
{{#if @field.description}}
|
||||
<FKText
|
||||
class={{concatClass
|
||||
"form-kit__container-description"
|
||||
(if this.descriptionFormat (concat "--" this.descriptionFormat))
|
||||
}}
|
||||
>{{@field.description}}</FKText>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
|
||||
<div
|
||||
class={{concatClass
|
||||
@@ -105,7 +94,9 @@ export default class FKControlWrapper extends Component {
|
||||
@before={{@before}}
|
||||
@after={{@after}}
|
||||
@height={{@height}}
|
||||
@preview={{@preview}}
|
||||
@selection={{@selection}}
|
||||
@includeNone={{@includeNone}}
|
||||
id={{@field.id}}
|
||||
name={{@field.name}}
|
||||
aria-invalid={{if this.error "true"}}
|
||||
|
@@ -3,6 +3,8 @@ import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { eq } from "truth-helpers";
|
||||
import FKLabel from "discourse/form-kit/components/fk/label";
|
||||
import FKOptional from "discourse/form-kit/components/fk/optional";
|
||||
import FKTooltip from "discourse/form-kit/components/fk/tooltip";
|
||||
|
||||
export default class FKControlCheckbox extends Component {
|
||||
static controlType = "checkbox";
|
||||
@@ -24,7 +26,9 @@ export default class FKControlCheckbox extends Component {
|
||||
/>
|
||||
<span class="form-kit__control-checkbox-content">
|
||||
<span class="form-kit__control-checkbox-title">
|
||||
{{@field.title}}
|
||||
<span>{{@field.title}}</span>
|
||||
<FKOptional @field={{@field}} />
|
||||
<FKTooltip @field={{@field}} />
|
||||
</span>
|
||||
{{#if (has-block)}}
|
||||
<span class="form-kit__control-checkbox-description">{{yield}}</span>
|
||||
|
@@ -2,6 +2,7 @@ import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import DEditor from "discourse/components/d-editor";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
|
||||
export default class FKControlComposer extends Component {
|
||||
@@ -13,7 +14,7 @@ export default class FKControlComposer extends Component {
|
||||
}
|
||||
|
||||
get style() {
|
||||
if (this.args.height) {
|
||||
if (!this.args.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,7 +26,10 @@ export default class FKControlComposer extends Component {
|
||||
@value={{readonly @field.value}}
|
||||
@change={{this.handleInput}}
|
||||
@disabled={{@field.disabled}}
|
||||
class="form-kit__control-composer"
|
||||
class={{concatClass
|
||||
"form-kit__control-composer"
|
||||
(if @preview "--preview")
|
||||
}}
|
||||
style={{this.style}}
|
||||
@textAreaId={{@field.id}}
|
||||
/>
|
||||
|
@@ -21,7 +21,9 @@ export default class FKControlSelect extends Component {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !this.args.field.validation?.includes("required");
|
||||
return (
|
||||
this.args.includeNone ?? !this.args.field.validation?.includes("required")
|
||||
);
|
||||
}
|
||||
|
||||
<template>
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import ValidationParser from "discourse/form-kit/lib/validation-parser";
|
||||
import Validator from "discourse/form-kit/lib/validator";
|
||||
@@ -8,6 +9,12 @@ import uniqueId from "discourse/helpers/unique-id";
|
||||
* Represents a field in a form with validation, registration, and field data management capabilities.
|
||||
*/
|
||||
export default class FKFieldData extends Component {
|
||||
/**
|
||||
* Type of the field.
|
||||
* @type {string}
|
||||
*/
|
||||
@tracked type;
|
||||
|
||||
/**
|
||||
* Unique identifier for the field.
|
||||
* @type {string}
|
||||
@@ -20,12 +27,6 @@ export default class FKFieldData extends Component {
|
||||
*/
|
||||
errorId = uniqueId();
|
||||
|
||||
/**
|
||||
* Type of the field.
|
||||
* @type {string}
|
||||
*/
|
||||
type;
|
||||
|
||||
/**
|
||||
* Initializes the FKFieldData component.
|
||||
* Validates the presence of required arguments and registers the field.
|
||||
@@ -37,8 +38,6 @@ export default class FKFieldData extends Component {
|
||||
if (!this.args.title?.length) {
|
||||
throw new Error("@title is required on `<form.Field />`.");
|
||||
}
|
||||
|
||||
this.args.registerField(this.name, this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,5 +1,8 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { hash } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { getOwner } from "@ember/owner";
|
||||
import curryComponent from "ember-curry-component";
|
||||
import FKControlCheckbox from "discourse/form-kit/components/fk/control/checkbox";
|
||||
import FKControlCode from "discourse/form-kit/components/fk/control/code";
|
||||
import FKControlComposer from "discourse/form-kit/components/fk/control/composer";
|
||||
@@ -40,6 +43,30 @@ export default class FKField extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
componentFor(component, field) {
|
||||
const instance = this;
|
||||
const baseArguments = {
|
||||
get errors() {
|
||||
return instance.args.errors;
|
||||
},
|
||||
unregisterField: instance.args.unregisterField,
|
||||
registerField: instance.args.registerField,
|
||||
component,
|
||||
field,
|
||||
};
|
||||
|
||||
if (!component.controlType) {
|
||||
throw new Error(
|
||||
`Static property \`controlType\` is required on component:\n\n ${component}`
|
||||
);
|
||||
}
|
||||
|
||||
field.type = component.controlType;
|
||||
|
||||
return curryComponent(FKControlWrapper, baseArguments, getOwner(this));
|
||||
}
|
||||
|
||||
<template>
|
||||
<FKFieldData
|
||||
@name={{@name}}
|
||||
@@ -66,104 +93,20 @@ export default class FKField extends Component {
|
||||
<this.wrapper @size={{@size}}>
|
||||
{{yield
|
||||
(hash
|
||||
Custom=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlCustom
|
||||
field=field
|
||||
)
|
||||
Code=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlCode
|
||||
field=field
|
||||
)
|
||||
Question=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlQuestion
|
||||
field=field
|
||||
)
|
||||
Textarea=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlTextarea
|
||||
field=field
|
||||
)
|
||||
Checkbox=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlCheckbox
|
||||
field=field
|
||||
)
|
||||
Image=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlImage
|
||||
field=field
|
||||
)
|
||||
Password=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlPassword
|
||||
field=field
|
||||
)
|
||||
Composer=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlComposer
|
||||
field=field
|
||||
)
|
||||
Icon=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlIcon
|
||||
field=field
|
||||
)
|
||||
Toggle=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlToggle
|
||||
field=field
|
||||
)
|
||||
Menu=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlMenu
|
||||
field=field
|
||||
)
|
||||
Select=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlSelect
|
||||
field=field
|
||||
)
|
||||
Input=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlInput
|
||||
field=field
|
||||
)
|
||||
RadioGroup=(component
|
||||
FKControlWrapper
|
||||
unregisterField=@unregisterField
|
||||
errors=@errors
|
||||
component=FKControlRadioGroup
|
||||
field=field
|
||||
)
|
||||
Custom=(this.componentFor FKControlCustom field)
|
||||
Code=(this.componentFor FKControlCode field)
|
||||
Question=(this.componentFor FKControlQuestion field)
|
||||
Textarea=(this.componentFor FKControlTextarea field)
|
||||
Checkbox=(this.componentFor FKControlCheckbox field)
|
||||
Image=(this.componentFor FKControlImage field)
|
||||
Password=(this.componentFor FKControlPassword field)
|
||||
Composer=(this.componentFor FKControlComposer field)
|
||||
Icon=(this.componentFor FKControlIcon field)
|
||||
Toggle=(this.componentFor FKControlToggle field)
|
||||
Menu=(this.componentFor FKControlMenu field)
|
||||
Select=(this.componentFor FKControlSelect field)
|
||||
Input=(this.componentFor FKControlInput field)
|
||||
RadioGroup=(this.componentFor FKControlRadioGroup field)
|
||||
errorId=field.errorId
|
||||
id=field.id
|
||||
name=field.name
|
||||
|
@@ -0,0 +1,11 @@
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
const FKOptional = <template>
|
||||
{{#unless @field.required}}
|
||||
<span class="form-kit__container-optional">({{i18n
|
||||
"form_kit.optional"
|
||||
}})</span>
|
||||
{{/unless}}
|
||||
</template>;
|
||||
|
||||
export default FKOptional;
|
@@ -0,0 +1,22 @@
|
||||
import Component from "@glimmer/component";
|
||||
import DTooltip from "float-kit/components/d-tooltip";
|
||||
|
||||
export default class FKTooltip extends Component {
|
||||
get isComponentTooltip() {
|
||||
return typeof this.args.field.tooltip === "object";
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if @field.tooltip}}
|
||||
{{#if this.isComponentTooltip}}
|
||||
<@field.tooltip />
|
||||
{{else}}
|
||||
<DTooltip
|
||||
class="form-kit__tooltip"
|
||||
@icon="circle-question"
|
||||
@content={{@field.tooltip}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
@@ -2,6 +2,7 @@ import { spinnerHTML } from "discourse/helpers/loading-spinner";
|
||||
import { iconHTML } from "discourse/lib/icon-library";
|
||||
import discourseLater from "discourse/lib/later";
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import { sanitize } from "discourse/lib/text";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default {
|
||||
@@ -15,10 +16,37 @@ export default {
|
||||
parentDiv.style.cursor = "";
|
||||
overlay.innerHTML = spinnerHTML;
|
||||
|
||||
const videoSrc = sanitizeUrl(parentDiv.dataset.videoSrc);
|
||||
const origSrc = sanitizeUrl(parentDiv.dataset.origSrc);
|
||||
const dataOrigSrcAttr =
|
||||
origSrc !== null ? `data-orig-src="${origSrc}"` : "";
|
||||
|
||||
if (videoSrc === null) {
|
||||
const existingNotice = wrapper.querySelector(".notice.error");
|
||||
if (existingNotice) {
|
||||
existingNotice.remove();
|
||||
}
|
||||
|
||||
const notice = document.createElement("div");
|
||||
notice.className = "notice error";
|
||||
notice.innerHTML =
|
||||
iconHTML("triangle-exclamation") + " " + i18n("invalid_video_url");
|
||||
wrapper.appendChild(notice);
|
||||
overlay.innerHTML = iconHTML("play");
|
||||
|
||||
parentDiv.style.cursor = "pointer";
|
||||
parentDiv.addEventListener(
|
||||
"click",
|
||||
(e) => handleVideoPlaceholderClick(helper, e),
|
||||
{ once: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const videoHTML = `
|
||||
<video width="100%" height="100%" preload="metadata" controls style="display:none">
|
||||
<source src="${parentDiv.dataset.videoSrc}" ${parentDiv.dataset.origSrc}>
|
||||
<a href="${parentDiv.dataset.videoSrc}">${parentDiv.dataset.videoSrc}</a>
|
||||
<source src="${videoSrc}" ${dataOrigSrcAttr}>
|
||||
<a href="${videoSrc}">${videoSrc}</a>
|
||||
</video>`;
|
||||
parentDiv.insertAdjacentHTML("beforeend", videoHTML);
|
||||
parentDiv.classList.add("video-container");
|
||||
@@ -108,6 +136,33 @@ export default {
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeUrl(url) {
|
||||
try {
|
||||
const parsedUrl = new URL(url, window.location.origin);
|
||||
|
||||
if (
|
||||
["http:", "https:"].includes(parsedUrl.protocol) ||
|
||||
url.startsWith("/")
|
||||
) {
|
||||
const sanitized = sanitize(url);
|
||||
|
||||
if (
|
||||
sanitized &&
|
||||
sanitized.trim() !== "" &&
|
||||
!sanitized.includes(">") &&
|
||||
!sanitized.includes("<")
|
||||
) {
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Invalid URL encountered:", url, e.message);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
api.decorateCookedElement(applyVideoPlaceholder, {
|
||||
onlyStream: true,
|
||||
});
|
||||
|
@@ -114,6 +114,7 @@ export default function (options) {
|
||||
const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea;
|
||||
let inputSelectedItems = [];
|
||||
|
||||
/** @type {AutocompleteHandler} */
|
||||
options.textHandler ??= new TextareaAutocompleteHandler(me[0]);
|
||||
|
||||
function handlePaste() {
|
||||
@@ -249,14 +250,14 @@ export default function (options) {
|
||||
options.textHandler.getCaretPosition();
|
||||
}
|
||||
|
||||
options.textHandler.replaceTerm({
|
||||
start: completeStart,
|
||||
end: completeEnd,
|
||||
term: (options.preserveKey ? options.key || "" : "") + term,
|
||||
});
|
||||
options.textHandler.replaceTerm(
|
||||
completeStart,
|
||||
completeEnd,
|
||||
(options.preserveKey ? options.key || "" : "") + term
|
||||
);
|
||||
|
||||
if (options && options.afterComplete) {
|
||||
options.afterComplete(options.textHandler.value, event);
|
||||
options.afterComplete(options.textHandler.getValue(), event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,7 +482,9 @@ export default function (options) {
|
||||
if (
|
||||
(term.length !== 0 && term.trim().length === 0) ||
|
||||
// close unless the caret is at the end of a word, like #line|<-
|
||||
options.textHandler.value[options.textHandler.getCaretPosition()]?.trim()
|
||||
options.textHandler
|
||||
.getValue()
|
||||
[options.textHandler.getCaretPosition()]?.trim()
|
||||
) {
|
||||
closeAutocomplete();
|
||||
return null;
|
||||
@@ -549,11 +552,11 @@ export default function (options) {
|
||||
}
|
||||
|
||||
let cp = options.textHandler.getCaretPosition();
|
||||
const key = options.textHandler.value[cp - 1];
|
||||
const key = options.textHandler.getValue()[cp - 1];
|
||||
|
||||
if (options.key) {
|
||||
if (options.onKeyUp && key !== options.key) {
|
||||
let match = options.onKeyUp(options.textHandler.value, cp);
|
||||
let match = options.onKeyUp(options.textHandler.getValue(), cp);
|
||||
|
||||
if (match && (await checkTriggerRule())) {
|
||||
completeStart = cp - match[0].length;
|
||||
@@ -565,7 +568,7 @@ export default function (options) {
|
||||
|
||||
if (completeStart === null && cp > 0) {
|
||||
if (key === options.key) {
|
||||
let prevChar = options.textHandler.value.charAt(cp - 2);
|
||||
let prevChar = options.textHandler.getValue().charAt(cp - 2);
|
||||
if (
|
||||
(!prevChar || ALLOWED_LETTERS_REGEXP.test(prevChar)) &&
|
||||
(await checkTriggerRule())
|
||||
@@ -575,11 +578,17 @@ export default function (options) {
|
||||
}
|
||||
}
|
||||
} else if (completeStart !== null) {
|
||||
let term = options.textHandler.value.substring(
|
||||
completeStart + (options.key ? 1 : 0),
|
||||
cp
|
||||
);
|
||||
updateAutoComplete(dataSource(term, options));
|
||||
let term = options.textHandler
|
||||
.getValue()
|
||||
.substring(completeStart + (options.key ? 1 : 0), cp);
|
||||
if (
|
||||
!options.key ||
|
||||
options.textHandler.getValue()[completeStart] === options.key
|
||||
) {
|
||||
updateAutoComplete(dataSource(term, options));
|
||||
} else {
|
||||
closeAutocomplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,12 +610,12 @@ export default function (options) {
|
||||
|
||||
while (prevIsGood && caretPos >= 0) {
|
||||
caretPos -= 1;
|
||||
prev = options.textHandler.value[caretPos];
|
||||
prev = options.textHandler.getValue()[caretPos];
|
||||
|
||||
stopFound = prev === options.key;
|
||||
|
||||
if (stopFound) {
|
||||
prev = options.textHandler.value[caretPos - 1];
|
||||
prev = options.textHandler.getValue()[caretPos - 1];
|
||||
const shouldTrigger = await checkTriggerRule({ backSpace });
|
||||
|
||||
if (
|
||||
@@ -614,10 +623,9 @@ export default function (options) {
|
||||
(prev === undefined || ALLOWED_LETTERS_REGEXP.test(prev))
|
||||
) {
|
||||
start = caretPos;
|
||||
term = options.textHandler.value.substring(
|
||||
caretPos + 1,
|
||||
initialCaretPos
|
||||
);
|
||||
term = options.textHandler
|
||||
.getValue()
|
||||
.substring(caretPos + 1, initialCaretPos);
|
||||
end = caretPos + term.length;
|
||||
break;
|
||||
}
|
||||
@@ -648,7 +656,7 @@ export default function (options) {
|
||||
inputSelectedItems.push("");
|
||||
}
|
||||
|
||||
const value = options.textHandler.value;
|
||||
const value = options.textHandler.getValue();
|
||||
if (typeof inputSelectedItems[0] === "string" && value.length > 0) {
|
||||
inputSelectedItems.pop();
|
||||
inputSelectedItems.push(value);
|
||||
@@ -694,7 +702,7 @@ export default function (options) {
|
||||
// allow people to right arrow out of completion
|
||||
if (
|
||||
e.which === keys.rightArrow &&
|
||||
options.textHandler.value[cp] === " "
|
||||
options.textHandler.getValue()[cp] === " "
|
||||
) {
|
||||
closeAutocomplete();
|
||||
return true;
|
||||
@@ -770,10 +778,9 @@ export default function (options) {
|
||||
return true;
|
||||
}
|
||||
|
||||
term = options.textHandler.value.substring(
|
||||
completeStart + (options.key ? 1 : 0),
|
||||
cp
|
||||
);
|
||||
term = options.textHandler
|
||||
.getValue()
|
||||
.substring(completeStart + (options.key ? 1 : 0), cp);
|
||||
|
||||
if (completeStart === cp && term === options.key) {
|
||||
closeAutocomplete();
|
||||
|
@@ -0,0 +1,107 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef PluginContext
|
||||
* @property {string} placeholder
|
||||
* @property {number} topicId
|
||||
* @property {number} categoryId
|
||||
* @property {import("discourse/models/session").default} session
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef PluginParams
|
||||
* @property {typeof import("discourse/static/prosemirror/lib/plugin-utils")} utils
|
||||
* @property {typeof import('prosemirror-model')} pmModel
|
||||
* @property {typeof import('prosemirror-view')} pmView
|
||||
* @property {typeof import('prosemirror-state')} pmState
|
||||
* @property {typeof import('prosemirror-history')} pmHistory
|
||||
* @property {typeof import('prosemirror-transform')} pmTransform
|
||||
* @property {typeof import('prosemirror-commands')} pmCommands
|
||||
* @property {import('prosemirror-model').Schema} schema
|
||||
* @property {() => PluginContext} getContext
|
||||
*/
|
||||
|
||||
/** @typedef {import('prosemirror-state').PluginSpec} PluginSpec */
|
||||
/** @typedef {((params: PluginParams) => PluginSpec)} RichPluginFn */
|
||||
/** @typedef {PluginSpec | RichPluginFn} RichPlugin */
|
||||
|
||||
/**
|
||||
* @typedef InputRuleObject
|
||||
* @property {RegExp} match
|
||||
* @property {string | ((state: import('prosemirror-state').EditorState, match: RegExpMatchArray, start: number, end: number) => import('prosemirror-state').Transaction | null)} handler
|
||||
* @property {{ undoable?: boolean, inCode?: boolean | "only" }} [options]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef InputRuleParams
|
||||
* @property {import('prosemirror-model').Schema} schema
|
||||
* @property {Function} markInputRule
|
||||
*/
|
||||
|
||||
/** @typedef {((params: InputRuleParams) => InputRuleObject) | InputRuleObject} RichInputRule */
|
||||
|
||||
// @ts-ignore we don't have type definitions for markdown-it
|
||||
/** @typedef {import("markdown-it").Token} MarkdownItToken */
|
||||
/** @typedef {(state: unknown, token: MarkdownItToken, tokenStream: MarkdownItToken[], index: number) => boolean | void} ParseFunction */
|
||||
/** @typedef {import("prosemirror-markdown").ParseSpec | ParseFunction} RichParseSpec */
|
||||
|
||||
/**
|
||||
* @typedef {(state: import("prosemirror-markdown").MarkdownSerializerState, node: import("prosemirror-model").Node, parent: import("prosemirror-model").Node, index: number) => void} SerializeNodeFn
|
||||
*/
|
||||
|
||||
/** @typedef {Record<string, import('prosemirror-state').Command>} KeymapSpec */
|
||||
/** @typedef {((params: PluginParams) => KeymapSpec)} RichKeymapFn */
|
||||
/** @typedef {KeymapSpec | RichKeymapFn} RichKeymap */
|
||||
|
||||
/**
|
||||
* @typedef {Object} RichEditorExtension
|
||||
* @property {Record<string, import('prosemirror-model').NodeSpec>} [nodeSpec]
|
||||
* Map containing Prosemirror node spec definitions, each key being the node name
|
||||
* See https://prosemirror.net/docs/ref/#model.NodeSpec
|
||||
* @property {Record<string, import('prosemirror-model').MarkSpec>} [markSpec]
|
||||
* Map containing Prosemirror mark spec definitions, each key being the mark name
|
||||
* See https://prosemirror.net/docs/ref/#model.MarkSpec
|
||||
* @property {RichInputRule | Array<RichInputRule>} [inputRules]
|
||||
* ProseMirror input rules. See https://prosemirror.net/docs/ref/#inputrules.InputRule
|
||||
* can be a function returning an array or an array of input rules
|
||||
* @property {Record<string, SerializeNodeFn>} [serializeNode]
|
||||
* Node serialization definition
|
||||
* @ts-ignore MarkSerializerSpec not currently exported
|
||||
* @property {Record<string, import('prosemirror-markdown').MarkSerializerSpec>} [serializeMark]
|
||||
* Mark serialization definition
|
||||
* @property {Record<string, RichParseSpec>} [parse]
|
||||
* Markdown-it token parse definition
|
||||
* @property {RichPlugin | Array<RichPlugin>} [plugins]
|
||||
* ProseMirror plugins
|
||||
* @property {Record<string, import('prosemirror-view').NodeViewConstructor>} [nodeViews]
|
||||
* ProseMirror node views
|
||||
* @property {RichKeymap} [keymap]
|
||||
* Additional keymap definitions
|
||||
*/
|
||||
|
||||
/** @type {RichEditorExtension[]} */
|
||||
const registeredExtensions = [];
|
||||
|
||||
/**
|
||||
* Registers an extension for the rich editor
|
||||
*
|
||||
* EXPERIMENTAL: This API will change without warning
|
||||
*
|
||||
* @param {RichEditorExtension} extension
|
||||
*/
|
||||
export function registerRichEditorExtension(extension) {
|
||||
registeredExtensions.push(extension);
|
||||
}
|
||||
|
||||
export function resetRichEditorExtensions() {
|
||||
registeredExtensions.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all extensions registered for the rich editor
|
||||
*
|
||||
* @returns {RichEditorExtension[]}
|
||||
*/
|
||||
export function getExtensions() {
|
||||
return registeredExtensions;
|
||||
}
|
@@ -0,0 +1,305 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Interface for text manipulation with an underlying editor implementation.
|
||||
*
|
||||
* @interface TextManipulation
|
||||
*/
|
||||
export const TextManipulation = {};
|
||||
|
||||
/**
|
||||
* Whether the editor allows a preview being shown
|
||||
* @name TextManipulation#allowPreview
|
||||
* @type {boolean}
|
||||
* @readonly
|
||||
*/
|
||||
|
||||
/**
|
||||
* Focuses the editor
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#focus
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Blurs and focuses the editor
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#blurAndFocus
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Indents/un-indents the current selection
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#indentSelection
|
||||
* @param {string} direction The direction to indent in. Either "right" or "left"
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configures an Autocomplete for the editor
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#autocomplete
|
||||
* @param {unknown} options The options for the jQuery autocomplete
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if the current selection is in a code block
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#inCodeBlock
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the current selection
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#getSelected
|
||||
* @param {unknown} trimLeading
|
||||
* @returns {unknown}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Selects the text from the given range
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#selectText
|
||||
* @param {number} from
|
||||
* @param {number} to
|
||||
* @param {unknown} [options]
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Applies the given head/tail to the selected text
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#applySurround
|
||||
* @param {string} selected The selected text
|
||||
* @param {string} head The text to be inserted before the selection
|
||||
* @param {string} tail The text to be inserted after the selection
|
||||
* @param {string} exampleKey The key of the example
|
||||
* @param {unknown} [opts]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Applies the list format to the selected text
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#applyList
|
||||
* @param {string} selected The selected text
|
||||
* @param {string} head The text to be inserted before the selection
|
||||
* @param {string} exampleKey The key of the example
|
||||
* @param {unknown} [opts]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formats the current selection as code
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#formatCode
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Adds text
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#addText
|
||||
* @param {string} selected The selected text
|
||||
* @param {string} text The text to be inserted
|
||||
*/
|
||||
|
||||
/**
|
||||
* Toggles the text (LTR/RTL) direction
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#toggleDirection
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Replaces text
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#replaceText
|
||||
* @param {string} oldValue The old value
|
||||
* @param {string} newValue The new value
|
||||
* @param {unknown} [opts]
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handles the paste event
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#paste
|
||||
* @param {ClipboardEvent} event The paste event
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Inserts the block
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#insertBlock
|
||||
* @param {string} block The block to be inserted
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Inserts text
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#insertText
|
||||
* @param {string} text The text to be inserted
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Applies the head/tail to the selected text
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#applySurroundSelection
|
||||
* @param {string} head The text to be inserted before the selection
|
||||
* @param {string} tail The text to be inserted after the selection
|
||||
* @param {string} exampleKey The key of the example
|
||||
* @param {unknown} [opts]
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Puts cursor at the end of the editor
|
||||
*
|
||||
* @method
|
||||
* @name TextManipulation#putCursorAtEnd
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* The placeholder handler instance
|
||||
*
|
||||
* @name TextManipulation#placeholder
|
||||
* @type {PlaceholderHandler}
|
||||
* @readonly
|
||||
*/
|
||||
|
||||
/** @typedef {import("@uppy/utils/lib/UppyFile").MinimalRequiredUppyFile<any,any>} UppyFile */
|
||||
|
||||
/**
|
||||
* Interface for handling placeholders on upload events
|
||||
*
|
||||
* @interface PlaceholderHandler
|
||||
*/
|
||||
export const PlaceholderHandler = {};
|
||||
|
||||
/**
|
||||
* Inserts a file
|
||||
*
|
||||
* @method
|
||||
* @name PlaceholderHandler#insert
|
||||
* @param {UppyFile} file The uploaded file
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Success event for file upload
|
||||
*
|
||||
* @method
|
||||
* @name PlaceholderHandler#success
|
||||
* @param {UppyFile} file The uploaded file
|
||||
* @param {string} markdown The markdown for the uploaded file
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cancels all uploads
|
||||
*
|
||||
* @method
|
||||
* @name PlaceholderHandler#cancelAll
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cancels one uploaded file
|
||||
*
|
||||
* @method
|
||||
* @name PlaceholderHandler#cancel
|
||||
* @param {UppyFile} file The uploaded file
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Progress event
|
||||
*
|
||||
* @method
|
||||
* @name PlaceholderHandler#progress
|
||||
* @param {UppyFile} file The uploaded file
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Progress complete event
|
||||
*
|
||||
* @method
|
||||
* @name PlaceholderHandler#progressComplete
|
||||
* @param {UppyFile} file The uploaded file
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interface for the Autocomplete handler
|
||||
*
|
||||
* @interface AutocompleteHandler
|
||||
*/
|
||||
export const AutocompleteHandler = {};
|
||||
|
||||
/**
|
||||
* Replaces the range with the given text
|
||||
*
|
||||
* @method
|
||||
* @name AutocompleteHandler#replaceTerm
|
||||
* @param {number} start The start of the range
|
||||
* @param {number} end The end of the range
|
||||
* @param {string} text The text to be inserted
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the caret position
|
||||
*
|
||||
* @method
|
||||
* @name AutocompleteHandler#getCaretPosition
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if the current selection is in a code block
|
||||
*
|
||||
* @method
|
||||
* @name AutocompleteHandler#inCodeBlock
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the caret coordinates
|
||||
*
|
||||
* @method
|
||||
* @name AutocompleteHandler#getCaretCoords
|
||||
* @param {number} caretPositon The caret position to get the coords for
|
||||
* @returns {{ top: number, left: number }}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the current value for the autocomplete
|
||||
*
|
||||
* @method
|
||||
* @name AutocompleteHandler#getValue
|
||||
* @returns {string}
|
||||
*/
|
@@ -48,6 +48,7 @@ export default function interceptClick(e) {
|
||||
target.classList.contains("ember-view")) ||
|
||||
target.classList.contains("lightbox") ||
|
||||
href.startsWith("mailto:") ||
|
||||
target.closest('[contenteditable="true"]') ||
|
||||
(href.match(/^http[s]?:\/\//i) &&
|
||||
!href.match(new RegExp("^https?:\\/\\/" + window.location.hostname, "i")))
|
||||
) {
|
||||
|
@@ -0,0 +1,5 @@
|
||||
export default async function loadRichEditor() {
|
||||
return (
|
||||
await import("discourse/static/prosemirror/components/prosemirror-editor")
|
||||
).default;
|
||||
}
|
@@ -3,7 +3,7 @@
|
||||
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
|
||||
// using the format described at https://keepachangelog.com/en/1.0.0/.
|
||||
|
||||
export const PLUGIN_API_VERSION = "2.0.1";
|
||||
export const PLUGIN_API_VERSION = "2.1.0";
|
||||
|
||||
import $ from "jquery";
|
||||
import { h } from "virtual-dom";
|
||||
@@ -66,6 +66,7 @@ import classPrepend, {
|
||||
withPrependsRolledBack,
|
||||
} from "discourse/lib/class-prepend";
|
||||
import { addPopupMenuOption } from "discourse/lib/composer/custom-popup-menu-options";
|
||||
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
|
||||
import deprecated from "discourse/lib/deprecated";
|
||||
import { registerDesktopNotificationHandler } from "discourse/lib/desktop-notifications";
|
||||
import { downloadCalendar } from "discourse/lib/download-calendar";
|
||||
@@ -3389,6 +3390,17 @@ class PluginApi {
|
||||
registerReportModeComponent(mode, componentClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an extension for the rich editor
|
||||
*
|
||||
* EXPERIMENTAL: This API will change without warning
|
||||
*
|
||||
* @param {RichEditorExtension} extension
|
||||
*/
|
||||
registerRichEditorExtension(extension) {
|
||||
registerRichEditorExtension(extension);
|
||||
}
|
||||
|
||||
#deprecatedWidgetOverride(widgetName, override) {
|
||||
// insert here the code to handle widget deprecations, e.g. for the header widgets we used:
|
||||
// if (DEPRECATED_HEADER_WIDGETS.includes(widgetName)) {
|
||||
@@ -3466,7 +3478,14 @@ function getPluginApi(version) {
|
||||
* @param {object} [opts] - Optional additional options to pass to the callback function.
|
||||
* @returns {*} The result of the `callback` function, if executed
|
||||
*/
|
||||
export function withPluginApi(version, apiCodeCallback, opts) {
|
||||
export function withPluginApi(...args) {
|
||||
let version, apiCodeCallback, opts;
|
||||
if (typeof args[0] === "function") {
|
||||
[version, apiCodeCallback, opts] = ["0", ...args];
|
||||
} else {
|
||||
[version, apiCodeCallback, opts] = args;
|
||||
}
|
||||
|
||||
opts = opts || {};
|
||||
|
||||
const api = getPluginApi(version);
|
||||
|
@@ -60,7 +60,9 @@ class SidebarAdminSectionLink extends BaseCustomSidebarSectionLink {
|
||||
|
||||
get text() {
|
||||
return this.adminSidebarNavLink.label
|
||||
? i18n(this.adminSidebarNavLink.label)
|
||||
? i18n(this.adminSidebarNavLink.label, {
|
||||
translatedFallback: this.adminSidebarNavLink.text,
|
||||
})
|
||||
: this.adminSidebarNavLink.text;
|
||||
}
|
||||
|
||||
@@ -316,6 +318,7 @@ function pluginAdminRouteLinks(router) {
|
||||
? [plugin.admin_route.location]
|
||||
: [],
|
||||
label: plugin.admin_route.label,
|
||||
text: plugin.humanized_name,
|
||||
icon: "gear",
|
||||
};
|
||||
});
|
||||
|
@@ -14,7 +14,7 @@ import {
|
||||
import SectionLink from "discourse/lib/sidebar/section-link";
|
||||
import AdminSectionLink from "discourse/lib/sidebar/user/community-section/admin-section-link";
|
||||
import InviteSectionLink from "discourse/lib/sidebar/user/community-section/invite-section-link";
|
||||
import MyDraftsSectionLink from "discourse/lib/sidebar/user/community-section/my-drafts-section-link";
|
||||
import MyPostsSectionLink from "discourse/lib/sidebar/user/community-section/my-posts-section-link";
|
||||
import ReviewSectionLink from "discourse/lib/sidebar/user/community-section/review-section-link";
|
||||
|
||||
const SPECIAL_LINKS_MAP = {
|
||||
@@ -22,7 +22,7 @@ const SPECIAL_LINKS_MAP = {
|
||||
"/about": AboutSectionLink,
|
||||
"/u": UsersSectionLink,
|
||||
"/faq": FAQSectionLink,
|
||||
"/my/activity": MyDraftsSectionLink,
|
||||
"/my/activity": MyPostsSectionLink,
|
||||
"/review": ReviewSectionLink,
|
||||
"/badges": BadgesSectionLink,
|
||||
"/admin": AdminSectionLink,
|
||||
|
@@ -4,13 +4,13 @@ import { i18n } from "discourse-i18n";
|
||||
|
||||
const USER_DRAFTS_CHANGED_EVENT = "user-drafts:changed";
|
||||
|
||||
export default class MyDraftsSectionLink extends BaseSectionLink {
|
||||
export default class MyPostsSectionLink extends BaseSectionLink {
|
||||
@tracked draftCount = this.currentUser?.draft_count;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
if (this.currentUser) {
|
||||
if (this.shouldDisplay) {
|
||||
this.appEvents.on(
|
||||
USER_DRAFTS_CHANGED_EVENT,
|
||||
this,
|
||||
@@ -20,7 +20,7 @@ export default class MyDraftsSectionLink extends BaseSectionLink {
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if (this.currentUser) {
|
||||
if (this.shouldDisplay) {
|
||||
this.appEvents.off(
|
||||
USER_DRAFTS_CHANGED_EVENT,
|
||||
this,
|
||||
@@ -38,11 +38,21 @@ export default class MyDraftsSectionLink extends BaseSectionLink {
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "my-drafts";
|
||||
return "my-posts";
|
||||
}
|
||||
|
||||
get route() {
|
||||
return "userActivity.drafts";
|
||||
if (this._hasDraft) {
|
||||
return "userActivity.drafts";
|
||||
} else {
|
||||
return "userActivity.index";
|
||||
}
|
||||
}
|
||||
|
||||
get currentWhen() {
|
||||
if (this._hasDraft) {
|
||||
return "userActivity.index userActivity.drafts";
|
||||
}
|
||||
}
|
||||
|
||||
get model() {
|
||||
@@ -50,11 +60,24 @@ export default class MyDraftsSectionLink extends BaseSectionLink {
|
||||
}
|
||||
|
||||
get title() {
|
||||
return i18n("sidebar.sections.community.links.my_drafts.title");
|
||||
if (this._hasDraft) {
|
||||
return i18n("sidebar.sections.community.links.my_posts.title_drafts");
|
||||
} else {
|
||||
return i18n("sidebar.sections.community.links.my_posts.title");
|
||||
}
|
||||
}
|
||||
|
||||
get text() {
|
||||
return i18n("sidebar.sections.community.links.my_drafts.content");
|
||||
if (this._hasDraft && this.currentUser?.new_new_view_enabled) {
|
||||
return i18n("sidebar.sections.community.links.my_posts.content_drafts");
|
||||
} else {
|
||||
return i18n(
|
||||
`sidebar.sections.community.links.${this.overridenName
|
||||
.toLowerCase()
|
||||
.replace(" ", "_")}.content`,
|
||||
{ defaultValue: this.overridenName }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get badgeText() {
|
||||
@@ -65,7 +88,7 @@ export default class MyDraftsSectionLink extends BaseSectionLink {
|
||||
if (this.currentUser.new_new_view_enabled) {
|
||||
return this.draftCount.toString();
|
||||
} else {
|
||||
return i18n("sidebar.sections.community.links.my_drafts.draft_count", {
|
||||
return i18n("sidebar.sections.community.links.my_posts.draft_count", {
|
||||
count: this.draftCount,
|
||||
});
|
||||
}
|
||||
@@ -75,6 +98,13 @@ export default class MyDraftsSectionLink extends BaseSectionLink {
|
||||
return this.draftCount > 0;
|
||||
}
|
||||
|
||||
get defaultPrefixValue() {
|
||||
if (this._hasDraft && this.currentUser?.new_new_view_enabled) {
|
||||
return "pencil";
|
||||
}
|
||||
return "user";
|
||||
}
|
||||
|
||||
get suffixCSSClass() {
|
||||
return "unread";
|
||||
}
|
||||
@@ -90,10 +120,6 @@ export default class MyDraftsSectionLink extends BaseSectionLink {
|
||||
}
|
||||
|
||||
get shouldDisplay() {
|
||||
return this.currentUser && this._hasDraft;
|
||||
}
|
||||
|
||||
get prefixValue() {
|
||||
return "far-pen-to-square";
|
||||
return this.currentUser;
|
||||
}
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
// @ts-check
|
||||
import { setOwner } from "@ember/owner";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import { next, schedule } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { isEmpty } from "@ember/utils";
|
||||
import $ from "jquery";
|
||||
@@ -19,6 +20,12 @@ import {
|
||||
} from "discourse/lib/utilities";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
/**
|
||||
* @typedef {import("discourse/lib/composer/text-manipulation").TextManipulation} TextManipulation
|
||||
* @typedef {import("discourse/lib/composer/text-manipulation").AutocompleteHandler} AutocompleteHandler
|
||||
* @typedef {import("discourse/lib/composer/text-manipulation").PlaceholderHandler} PlaceholderHandler
|
||||
*/
|
||||
|
||||
const INDENT_DIRECTION_LEFT = "left";
|
||||
const INDENT_DIRECTION_RIGHT = "right";
|
||||
|
||||
@@ -33,9 +40,13 @@ const OP = {
|
||||
|
||||
const FOUR_SPACES_INDENT = "4-spaces-indent";
|
||||
|
||||
// Our head can be a static string or a function that returns a string
|
||||
// based on input (like for numbered lists).
|
||||
export function getHead(head, prev) {
|
||||
/**
|
||||
* Our head can be a static string or a function that returns a string
|
||||
* based on input (like for numbered lists).
|
||||
*
|
||||
* @returns {[string, number]}
|
||||
*/
|
||||
function getHead(head, prev) {
|
||||
if (typeof head === "string") {
|
||||
return [head, head.length];
|
||||
} else {
|
||||
@@ -43,12 +54,15 @@ export function getHead(head, prev) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @implements {TextManipulation} */
|
||||
export default class TextareaTextManipulation {
|
||||
@service appEvents;
|
||||
@service siteSettings;
|
||||
@service capabilities;
|
||||
@service currentUser;
|
||||
|
||||
allowPreview = true;
|
||||
|
||||
eventPrefix;
|
||||
textarea;
|
||||
$textarea;
|
||||
@@ -816,11 +830,18 @@ export default class TextareaTextManipulation {
|
||||
}
|
||||
|
||||
putCursorAtEnd() {
|
||||
putCursorAtEnd(this.textarea);
|
||||
if (this.capabilities.isIOS) {
|
||||
putCursorAtEnd(this.textarea);
|
||||
} else {
|
||||
// in some browsers, the focus() called by putCursorAtEnd doesn't bubble the event to set
|
||||
// isEditorFoused=true and bring the focus indicator to the wrapper, unless we do it on next tick
|
||||
next(() => putCursorAtEnd(this.textarea));
|
||||
}
|
||||
}
|
||||
|
||||
autocomplete(options) {
|
||||
return this.$textarea.autocomplete(
|
||||
// @ts-ignore
|
||||
this.$textarea.autocomplete(
|
||||
options instanceof Object
|
||||
? { textHandler: this.autocompleteHandler, ...options }
|
||||
: options
|
||||
@@ -838,6 +859,7 @@ function insertAtTextarea(textarea, start, end, text) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @implements {AutocompleteHandler} */
|
||||
export class TextareaAutocompleteHandler {
|
||||
textarea;
|
||||
$textarea;
|
||||
@@ -847,12 +869,13 @@ export class TextareaAutocompleteHandler {
|
||||
this.$textarea = $(textarea);
|
||||
}
|
||||
|
||||
get value() {
|
||||
getValue() {
|
||||
return this.textarea.value;
|
||||
}
|
||||
|
||||
replaceTerm({ start, end, term }) {
|
||||
const space = this.value.substring(end + 1, end + 2) === " " ? "" : " ";
|
||||
replaceTerm(start, end, term) {
|
||||
const space =
|
||||
this.getValue().substring(end + 1, end + 2) === " " ? "" : " ";
|
||||
insertAtTextarea(this.textarea, start, end + 1, term + space);
|
||||
setCaretPosition(this.textarea, start + 1 + term.trim().length);
|
||||
}
|
||||
@@ -862,6 +885,7 @@ export class TextareaAutocompleteHandler {
|
||||
}
|
||||
|
||||
getCaretCoords(start) {
|
||||
// @ts-ignore
|
||||
return this.$textarea.caretPosition({ pos: start + 1 });
|
||||
}
|
||||
|
||||
@@ -873,9 +897,11 @@ export class TextareaAutocompleteHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/** @implements {PlaceholderHandler} */
|
||||
class TextareaPlaceholderHandler {
|
||||
@service composer;
|
||||
|
||||
/** @type {TextareaTextManipulation} */
|
||||
textManipulation;
|
||||
|
||||
#placeholders = {};
|
||||
|
@@ -345,11 +345,6 @@ export function applyValueTransformer(
|
||||
|
||||
try {
|
||||
const value = valueCallback({ value: newValue, context });
|
||||
if (mutable && typeof value !== "undefined") {
|
||||
throw new Error(
|
||||
`${prefix}: transformer "${transformerName}" expects the value to be mutated instead of returned. Remove the return value in your transformer.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!mutable) {
|
||||
newValue = value;
|
||||
|
@@ -52,7 +52,8 @@ export default class UppyComposerUpload {
|
||||
uploadPreProcessors;
|
||||
uploadHandlers;
|
||||
|
||||
textManipulation;
|
||||
/** @type {PlaceholderHandler} */
|
||||
placeholderHandler;
|
||||
|
||||
#inProgressUploads = [];
|
||||
#bufferedUploadErrors = [];
|
||||
@@ -334,7 +335,7 @@ export default class UppyComposerUpload {
|
||||
})
|
||||
);
|
||||
|
||||
this.textManipulation.placeholder.insert(file);
|
||||
this.placeholderHandler.insert(file);
|
||||
|
||||
this.appEvents.trigger(
|
||||
`${this.composerEventPrefix}:upload-started`,
|
||||
@@ -369,7 +370,7 @@ export default class UppyComposerUpload {
|
||||
file,
|
||||
upload.url,
|
||||
() => {
|
||||
this.textManipulation.placeholder.success(file, markdown);
|
||||
this.placeholderHandler.success(file, markdown);
|
||||
|
||||
this.appEvents.trigger(
|
||||
`${this.composerEventPrefix}:upload-success`,
|
||||
@@ -395,7 +396,7 @@ export default class UppyComposerUpload {
|
||||
this.uppyWrapper.uppyInstance.on("cancel-all", () => {
|
||||
// Do the manual cancelling work only if the user clicked cancel
|
||||
if (this.#userCancelled) {
|
||||
this.textManipulation.placeholder.cancelAll();
|
||||
this.placeholderHandler.cancelAll();
|
||||
this.#userCancelled = false;
|
||||
this.#reset();
|
||||
|
||||
@@ -480,13 +481,13 @@ export default class UppyComposerUpload {
|
||||
});
|
||||
|
||||
this.uppyWrapper.onPreProcessProgress((file) => {
|
||||
this.textManipulation.placeholder.progress(file);
|
||||
this.placeholderHandler.progress(file);
|
||||
});
|
||||
|
||||
this.uppyWrapper.onPreProcessComplete(
|
||||
(file) => {
|
||||
run(() => {
|
||||
this.textManipulation.placeholder.progressComplete(file);
|
||||
this.placeholderHandler.progressComplete(file);
|
||||
});
|
||||
},
|
||||
() => {
|
||||
@@ -529,13 +530,13 @@ export default class UppyComposerUpload {
|
||||
}
|
||||
|
||||
#resetUpload(file) {
|
||||
this.textManipulation.placeholder.cancel(file);
|
||||
this.placeholderHandler.cancel(file);
|
||||
}
|
||||
|
||||
@bind
|
||||
_pasteEventListener(event) {
|
||||
if (
|
||||
document.activeElement !== document.querySelector(this.editorInputClass)
|
||||
!document.querySelector(this.editorInputClass)?.contains(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -550,6 +551,7 @@ export default class UppyComposerUpload {
|
||||
}
|
||||
|
||||
if (event && event.clipboardData && event.clipboardData.files) {
|
||||
event.preventDefault();
|
||||
this._addFiles([...event.clipboardData.files], { pasted: true });
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,116 @@
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { isEmpty } from "@ember/utils";
|
||||
import discourseDebounce from "discourse/lib/debounce";
|
||||
import User from "discourse/models/user";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
function failedResult(attrs) {
|
||||
return {
|
||||
shouldCheck: false,
|
||||
failed: true,
|
||||
ok: false,
|
||||
element: document.querySelector("#new-account-username"),
|
||||
...attrs,
|
||||
};
|
||||
}
|
||||
|
||||
function validResult(attrs) {
|
||||
return { ok: true, ...attrs };
|
||||
}
|
||||
|
||||
export default class UsernameValidationHelper {
|
||||
@tracked usernameValidationResult;
|
||||
checkedUsername = null;
|
||||
|
||||
constructor(owner) {
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
async fetchExistingUsername() {
|
||||
const result = await User.checkUsername(null, this.owner.accountEmail);
|
||||
|
||||
if (
|
||||
result.suggestion &&
|
||||
(isEmpty(this.owner.accountUsername) ||
|
||||
this.owner.accountUsername === this.owner.get("authOptions.username"))
|
||||
) {
|
||||
this.owner.accountUsername = result.suggestion;
|
||||
this.owner.prefilledUsername = result.suggestion;
|
||||
}
|
||||
}
|
||||
|
||||
get usernameValidation() {
|
||||
if (
|
||||
this.usernameValidationResult &&
|
||||
this.checkedUsername === this.owner.accountUsername
|
||||
) {
|
||||
return this.usernameValidationResult;
|
||||
}
|
||||
|
||||
const result = this.basicUsernameValidation(this.owner.accountUsername);
|
||||
|
||||
if (result.shouldCheck) {
|
||||
discourseDebounce(this, this.checkUsernameAvailability, 500);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
basicUsernameValidation(username) {
|
||||
if (username && username === this.owner.prefilledUsername) {
|
||||
return validResult({ reason: i18n("user.username.prefilled") });
|
||||
}
|
||||
|
||||
if (isEmpty(username)) {
|
||||
return failedResult({
|
||||
message: i18n("user.username.required"),
|
||||
reason: this.owner.forceValidationReason
|
||||
? i18n("user.username.required")
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (username.length < this.owner.siteSettings.min_username_length) {
|
||||
return failedResult({ reason: i18n("user.username.too_short") });
|
||||
}
|
||||
|
||||
if (username.length > this.owner.siteSettings.max_username_length) {
|
||||
return failedResult({ reason: i18n("user.username.too_long") });
|
||||
}
|
||||
|
||||
return failedResult({
|
||||
shouldCheck: true,
|
||||
reason: i18n("user.username.checking"),
|
||||
});
|
||||
}
|
||||
|
||||
async checkUsernameAvailability() {
|
||||
const result = await User.checkUsername(
|
||||
this.owner.accountUsername,
|
||||
this.owner.accountEmail
|
||||
);
|
||||
|
||||
if (this.owner.isDestroying || this.owner.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkedUsername = this.owner.accountUsername;
|
||||
this.owner.isDeveloper = !!result.is_developer;
|
||||
|
||||
if (result.available) {
|
||||
this.usernameValidationResult = validResult({
|
||||
reason: i18n("user.username.available"),
|
||||
});
|
||||
} else if (result.suggestion) {
|
||||
this.usernameValidationResult = failedResult({
|
||||
reason: i18n("user.username.not_available", result),
|
||||
});
|
||||
} else {
|
||||
this.usernameValidationResult = failedResult({
|
||||
reason: result.errors
|
||||
? result.errors.join(" ")
|
||||
: i18n("user.username.not_available_no_suggestion"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,3 +1,4 @@
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import EmberObject, { action, computed } from "@ember/object";
|
||||
import { alias, and, or, reads } from "@ember/object/computed";
|
||||
import { cancel, scheduleOnce } from "@ember/runloop";
|
||||
@@ -27,7 +28,6 @@ import { getOwnerWithFallback } from "discourse/lib/get-owner";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
|
||||
import { wantsNewWindow } from "discourse/lib/intercept-click";
|
||||
import { buildQuote } from "discourse/lib/quote";
|
||||
import { emojiUnescape } from "discourse/lib/text";
|
||||
import {
|
||||
authorizesOneOrMoreExtensions,
|
||||
@@ -106,6 +106,8 @@ export default class ComposerService extends Service {
|
||||
@service siteSettings;
|
||||
@service store;
|
||||
|
||||
@tracked showPreview = true;
|
||||
@tracked allowPreview = true;
|
||||
checkedMessages = false;
|
||||
messageCount = null;
|
||||
showEditReason = false;
|
||||
@@ -119,7 +121,7 @@ export default class ComposerService extends Service {
|
||||
uploadProgress;
|
||||
topic = null;
|
||||
linkLookup = null;
|
||||
showPreview = true;
|
||||
|
||||
composerHeight = null;
|
||||
|
||||
@and("site.mobileView", "showPreview") forcePreview;
|
||||
@@ -135,6 +137,10 @@ export default class ComposerService extends Service {
|
||||
return getOwnerWithFallback(this).lookup("controller:topic");
|
||||
}
|
||||
|
||||
get isPreviewVisible() {
|
||||
return this.showPreview && this.allowPreview;
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return this.model?.composeState === Composer.OPEN;
|
||||
}
|
||||
@@ -841,45 +847,6 @@ export default class ComposerService extends Service {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Import a quote from the post
|
||||
@action
|
||||
async importQuote(toolbarEvent) {
|
||||
const postStream = this.get("topic.postStream");
|
||||
let postId = this.get("model.post.id");
|
||||
|
||||
// If there is no current post, use the first post id from the stream
|
||||
if (!postId && postStream) {
|
||||
postId = postStream.get("stream.firstObject");
|
||||
}
|
||||
|
||||
// If we're editing a post, fetch the reply when importing a quote
|
||||
if (this.get("model.editingPost")) {
|
||||
const replyToPostNumber = this.get("model.post.reply_to_post_number");
|
||||
if (replyToPostNumber) {
|
||||
const replyPost = postStream.posts.findBy(
|
||||
"post_number",
|
||||
replyToPostNumber
|
||||
);
|
||||
|
||||
if (replyPost) {
|
||||
postId = replyPost.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!postId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.set("model.loading", true);
|
||||
|
||||
const post = await this.store.find("post", postId);
|
||||
const quote = buildQuote(post, post.raw, { full: true });
|
||||
|
||||
toolbarEvent.addText(quote);
|
||||
this.set("model.loading", false);
|
||||
}
|
||||
|
||||
@action
|
||||
saveAction(ignore, event) {
|
||||
this.save(false, {
|
||||
|
@@ -32,6 +32,7 @@ export const CRITICAL_DEPRECATIONS = [
|
||||
"discourse.qunit.acceptance-function",
|
||||
"discourse.qunit.global-exists",
|
||||
"discourse.post-stream.trigger-new-post",
|
||||
"discourse.hbr-topic-list-overrides",
|
||||
];
|
||||
|
||||
if (DEBUG) {
|
||||
|
@@ -4,7 +4,7 @@ import loadPluginFeatures from "./features";
|
||||
import MentionsParser from "./mentions-parser";
|
||||
import buildOptions from "./options";
|
||||
|
||||
function buildEngine(options) {
|
||||
export function buildEngine(options) {
|
||||
return DiscourseMarkdownIt.withCustomFeatures(
|
||||
loadPluginFeatures()
|
||||
).withOptions(buildOptions(options));
|
||||
|
@@ -0,0 +1,249 @@
|
||||
// @ts-check
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { getOwner } from "@ember/owner";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
||||
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||||
import { next } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import "../extensions/register-default";
|
||||
import { baseKeymap } from "prosemirror-commands";
|
||||
import * as ProsemirrorCommands from "prosemirror-commands";
|
||||
import { dropCursor } from "prosemirror-dropcursor";
|
||||
import { gapCursor } from "prosemirror-gapcursor";
|
||||
import * as ProsemirrorHistory from "prosemirror-history";
|
||||
import { history } from "prosemirror-history";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import * as ProsemirrorModel from "prosemirror-model";
|
||||
import * as ProsemirrorState from "prosemirror-state";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as ProsemirrorTransform from "prosemirror-transform";
|
||||
import * as ProsemirrorView from "prosemirror-view";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import { getExtensions } from "discourse/lib/composer/rich-editor-extensions";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
import { buildInputRules } from "../core/inputrules";
|
||||
import { buildKeymap } from "../core/keymap";
|
||||
import Parser from "../core/parser";
|
||||
import { extractNodeViews, extractPlugins } from "../core/plugin";
|
||||
import { createSchema } from "../core/schema";
|
||||
import Serializer from "../core/serializer";
|
||||
import placeholder from "../extensions/placeholder";
|
||||
import * as utils from "../lib/plugin-utils";
|
||||
import TextManipulation from "../lib/text-manipulation";
|
||||
|
||||
const AUTOCOMPLETE_KEY_DOWN_SUPPRESS = ["Enter", "Tab"];
|
||||
|
||||
/**
|
||||
* @typedef ProsemirrorEditorArgs
|
||||
* @property {string} [value] The markdown content to be rendered in the editor
|
||||
* @property {string} [placeholder] The placeholder text to be displayed when the editor is empty
|
||||
* @property {boolean} [disabled] Whether the editor should be disabled
|
||||
* @property {Record<string, () => void>} [keymap] A mapping of keybindings to commands
|
||||
* @property {(value: { target: { value: string } }) => void} [change] A callback called when the editor content changes
|
||||
* @property {() => void} [focusIn] A callback called when the editor gains focus
|
||||
* @property {() => void} [focusOut] A callback called when the editor loses focus
|
||||
* @property {(textManipulation: TextManipulation) => undefined | (() => void)} [onSetup] A callback called when the editor is set up, may return a destructor
|
||||
* @property {number} [topicId] The ID of the topic being edited, if any
|
||||
* @property {number} [categoryId] The ID of the category of the topic being edited, if any
|
||||
* @property {string} [class] The class to be added to the ProseMirror contentEditable editor
|
||||
* @property {boolean} [includeDefault] If default node and mark spec/parse/serialize/inputRules definitions from ProseMirror should be included
|
||||
* @property {import("discourse/lib/composer/rich-editor-extensions").RichEditorExtension[]} [extensions] A list of extensions to be used with the editor INSTEAD of the ones registered through the API
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef ProsemirrorEditorSignature
|
||||
* @property {ProsemirrorEditorArgs} Args
|
||||
*/
|
||||
|
||||
/**
|
||||
* @extends {Component<ProsemirrorEditorSignature>}
|
||||
*/
|
||||
export default class ProsemirrorEditor extends Component {
|
||||
@service session;
|
||||
@service dialog;
|
||||
|
||||
schema = createSchema(this.extensions, this.args.includeDefault);
|
||||
view;
|
||||
|
||||
#lastSerialized;
|
||||
/** @type {undefined | (() => void)} */
|
||||
#destructor;
|
||||
|
||||
get pluginParams() {
|
||||
return {
|
||||
utils,
|
||||
schema: this.schema,
|
||||
pmState: ProsemirrorState,
|
||||
pmModel: ProsemirrorModel,
|
||||
pmView: ProsemirrorView,
|
||||
pmHistory: ProsemirrorHistory,
|
||||
pmTransform: ProsemirrorTransform,
|
||||
pmCommands: ProsemirrorCommands,
|
||||
getContext: () => ({
|
||||
placeholder: this.args.placeholder,
|
||||
topicId: this.args.topicId,
|
||||
categoryId: this.args.categoryId,
|
||||
session: this.session,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
get extensions() {
|
||||
const extensions = this.args.extensions ?? getExtensions();
|
||||
|
||||
// enforcing core extensions
|
||||
return extensions.includes(placeholder)
|
||||
? extensions
|
||||
: [placeholder, ...extensions];
|
||||
}
|
||||
|
||||
get keymapFromArgs() {
|
||||
const replacements = { tab: "Tab" };
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(this.args.keymap ?? {})) {
|
||||
const pmKey = key
|
||||
.split("+")
|
||||
.map((word) => replacements[word] ?? word)
|
||||
.join("-");
|
||||
result[pmKey] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@action
|
||||
handleAsyncPlugin(plugin) {
|
||||
const state = this.view.state.reconfigure({
|
||||
plugins: [...this.view.state.plugins, plugin],
|
||||
});
|
||||
|
||||
this.view.updateState(state);
|
||||
}
|
||||
|
||||
@action
|
||||
setup(container) {
|
||||
const params = this.pluginParams;
|
||||
|
||||
const plugins = [
|
||||
buildInputRules(this.extensions, this.schema, this.args.includeDefault),
|
||||
keymap(
|
||||
buildKeymap(
|
||||
this.extensions,
|
||||
this.schema,
|
||||
this.keymapFromArgs,
|
||||
params,
|
||||
this.args.includeDefault
|
||||
)
|
||||
),
|
||||
keymap(baseKeymap),
|
||||
dropCursor({ color: "var(--primary)" }),
|
||||
gapCursor(),
|
||||
history(),
|
||||
...extractPlugins(this.extensions, params, this.handleAsyncPlugin),
|
||||
];
|
||||
|
||||
this.parser = new Parser(this.extensions, this.args.includeDefault);
|
||||
this.serializer = new Serializer(this.extensions, this.args.includeDefault);
|
||||
|
||||
const state = EditorState.create({ schema: this.schema, plugins });
|
||||
|
||||
this.view = new EditorView(container, {
|
||||
state,
|
||||
nodeViews: extractNodeViews(this.extensions),
|
||||
attributes: { class: this.args.class },
|
||||
editable: () => this.args.disabled !== true,
|
||||
dispatchTransaction: (tr) => {
|
||||
this.view.updateState(this.view.state.apply(tr));
|
||||
|
||||
if (tr.docChanged && tr.getMeta("addToHistory") !== false) {
|
||||
// If this gets expensive, we can debounce it
|
||||
const value = this.serializer.convert(this.view.state.doc);
|
||||
this.#lastSerialized = value;
|
||||
this.args.change?.({ target: { value } });
|
||||
}
|
||||
},
|
||||
handleDOMEvents: {
|
||||
focus: () => {
|
||||
this.args.focusIn?.();
|
||||
return false;
|
||||
},
|
||||
blur: () => {
|
||||
next(() => this.args.focusOut?.());
|
||||
return false;
|
||||
},
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
// suppress if Enter/Tab and the autocomplete is open
|
||||
return (
|
||||
AUTOCOMPLETE_KEY_DOWN_SUPPRESS.includes(event.key) &&
|
||||
!!document.querySelector(".autocomplete")
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
this.textManipulation = new TextManipulation(getOwner(this), {
|
||||
schema: this.schema,
|
||||
view: this.view,
|
||||
convertFromMarkdown: this.convertFromMarkdown,
|
||||
convertToMarkdown: this.serializer.convert.bind(this.serializer),
|
||||
});
|
||||
|
||||
this.#destructor = this.args.onSetup?.(this.textManipulation);
|
||||
|
||||
this.convertFromValue();
|
||||
}
|
||||
|
||||
@bind
|
||||
convertFromMarkdown(markdown) {
|
||||
try {
|
||||
return this.parser.convert(this.schema, markdown);
|
||||
} catch (e) {
|
||||
next(() => this.dialog.alert(e.message));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
convertFromValue() {
|
||||
// Ignore the markdown we just serialized
|
||||
if (this.args.value === this.#lastSerialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = this.convertFromMarkdown(this.args.value);
|
||||
|
||||
const tr = this.view.state.tr;
|
||||
tr.replaceWith(0, this.view.state.doc.content.size, doc.content).setMeta(
|
||||
"addToHistory",
|
||||
false
|
||||
);
|
||||
this.view.updateState(this.view.state.apply(tr));
|
||||
}
|
||||
|
||||
@action
|
||||
teardown() {
|
||||
this.#destructor?.();
|
||||
this.view.destroy();
|
||||
}
|
||||
|
||||
@action
|
||||
updateContext(element, [key, value]) {
|
||||
this.view.dispatch(
|
||||
this.view.state.tr
|
||||
.setMeta("addToHistory", false)
|
||||
.setMeta("discourseContextChanged", { key, value })
|
||||
);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="ProseMirror-container"
|
||||
{{didInsert this.setup}}
|
||||
{{didUpdate this.convertFromValue @value}}
|
||||
{{didUpdate this.updateContext "placeholder" @placeholder}}
|
||||
{{willDestroy this.teardown}}
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
}
|
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
InputRule,
|
||||
inputRules,
|
||||
smartQuotes,
|
||||
textblockTypeInputRule,
|
||||
wrappingInputRule,
|
||||
} from "prosemirror-inputrules";
|
||||
|
||||
export function buildInputRules(extensions, schema, includeDefault = true) {
|
||||
const rules = [];
|
||||
|
||||
if (includeDefault) {
|
||||
rules.push(
|
||||
// TODO(renato) smartQuotes should respect `markdown_typographer_quotation_marks`
|
||||
...smartQuotes,
|
||||
...[
|
||||
wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote),
|
||||
|
||||
orderedListRule(schema.nodes.ordered_list),
|
||||
bulletListRule(schema.nodes.bullet_list),
|
||||
|
||||
textblockTypeInputRule(/^```$/, schema.nodes.code_block),
|
||||
textblockTypeInputRule(/^ {4}$/, schema.nodes.code_block),
|
||||
|
||||
headingRule(schema.nodes.heading, 6),
|
||||
|
||||
markInputRule(/\*\*([^*]+)\*\*$/, schema.marks.strong),
|
||||
markInputRule(/(?<=^|\s)__([^_]+)__$/, schema.marks.strong),
|
||||
markInputRule(/(?:^|(?<!\*))\*([^*]+)\*$/, schema.marks.em),
|
||||
markInputRule(/(?<=^|\s)_([^_]+)_$/, schema.marks.em),
|
||||
markInputRule(/`([^`]+)`$/, schema.marks.code),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
rules.push(...extractInputRules(extensions, schema));
|
||||
|
||||
return inputRules({ rules });
|
||||
}
|
||||
|
||||
function extractInputRules(extensions, schema) {
|
||||
return extensions.flatMap(({ inputRules: extensionRules }) =>
|
||||
extensionRules ? processInputRule(extensionRules, schema) : []
|
||||
);
|
||||
}
|
||||
|
||||
function processInputRule(inputRule, schema) {
|
||||
if (inputRule instanceof Array) {
|
||||
return inputRule.map((rule) => processInputRule(rule, schema));
|
||||
}
|
||||
|
||||
if (inputRule instanceof Function) {
|
||||
inputRule = inputRule({ schema, markInputRule });
|
||||
}
|
||||
|
||||
if (inputRule instanceof InputRule) {
|
||||
return inputRule;
|
||||
}
|
||||
|
||||
if (
|
||||
inputRule.match instanceof RegExp &&
|
||||
inputRule.handler instanceof Function
|
||||
) {
|
||||
return new InputRule(inputRule.match, inputRule.handler, inputRule.options);
|
||||
}
|
||||
|
||||
throw new Error("Input rule must have a match regex and a handler function");
|
||||
}
|
||||
|
||||
function orderedListRule(nodeType) {
|
||||
return wrappingInputRule(
|
||||
/^(\d+)\.\s$/,
|
||||
nodeType,
|
||||
(match) => ({ order: +match[1] }),
|
||||
(match, node) => node.childCount + node.attrs.order === +match[1]
|
||||
);
|
||||
}
|
||||
|
||||
function bulletListRule(nodeType) {
|
||||
return wrappingInputRule(/^\s*([-+*])\s$/, nodeType);
|
||||
}
|
||||
|
||||
function headingRule(nodeType, maxLevel) {
|
||||
return textblockTypeInputRule(
|
||||
new RegExp("^(#{1," + maxLevel + "})\\s$"),
|
||||
nodeType,
|
||||
(match) => ({ level: match[1].length })
|
||||
);
|
||||
}
|
||||
|
||||
// https://discuss.prosemirror.net/t/input-rules-for-wrapping-marks/537
|
||||
function markInputRule(regexp, markType, getAttrs) {
|
||||
return new InputRule(regexp, (state, match, start, end) => {
|
||||
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
|
||||
const tr = state.tr;
|
||||
|
||||
if (state.doc.rangeHasMark(start, end, markType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (match[1]) {
|
||||
let textStart = start + match[0].indexOf(match[1]);
|
||||
let textEnd = textStart + match[1].length;
|
||||
if (textEnd < end) {
|
||||
tr.delete(textEnd, end);
|
||||
}
|
||||
if (textStart > start) {
|
||||
tr.delete(start, textStart);
|
||||
}
|
||||
end = start + match[1].length;
|
||||
|
||||
tr.addMark(start, end, markType.create(attrs));
|
||||
tr.removeStoredMark(markType);
|
||||
} else {
|
||||
tr.delete(start, end);
|
||||
tr.insertText(" ");
|
||||
tr.addMark(start, start + 1, markType.create(attrs));
|
||||
tr.removeStoredMark(markType);
|
||||
tr.insertText(" ");
|
||||
|
||||
tr.setSelection(
|
||||
state.selection.constructor.create(tr.doc, start, start + 1)
|
||||
);
|
||||
}
|
||||
|
||||
return tr;
|
||||
});
|
||||
}
|
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
chainCommands,
|
||||
exitCode,
|
||||
selectParentNode,
|
||||
setBlockType,
|
||||
} from "prosemirror-commands";
|
||||
import { redo, undo } from "prosemirror-history";
|
||||
import { undoInputRule } from "prosemirror-inputrules";
|
||||
import { splitListItem } from "prosemirror-schema-list";
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== "undefined"
|
||||
? /Mac|iP(hone|[oa]d)/.test(navigator.platform)
|
||||
: false;
|
||||
|
||||
export function buildKeymap(
|
||||
extensions,
|
||||
schema,
|
||||
initialKeymap,
|
||||
params,
|
||||
includeDefault = true
|
||||
) {
|
||||
const keys = {
|
||||
...initialKeymap,
|
||||
...extractKeymap(extensions, params),
|
||||
};
|
||||
|
||||
keys["Mod-z"] = undo;
|
||||
keys["Shift-Mod-z"] = redo;
|
||||
keys["Backspace"] = undoInputRule;
|
||||
if (!isMac) {
|
||||
keys["Mod-y"] = redo;
|
||||
}
|
||||
keys["Escape"] = selectParentNode;
|
||||
|
||||
// The above keys are always included
|
||||
if (!includeDefault) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
keys["Shift-Enter"] = chainCommands(exitCode, (state, dispatch) => {
|
||||
if (dispatch) {
|
||||
dispatch(
|
||||
state.tr
|
||||
.replaceSelectionWith(schema.nodes.hard_break.create())
|
||||
.scrollIntoView()
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
keys["Mod-Shift-0"] = setBlockType(schema.nodes.paragraph);
|
||||
keys["Enter"] = splitListItem(schema.nodes.list_item);
|
||||
|
||||
for (let level = 1; level <= 6; level++) {
|
||||
keys["Mod-Shift-" + level] = setBlockType(schema.nodes.heading, { level });
|
||||
}
|
||||
|
||||
keys["Mod-Shift-_"] = (state, dispatch) => {
|
||||
dispatch?.(
|
||||
state.tr
|
||||
.replaceSelectionWith(schema.nodes.horizontal_rule.create())
|
||||
.scrollIntoView()
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function extractKeymap(extensions, params) {
|
||||
return {
|
||||
...extensions.map(({ keymap }) => {
|
||||
return keymap instanceof Function ? keymap(params) : keymap;
|
||||
}),
|
||||
};
|
||||
}
|
@@ -0,0 +1,84 @@
|
||||
import { defaultMarkdownParser, MarkdownParser } from "prosemirror-markdown";
|
||||
import { parse } from "../lib/markdown-it";
|
||||
|
||||
// TODO(renato): We need a workaround for this parsing issue:
|
||||
// https://github.com/ProseMirror/prosemirror-markdown/issues/82
|
||||
// a solution may be a markStack in the state ignoring nested marks
|
||||
|
||||
export default class Parser {
|
||||
#multipleParseSpecs = {};
|
||||
|
||||
constructor(extensions, includeDefault = true) {
|
||||
this.parseTokens = includeDefault
|
||||
? {
|
||||
...defaultMarkdownParser.tokens,
|
||||
bbcode_b: { mark: "strong" },
|
||||
bbcode_i: { mark: "em" },
|
||||
}
|
||||
: {};
|
||||
|
||||
this.postParseTokens = includeDefault
|
||||
? { softbreak: (state) => state.addNode(state.schema.nodes.hard_break) }
|
||||
: {};
|
||||
|
||||
for (const [key, value] of Object.entries(
|
||||
this.#extractParsers(extensions)
|
||||
)) {
|
||||
// Not a ParseSpec
|
||||
if (typeof value === "function") {
|
||||
this.postParseTokens[key] = value;
|
||||
} else {
|
||||
this.parseTokens[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
convert(schema, text) {
|
||||
const parser = new MarkdownParser(schema, { parse }, this.parseTokens);
|
||||
|
||||
// Adding function parse handlers directly
|
||||
Object.assign(parser.tokenHandlers, this.postParseTokens);
|
||||
|
||||
return parser.parse(text);
|
||||
}
|
||||
|
||||
#extractParsers(extensions) {
|
||||
const parsers = {};
|
||||
for (const { parse: parseObj } of extensions) {
|
||||
if (!parseObj) {
|
||||
continue;
|
||||
}
|
||||
for (const [token, parseSpec] of Object.entries(parseObj)) {
|
||||
if (parsers[token] !== undefined) {
|
||||
if (this.#multipleParseSpecs[token] === undefined) {
|
||||
// switch to use multipleParseSpecs
|
||||
this.#multipleParseSpecs[token] = [parsers[token]];
|
||||
parsers[token] = this.#multipleParser(token);
|
||||
}
|
||||
this.#multipleParseSpecs[token].push(parseSpec);
|
||||
continue;
|
||||
}
|
||||
parsers[token] = parseSpec;
|
||||
}
|
||||
}
|
||||
return parsers;
|
||||
}
|
||||
|
||||
#multipleParser(tokenName) {
|
||||
return (state, token, tokens, i) => {
|
||||
const parseSpecs = this.#multipleParseSpecs[tokenName];
|
||||
|
||||
for (const parseSpec of parseSpecs) {
|
||||
if (parseSpec(state, token, tokens, i)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No parser processed ${tokenName} token for tag: ${
|
||||
token.tag
|
||||
}, attrs: ${JSON.stringify(token.attrs)}`
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
@@ -0,0 +1,49 @@
|
||||
import { Plugin } from "prosemirror-state";
|
||||
|
||||
export function extractNodeViews(extensions) {
|
||||
/** @type {Record<string, import('prosemirror-view').NodeViewConstructor>} */
|
||||
const allNodeViews = {};
|
||||
for (const { nodeViews } of extensions) {
|
||||
if (nodeViews) {
|
||||
for (const [name, NodeViewClass] of Object.entries(nodeViews)) {
|
||||
allNodeViews[name] = (...args) => new NodeViewClass(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
return allNodeViews;
|
||||
}
|
||||
|
||||
export function extractPlugins(extensions, params, view) {
|
||||
return (
|
||||
extensions
|
||||
.flatMap((extension) => extension.plugins || [])
|
||||
.flatMap((plugin) => processPlugin(plugin, params, view))
|
||||
// filter async plugins from initial load
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function processPlugin(pluginArg, params, handleAsyncPlugin) {
|
||||
if (typeof pluginArg === "function") {
|
||||
const ret = pluginArg(params);
|
||||
|
||||
if (ret instanceof Promise) {
|
||||
ret.then((plugin) => handleAsyncPlugin(processPlugin(plugin, params)));
|
||||
return;
|
||||
}
|
||||
|
||||
return processPlugin(ret, params, handleAsyncPlugin);
|
||||
}
|
||||
|
||||
if (pluginArg instanceof Array) {
|
||||
return pluginArg.map((plugin) =>
|
||||
processPlugin(plugin, params, handleAsyncPlugin)
|
||||
);
|
||||
}
|
||||
|
||||
if (pluginArg instanceof Plugin) {
|
||||
return pluginArg;
|
||||
}
|
||||
|
||||
return new Plugin(pluginArg);
|
||||
}
|
@@ -0,0 +1,41 @@
|
||||
import OrderedMap from "orderedmap";
|
||||
import { schema as defaultMarkdownSchema } from "prosemirror-markdown";
|
||||
import { Schema } from "prosemirror-model";
|
||||
|
||||
export function createSchema(extensions, includeDefault = true) {
|
||||
let nodes = includeDefault
|
||||
? defaultMarkdownSchema.spec.nodes
|
||||
: new OrderedMap([]);
|
||||
|
||||
let marks = includeDefault
|
||||
? defaultMarkdownSchema.spec.marks
|
||||
: new OrderedMap([]);
|
||||
|
||||
for (const [type, spec] of Object.entries(extractNodes(extensions))) {
|
||||
nodes = nodes.update(type, spec);
|
||||
}
|
||||
|
||||
for (const [type, spec] of Object.entries(extractMarks(extensions))) {
|
||||
marks = spec.before
|
||||
? marks.addBefore(spec.before, type, spec)
|
||||
: marks.update(type, spec);
|
||||
}
|
||||
|
||||
return new Schema({ nodes, marks });
|
||||
}
|
||||
|
||||
function extractNodes(extensions) {
|
||||
const nodes = {};
|
||||
for (const extension of extensions) {
|
||||
Object.assign(nodes, extension.nodeSpec);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function extractMarks(extensions) {
|
||||
const marks = {};
|
||||
for (const extension of extensions) {
|
||||
Object.assign(marks, extension.markSpec);
|
||||
}
|
||||
return marks;
|
||||
}
|
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
defaultMarkdownSerializer,
|
||||
MarkdownSerializer,
|
||||
} from "prosemirror-markdown";
|
||||
|
||||
export default class Serializer {
|
||||
#pmSerializer;
|
||||
|
||||
constructor(extensions, includeDefault = true) {
|
||||
this.nodes = includeDefault ? { ...defaultMarkdownSerializer.nodes } : {};
|
||||
this.nodes.hard_break = (state) => state.write("\n");
|
||||
|
||||
this.marks = includeDefault ? { ...defaultMarkdownSerializer.marks } : {};
|
||||
|
||||
this.#extractNodeSerializers(extensions);
|
||||
this.#extractMarkSerializers(extensions);
|
||||
|
||||
this.#pmSerializer = new MarkdownSerializer(this.nodes, this.marks);
|
||||
}
|
||||
|
||||
convert(doc) {
|
||||
return this.#pmSerializer.serialize(doc);
|
||||
}
|
||||
|
||||
#extractNodeSerializers(extensions) {
|
||||
for (const { serializeNode } of extensions) {
|
||||
Object.assign(this.nodes, serializeNode);
|
||||
}
|
||||
}
|
||||
|
||||
#extractMarkSerializers(extensions) {
|
||||
for (const { serializeMark } of extensions) {
|
||||
Object.assign(this.marks, serializeMark);
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* This extension is considered a "core" extension, it's autoloaded by ProsemirrorEditor
|
||||
*
|
||||
* @type {RichEditorExtension}
|
||||
*/
|
||||
const extension = {
|
||||
plugins({
|
||||
pmState: { Plugin },
|
||||
pmView: { Decoration, DecorationSet },
|
||||
getContext,
|
||||
}) {
|
||||
let placeholder;
|
||||
|
||||
return new Plugin({
|
||||
view() {
|
||||
placeholder = getContext().placeholder;
|
||||
return {};
|
||||
},
|
||||
state: {
|
||||
init() {
|
||||
return placeholder;
|
||||
},
|
||||
apply(tr) {
|
||||
const contextChanged = tr.getMeta("discourseContextChanged");
|
||||
if (contextChanged?.key === "placeholder") {
|
||||
placeholder = contextChanged.value;
|
||||
}
|
||||
|
||||
return placeholder;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
const { $head } = state.selection;
|
||||
|
||||
if (
|
||||
state.doc.childCount === 1 &&
|
||||
state.doc.firstChild === $head.parent &&
|
||||
isEmptyParagraph($head.parent)
|
||||
) {
|
||||
const decoration = Decoration.node($head.before(), $head.after(), {
|
||||
"data-placeholder": this.getState(state),
|
||||
});
|
||||
return DecorationSet.create(state.doc, [decoration]);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function isEmptyParagraph(node) {
|
||||
return node.type.name === "paragraph" && node.nodeSize === 2;
|
||||
}
|
||||
|
||||
export default extension;
|
@@ -0,0 +1,27 @@
|
||||
import { buildEngine } from "discourse/static/markdown-it";
|
||||
import loadPluginFeatures from "discourse/static/markdown-it/features";
|
||||
import defaultFeatures from "discourse-markdown-it/features/index";
|
||||
|
||||
let engine;
|
||||
|
||||
function getEngine() {
|
||||
engine ??= buildEngine({
|
||||
featuresOverride: [...defaultFeatures, ...loadPluginFeatures()]
|
||||
.map(({ id }) => id)
|
||||
// Avoid oneboxing when parsing, we'll handle that separately
|
||||
.filter((id) => id !== "onebox"),
|
||||
});
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
export const parse = (text) => getEngine().parse(text);
|
||||
|
||||
export const getLinkify = () => getEngine().linkify;
|
||||
|
||||
export const isBoundary = (str, index) =>
|
||||
!str ||
|
||||
getEngine().options.engine.utils.isWhiteSpace(str.charCodeAt(index)) ||
|
||||
getEngine().options.engine.utils.isPunctChar(
|
||||
String.fromCharCode(str.charCodeAt(index))
|
||||
);
|
@@ -0,0 +1 @@
|
||||
export { getLinkify, isBoundary } from "../lib/markdown-it";
|
@@ -0,0 +1,490 @@
|
||||
// @ts-check
|
||||
import { setOwner } from "@ember/owner";
|
||||
import { next } from "@ember/runloop";
|
||||
import $ from "jquery";
|
||||
import { lift, setBlockType, toggleMark, wrapIn } from "prosemirror-commands";
|
||||
import { liftListItem, sinkListItem } from "prosemirror-schema-list";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
/**
|
||||
* @typedef {import("discourse/lib/composer/text-manipulation").TextManipulation} TextManipulation
|
||||
* @typedef {import("discourse/lib/composer/text-manipulation").AutocompleteHandler} AutocompleteHandler
|
||||
* @typedef {import("discourse/lib/composer/text-manipulation").PlaceholderHandler} PlaceholderHandler
|
||||
*/
|
||||
|
||||
/** @implements {TextManipulation} */
|
||||
export default class ProsemirrorTextManipulation {
|
||||
allowPreview = false;
|
||||
|
||||
/** @type {import("prosemirror-model").Schema} */
|
||||
schema;
|
||||
/** @type {import("prosemirror-view").EditorView} */
|
||||
view;
|
||||
/** @type {PlaceholderHandler} */
|
||||
placeholder;
|
||||
/** @type {AutocompleteHandler} */
|
||||
autocompleteHandler;
|
||||
convertFromMarkdown;
|
||||
convertToMarkdown;
|
||||
|
||||
constructor(owner, { schema, view, convertFromMarkdown, convertToMarkdown }) {
|
||||
setOwner(this, owner);
|
||||
this.schema = schema;
|
||||
this.view = view;
|
||||
this.convertFromMarkdown = convertFromMarkdown;
|
||||
this.convertToMarkdown = convertToMarkdown;
|
||||
|
||||
this.placeholder = new ProsemirrorPlaceholderHandler({
|
||||
schema,
|
||||
view,
|
||||
convertFromMarkdown,
|
||||
});
|
||||
this.autocompleteHandler = new ProsemirrorAutocompleteHandler({
|
||||
schema,
|
||||
view,
|
||||
convertFromMarkdown,
|
||||
});
|
||||
}
|
||||
|
||||
getSelected() {
|
||||
const start = this.view.state.selection.from;
|
||||
const end = this.view.state.selection.to;
|
||||
const value = this.view.state.doc.textBetween(start, end, " ", " ");
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
pre: "",
|
||||
value,
|
||||
post: "",
|
||||
};
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.view.focus();
|
||||
}
|
||||
|
||||
blurAndFocus() {
|
||||
this.focus();
|
||||
}
|
||||
|
||||
putCursorAtEnd() {
|
||||
this.focus();
|
||||
next(() => (this.view.dom.scrollTop = this.view.dom.scrollHeight));
|
||||
}
|
||||
|
||||
autocomplete(options) {
|
||||
// @ts-ignore
|
||||
$(this.view.dom).autocomplete(
|
||||
options instanceof Object
|
||||
? { textHandler: this.autocompleteHandler, ...options }
|
||||
: options
|
||||
);
|
||||
}
|
||||
|
||||
applySurroundSelection(head, tail, exampleKey) {
|
||||
this.applySurround(this.getSelected(), head, tail, exampleKey);
|
||||
}
|
||||
|
||||
applySurround(sel, head, tail, exampleKey) {
|
||||
const applySurroundMap = {
|
||||
italic_text: this.schema.marks.em,
|
||||
bold_text: this.schema.marks.strong,
|
||||
code_title: this.schema.marks.code,
|
||||
};
|
||||
|
||||
if (applySurroundMap[exampleKey]) {
|
||||
toggleMark(applySurroundMap[exampleKey])(
|
||||
this.view.state,
|
||||
this.view.dispatch
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const text = head + i18n(`composer.${exampleKey}`) + tail;
|
||||
const doc = this.convertFromMarkdown(text);
|
||||
|
||||
this.view.dispatch(
|
||||
this.view.state.tr.replaceWith(sel.start, sel.end, doc.content.firstChild)
|
||||
);
|
||||
}
|
||||
|
||||
addText(sel, text) {
|
||||
const doc = this.convertFromMarkdown(text);
|
||||
|
||||
// assumes it returns a single block node
|
||||
const content =
|
||||
doc.content.firstChild.type.name === "paragraph"
|
||||
? doc.content.firstChild.content
|
||||
: doc.content.firstChild;
|
||||
|
||||
this.view.dispatch(
|
||||
this.view.state.tr.replaceWith(sel.start, sel.end, content)
|
||||
);
|
||||
|
||||
this.focus();
|
||||
}
|
||||
|
||||
insertBlock(block) {
|
||||
const doc = this.convertFromMarkdown(block);
|
||||
const node = doc.content.firstChild;
|
||||
|
||||
const tr = this.view.state.tr.replaceSelectionWith(node);
|
||||
if (!tr.selection.$from.nodeAfter) {
|
||||
tr.setSelection(new TextSelection(tr.doc.resolve(tr.selection.from + 1)));
|
||||
}
|
||||
this.view.dispatch(tr);
|
||||
|
||||
this.focus();
|
||||
}
|
||||
|
||||
applyList(_selection, head, exampleKey) {
|
||||
let command;
|
||||
|
||||
const isInside = (type) => {
|
||||
const $from = this.view.state.selection.$from;
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
const parent = $from.node(depth);
|
||||
if (parent.type === type) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (exampleKey === "list_item") {
|
||||
const nodeType =
|
||||
head === "* "
|
||||
? this.schema.nodes.bullet_list
|
||||
: this.schema.nodes.ordered_list;
|
||||
|
||||
command = isInside(this.schema.nodes.list_item) ? lift : wrapIn(nodeType);
|
||||
} else if (exampleKey === "blockquote_text") {
|
||||
command = isInside(this.schema.nodes.blockquote)
|
||||
? lift
|
||||
: wrapIn(this.schema.nodes.blockquote);
|
||||
} else {
|
||||
throw new Error("Unknown exampleKey");
|
||||
}
|
||||
|
||||
command?.(this.view.state, this.view.dispatch);
|
||||
}
|
||||
|
||||
formatCode() {
|
||||
let command;
|
||||
|
||||
const selection = this.view.state.selection;
|
||||
|
||||
if (selection.$from.parent.type === this.schema.nodes.code_block) {
|
||||
command = setBlockType(this.schema.nodes.paragraph);
|
||||
} else if (
|
||||
selection.$from.pos !== selection.$to.pos &&
|
||||
selection.$from.parent === selection.$to.parent
|
||||
) {
|
||||
command = toggleMark(this.schema.marks.code);
|
||||
} else {
|
||||
command = setBlockType(this.schema.nodes.code_block);
|
||||
}
|
||||
|
||||
command?.(this.view.state, this.view.dispatch);
|
||||
}
|
||||
|
||||
@bind
|
||||
emojiSelected(code) {
|
||||
this.view.dispatch(
|
||||
this.view.state.tr
|
||||
.replaceSelectionWith(this.schema.nodes.emoji.create({ code }))
|
||||
.insertText(" ")
|
||||
);
|
||||
}
|
||||
|
||||
@bind
|
||||
paste() {
|
||||
// Intentionally no-op
|
||||
// Pasting markdown is being handled by the markdown-paste extension
|
||||
// Pasting a url on top of a text is being handled by the link extension
|
||||
}
|
||||
|
||||
selectText(from, length, opts) {
|
||||
const tr = this.view.state.tr.setSelection(
|
||||
new TextSelection(
|
||||
this.view.state.doc.resolve(from),
|
||||
this.view.state.doc.resolve(from + length)
|
||||
)
|
||||
);
|
||||
|
||||
if (opts.scroll) {
|
||||
tr.scrollIntoView();
|
||||
}
|
||||
|
||||
this.view.dispatch(tr);
|
||||
}
|
||||
|
||||
@bind
|
||||
inCodeBlock() {
|
||||
return this.autocompleteHandler.inCodeBlock();
|
||||
}
|
||||
|
||||
indentSelection(direction) {
|
||||
const { selection } = this.view.state;
|
||||
|
||||
const isInsideListItem =
|
||||
selection.$head.depth > 0 &&
|
||||
selection.$head.node(-1).type === this.schema.nodes.list_item;
|
||||
|
||||
if (isInsideListItem) {
|
||||
const command =
|
||||
direction === "right"
|
||||
? sinkListItem(this.schema.nodes.list_item)
|
||||
: liftListItem(this.schema.nodes.list_item);
|
||||
command(this.view.state, this.view.dispatch);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
insertText(text) {
|
||||
const doc = this.convertFromMarkdown(text);
|
||||
|
||||
this.view.dispatch(
|
||||
this.view.state.tr
|
||||
.replaceSelectionWith(doc.content.firstChild)
|
||||
.scrollIntoView()
|
||||
);
|
||||
|
||||
this.focus();
|
||||
}
|
||||
|
||||
replaceText(oldValue, newValue, opts = {}) {
|
||||
// Replacing Markdown text is not reliable and should eventually be deprecated
|
||||
|
||||
const markdown = this.convertToMarkdown(this.view.state.doc);
|
||||
|
||||
const regex = opts.regex || new RegExp(oldValue, "g");
|
||||
const index = opts.index || 0;
|
||||
let matchCount = 0;
|
||||
|
||||
const newMarkdown = markdown.replace(regex, (match) => {
|
||||
if (matchCount++ === index) {
|
||||
return newValue;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
if (markdown === newMarkdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDoc = this.convertFromMarkdown(newMarkdown);
|
||||
if (!newDoc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = newValue.length - oldValue.length;
|
||||
const startOffset = this.view.state.selection.from + diff;
|
||||
const endOffset = this.view.state.selection.to + diff;
|
||||
|
||||
const tr = this.view.state.tr.replaceWith(
|
||||
0,
|
||||
this.view.state.doc.content.size,
|
||||
newDoc.content
|
||||
);
|
||||
|
||||
if (
|
||||
!opts.skipNewSelection &&
|
||||
(opts.forceFocus || this.view.dom === document.activeElement)
|
||||
) {
|
||||
const adjustedStart = Math.min(startOffset, tr.doc.content.size);
|
||||
const adjustedEnd = Math.min(endOffset, tr.doc.content.size);
|
||||
|
||||
tr.setSelection(TextSelection.create(tr.doc, adjustedStart, adjustedEnd));
|
||||
}
|
||||
|
||||
this.view.dispatch(tr);
|
||||
}
|
||||
|
||||
toggleDirection() {
|
||||
this.view.dom.dir = this.view.dom.dir === "rtl" ? "ltr" : "rtl";
|
||||
}
|
||||
}
|
||||
|
||||
/** @implements {AutocompleteHandler} */
|
||||
class ProsemirrorAutocompleteHandler {
|
||||
/** @type {import("prosemirror-view").EditorView} */
|
||||
view;
|
||||
/** @type {import("prosemirror-model").Schema} */
|
||||
schema;
|
||||
convertFromMarkdown;
|
||||
|
||||
constructor({ schema, view, convertFromMarkdown }) {
|
||||
this.schema = schema;
|
||||
this.view = view;
|
||||
this.convertFromMarkdown = convertFromMarkdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The textual value of the selected text block
|
||||
* @returns {string}
|
||||
*/
|
||||
getValue() {
|
||||
return (
|
||||
(this.view.state.selection.$head.nodeBefore?.textContent ?? "") +
|
||||
(this.view.state.selection.$head.nodeAfter?.textContent ?? "") || " "
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the term between start-end in the currently selected text block
|
||||
*
|
||||
* It uses input rules to convert it to a node if possible
|
||||
*
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @param {String} term
|
||||
*/
|
||||
replaceTerm(start, end, term) {
|
||||
const node = this.view.state.selection.$head.nodeBefore;
|
||||
const from = this.view.state.selection.from - node.nodeSize + start;
|
||||
const to = this.view.state.selection.from - node.nodeSize + end + 1;
|
||||
|
||||
const doc = this.convertFromMarkdown(term);
|
||||
|
||||
const tr = this.view.state.tr.replaceWith(
|
||||
from,
|
||||
to,
|
||||
doc.content.firstChild.content
|
||||
);
|
||||
tr.insertText(" ", tr.selection.from);
|
||||
|
||||
this.view.dispatch(tr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the textual caret position within the selected text block
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
getCaretPosition() {
|
||||
const node = this.view.state.selection.$head.nodeBefore;
|
||||
|
||||
if (!node?.isText) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return node.nodeSize;
|
||||
}
|
||||
|
||||
getCaretCoords(start) {
|
||||
const node = this.view.state.selection.$head.nodeBefore;
|
||||
const pos = this.view.state.selection.from - node.nodeSize + start;
|
||||
const { left, top } = this.view.coordsAtPos(pos);
|
||||
|
||||
const rootRect = this.view.dom.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
left: left - rootRect.left,
|
||||
top: top - rootRect.top,
|
||||
};
|
||||
}
|
||||
|
||||
async inCodeBlock() {
|
||||
return (
|
||||
this.view.state.selection.$from.parent.type ===
|
||||
this.schema.nodes.code_block
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @implements {PlaceholderHandler} */
|
||||
class ProsemirrorPlaceholderHandler {
|
||||
view;
|
||||
schema;
|
||||
convertFromMarkdown;
|
||||
|
||||
constructor({ schema, view, convertFromMarkdown }) {
|
||||
this.schema = schema;
|
||||
this.view = view;
|
||||
this.convertFromMarkdown = convertFromMarkdown;
|
||||
}
|
||||
|
||||
insert(file) {
|
||||
const isEmptyParagraph =
|
||||
this.view.state.selection.$from.parent.type.name === "paragraph" &&
|
||||
this.view.state.selection.$from.parent.nodeSize === 2;
|
||||
|
||||
const imageNode = this.schema.nodes.image.create({
|
||||
src: URL.createObjectURL(file.data),
|
||||
alt: i18n("uploading_filename", { filename: file.name }),
|
||||
title: file.id,
|
||||
width: 120,
|
||||
"data-placeholder": true,
|
||||
});
|
||||
|
||||
this.view.dispatch(
|
||||
this.view.state.tr.insert(
|
||||
this.view.state.selection.from,
|
||||
isEmptyParagraph
|
||||
? imageNode
|
||||
: this.schema.nodes.paragraph.create(null, imageNode)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
progress() {}
|
||||
progressComplete() {}
|
||||
|
||||
cancelAll() {
|
||||
this.view.state.doc.descendants((node, pos) => {
|
||||
if (
|
||||
node.type === this.schema.nodes.image &&
|
||||
node.attrs["data-placeholder"]
|
||||
) {
|
||||
this.view.dispatch(this.view.state.tr.delete(pos, pos + node.nodeSize));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(file) {
|
||||
this.view.state.doc.descendants((node, pos) => {
|
||||
if (
|
||||
node.type === this.schema.nodes.image &&
|
||||
node.attrs["data-placeholder"] &&
|
||||
node.attrs?.title === file.id
|
||||
) {
|
||||
this.view.dispatch(this.view.state.tr.delete(pos, pos + node.nodeSize));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
success(file, markdown) {
|
||||
/** @type {null | { node: import("prosemirror-model").Node, pos: number }} */
|
||||
let nodeToReplace = null;
|
||||
this.view.state.doc.descendants((node, pos) => {
|
||||
if (
|
||||
node.type === this.schema.nodes.image &&
|
||||
node.attrs["data-placeholder"] &&
|
||||
node.attrs?.title === file.id
|
||||
) {
|
||||
nodeToReplace = { node, pos };
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!nodeToReplace) {
|
||||
return;
|
||||
}
|
||||
|
||||
// keeping compatibility with plugins that change the upload markdown
|
||||
const doc = this.convertFromMarkdown(markdown);
|
||||
|
||||
this.view.dispatch(
|
||||
this.view.state.tr.replaceWith(
|
||||
nodeToReplace.pos,
|
||||
nodeToReplace.pos + nodeToReplace.node.nodeSize,
|
||||
doc.content.firstChild.content
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
@@ -17,11 +17,15 @@ import { i18n } from "discourse-i18n";
|
||||
export default RouteTemplate(
|
||||
class extends Component {
|
||||
@service siteSettings;
|
||||
@service currentUser;
|
||||
|
||||
@tracked accountActivated = false;
|
||||
@tracked isLoading = false;
|
||||
@tracked needsApproval = false;
|
||||
@tracked errorMessage = null;
|
||||
@tracked
|
||||
errorMessage = this.currentUser
|
||||
? i18n("user.activate_account.already_done")
|
||||
: null;
|
||||
|
||||
get signupStep() {
|
||||
if (this.needsApproval) {
|
||||
|
@@ -166,9 +166,7 @@ registerButton(
|
||||
return likeCount(attrs);
|
||||
}
|
||||
|
||||
const className = attrs.liked
|
||||
? "toggle-like has-like fade-out"
|
||||
: "toggle-like like";
|
||||
const className = attrs.liked ? "toggle-like has-like" : "toggle-like like";
|
||||
|
||||
const button = {
|
||||
action: "like",
|
||||
|
@@ -34,7 +34,21 @@
|
||||
"immer": "^10.1.1",
|
||||
"jspreadsheet-ce": "^4.15.0",
|
||||
"morphlex": "^0.0.16",
|
||||
"pretty-text": "workspace:1.0.0"
|
||||
"orderedmap": "^2.1.1",
|
||||
"pretty-text": "workspace:1.0.0",
|
||||
"prosemirror-commands": "^1.6.0",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-highlightjs": "^0.9.1",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.1",
|
||||
"prosemirror-model": "^1.23.0",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-transform": "^1.10.2",
|
||||
"prosemirror-view": "^1.34.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.7",
|
||||
@@ -98,7 +112,7 @@
|
||||
"ember-modifier": "^4.2.0",
|
||||
"ember-qunit": "^9.0.1",
|
||||
"ember-source": "~5.12.0",
|
||||
"ember-template-imports": "^4.2.0",
|
||||
"ember-template-imports": "^4.3.0",
|
||||
"ember-test-selectors": "^7.0.0",
|
||||
"float-kit": "workspace:1.0.0",
|
||||
"html-entities": "^2.5.2",
|
||||
@@ -130,7 +144,7 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
},
|
||||
"ember": {
|
||||
"edition": "octane"
|
||||
|
@@ -12,6 +12,7 @@ acceptance("Admin - Plugins", function (needs) {
|
||||
{
|
||||
id: "some-test-plugin",
|
||||
name: "some-test-plugin",
|
||||
humanized_name: "Some test plugin",
|
||||
about: "Plugin description",
|
||||
version: "0.1",
|
||||
url: "https://example.com",
|
||||
@@ -44,7 +45,7 @@ acceptance("Admin - Plugins", function (needs) {
|
||||
.dom(
|
||||
"table.admin-plugins-list .admin-plugins-list__row .admin-plugins-list__name-details .admin-plugins-list__name-with-badges .admin-plugins-list__name"
|
||||
)
|
||||
.hasText("Some Test Plugin", "displays the plugin in the table");
|
||||
.hasText("Some test plugin", "displays the plugin in the table");
|
||||
|
||||
assert
|
||||
.dom(".admin-plugins .admin-config-page .alert-error")
|
||||
|
@@ -214,7 +214,7 @@ acceptance("Composer", function (needs) {
|
||||
"supports keyboard shortcuts"
|
||||
);
|
||||
|
||||
await click("#reply-control a.cancel");
|
||||
await click("#reply-control button.cancel");
|
||||
assert.dom(".d-modal").exists("pops up a confirmation dialog");
|
||||
|
||||
await click(".d-modal__footer .discard-draft");
|
||||
@@ -776,7 +776,7 @@ acceptance("Composer", function (needs) {
|
||||
await visit("/t/topic-with-whisper/960");
|
||||
|
||||
await click(".topic-post:nth-of-type(3) button.reply");
|
||||
await click("#reply-control .save-or-cancel a.cancel");
|
||||
await click("#reply-control .save-or-cancel button.cancel");
|
||||
await click(".topic-footer-main-buttons button.create");
|
||||
await click(".reply-details summary div");
|
||||
assert
|
||||
|
@@ -481,13 +481,50 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) {
|
||||
.doesNotExist();
|
||||
});
|
||||
|
||||
test("clicking on my drafts link", async function (assert) {
|
||||
updateCurrentUser({ draft_count: 1 });
|
||||
test("clicking on my posts link", async function (assert) {
|
||||
await visit("/t/280");
|
||||
await click(
|
||||
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts']"
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/u/${loggedInUser().username}/activity`,
|
||||
"should transition to the user's activity url"
|
||||
);
|
||||
|
||||
assert
|
||||
.dom(
|
||||
".sidebar-section[data-section-name='community'] .sidebar-section-link.active"
|
||||
)
|
||||
.exists({ count: 1 }, "only one link is marked as active");
|
||||
|
||||
assert
|
||||
.dom(
|
||||
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts'].active"
|
||||
)
|
||||
.exists("the my posts link is marked as active");
|
||||
|
||||
await visit(`/u/${loggedInUser().username}/activity/drafts`);
|
||||
|
||||
assert
|
||||
.dom(
|
||||
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts'].active"
|
||||
)
|
||||
.doesNotExist(
|
||||
"the my posts link is not marked as active when user has no drafts and visiting the user activity drafts URL"
|
||||
);
|
||||
});
|
||||
|
||||
test("clicking on my posts link when user has a draft", async function (assert) {
|
||||
await visit("/t/280");
|
||||
|
||||
await publishToMessageBus(`/user-drafts/${loggedInUser().id}`, {
|
||||
draft_count: 1,
|
||||
});
|
||||
|
||||
await click(
|
||||
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-drafts']"
|
||||
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts']"
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
@@ -504,24 +541,45 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) {
|
||||
|
||||
assert
|
||||
.dom(
|
||||
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-drafts'].active"
|
||||
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts'].active"
|
||||
)
|
||||
.exists("the my drafts link is marked as active");
|
||||
.exists("the my posts link is marked as active");
|
||||
|
||||
await visit(`/u/${loggedInUser().username}/activity`);
|
||||
|
||||
assert
|
||||
.dom(
|
||||
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts'].active"
|
||||
)
|
||||
.exists("the my posts link is marked as active");
|
||||
});
|
||||
|
||||
test("my drafts link is visible when user has drafts", async function (assert) {
|
||||
updateCurrentUser({ draft_count: 1 });
|
||||
|
||||
test("my posts title changes when drafts are present", async function (assert) {
|
||||
await visit("/");
|
||||
|
||||
assert
|
||||
.dom(".sidebar-section-link[data-link-name='my-drafts']")
|
||||
.exists("my drafts link is displayed when drafts are present");
|
||||
.dom(".sidebar-section-link[data-link-name='my-posts']")
|
||||
.hasAttribute(
|
||||
"title",
|
||||
i18n("sidebar.sections.community.links.my_posts.title"),
|
||||
"displays the default title when no drafts are present"
|
||||
);
|
||||
|
||||
await publishToMessageBus(`/user-drafts/${loggedInUser().id}`, {
|
||||
draft_count: 1,
|
||||
});
|
||||
|
||||
assert
|
||||
.dom(".sidebar-section-link[data-link-name='my-posts']")
|
||||
.hasAttribute(
|
||||
"title",
|
||||
i18n("sidebar.sections.community.links.my_posts.title_drafts"),
|
||||
"displays the draft title when drafts are present"
|
||||
);
|
||||
});
|
||||
|
||||
test("my posts changes its text when drafts are present and new new view experiment is enabled", async function (assert) {
|
||||
updateCurrentUser({
|
||||
draft_count: 1,
|
||||
user_option: {
|
||||
sidebar_show_count_of_new_items: true,
|
||||
},
|
||||
@@ -529,17 +587,28 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) {
|
||||
});
|
||||
await visit("/");
|
||||
|
||||
assert
|
||||
.dom(".sidebar-section-link[data-link-name='my-posts']")
|
||||
.hasText(
|
||||
i18n("sidebar.sections.community.links.my_posts.content"),
|
||||
"displays the default text when no drafts are present"
|
||||
);
|
||||
|
||||
await publishToMessageBus(`/user-drafts/${loggedInUser().id}`, {
|
||||
draft_count: 1,
|
||||
});
|
||||
|
||||
assert
|
||||
.dom(
|
||||
".sidebar-section-link[data-link-name='my-drafts'] .sidebar-section-link-content-text"
|
||||
".sidebar-section-link[data-link-name='my-posts'] .sidebar-section-link-content-text"
|
||||
)
|
||||
.hasText(
|
||||
i18n("sidebar.sections.community.links.my_drafts.content"),
|
||||
i18n("sidebar.sections.community.links.my_posts.content_drafts"),
|
||||
"displays the text that's appropriate for when drafts are present"
|
||||
);
|
||||
assert
|
||||
.dom(
|
||||
".sidebar-section-link[data-link-name='my-drafts'] .sidebar-section-link-content-badge"
|
||||
".sidebar-section-link[data-link-name='my-posts'] .sidebar-section-link-content-badge"
|
||||
)
|
||||
.hasText("1", "displays the draft count with no text");
|
||||
});
|
||||
|
@@ -47,7 +47,7 @@ acceptance("User Drafts", function (needs) {
|
||||
);
|
||||
|
||||
assert
|
||||
.dom(".user-stream-item:nth-child(2) a.avatar-link")
|
||||
.hasAttribute("href", "/u/eviltrout", "has correct avatar link");
|
||||
.dom(".user-stream-item:nth-child(2) .draft-icon .d-icon")
|
||||
.hasClass("d-icon-reply", "has correct icon");
|
||||
});
|
||||
});
|
||||
|
@@ -47,4 +47,30 @@ acceptance("Video Placeholder Test", function () {
|
||||
.hasStyle({ display: "block" }, "The video is no longer hidden");
|
||||
assert.dom(".video-placeholder-wrapper").doesNotExist();
|
||||
});
|
||||
|
||||
test("displays an error for invalid video URL and allows retry", async function (assert) {
|
||||
await visit("/t/54081");
|
||||
|
||||
const placeholder = document.querySelector(".video-placeholder-container");
|
||||
placeholder.setAttribute(
|
||||
"data-video-src",
|
||||
'http://example.com/video.mp4"><script>alert(1)</script>'
|
||||
);
|
||||
|
||||
await click(".video-placeholder-overlay");
|
||||
|
||||
assert
|
||||
.dom(".video-placeholder-wrapper .notice.error")
|
||||
.exists("An error message is displayed for an invalid URL");
|
||||
assert
|
||||
.dom(".video-placeholder-wrapper .notice.error")
|
||||
.hasText(
|
||||
"This video cannot be played because the URL is invalid or unavailable.",
|
||||
"Error message is correct"
|
||||
);
|
||||
|
||||
assert
|
||||
.dom("video")
|
||||
.doesNotExist("No video element is created for invalid URL");
|
||||
});
|
||||
});
|
||||
|
@@ -0,0 +1,64 @@
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { click, render, settled, waitFor } from "@ember/test-helpers";
|
||||
import DEditor from "discourse/components/d-editor";
|
||||
|
||||
export async function testMarkdown(
|
||||
assert,
|
||||
markdown,
|
||||
expectedHtml,
|
||||
expectedMarkdown
|
||||
) {
|
||||
const self = new (class {
|
||||
@tracked value = markdown;
|
||||
@tracked view;
|
||||
})();
|
||||
const handleSetup = (textManipulation) => {
|
||||
self.view = textManipulation.view;
|
||||
};
|
||||
|
||||
await render(<template>
|
||||
<DEditor
|
||||
@value={{self.value}}
|
||||
@processPreview={{false}}
|
||||
@onSetup={{handleSetup}}
|
||||
/>
|
||||
</template>);
|
||||
await click(".composer-toggle-switch");
|
||||
|
||||
await waitFor(".ProseMirror");
|
||||
await settled();
|
||||
const editor = document.querySelector(".ProseMirror");
|
||||
|
||||
// typeIn for contentEditable isn't reliable, and is slower
|
||||
const tr = self.view.state.tr;
|
||||
// insert a paragraph to enforce serialization
|
||||
tr.insert(
|
||||
tr.doc.content.size,
|
||||
self.view.state.schema.node(
|
||||
"paragraph",
|
||||
null,
|
||||
self.view.state.schema.text("X")
|
||||
)
|
||||
);
|
||||
// then delete it
|
||||
tr.delete(tr.doc.content.size - 3, tr.doc.content.size);
|
||||
|
||||
self.view.dispatch(tr);
|
||||
|
||||
await settled();
|
||||
|
||||
const html = editor.innerHTML
|
||||
// we don't care about some PM-specifics
|
||||
.replace(' class="ProseMirror-selectednode"', "")
|
||||
.replace('<img class="ProseMirror-separator" alt="">', "")
|
||||
.replace('<br class="ProseMirror-trailingBreak">', "")
|
||||
// or artifacts
|
||||
.replace('class=""', "");
|
||||
|
||||
assert.strictEqual(html, expectedHtml, `HTML should match for "${markdown}"`);
|
||||
assert.strictEqual(
|
||||
self.value,
|
||||
expectedMarkdown,
|
||||
`Markdown should match for "${markdown}"`
|
||||
);
|
||||
}
|
@@ -44,5 +44,31 @@ module(
|
||||
|
||||
assert.dom(".form-kit__control-checkbox").hasAttribute("disabled");
|
||||
});
|
||||
|
||||
test("@tooltip", async function (assert) {
|
||||
await render(<template>
|
||||
<Form as |form|>
|
||||
<form.Field @tooltip="test" @name="foo" @title="Foo" as |field|>
|
||||
<field.Checkbox />
|
||||
</form.Field>
|
||||
</Form>
|
||||
</template>);
|
||||
|
||||
assert
|
||||
.dom(".form-kit__control-checkbox-content .form-kit__tooltip")
|
||||
.exists();
|
||||
});
|
||||
|
||||
test("optional", async function (assert) {
|
||||
await render(<template>
|
||||
<Form as |form|>
|
||||
<form.Field @name="foo" @title="Foo" as |field|>
|
||||
<field.Checkbox />
|
||||
</form.Field>
|
||||
</Form>
|
||||
</template>);
|
||||
|
||||
assert.form().field("foo").hasTitle("Foo (optional)");
|
||||
});
|
||||
}
|
||||
);
|
||||
|
@@ -44,5 +44,41 @@ module(
|
||||
|
||||
assert.dom(".d-editor-input").hasAttribute("disabled");
|
||||
});
|
||||
|
||||
test("@height", async function (assert) {
|
||||
await render(<template>
|
||||
<Form as |form|>
|
||||
<form.Field @name="foo" @title="Foo" as |field|>
|
||||
<field.Composer @height={{42}} />
|
||||
</form.Field>
|
||||
</Form>
|
||||
</template>);
|
||||
|
||||
assert
|
||||
.dom(".form-kit__control-composer")
|
||||
.hasAttribute("style", "height: 42px");
|
||||
});
|
||||
|
||||
test("@preview", async function (assert) {
|
||||
await render(<template>
|
||||
<Form as |form|>
|
||||
<form.Field @name="foo" @title="Foo" as |field|>
|
||||
<field.Composer />
|
||||
</form.Field>
|
||||
</Form>
|
||||
</template>);
|
||||
|
||||
assert.dom(".form-kit__control-composer.--preview").doesNotExist();
|
||||
|
||||
await render(<template>
|
||||
<Form as |form|>
|
||||
<form.Field @name="foo" @title="Foo" as |field|>
|
||||
<field.Composer @preview={{true}} />
|
||||
</form.Field>
|
||||
</Form>
|
||||
</template>);
|
||||
|
||||
assert.dom(".form-kit__control-composer.--preview").exists();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
@@ -124,6 +124,22 @@ module(
|
||||
NO_VALUE_OPTION,
|
||||
"it has the none when no value is present and field is not required"
|
||||
);
|
||||
|
||||
await render(<template>
|
||||
<Form @data={{hash foo="1"}} as |form|>
|
||||
<form.Field @name="foo" @title="Foo" as |field|>
|
||||
<field.Select @includeNone={{false}} />
|
||||
</form.Field>
|
||||
</Form>
|
||||
</template>);
|
||||
|
||||
assert
|
||||
.form()
|
||||
.field("foo")
|
||||
.hasNoValue(
|
||||
NO_VALUE_OPTION,
|
||||
"it doesn’t have the none for an optional field when value is present and includeNone is false"
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
@@ -83,8 +83,8 @@ module("Integration | Component | FormKit | Field", function (hooks) {
|
||||
|
||||
await render(<template>
|
||||
<Form as |form|>
|
||||
<form.Field @name="foo.bar" @title="Foo" @size={{8}}>
|
||||
Test
|
||||
<form.Field @name="foo.bar" @title="Foo" @size={{8}} as |field|>
|
||||
<field.Input />
|
||||
</form.Field>
|
||||
</Form>
|
||||
</template>);
|
||||
@@ -102,8 +102,8 @@ module("Integration | Component | FormKit | Field", function (hooks) {
|
||||
|
||||
await render(<template>
|
||||
<Form as |form|>
|
||||
<form.Field @name="foo" @size={{8}}>
|
||||
Test
|
||||
<form.Field @name="foo" @size={{8}} as |field|>
|
||||
<field.Input />
|
||||
</form.Field>
|
||||
</Form>
|
||||
</template>);
|
||||
|
@@ -22,9 +22,41 @@ module(
|
||||
</Form>
|
||||
</template>);
|
||||
|
||||
assert.form().field("foo").hasTitle("Foo");
|
||||
assert.form().field("bar").hasTitle("Bar");
|
||||
assert.form().field("foo").hasTitle("Foo (optional)");
|
||||
assert.form().field("bar").hasTitle("Bar (optional)");
|
||||
assert.form().field("bar").hasDescription("A description");
|
||||
});
|
||||
|
||||
test("@title", async function (assert) {
|
||||
await render(<template>
|
||||
<Form as |form|>
|
||||
<form.CheckboxGroup @title="bar" as |checkboxGroup|>
|
||||
<checkboxGroup.Field @name="foo" @title="Foo" as |field|>
|
||||
<field.Checkbox />
|
||||
</checkboxGroup.Field>
|
||||
</form.CheckboxGroup>
|
||||
</Form>
|
||||
</template>);
|
||||
|
||||
assert
|
||||
.dom(".form-kit__checkbox-group .form-kit__fieldset-title")
|
||||
.hasText("bar");
|
||||
});
|
||||
|
||||
test("@description", async function (assert) {
|
||||
await render(<template>
|
||||
<Form as |form|>
|
||||
<form.CheckboxGroup @description="bar" as |checkboxGroup|>
|
||||
<checkboxGroup.Field @name="foo" @title="Foo" as |field|>
|
||||
<field.Checkbox />
|
||||
</checkboxGroup.Field>
|
||||
</form.CheckboxGroup>
|
||||
</Form>
|
||||
</template>);
|
||||
|
||||
assert
|
||||
.dom(".form-kit__checkbox-group .form-kit__fieldset-description")
|
||||
.hasText("bar");
|
||||
});
|
||||
}
|
||||
);
|
||||
|
@@ -0,0 +1,203 @@
|
||||
import { render } from "@ember/test-helpers";
|
||||
import { module, test } from "qunit";
|
||||
import { resetRichEditorExtensions } from "discourse/lib/composer/rich-editor-extensions";
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import ProsemirrorEditor from "discourse/static/prosemirror/components/prosemirror-editor";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import { testMarkdown } from "discourse/tests/helpers/rich-editor-helper";
|
||||
|
||||
module("Integration | Component | prosemirror-editor", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.afterEach(function () {
|
||||
resetRichEditorExtensions();
|
||||
});
|
||||
|
||||
test("renders the editor", async function (assert) {
|
||||
await render(<template><ProsemirrorEditor /></template>);
|
||||
assert.dom(".ProseMirror").exists("it renders the ProseMirror editor");
|
||||
});
|
||||
|
||||
test("renders the editor with minimum extensions", async function (assert) {
|
||||
const minimumExtensions = [
|
||||
{ nodeSpec: { doc: { content: "inline*" }, text: { group: "inline" } } },
|
||||
];
|
||||
|
||||
await render(<template>
|
||||
<ProsemirrorEditor
|
||||
@includeDefault={{false}}
|
||||
@extensions={{minimumExtensions}}
|
||||
/>
|
||||
</template>);
|
||||
|
||||
assert.dom(".ProseMirror").exists("it renders the ProseMirror editor");
|
||||
});
|
||||
|
||||
test("supports registered nodeSpec/parser/serializer", async function (assert) {
|
||||
this.siteSettings.rich_editor = true;
|
||||
|
||||
withPluginApi("2.1.0", (api) => {
|
||||
// Multiple parsers can be registered for the same node type
|
||||
api.registerRichEditorExtension({
|
||||
parse: { wrap_open() {}, wrap_close() {} },
|
||||
});
|
||||
|
||||
api.registerRichEditorExtension({
|
||||
nodeSpec: {
|
||||
marquee: {
|
||||
content: "block*",
|
||||
group: "block",
|
||||
parseDOM: [{ tag: "marquee" }],
|
||||
toDOM: () => ["marquee", 0],
|
||||
},
|
||||
},
|
||||
parse: {
|
||||
wrap_open(state, token) {
|
||||
if (token.attrGet("data-wrap") === "marquee") {
|
||||
state.openNode(state.schema.nodes.marquee);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
wrap_close(state) {
|
||||
if (state.top().type.name === "marquee") {
|
||||
state.closeNode();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
serializeNode: {
|
||||
marquee(state, node) {
|
||||
state.write("[wrap=marquee]\n");
|
||||
state.renderContent(node);
|
||||
state.write("[/wrap]\n\n");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
api.registerRichEditorExtension({
|
||||
parse: { wrap_open() {}, wrap_close() {} },
|
||||
});
|
||||
});
|
||||
|
||||
await testMarkdown(
|
||||
assert,
|
||||
"[wrap=marquee]\nHello\n[wrap=marquee]\nWorld\n[/wrap]\nInner\n[/wrap]\n\nText",
|
||||
"<marquee><p>Hello</p><marquee><p>World</p></marquee><p>Inner</p></marquee><p>Text</p>",
|
||||
"[wrap=marquee]\nHello\n\n[wrap=marquee]\nWorld\n\n[/wrap]\n\nInner\n\n[/wrap]\n\nText"
|
||||
);
|
||||
});
|
||||
|
||||
test("supports registered markSpec/parser/serializer", async function (assert) {
|
||||
this.siteSettings.rich_editor = true;
|
||||
|
||||
withPluginApi("2.1.0", (api) => {
|
||||
api.registerRichEditorExtension({
|
||||
// just for testing purpose - our actual hashtag is a node, not a mark
|
||||
markSpec: {
|
||||
hashtag: {
|
||||
parseDOM: [{ tag: "span.hashtag-raw" }],
|
||||
toDOM: () => ["span", { class: "hashtag-raw" }],
|
||||
},
|
||||
},
|
||||
parse: {
|
||||
span_open(state, token, tokens, i) {
|
||||
if (token.attrGet("class") === "hashtag-raw") {
|
||||
// Remove the # from the content
|
||||
tokens[i + 1].content = tokens[i + 1].content.slice(1);
|
||||
state.openMark(state.schema.marks.hashtag.create());
|
||||
return true;
|
||||
}
|
||||
},
|
||||
span_close(state) {
|
||||
state.closeMark(state.schema.marks.hashtag);
|
||||
},
|
||||
},
|
||||
serializeMark: { hashtag: { open: "#", close: "" } },
|
||||
});
|
||||
});
|
||||
|
||||
await testMarkdown(
|
||||
assert,
|
||||
"Hello #tag #test",
|
||||
'<p>Hello <span class="hashtag-raw">tag</span> <span class="hashtag-raw">test</span></p>',
|
||||
"Hello #tag #test"
|
||||
);
|
||||
});
|
||||
|
||||
test("supports registered nodeViews", async function (assert) {
|
||||
this.siteSettings.rich_editor = true;
|
||||
|
||||
const state = {};
|
||||
|
||||
withPluginApi("2.1.0", (api) => {
|
||||
api.registerRichEditorExtension({
|
||||
nodeViews: {
|
||||
paragraph: class CustomNodeView {
|
||||
constructor() {
|
||||
this.dom = document.createElement("p");
|
||||
this.dom.className = "custom-p";
|
||||
|
||||
state.updated = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await render(<template><ProsemirrorEditor /></template>);
|
||||
|
||||
assert.true(
|
||||
state.updated,
|
||||
"it calls the update method of the custom node view"
|
||||
);
|
||||
|
||||
assert.dom(".custom-p").exists("it renders the custom node view for p");
|
||||
});
|
||||
|
||||
test("supports registered plugins with array, object or function", async function (assert) {
|
||||
this.siteSettings.rich_editor = true;
|
||||
|
||||
const state = {};
|
||||
|
||||
withPluginApi("2.1.0", (api) => {
|
||||
// plugins can be an array
|
||||
api.registerRichEditorExtension({
|
||||
plugins: [
|
||||
{
|
||||
view() {
|
||||
state.plugin1 = true;
|
||||
return {};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// or the plugin object itself
|
||||
api.registerRichEditorExtension({
|
||||
plugins: {
|
||||
view() {
|
||||
state.plugin2 = true;
|
||||
return {};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// or a function that returns the plugin object
|
||||
api.registerRichEditorExtension({
|
||||
plugins: ({ pmState: { Plugin } }) =>
|
||||
new Plugin({
|
||||
view() {
|
||||
state.plugin3 = true;
|
||||
return {};
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await render(<template><ProsemirrorEditor /></template>);
|
||||
|
||||
assert.true(state.plugin1, "plugin1's view fn was called");
|
||||
assert.true(state.plugin2, "plugin2's view fn was called");
|
||||
assert.true(state.plugin3, "plugin3's view fn was called");
|
||||
});
|
||||
});
|
@@ -0,0 +1,152 @@
|
||||
import { module, test } from "qunit";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import { testMarkdown } from "discourse/tests/helpers/rich-editor-helper";
|
||||
|
||||
module(
|
||||
"Integration | Component | prosemirror-editor - prosemirror-markdown defaults",
|
||||
function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
const testCases = {
|
||||
"paragraphs and hard breaks": [
|
||||
["Hello", "<p>Hello</p>", "Hello"],
|
||||
["Hello\nWorld", "<p>Hello<br>World</p>", "Hello\nWorld"],
|
||||
["Hello\n\nWorld", "<p>Hello</p><p>World</p>", "Hello\n\nWorld"],
|
||||
],
|
||||
blockquotes: [
|
||||
["> Hello", "<blockquote><p>Hello</p></blockquote>", "> Hello"],
|
||||
[
|
||||
"> Hello\n> World",
|
||||
"<blockquote><p>Hello<br>World</p></blockquote>",
|
||||
"> Hello\n> World",
|
||||
],
|
||||
[
|
||||
"> Hello\n\n> World",
|
||||
"<blockquote><p>Hello</p></blockquote><blockquote><p>World</p></blockquote>",
|
||||
"> Hello\n\n> World",
|
||||
],
|
||||
],
|
||||
"horizontal rule": [
|
||||
[
|
||||
"Hey\n\n---",
|
||||
'<p>Hey</p><div contenteditable="false" draggable="true"><hr></div>',
|
||||
"Hey\n\n---",
|
||||
],
|
||||
[
|
||||
"***",
|
||||
'<div contenteditable="false" draggable="true"><hr></div>',
|
||||
"---",
|
||||
],
|
||||
],
|
||||
"heading (level 1-6)": [
|
||||
["# Hello", "<h1>Hello</h1>", "# Hello"],
|
||||
["# Hello\nWorld", "<h1>Hello</h1><p>World</p>", "# Hello\n\nWorld"],
|
||||
["## Hello", "<h2>Hello</h2>", "## Hello"],
|
||||
["### Hello", "<h3>Hello</h3>", "### Hello"],
|
||||
["#### Hello", "<h4>Hello</h4>", "#### Hello"],
|
||||
["##### Hello", "<h5>Hello</h5>", "##### Hello"],
|
||||
["###### Hello", "<h6>Hello</h6>", "###### Hello"],
|
||||
],
|
||||
"code block": [
|
||||
["```\nHello\n```", "<pre><code>Hello</code></pre>", "```\nHello\n```"],
|
||||
[
|
||||
"```\nHello\nWorld\n```",
|
||||
"<pre><code>Hello\nWorld</code></pre>",
|
||||
"```\nHello\nWorld\n```",
|
||||
],
|
||||
[
|
||||
"```\nHello\n\nWorld\n```",
|
||||
"<pre><code>Hello\n\nWorld</code></pre>",
|
||||
"```\nHello\n\nWorld\n```",
|
||||
],
|
||||
[
|
||||
"```ruby\nHello\n```\n\nWorld",
|
||||
'<pre data-params="ruby"><code>Hello</code></pre><p>World</p>',
|
||||
"```ruby\nHello\n```\n\nWorld",
|
||||
],
|
||||
],
|
||||
"ordered lists": [
|
||||
[
|
||||
"1. Hello",
|
||||
`<ol data-tight="true"><li><p>Hello</p></li></ol>`,
|
||||
"1. Hello",
|
||||
],
|
||||
[
|
||||
"1. Hello\n2. World",
|
||||
`<ol data-tight="true"><li><p>Hello</p></li><li><p>World</p></li></ol>`,
|
||||
"1. Hello\n2. World",
|
||||
],
|
||||
[
|
||||
"5. Hello\n\n6. World",
|
||||
`<ol start="5"><li><p>Hello</p></li><li><p>World</p></li></ol>`,
|
||||
"5. Hello\n\n6. World",
|
||||
],
|
||||
],
|
||||
"bullet lists": [
|
||||
[
|
||||
"* Hello",
|
||||
'<ul data-tight="true"><li><p>Hello</p></li></ul>',
|
||||
"* Hello",
|
||||
],
|
||||
[
|
||||
"* Hello\n* World",
|
||||
'<ul data-tight="true"><li><p>Hello</p></li><li><p>World</p></li></ul>',
|
||||
"* Hello\n* World",
|
||||
],
|
||||
[
|
||||
"* Hello\n\n* World",
|
||||
"<ul><li><p>Hello</p></li><li><p>World</p></li></ul>",
|
||||
"* Hello\n\n* World",
|
||||
],
|
||||
],
|
||||
images: [
|
||||
[
|
||||
"",
|
||||
'<p><img src="src" alt="alt" contenteditable="false" draggable="true"></p>',
|
||||
"",
|
||||
],
|
||||
[
|
||||
'',
|
||||
'<p><img src="src" alt="alt" title="title" contenteditable="false" draggable="true"></p>',
|
||||
'',
|
||||
],
|
||||
],
|
||||
em: [
|
||||
["*Hello*", "<p><em>Hello</em></p>", "*Hello*"],
|
||||
["_Hello_", "<p><em>Hello</em></p>", "*Hello*"],
|
||||
],
|
||||
strong: [
|
||||
["**Hello**", "<p><strong>Hello</strong></p>", "**Hello**"],
|
||||
["__Hello__", "<p><strong>Hello</strong></p>", "**Hello**"],
|
||||
],
|
||||
link: [
|
||||
["[text](href)", '<p><a href="href">text</a></p>', "[text](href)"],
|
||||
[
|
||||
'[text](href "title")',
|
||||
'<p><a href="href" title="title">text</a></p>',
|
||||
'[text](href "title")',
|
||||
],
|
||||
],
|
||||
code: [
|
||||
["Hel`lo wo`rld", "<p>Hel<code>lo wo</code>rld</p>", "Hel`lo wo`rld"],
|
||||
],
|
||||
"all marks": [
|
||||
[
|
||||
"___[`Hello`](https://example.com)___",
|
||||
'<p><em><strong><a href="https://example.com"><code>Hello</code></a></strong></em></p>',
|
||||
"***[`Hello`](https://example.com)***",
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
Object.entries(testCases).forEach(([name, tests]) => {
|
||||
tests.forEach(([markdown, expectedHtml, expectedMarkdown]) => {
|
||||
test(name, async function (assert) {
|
||||
this.siteSettings.rich_editor = true;
|
||||
|
||||
await testMarkdown(assert, markdown, expectedHtml, expectedMarkdown);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
@@ -0,0 +1,56 @@
|
||||
import { settled } from "@ember/test-helpers";
|
||||
import { setupTest } from "ember-qunit";
|
||||
import { applyInlineOneboxes } from "pretty-text/inline-oneboxer";
|
||||
import { module, test } from "qunit";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import pretender, { response } from "discourse/tests/helpers/create-pretender";
|
||||
|
||||
module("Unit | Pretty Text | Inline Oneboxer", function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
let links;
|
||||
hooks.beforeEach(function () {
|
||||
links = {};
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const url = `http://example.com/url-${i}`;
|
||||
links[url] = document.createElement("DIV");
|
||||
}
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
links = {};
|
||||
});
|
||||
|
||||
test("batches requests when oneboxing more than 10 urls", async function (assert) {
|
||||
const requestedUrls = [];
|
||||
let requestCount = 0;
|
||||
|
||||
pretender.get("/inline-onebox", async (request) => {
|
||||
requestCount++;
|
||||
requestedUrls.push(...request.queryParams.urls);
|
||||
return response(200, { "inline-oneboxes": [] });
|
||||
});
|
||||
|
||||
applyInlineOneboxes(links, ajax);
|
||||
await settled();
|
||||
|
||||
assert.strictEqual(
|
||||
requestCount,
|
||||
2,
|
||||
"it splits the 11 urls into 2 requests"
|
||||
);
|
||||
assert.deepEqual(requestedUrls, [
|
||||
"http://example.com/url-0",
|
||||
"http://example.com/url-1",
|
||||
"http://example.com/url-2",
|
||||
"http://example.com/url-3",
|
||||
"http://example.com/url-4",
|
||||
"http://example.com/url-5",
|
||||
"http://example.com/url-6",
|
||||
"http://example.com/url-7",
|
||||
"http://example.com/url-8",
|
||||
"http://example.com/url-9",
|
||||
"http://example.com/url-10",
|
||||
]);
|
||||
});
|
||||
});
|
@@ -547,26 +547,6 @@ module("Unit | Utility | transformers", function (hooks) {
|
||||
applyMutableValueTransformer("test-mutable-transformer", value);
|
||||
assert.true(mutated, "the value is mutated");
|
||||
});
|
||||
|
||||
test("raises an exception if the transformer returns a value different from undefined", function (assert) {
|
||||
assert.throws(
|
||||
() => {
|
||||
withPluginApi("1.34.0", (api) => {
|
||||
api.registerValueTransformer(
|
||||
"test-mutable-transformer",
|
||||
() => "unexpected value"
|
||||
);
|
||||
});
|
||||
|
||||
applyMutableValueTransformer(
|
||||
"test-mutable-transformer",
|
||||
"default value"
|
||||
);
|
||||
},
|
||||
/expects the value to be mutated instead of returned. Remove the return value in your transformer./,
|
||||
"logs warning to the console when the transformer returns a value different from undefined"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module("pluginApi.addBehaviorTransformerName", function (innerHooks) {
|
||||
|
@@ -1,19 +0,0 @@
|
||||
import { setupTest } from "ember-qunit";
|
||||
import { module, test } from "qunit";
|
||||
import AdminPlugin from "admin/models/admin-plugin";
|
||||
|
||||
module("Unit | Model | admin plugin", function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test("nameTitleized", function (assert) {
|
||||
const adminPlugin = AdminPlugin.create({
|
||||
name: "docker_manager",
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
adminPlugin.nameTitleized,
|
||||
"Docker Manager",
|
||||
"it should return titleized name replacing underscores with spaces"
|
||||
);
|
||||
});
|
||||
});
|
@@ -18,7 +18,7 @@
|
||||
"ember-auto-import": "^2.10.0",
|
||||
"ember-cli-babel": "^8.2.0",
|
||||
"ember-cli-htmlbars": "^6.3.0",
|
||||
"ember-template-imports": "^4.2.0",
|
||||
"ember-template-imports": "^4.3.0",
|
||||
"truth-helpers": "workspace:1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -45,7 +45,7 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
},
|
||||
"ember": {
|
||||
"edition": "default"
|
||||
|
@@ -1,6 +1,6 @@
|
||||
const _cache = {};
|
||||
|
||||
export function applyInlineOneboxes(inline, ajax, opts) {
|
||||
export async function applyInlineOneboxes(inline, ajax, opts) {
|
||||
opts = opts || {};
|
||||
|
||||
const urls = Object.keys(inline).filter((url) => !_cache[url]);
|
||||
@@ -14,26 +14,35 @@ export function applyInlineOneboxes(inline, ajax, opts) {
|
||||
return;
|
||||
}
|
||||
|
||||
ajax("/inline-onebox", {
|
||||
data: {
|
||||
urls,
|
||||
category_id: opts.categoryId,
|
||||
topic_id: opts.topicId,
|
||||
},
|
||||
}).then((result) => {
|
||||
result["inline-oneboxes"].forEach((onebox) => {
|
||||
if (onebox.title) {
|
||||
_cache[onebox.url] = onebox;
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < urls.length; i += batchSize) {
|
||||
const batch = urls.slice(i, i + batchSize);
|
||||
|
||||
let links = inline[onebox.url] || [];
|
||||
links.forEach((link) => {
|
||||
link.innerText = onebox.title;
|
||||
link.classList.add("inline-onebox");
|
||||
link.classList.remove("inline-onebox-loading");
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
try {
|
||||
const result = await ajax("/inline-onebox", {
|
||||
data: {
|
||||
urls: batch,
|
||||
category_id: opts.categoryId,
|
||||
topic_id: opts.topicId,
|
||||
},
|
||||
});
|
||||
result["inline-oneboxes"].forEach((onebox) => {
|
||||
if (onebox.title) {
|
||||
_cache[onebox.url] = onebox;
|
||||
|
||||
let links = inline[onebox.url] || [];
|
||||
links.forEach((link) => {
|
||||
link.innerText = onebox.title;
|
||||
link.classList.add("inline-onebox");
|
||||
link.classList.remove("inline-onebox-loading");
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Inline onebox request failed", err, batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cachedInlineOnebox(url) {
|
||||
|
@@ -45,7 +45,7 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
},
|
||||
"ember": {
|
||||
"edition": "default"
|
||||
|
@@ -20,7 +20,7 @@
|
||||
"ember-auto-import": "^2.10.0",
|
||||
"ember-cli-babel": "^8.2.0",
|
||||
"ember-cli-htmlbars": "^6.3.0",
|
||||
"ember-template-imports": "^4.2.0"
|
||||
"ember-template-imports": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ember/optional-features": "^2.2.0",
|
||||
@@ -46,7 +46,7 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
},
|
||||
"ember": {
|
||||
"edition": "default"
|
||||
|
@@ -10,7 +10,7 @@
|
||||
"@babel/standalone": "^7.26.7",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"babel-plugin-ember-template-compilation": "^2.3.0",
|
||||
"content-tag": "^3.1.0",
|
||||
"content-tag": "^3.1.1",
|
||||
"decorator-transforms": "^2.3.0",
|
||||
"discourse": "workspace:0.0.0",
|
||||
"discourse-widget-hbs": "workspace:1.0.0",
|
||||
@@ -26,6 +26,6 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
}
|
||||
}
|
||||
|
@@ -25,7 +25,7 @@
|
||||
"node": ">= 18",
|
||||
"npm": "please-use-pnpm",
|
||||
"yarn": "please-use-pnpm",
|
||||
"pnpm": ">= 9"
|
||||
"pnpm": "^9"
|
||||
},
|
||||
"ember": {
|
||||
"edition": "octane"
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user