From 3e89c981033ad68f5207bc4b2b2c406f06a54d38 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Tue, 25 Nov 2025 06:49:03 +1100 Subject: [PATCH 01/19] FEATURE: shift to yjs Makes shared edits much more reliable --- .../revision_controller.rb | 33 +- app/models/shared_edit_revision.rb | 221 +- .../discourse/services/shared-edit-manager.js | 536 +- .../common/discourse-shared-edits.scss | 2 +- ...251124000000_resize_shared_edit_columns.rb | 8 + lib/discourse_shared_edits/yjs.rb | 203 + lib/ot_text_unicode.rb | 35 - lib/tasks/yjs.rake | 17 + package.json | 5 +- plugin.rb | 2 +- pnpm-lock.yaml | 26 + public/javascripts/text-unicode-dist.js | 1 - public/javascripts/yjs-dist.js | 8774 +++++++++++++++++ spec/lib/ot_text_unicode_spec.rb | 21 - spec/lib/yjs_spec.rb | 29 + spec/models/shared_edit_revision_spec.rb | 103 +- spec/requests/revision_controller_spec.rb | 41 +- spec/system/edit_spec.rb | 14 +- support/text-unicode-webpack/package.json | 16 - support/text-unicode-webpack/src/index.js | 4 - .../text-unicode-webpack/webpack.config.js | 20 - support/text-unicode-webpack/yarn.lock | 2086 ---- test/javascripts/acceptance/composer-test.js | 9 +- 23 files changed, 9681 insertions(+), 2525 deletions(-) create mode 100644 db/migrate/20251124000000_resize_shared_edit_columns.rb create mode 100644 lib/discourse_shared_edits/yjs.rb delete mode 100644 lib/ot_text_unicode.rb create mode 100644 lib/tasks/yjs.rake delete mode 100644 public/javascripts/text-unicode-dist.js create mode 100644 public/javascripts/yjs-dist.js delete mode 100644 spec/lib/ot_text_unicode_spec.rb create mode 100644 spec/lib/yjs_spec.rb delete mode 100644 support/text-unicode-webpack/package.json delete mode 100644 support/text-unicode-webpack/src/index.js delete mode 100644 support/text-unicode-webpack/webpack.config.js delete mode 100644 support/text-unicode-webpack/yarn.lock diff --git a/app/controllers/discourse_shared_edits/revision_controller.rb b/app/controllers/discourse_shared_edits/revision_controller.rb index 288d9a3..a7def52 100644 --- a/app/controllers/discourse_shared_edits/revision_controller.rb +++ b/app/controllers/discourse_shared_edits/revision_controller.rb @@ -24,8 +24,13 @@ def latest post = Post.find(params[:post_id].to_i) guardian.ensure_can_see!(post) SharedEditRevision.commit!(post.id, apply_to_post: false) - version, raw = SharedEditRevision.latest_raw(post) - render json: { raw: raw, version: version } + latest = SharedEditRevision.where(post_id: post.id).order("version desc").first + + render json: { + raw: DiscourseSharedEdits::Yjs.text_from_state(latest.raw), + version: latest.version, + state: latest.raw, + } end def commit @@ -39,39 +44,23 @@ def commit end def revise - params.require(:revision) + params.require(:update) params.require(:client_id) - params.require(:version) - - master_version = params[:version].to_i post = Post.find(params[:post_id].to_i) guardian.ensure_can_see!(post) - version, revision = + version, update = SharedEditRevision.revise!( post_id: post.id, user_id: current_user.id, client_id: params[:client_id], - version: master_version, - revision: params[:revision], + update: params[:update], ) - revisions = - if version == master_version + 1 - [{ version: version, revision: revision, client_id: params[:client_id] }] - else - SharedEditRevision - .where(post_id: post.id) - .where("version > ?", master_version) - .order(:version) - .pluck(:revision, :version, :client_id) - .map { |r, v, c| { version: v, revision: r, client_id: c } } - end - SharedEditRevision.ensure_will_commit(post.id) - render json: { version: version, revisions: revisions } + render json: { version: version, update: update } end protected diff --git a/app/models/shared_edit_revision.rb b/app/models/shared_edit_revision.rb index 0fefff7..9891c63 100644 --- a/app/models/shared_edit_revision.rb +++ b/app/models/shared_edit_revision.rb @@ -34,175 +34,126 @@ def self.toggle_shared_edits!(post_id, enable) end def self.init!(post) - if !SharedEditRevision.where(post_id: post.id).exists? - revision_id = last_revision_id_for_post(post) - - SharedEditRevision.create!( - post: post, - client_id: "system", - user_id: Discourse.system_user.id, - version: 1, - revision: "[]", - raw: post.raw, - post_revision_id: revision_id, - ) - end + return if SharedEditRevision.where(post_id: post.id).exists? + + revision_id = last_revision_id_for_post(post) + initial_state = DiscourseSharedEdits::Yjs.state_from_text(post.raw) + + SharedEditRevision.create!( + post: post, + client_id: "system", + user_id: Discourse.system_user.id, + version: 1, + revision: "", + raw: initial_state[:state], + post_revision_id: revision_id, + ) end def self.commit!(post_id, apply_to_post: true) - version_with_raw = - SharedEditRevision - .where(post_id: post_id) - .where("raw IS NOT NULL") - .order("version desc") - .first - - return if !version_with_raw - - raw = version_with_raw.raw - - to_resolve = - SharedEditRevision - .where(post_id: post_id) - .where("version > ?", version_with_raw.version) - .order(:version) - - last_revision = version_with_raw + latest = SharedEditRevision.where(post_id: post_id).order("version desc").first - editors = [] + return if !latest&.raw + return if latest.post_revision_id && !apply_to_post - to_resolve.each do |rev| - raw = OtTextUnicode.apply(raw, rev.revision) - last_revision = rev - editors << rev.user_id - end + raw = DiscourseSharedEdits::Yjs.text_from_state(latest.raw) - last_revision.update!(raw: raw) if last_revision.raw != raw - return if last_revision.post_revision_id - return if !apply_to_post + return raw if latest.post_revision_id || !apply_to_post post = Post.find(post_id) revisor = PostRevisor.new(post) - # TODO decide if we need fidelity here around skip_revision - # skip_revision: true - opts = { bypass_rate_limiter: true, bypass_bump: true, skip_staff_log: true } - # revise must be called outside of transaction - # otherwise you get phantom edits where and edit can take 2 cycles - # to take done = revisor.revise!(Discourse.system_user, { raw: raw }, opts) - Post.transaction do - if done - last_post_revision = PostRevision.where(post: post).limit(1).order("number desc").first + return raw if !done + + last_post_revision = PostRevision.where(post: post).limit(1).order("number desc").first - reason = last_post_revision.modifications["edit_reason"] || "" + SharedEditRevision.transaction do + last_committed_version = + SharedEditRevision + .where(post_id: post_id) + .where.not(post_revision_id: nil) + .maximum(:version) || 0 - reason = reason[1] if Array === reason + editors = + SharedEditRevision + .where(post_id: post_id) + .where("version > ?", last_committed_version) + .pluck(:user_id) + .uniq - usernames = reason&.split(",")&.map(&:strip) || [] + reason = last_post_revision.modifications["edit_reason"] || "" + reason = reason[1] if Array === reason - if usernames.length > 0 - reason_length = I18n.t("shared_edits.reason", users: "").length - usernames[0] = usernames[0][reason_length..-1] - end + usernames = reason&.split(",")&.map(&:strip) || [] - User.where(id: editors).pluck(:username).each { |name| usernames << name } + if usernames.length > 0 + reason_length = I18n.t("shared_edits.reason", users: "").length + usernames[0] = usernames[0][reason_length..-1] + end - usernames.uniq! + User.where(id: editors).pluck(:username).each { |name| usernames << name } - new_reason = I18n.t("shared_edits.reason", users: usernames.join(", ")) + usernames.uniq! - if new_reason != reason - last_post_revision.modifications["edit_reason"] = [nil, new_reason] - last_post_revision.save! - post.update!(edit_reason: new_reason) - end + new_reason = I18n.t("shared_edits.reason", users: usernames.join(", ")) - last_revision.update!(post_revision_id: last_post_revision.id) + if new_reason != reason + last_post_revision.modifications["edit_reason"] = [nil, new_reason] + last_post_revision.save! + post.update!(edit_reason: new_reason) end + + latest.update!(post_revision_id: last_post_revision.id) end raw end def self.latest_raw(post_id) - SharedEditRevision - .where("raw IS NOT NULL") - .where(post_id: post_id) - .order("version desc") - .limit(1) - .pluck(:version, :raw) - .first + latest = + SharedEditRevision + .where("raw IS NOT NULL") + .where(post_id: post_id) + .order("version desc") + .limit(1) + .first + + return if !latest + + [latest.version, DiscourseSharedEdits::Yjs.text_from_state(latest.raw)] end - def self.revise!(post_id:, user_id:, client_id:, revision:, version:) - revision = revision.to_json if !(String === revision) - - args = { - user_id: user_id, - client_id: client_id, - revision: revision, - post_id: post_id, - version: version + 1, - now: Time.zone.now, - } - - rows = DB.exec(<<~SQL, args) - INSERT INTO shared_edit_revisions - ( - post_id, - user_id, - client_id, - revision, - version, - created_at, - updated_at - ) - SELECT - :post_id, - :user_id, - :client_id, - :revision, - :version, - :now, - :now - WHERE :version = ( - SELECT MAX(version) + 1 - FROM shared_edit_revisions - WHERE post_id = :post_id - ) - SQL - - if rows == 1 - post = Post.find(post_id) - message = { version: version + 1, revision: revision, client_id: client_id, user_id: user_id } - post.publish_message!("/shared_edits/#{post.id}", message) - [version + 1, revision] - else - missing = - SharedEditRevision - .where(post_id: post_id) - .where("version > ?", version) - .order(:version) - .pluck(:version, :revision) + def self.revise!(post_id:, user_id:, client_id:, update:) + SharedEditRevision.transaction do + latest = SharedEditRevision.where(post_id: post_id).lock.order("version desc").first + raise StandardError, "shared edits not initialized" if !latest - raise StandardError, "no revisions to apply" if missing.length == 0 + applied = DiscourseSharedEdits::Yjs.apply_update(latest.raw, update) - missing.each do |missing_version, missing_revision| - revision = OtTextUnicode.transform(revision, missing_revision) - version = missing_version - end + revision = + SharedEditRevision.create!( + post_id: post_id, + user_id: user_id, + client_id: client_id, + revision: update, + raw: applied[:state], + version: latest.version + 1, + ) - revise!( - post_id: post_id, - user_id: user_id, + post = Post.find(post_id) + message = { + version: revision.version, + update: update, client_id: client_id, - revision: revision, - version: version, - ) + user_id: user_id, + } + post.publish_message!("/shared_edits/#{post.id}", message) + + [revision.version, update] end end end @@ -213,8 +164,8 @@ def self.revise!(post_id:, user_id:, client_id:, revision:, version:) # # id :bigint not null, primary key # post_id :integer not null -# raw :string -# revision :string not null +# raw :text +# revision :text not null # user_id :integer not null # client_id :string not null # version :integer not null diff --git a/assets/javascripts/discourse/services/shared-edit-manager.js b/assets/javascripts/discourse/services/shared-edit-manager.js index be421fe..96f4cfe 100644 --- a/assets/javascripts/discourse/services/shared-edit-manager.js +++ b/assets/javascripts/discourse/services/shared-edit-manager.js @@ -4,83 +4,214 @@ import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import loadScript from "discourse/lib/load-script"; -const THROTTLE_SAVE = 500; +const THROTTLE_SAVE = 350; +const TEXTAREA_SELECTOR = "#reply-control textarea.d-editor-input"; -let loadedTextUnicode = false; +let yjsPromise; -function diff(before, after) { - const diffLib = window.otLib.default.OtDiff.diff; - const changes = diffLib(before, after); - return compress(changes); +async function ensureYjsLoaded() { + if (!yjsPromise) { + yjsPromise = loadScript( + "/plugins/discourse-shared-edits/javascripts/yjs-dist.js" + ).then(() => window.Y); + } + + return yjsPromise; +} + +function base64ToUint8Array(str) { + if (!str) { + return new Uint8Array(); + } + + return Uint8Array.from(atob(str), (c) => c.charCodeAt(0)); +} + +function uint8ArrayToBase64(uint8) { + let binary = ""; + uint8.forEach((b) => (binary += String.fromCharCode(b))); + return btoa(binary); } -function compress(change) { - const compressed = []; +function applyDiff(yText, before, after) { + if (before === after) { + return; + } + + let start = 0; + while ( + start < before.length && + start < after.length && + before[start] === after[start] + ) { + start++; + } + + let endBefore = before.length - 1; + let endAfter = after.length - 1; - if (change.action !== "noop") { - if (change.start > 0) { - compressed.push(change.start); + while ( + endBefore >= start && + endAfter >= start && + before[endBefore] === after[endAfter] + ) { + endBefore--; + endAfter--; + } + + const removeCount = Math.max(0, endBefore - start + 1); + const insertText = endAfter >= start ? after.slice(start, endAfter + 1) : ""; + + if (removeCount > 0) { + yText.delete(start, removeCount); + } + + if (insertText.length > 0) { + yText.insert(start, insertText); + } +} + +function transformSelection(selection, delta) { + if (!selection) { + return null; + } + + let { start, end } = selection; + let index = 0; + + delta.forEach((op) => { + if (op.retain) { + index += op.retain; + return; } - switch (change.action) { - case "replace": - compressed.push({ d: change.remove }); - compressed.push(change.payload); - break; - case "insert": - compressed.push(change.payload); - break; - case "delete": - compressed.push({ d: change.remove }); - break; + if (op.delete) { + const count = op.delete; + if (start > index) { + start = Math.max(index, start - count); + } + if (end > index) { + end = Math.max(index, end - count); + } + return; } - } - return compressed; + if (op.insert) { + const count = typeof op.insert === "string" ? op.insert.length : 0; + if (start >= index) { + start += count; + } + if (end >= index) { + end += count; + } + index += count; + } + }); + + return { start, end }; } +/** + * Coordinates collaborative post editing with Yjs and the Discourse message bus. + * @class SharedEditManager + */ export default class SharedEditManager extends Service { @service composer; @service messageBus; ajaxInProgress = false; - raw = null; - version = null; + doc = null; + text = null; + currentPostId = null; + pendingUpdates = []; + suppressComposerChange = false; + textObserver = null; + inFlightRequest = null; + #pendingRelativeSelection = null; + + /** + * Apply updates received from the message bus. + * @param {{client_id: string, update: string}} message + * @returns {void} + */ + #onRemoteMessage = (message) => { + if (!this.doc || message.client_id === this.messageBus.clientId) { + return; + } + + this.#pendingRelativeSelection = this.#captureRelativeSelection(); + + const update = base64ToUint8Array(message.update); + window.Y.applyUpdate(this.doc, update, "remote"); + }; + + /** + * Queue outbound updates generated locally so they can be batched to the server. + * @param {Uint8Array} update + * @param {unknown} origin + * @returns {void} + */ + #handleDocUpdate = (update, origin) => { + if (origin !== this) { + return; + } + this.pendingUpdates.push(update); + this.#sendUpdatesThrottled(); + }; + + /** + * Start syncing the current composer with the shared Yjs document for the post. + * @returns {Promise} + */ async subscribe() { try { - const data = await ajax(`/shared_edits/p/${this.#postId}`); + const postId = this.#postId; + + if (!postId) { + return; + } + + const data = await ajax(`/shared_edits/p/${postId}`); if (!this.composer.model || this.isDestroying || this.isDestroyed) { return; } - this.version = data.version; - this.raw = data.raw; - this.composer.model.set("reply", data.raw); - - this.addObserver("composer.model.reply", this, this.#update); - this.messageBus.subscribe(`/shared_edits/${this.#postId}`, (message) => { - if ( - message.client_id !== this.messageBus.clientId && - !this.ajaxInProgress - ) { - this.#applyRevisions([message]); - } - }); + this.currentPostId = postId; + this.#setupDoc(data.state, data.raw); + + this.addObserver("composer.model.reply", this, this.#onComposerChange); + this.messageBus.subscribe( + `/shared_edits/${postId}`, + this.#onRemoteMessage + ); } catch (e) { popupAjaxError(e); } } + /** + * Finalize the shared edit session and persist the composed content back to the post. + * @returns {Promise} + */ async commit() { + const postId = this.currentPostId || this.#postId; + + if (!postId) { + return; + } + try { - this.removeObserver("composer.model.reply", this, this.#update); - this.messageBus.unsubscribe(`/shared_edits/${this.#postId}`); - this.raw = null; - this.version = null; + await this.#flushPendingUpdates(); - await ajax(`/shared_edits/p/${this.#postId}/commit`, { + this.removeObserver("composer.model.reply", this, this.#onComposerChange); + this.messageBus.unsubscribe(`/shared_edits/${postId}`); + this.#teardownDoc(); + this.pendingUpdates = []; + this.currentPostId = null; + + await ajax(`/shared_edits/p/${postId}/commit`, { method: "PUT", }); } catch (e) { @@ -88,123 +219,278 @@ export default class SharedEditManager extends Service { } } - async #update() { - if (!loadedTextUnicode) { - await loadScript( - "/plugins/discourse-shared-edits/javascripts/text-unicode-dist.js" - ); - loadedTextUnicode = true; + /** + * Prepare a Yjs document for the session using the latest server state. + * @param {string} state base64 encoded Yjs update representing current state + * @param {string} raw fallback raw post text for empty states + * @returns {void} + */ + async #setupDoc(state, raw) { + this.#teardownDoc(); + + const Y = await ensureYjsLoaded(); + + this.doc = new Y.Doc(); + this.text = this.doc.getText("post"); + + const initialUpdate = base64ToUint8Array(state); + + if (initialUpdate.length > 0) { + Y.applyUpdate(this.doc, initialUpdate, "remote"); + } else if (raw) { + this.text.insert(0, raw); } - this.#sendDiffThrottled(); + this.textObserver = (event, transaction) => + this.#handleTextChange(event, transaction); + this.text.observe(this.textObserver); + this.doc.on("update", this.#handleDocUpdate); + + this.suppressComposerChange = true; + this.composer.model.set("reply", this.text.toString()); + this.suppressComposerChange = false; } - get #postId() { - return this.composer.model?.post.id; + /** + * Remove observers and clear the current Yjs document. + * @returns {void} + */ + #teardownDoc() { + if (this.text && this.textObserver) { + this.text.unobserve(this.textObserver); + } + + if (this.doc) { + this.doc.off("update", this.#handleDocUpdate); + } + + this.doc = null; + this.text = null; + this.textObserver = null; } - #sendDiffThrottled() { - debounce(this, this.#sendDiff, THROTTLE_SAVE); + /** + * @returns {number|undefined} id of the post currently being edited + */ + get #postId() { + return this.composer.model?.post.id; } - async #sendDiff() { - if (!this.composer.model || !this.version) { + /** + * Reflect composer text changes into the shared Yjs document. + * @returns {void} + */ + #onComposerChange() { + if (!this.composer.model || !this.text || this.suppressComposerChange) { return; } - if (this.ajaxInProgress) { - this.#sendDiffThrottled(); + const current = this.text.toString(); + const next = this.composer.model.reply || ""; + + if (current === next) { return; } - const changes = diff(this.raw, this.composer.model.reply); - const submittedRaw = this.composer.model.reply; + this.doc.transact(() => applyDiff(this.text, current, next), this); + } - if (changes.length === 0) { + /** + * Update composer text and selection when the shared document changes. + * @param {import("yjs").YTextEvent} event + * @param {import("yjs").Transaction} transaction + * @returns {void} + */ + #handleTextChange(event, transaction) { + if (transaction?.origin === this) { return; } - this.ajaxInProgress = true; - - try { - const result = await ajax(`/shared_edits/p/${this.#postId}`, { - method: "PUT", - data: { - revision: JSON.stringify(changes), - version: this.version, - client_id: this.messageBus.clientId, - }, - }); - - const inProgressChanges = diff(submittedRaw, this.composer.model.reply); - this.#applyRevisions(result.revisions, inProgressChanges); - } finally { - this.ajaxInProgress = false; + const textarea = document.querySelector(TEXTAREA_SELECTOR); + const selection = + textarea && typeof textarea.selectionStart === "number" + ? { + start: textarea.selectionStart, + end: textarea.selectionEnd, + } + : null; + + let adjustedSelection = null; + let scrollTop = textarea?.scrollTop; + + if (this.#pendingRelativeSelection) { + adjustedSelection = this.#absoluteSelectionFromRelative( + this.#pendingRelativeSelection + ); + if (scrollTop === undefined || scrollTop === null) { + scrollTop = this.#pendingRelativeSelection?.scrollTop; + } + this.#pendingRelativeSelection = null; } - } - #applyRevisions(revs, inProgressChanges) { - let newRaw = this.raw; - let newVersion = this.version; - let currentChanges = - inProgressChanges || diff(this.raw, this.composer.model.reply); + if (!adjustedSelection) { + adjustedSelection = transformSelection(selection, event.delta || []); + } - const otUnicode = window.otLib.default.OtUnicode; + const text = this.text.toString(); + this.suppressComposerChange = true; + this.composer.model?.set("reply", text); + this.suppressComposerChange = false; - let newChanges = []; + if (textarea) { + textarea.value = text; - for (const revision of revs) { - if (revision.version !== newVersion + 1) { - continue; + if (adjustedSelection) { + textarea.selectionStart = adjustedSelection.start; + textarea.selectionEnd = adjustedSelection.end; } - const parsedRevision = JSON.parse(revision.revision); - newRaw = otUnicode.apply(newRaw, parsedRevision); - newVersion = revision.version; - - if (revision.client_id !== this.messageBus.clientId) { - newChanges = otUnicode.compose(newChanges, parsedRevision); - currentChanges = otUnicode.transform( - currentChanges, - parsedRevision, - "left" - ); + if (scrollTop !== undefined) { + window.requestAnimationFrame(() => { + textarea.scrollTop = scrollTop; + }); } } + } + + /** + * Capture the current selection as Yjs relative positions so it survives remote updates. + * @returns {{ start: import("yjs").RelativePosition, end: import("yjs").RelativePosition, scrollTop?: number }|null} + */ + #captureRelativeSelection() { + const textarea = document.querySelector(TEXTAREA_SELECTOR); + + if ( + !textarea || + typeof textarea.selectionStart !== "number" || + typeof textarea.selectionEnd !== "number" + ) { + return null; + } - this.raw = newRaw; - this.version = newVersion; + const Y = window.Y; + return { + start: Y.createRelativePositionFromTypeIndex( + this.text, + textarea.selectionStart, + 0 + ), + end: Y.createRelativePositionFromTypeIndex( + this.text, + textarea.selectionEnd, + 0 + ), + scrollTop: textarea.scrollTop, + }; + } - if (currentChanges.length > 0) { - newRaw = otUnicode.apply(newRaw, currentChanges); + /** + * Convert previously captured relative selection back to absolute indexes. + * @param {{ start: import("yjs").RelativePosition, end: import("yjs").RelativePosition, scrollTop?: number }|null} rel + * @returns {{ start: number, end: number, scrollTop?: number }|null} + */ + #absoluteSelectionFromRelative(rel) { + if (!rel) { + return null; } - if (newRaw !== this.composer.model.reply) { - const input = document.querySelector( - "#reply-control textarea.d-editor-input" - ); + const Y = window.Y; + + const startAbs = Y.createAbsolutePositionFromRelativePosition( + rel.start, + this.doc + ); + const endAbs = Y.createAbsolutePositionFromRelativePosition( + rel.end, + this.doc + ); + + if ( + !startAbs || + !endAbs || + startAbs.type !== this.text || + endAbs.type !== this.text + ) { + return null; + } - if (input.selectionStart || input.selectionStart === 0) { - const selLength = input.selectionEnd - input.selectionStart; - const position = otUnicode.transformPosition( - input.selectionStart, - newChanges - ); + return { + start: startAbs.index, + end: endAbs.index, + scrollTop: rel.scrollTop, + }; + } - // still need to compensate for scrollHeight changes - // but at least this is mostly stable - const scrollTop = input.scrollTop; + /** + * Debounced enqueue of outbound updates to reduce request volume. + * @returns {void} + */ + #sendUpdatesThrottled() { + debounce(this, this.#sendUpdates, THROTTLE_SAVE); + } - input.value = newRaw; - input.selectionStart = position; - input.selectionEnd = position + selLength; + /** + * Immediately send any queued updates before shutting down the session. + * @returns {Promise} + */ + async #flushPendingUpdates() { + if (this.inFlightRequest) { + await this.inFlightRequest; + } - window.requestAnimationFrame(() => { - input.scrollTop = scrollTop; - }); + if (this.pendingUpdates.length) { + await this.#sendUpdates(true); + } + + if (this.inFlightRequest) { + await this.inFlightRequest; + } + } + + /** + * Send merged Yjs updates to the server. + * @param {boolean} immediate + * @returns {Promise} + */ + async #sendUpdates(immediate = false) { + const postId = this.currentPostId || this.#postId; + + if (!this.doc || this.pendingUpdates.length === 0 || !postId) { + return; + } + + if (this.ajaxInProgress) { + if (!immediate) { + this.#sendUpdatesThrottled(); + return; } - this.composer.model.set("reply", newRaw); + if (this.inFlightRequest) { + await this.inFlightRequest; + } + } + + const payload = + this.pendingUpdates.length === 1 + ? this.pendingUpdates[0] + : window.Y.mergeUpdates(this.pendingUpdates); + + this.pendingUpdates = []; + this.ajaxInProgress = true; + + try { + this.inFlightRequest = ajax(`/shared_edits/p/${postId}`, { + method: "PUT", + data: { + update: uint8ArrayToBase64(payload), + client_id: this.messageBus.clientId, + }, + }); + + await this.inFlightRequest; + } finally { + this.inFlightRequest = null; + this.ajaxInProgress = false; } } } diff --git a/assets/stylesheets/common/discourse-shared-edits.scss b/assets/stylesheets/common/discourse-shared-edits.scss index 4a888ff..4f34d0b 100644 --- a/assets/stylesheets/common/discourse-shared-edits.scss +++ b/assets/stylesheets/common/discourse-shared-edits.scss @@ -1,4 +1,4 @@ -#reply-control.composer-action-sharedEdit { +#reply-control.composer-action-shared-edit { .save-or-cancel { button, a { diff --git a/db/migrate/20251124000000_resize_shared_edit_columns.rb b/db/migrate/20251124000000_resize_shared_edit_columns.rb new file mode 100644 index 0000000..03b5b09 --- /dev/null +++ b/db/migrate/20251124000000_resize_shared_edit_columns.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ResizeSharedEditColumns < ActiveRecord::Migration[7.0] + def change + change_column :shared_edit_revisions, :raw, :text + change_column :shared_edit_revisions, :revision, :text + end +end diff --git a/lib/discourse_shared_edits/yjs.rb b/lib/discourse_shared_edits/yjs.rb new file mode 100644 index 0000000..9dd9770 --- /dev/null +++ b/lib/discourse_shared_edits/yjs.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require "base64" +require "mini_racer" + +module DiscourseSharedEdits + module Yjs + LOCK = Mutex.new + + class << self + def context + LOCK.synchronize do + return @context if @context + + ctx = MiniRacer::Context.new + ctx.eval(<<~JS) + const global = this; + var module = { exports: {} }; + var exports = module.exports; + + if (!global.crypto) { + global.crypto = { + getRandomValues(array) { + if (!array || typeof array.length !== "number") { + throw new Error("Expected typed array"); + } + for (let i = 0; i < array.length; i++) { + array[i] = Math.floor(Math.random() * 256); + } + return array; + }, + }; + } + JS + + yjs_path = File.expand_path("../../public/javascripts/yjs-dist.js", __dir__) + ctx.eval(File.read(yjs_path)) + + ctx.eval(<<~JS) + const YRef = global.Y; + + function docFromText(text) { + const doc = new YRef.Doc(); + doc.getText("post").insert(0, text || ""); + return doc; + } + + function yDocFromState(state) { + const doc = new YRef.Doc(); + if (state && state.length) { + YRef.applyUpdate(doc, new Uint8Array(state)); + } + return doc; + } + + function encodeState(doc) { + return Array.from(YRef.encodeStateAsUpdate(doc)); + } + + function docText(doc) { + return doc.getText("post").toString(); + } + + function applyUpdateToState(state, update) { + const doc = yDocFromState(state); + if (update && update.length) { + YRef.applyUpdate(doc, new Uint8Array(update)); + } + + return { state: encodeState(doc), text: docText(doc) }; + } + + function stateFromText(text) { + const doc = docFromText(text); + return { state: encodeState(doc), text: docText(doc) }; + } + + function updateFromTextChange(oldText, newText) { + const doc = docFromText(oldText); + const before = YRef.encodeStateVector(doc); + const text = doc.getText("post"); + const oldVal = oldText || ""; + const newVal = newText || ""; + + let start = 0; + while ( + start < oldVal.length && + start < newVal.length && + oldVal[start] === newVal[start] + ) { + start++; + } + + let endOld = oldVal.length - 1; + let endNew = newVal.length - 1; + + while ( + endOld >= start && + endNew >= start && + oldVal[endOld] === newVal[endNew] + ) { + endOld--; + endNew--; + } + + const removeCount = Math.max(0, endOld - start + 1); + const insertText = + endNew >= start ? newVal.slice(start, endNew + 1) : ""; + + if (removeCount > 0) { + text.delete(start, removeCount); + } + + if (insertText.length > 0) { + text.insert(start, insertText); + } + + return Array.from(YRef.encodeStateAsUpdate(doc, before)); + } + + function updateFromState(state, newText) { + const doc = yDocFromState(state); + const before = YRef.encodeStateVector(doc); + const text = doc.getText("post"); + const current = text.toString(); + const desired = newText || ""; + + let start = 0; + while ( + start < current.length && + start < desired.length && + current[start] === desired[start] + ) { + start++; + } + + let endCurrent = current.length - 1; + let endDesired = desired.length - 1; + + while ( + endCurrent >= start && + endDesired >= start && + current[endCurrent] === desired[endDesired] + ) { + endCurrent--; + endDesired--; + } + + const removeCount = Math.max(0, endCurrent - start + 1); + const insertText = + endDesired >= start ? desired.slice(start, endDesired + 1) : ""; + + if (removeCount > 0) { + text.delete(start, removeCount); + } + + if (insertText.length > 0) { + text.insert(start, insertText); + } + + return Array.from(YRef.encodeStateAsUpdate(doc, before)); + } + JS + + @context = ctx + end + end + + def state_from_text(text) + result = context.call("stateFromText", text) + { state: encode(result["state"]), text: result["text"] } + end + + def apply_update(state_b64, update_b64) + result = context.call("applyUpdateToState", decode(state_b64), decode(update_b64)) + + { state: encode(result["state"]), text: result["text"] } + end + + def text_from_state(state_b64) + context.call("applyUpdateToState", decode(state_b64), [])["text"] + end + + def update_from_text_change(old_text, new_text) + encode(context.call("updateFromTextChange", old_text, new_text)) + end + + def update_from_state(state_b64, new_text) + encode(context.call("updateFromState", decode(state_b64), new_text)) + end + + private + + def encode(array) + Base64.strict_encode64(array.pack("C*")) + end + + def decode(str) + Base64.decode64(str.to_s).bytes + end + end + end +end diff --git a/lib/ot_text_unicode.rb b/lib/ot_text_unicode.rb deleted file mode 100644 index 36e6f4b..0000000 --- a/lib/ot_text_unicode.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module OtTextUnicode - LOCK = Mutex.new - - def self.context - LOCK.synchronize do - return @context if @context - context = MiniRacer::Context.new - context.eval("module = {exports: {}}") - ot_path = File.expand_path("../../public/javascripts/text-unicode-dist.js", __FILE__) - - context.eval("window = {}; #{File.read(ot_path)}; ot = window.otLib.default.OtUnicode") - - @context = context - end - end - - def self.apply(text, ops = []) - json = String === ops ? ops : ops.to_json - context.eval("ot.apply(#{text.inspect}, #{json})") - end - - def self.compose(ops1 = [], ops2 = []) - json1 = String === ops1 ? ops1 : ops1.to_json - json2 = String === ops2 ? ops2 : ops2.to_json - context.eval("ot.compose(#{json1}, #{json2})") - end - - def self.transform(ops1 = [], ops2 = [], side = "right") - json1 = String === ops1 ? ops1 : ops1.to_json - json2 = String === ops2 ? ops2 : ops2.to_json - context.eval("ot.transform(#{json1}, #{json2}, #{side.inspect})") - end -end diff --git a/lib/tasks/yjs.rake b/lib/tasks/yjs.rake new file mode 100644 index 0000000..be635aa --- /dev/null +++ b/lib/tasks/yjs.rake @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +namespace :shared_edits do + namespace :yjs do + desc "Rebuild bundled Yjs for the shared edits plugin (do not edit the bundled file manually)" + task :build do + cmd = [ + "cd", + Rails.root.join("plugins", "discourse-shared-edits"), + "&&", + "pnpm exec esbuild node_modules/yjs/dist/yjs.mjs --bundle --format=iife --global-name=Y --platform=browser --outfile=public/javascripts/yjs-dist.js", + ].join(" ") + + system(cmd) || raise("Failed to bundle Yjs") + end + end +end diff --git a/package.json b/package.json index fd1ab79..a9b3047 100644 --- a/package.json +++ b/package.json @@ -13,5 +13,8 @@ "yarn": "please-use-pnpm", "pnpm": "9.x" }, - "packageManager": "pnpm@9.15.5" + "packageManager": "pnpm@9.15.5", + "dependencies": { + "yjs": "^13.6.27" + } } diff --git a/plugin.rb b/plugin.rb index d9bfa26..f4bada6 100644 --- a/plugin.rb +++ b/plugin.rb @@ -22,7 +22,7 @@ class Engine < ::Rails::Engine end end - require_relative "lib/ot_text_unicode" + require_relative "lib/discourse_shared_edits/yjs" require_relative "app/models/shared_edit_revision" require_relative "app/controllers/discourse_shared_edits/revision_controller" require_relative "app/jobs/commit_shared_revision" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 954d10f..2f85687 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + yjs: + specifier: ^13.6.27 + version: 13.6.27 devDependencies: '@discourse/lint-configs': specifier: 2.32.0 @@ -1053,6 +1057,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1112,6 +1119,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.114: + resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} + engines: {node: '>=16'} + hasBin: true + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -1643,6 +1655,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yjs@13.6.27: + resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2881,6 +2897,8 @@ snapshots: isexe@2.0.0: {} + isomorphic.js@0.2.5: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -2930,6 +2948,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.114: + dependencies: + isomorphic.js: 0.2.5 + lines-and-columns@1.2.4: {} locate-path@6.0.0: @@ -3551,4 +3573,8 @@ snapshots: yallist@3.1.1: {} + yjs@13.6.27: + dependencies: + lib0: 0.2.114 + yocto-queue@0.1.0: {} diff --git a/public/javascripts/text-unicode-dist.js b/public/javascripts/text-unicode-dist.js deleted file mode 100644 index 4628eb6..0000000 --- a/public/javascripts/text-unicode-dist.js +++ /dev/null @@ -1 +0,0 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.otLib=t():e.otLib=t()}(window,(()=>(()=>{var e={75:(e,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n=function(){function e(e,t){for(var n=0;nn&&t.length-r>n&&e[e.length-1-r]==t[t.length-1-r];)r++;return r}},{key:"getChangeStart",value:function(e,t){for(var n=0;n0?{action:"insert",start:e.changeStart,payload:e.newString.slice(e.changeStart,e.changeEndIndexNew)}:e.charsRemoved>0&&0===e.charsAdded?{action:"delete",start:e.changeStart,remove:e.charsRemoved}:e.charsRemoved>0&&e.charsAdded>0?{action:"replace",start:e.changeStart,remove:e.charsRemoved,payload:e.newString.substr(e.changeStart,e.charsAdded)}:{action:"noop"},e.raw&&(t.raw=e),t}}]),e}();t.default=new r},935:(e,t,n)=>{"use strict";var r,o=function(){function e(e,t){for(var n=0;n2&&void 0!==arguments[2]&&arguments[2],r=i.default.assignOpts(e,t,n);return i.default.payload(r)}},{key:"transform",value:function(e,t){return this[t.action](e,t)}},{key:"insert",value:function(e,t){return e.slice(0,parseInt(t.start))+t.payload+e.slice(parseInt(t.start))}},{key:"delete",value:function(e,t){return e.slice(0,parseInt(t.start))+e.slice(parseInt(t.start)+parseInt(t.remove))}},{key:"replace",value:function(e,t){return this.insert(this.delete(e,t),t)}},{key:"noop",value:function(e){return e}}]),e}();t.default=new s},213:(e,t,n)=>{e.exports=n(935)},48:(e,t,n)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0});const r=n(562),o=n(681);function i(e,t){return{get:e,getLength:()=>e().length,insert(n,r,i){const s=o.strPosToUni(e(),n);return t([s,r],i)},remove(n,r,i){const s=o.strPosToUni(e(),n);return t([s,{d:r}],i)},_onOp(e){r.eachOp(e,((e,t,n)=>{switch(typeof e){case"string":this.onInsert&&this.onInsert(n,e);break;case"object":const t=r.dlen(e.d);this.onRemove&&this.onRemove(n,t)}}))},onInsert:null,onRemove:null}}t.default=i,i.provides={text:!0}},700:function(e,t,n){"use strict";var r=this&&this.__createBinding||(Object.create?function(e,t,n,r){void 0===r&&(r=n),Object.defineProperty(e,r,{enumerable:!0,get:function(){return t[n]}})}:function(e,t,n,r){void 0===r&&(r=n),e[r]=t[n]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),i=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)Object.hasOwnProperty.call(e,n)&&r(t,e,n);return o(t,e),t},s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.type=t.remove=t.insert=void 0;const a=n(681),c=i(n(562)),u=s(n(48)),l={create:e=>e,toString:e=>e,builder(e){if("string"!=typeof e)throw Error("Invalid document snapshot: "+e);const t=[];return{skip(n){let r=a.uniToStrPos(e,n);if(r>e.length)throw Error("The op is too long for this document");t.push(e.slice(0,r)),e=e.slice(r)},append(e){t.push(e)},del(t){e=e.slice(a.uniToStrPos(e,t))},build:()=>t.join("")+e}},slice:c.uniSlice},d=c.default(l),f=Object.assign(Object.assign({},d),{api:u.default});t.type=f,t.insert=(e,t)=>0===t.length?[]:0===e?[t]:[e,t],t.remove=(e,t)=>0===c.dlen(t)?[]:0===e?[{d:t}]:[e,{d:t}];var p=n(562);Object.defineProperty(t,"makeType",{enumerable:!0,get:function(){return p.default}})},562:(e,t,n)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.uniSlice=t.dlen=t.eachOp=void 0;const r=n(681),o=e=>{if(!Array.isArray(e))throw Error("Op must be an array of components");let n=null;for(let r=0;r0))throw Error("Inserts cannot be empty");break;case"number":if(!(o>0))throw Error("Skip components must be >0");if("number"==typeof n)throw Error("Adjacent skip components should be combined")}n=o}if("number"==typeof n)throw Error("Op has a trailing skip")};function i(e,n){let o=0,i=0;for(let s=0;s{r(t(e,n,o))})),p(n)}t.eachOp=i;const a=e=>e,c=e=>s(e,a);t.dlen=e=>"number"==typeof e?e:r.strPosToUni(e);const u=e=>n=>{if(n&&0!==n.d&&""!==n.d)if(0===e.length)e.push(n);else if(typeof n==typeof e[e.length-1])if("object"==typeof n){const r=e[e.length-1];r.d="string"==typeof r.d&&"string"==typeof n.d?r.d+n.d:t.dlen(r.d)+t.dlen(n.d)}else e[e.length-1]+=n;else e.push(n)},l=e=>"number"==typeof e?e:"string"==typeof e?r.strPosToUni(e):"number"==typeof e.d?e.d:r.strPosToUni(e.d);t.uniSlice=(e,t,n)=>{const o=r.uniToStrPos(e,t),i=null==n?1/0:r.uniToStrPos(e,n);return e.slice(o,i)};const d=(e,n,r)=>"number"==typeof e?null==r?e-n:Math.min(e,r)-n:t.uniSlice(e,n,r),f=e=>{let n=0,o=0;return{take:(i,s)=>{if(n===e.length)return-1===i?null:i;const a=e[n];let c;if("number"==typeof a)return-1===i||a-o<=i?(c=a-o,++n,o=0,c):(o+=i,i);if("string"==typeof a){if(-1===i||"i"===s||r.strPosToUni(a.slice(o))<=i)return c=a.slice(o),++n,o=0,c;{const e=o+r.uniToStrPos(a.slice(o),i);return c=a.slice(o,e),o=e,c}}if(-1===i||"d"===s||t.dlen(a.d)-o<=i)return c={d:d(a.d,o)},++n,o=0,c;{let e=d(a.d,o,o+i);return o+=i,{d:e}}},peek:()=>e[n]}},p=e=>(e.length>0&&"number"==typeof e[e.length-1]&&e.pop(),e);function h(e,n,i){if("left"!==i&&"right"!==i)throw Error("side ("+i+") must be 'left' or 'right'");o(e),o(n);const s=[],a=u(s),{take:c,peek:d}=f(e);for(let e=0;e0;)u=c(s,"i"),a(u),"string"!=typeof u&&(s-=l(u));break;case"string":"left"===i&&"string"==typeof d()&&a(c(-1)),a(r.strPosToUni(o));break;case"object":for(s=t.dlen(o.d);s>0;)switch(u=c(s,"i"),typeof u){case"number":s-=u;break;case"string":a(u);break;case"object":s-=t.dlen(u.d)}}}let h;for(;h=c(-1);)a(h);return p(s)}function b(e,n){o(e),o(n);const i=[],s=u(i),{take:a}=f(e);for(let e=0;e0;)c=a(i,"d"),s(c),"object"!=typeof c&&(i-=l(c));break;case"string":s(o);break;case"object":i=t.dlen(o.d);let e=0;for(;e{let o=0;for(let i=0;io;i++){const s=n[i];switch(typeof s){case"number":o+=s;break;case"string":const n=r.strPosToUni(s);o+=n,e+=n;break;case"object":e-=Math.min(t.dlen(s.d),e-o)}}return e},y=(e,t)=>"number"==typeof e?g(e,t):e.map((e=>g(e,t)));function m(e,t,n){return s(e,((e,r)=>"object"==typeof e&&"number"==typeof e.d?{d:n.slice(t,r,r+e.d)}:e))}function v(e){return s(e,(e=>{switch(typeof e){case"object":if("number"==typeof e.d)throw Error("Cannot invert text op: Deleted characters missing from operation. makeInvertible must be called first.");return e.d;case"string":return{d:e};case"number":return e}}))}function w(e){return s(e,(e=>"object"==typeof e&&"string"==typeof e.d?{d:r.strPosToUni(e.d)}:e))}function k(e){let t=!0;return i(e,(e=>{"object"==typeof e&&"number"==typeof e.d&&(t=!1)})),t}t.default=function(e){return{name:"text-unicode",uri:"http://sharejs.org/types/text-unicode",trim:p,normalize:c,checkOp:o,create(t=""){if("string"!=typeof t)throw Error("Initial data must be a string");return e.create(t)},apply(n,r){o(r);const i=e.builder(n);for(let e=0;em(t,n,e),stripInvertible:w,invert:v,invertWithDoc:(t,n)=>v(m(t,n,e)),isNoop:e=>0===e.length}}},681:(e,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.uniToStrPos=t.strPosToUni=void 0,t.strPosToUni=(e,t=e.length)=>{let n=0,r=0;for(;r=55296&&t<=57343&&(n++,r++)}if(r!==t)throw Error("Invalid offset - splits unicode bytes");return r-n},t.uniToStrPos=(e,t)=>{let n=0;for(;t>0;t--){const t=e.charCodeAt(n);n+=t>=55296&&t<=57343?2:1}return n}}},t={};function n(r){var o=t[r];if(void 0!==o)return o.exports;var i=t[r]={exports:{}};return e[r].call(i.exports,i,i.exports,n),i.exports}n.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var r={};return(()=>{"use strict";n.r(r),n.d(r,{default:()=>o});var e=n(213),t=n(700);const o={OtDiff:e.default,OtUnicode:t.type}})(),r})())); \ No newline at end of file diff --git a/public/javascripts/yjs-dist.js b/public/javascripts/yjs-dist.js new file mode 100644 index 0000000..a4cd911 --- /dev/null +++ b/public/javascripts/yjs-dist.js @@ -0,0 +1,8774 @@ +var Y = (() => { + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __export = (target, all2) => { + for (var name in all2) + __defProp(target, name, { get: all2[name], enumerable: true }); + }; + var __copyProps = (to, from2, except, desc) => { + if (from2 && typeof from2 === "object" || typeof from2 === "function") { + for (let key of __getOwnPropNames(from2)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from2[key], enumerable: !(desc = __getOwnPropDesc(from2, key)) || desc.enumerable }); + } + return to; + }; + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + + // node_modules/.pnpm/yjs@13.6.27/node_modules/yjs/dist/yjs.mjs + var yjs_exports = {}; + __export(yjs_exports, { + AbsolutePosition: () => AbsolutePosition, + AbstractConnector: () => AbstractConnector, + AbstractStruct: () => AbstractStruct, + AbstractType: () => AbstractType, + Array: () => YArray, + ContentAny: () => ContentAny, + ContentBinary: () => ContentBinary, + ContentDeleted: () => ContentDeleted, + ContentDoc: () => ContentDoc, + ContentEmbed: () => ContentEmbed, + ContentFormat: () => ContentFormat, + ContentJSON: () => ContentJSON, + ContentString: () => ContentString, + ContentType: () => ContentType, + Doc: () => Doc, + GC: () => GC, + ID: () => ID, + Item: () => Item, + Map: () => YMap, + PermanentUserData: () => PermanentUserData, + RelativePosition: () => RelativePosition, + Skip: () => Skip, + Snapshot: () => Snapshot, + Text: () => YText, + Transaction: () => Transaction, + UndoManager: () => UndoManager, + UpdateDecoderV1: () => UpdateDecoderV1, + UpdateDecoderV2: () => UpdateDecoderV2, + UpdateEncoderV1: () => UpdateEncoderV1, + UpdateEncoderV2: () => UpdateEncoderV2, + XmlElement: () => YXmlElement, + XmlFragment: () => YXmlFragment, + XmlHook: () => YXmlHook, + XmlText: () => YXmlText, + YArrayEvent: () => YArrayEvent, + YEvent: () => YEvent, + YMapEvent: () => YMapEvent, + YTextEvent: () => YTextEvent, + YXmlEvent: () => YXmlEvent, + applyUpdate: () => applyUpdate, + applyUpdateV2: () => applyUpdateV2, + cleanupYTextFormatting: () => cleanupYTextFormatting, + compareIDs: () => compareIDs, + compareRelativePositions: () => compareRelativePositions, + convertUpdateFormatV1ToV2: () => convertUpdateFormatV1ToV2, + convertUpdateFormatV2ToV1: () => convertUpdateFormatV2ToV1, + createAbsolutePositionFromRelativePosition: () => createAbsolutePositionFromRelativePosition, + createDeleteSet: () => createDeleteSet, + createDeleteSetFromStructStore: () => createDeleteSetFromStructStore, + createDocFromSnapshot: () => createDocFromSnapshot, + createID: () => createID, + createRelativePositionFromJSON: () => createRelativePositionFromJSON, + createRelativePositionFromTypeIndex: () => createRelativePositionFromTypeIndex, + createSnapshot: () => createSnapshot, + decodeRelativePosition: () => decodeRelativePosition, + decodeSnapshot: () => decodeSnapshot, + decodeSnapshotV2: () => decodeSnapshotV2, + decodeStateVector: () => decodeStateVector, + decodeUpdate: () => decodeUpdate, + decodeUpdateV2: () => decodeUpdateV2, + diffUpdate: () => diffUpdate, + diffUpdateV2: () => diffUpdateV2, + emptySnapshot: () => emptySnapshot, + encodeRelativePosition: () => encodeRelativePosition, + encodeSnapshot: () => encodeSnapshot, + encodeSnapshotV2: () => encodeSnapshotV2, + encodeStateAsUpdate: () => encodeStateAsUpdate, + encodeStateAsUpdateV2: () => encodeStateAsUpdateV2, + encodeStateVector: () => encodeStateVector, + encodeStateVectorFromUpdate: () => encodeStateVectorFromUpdate, + encodeStateVectorFromUpdateV2: () => encodeStateVectorFromUpdateV2, + equalDeleteSets: () => equalDeleteSets, + equalSnapshots: () => equalSnapshots, + findIndexSS: () => findIndexSS, + findRootTypeKey: () => findRootTypeKey, + getItem: () => getItem, + getItemCleanEnd: () => getItemCleanEnd, + getItemCleanStart: () => getItemCleanStart, + getState: () => getState, + getTypeChildren: () => getTypeChildren, + isDeleted: () => isDeleted, + isParentOf: () => isParentOf, + iterateDeletedStructs: () => iterateDeletedStructs, + logType: () => logType, + logUpdate: () => logUpdate, + logUpdateV2: () => logUpdateV2, + mergeDeleteSets: () => mergeDeleteSets, + mergeUpdates: () => mergeUpdates, + mergeUpdatesV2: () => mergeUpdatesV2, + obfuscateUpdate: () => obfuscateUpdate, + obfuscateUpdateV2: () => obfuscateUpdateV2, + parseUpdateMeta: () => parseUpdateMeta, + parseUpdateMetaV2: () => parseUpdateMetaV2, + readUpdate: () => readUpdate, + readUpdateV2: () => readUpdateV2, + relativePositionToJSON: () => relativePositionToJSON, + snapshot: () => snapshot, + snapshotContainsUpdate: () => snapshotContainsUpdate, + transact: () => transact, + tryGc: () => tryGc, + typeListToArraySnapshot: () => typeListToArraySnapshot, + typeMapGetAllSnapshot: () => typeMapGetAllSnapshot, + typeMapGetSnapshot: () => typeMapGetSnapshot + }); + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/map.js + var create = () => /* @__PURE__ */ new Map(); + var copy = (m) => { + const r = create(); + m.forEach((v, k) => { + r.set(k, v); + }); + return r; + }; + var setIfUndefined = (map2, key, createT) => { + let set = map2.get(key); + if (set === void 0) { + map2.set(key, set = createT()); + } + return set; + }; + var map = (m, f) => { + const res = []; + for (const [key, value] of m) { + res.push(f(value, key)); + } + return res; + }; + var any = (m, f) => { + for (const [key, value] of m) { + if (f(value, key)) { + return true; + } + } + return false; + }; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/set.js + var create2 = () => /* @__PURE__ */ new Set(); + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/array.js + var last = (arr) => arr[arr.length - 1]; + var appendTo = (dest, src) => { + for (let i = 0; i < src.length; i++) { + dest.push(src[i]); + } + }; + var from = Array.from; + var some = (arr, f) => { + for (let i = 0; i < arr.length; i++) { + if (f(arr[i], i, arr)) { + return true; + } + } + return false; + }; + var unfold = (len, f) => { + const array = new Array(len); + for (let i = 0; i < len; i++) { + array[i] = f(i, array); + } + return array; + }; + var isArray = Array.isArray; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/observable.js + var ObservableV2 = class { + constructor() { + this._observers = create(); + } + /** + * @template {keyof EVENTS & string} NAME + * @param {NAME} name + * @param {EVENTS[NAME]} f + */ + on(name, f) { + setIfUndefined( + this._observers, + /** @type {string} */ + name, + create2 + ).add(f); + return f; + } + /** + * @template {keyof EVENTS & string} NAME + * @param {NAME} name + * @param {EVENTS[NAME]} f + */ + once(name, f) { + const _f = (...args2) => { + this.off( + name, + /** @type {any} */ + _f + ); + f(...args2); + }; + this.on( + name, + /** @type {any} */ + _f + ); + } + /** + * @template {keyof EVENTS & string} NAME + * @param {NAME} name + * @param {EVENTS[NAME]} f + */ + off(name, f) { + const observers = this._observers.get(name); + if (observers !== void 0) { + observers.delete(f); + if (observers.size === 0) { + this._observers.delete(name); + } + } + } + /** + * Emit a named event. All registered event listeners that listen to the + * specified name will receive the event. + * + * @todo This should catch exceptions + * + * @template {keyof EVENTS & string} NAME + * @param {NAME} name The event name. + * @param {Parameters} args The arguments that are applied to the event listener. + */ + emit(name, args2) { + return from((this._observers.get(name) || create()).values()).forEach((f) => f(...args2)); + } + destroy() { + this._observers = create(); + } + }; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/math.js + var floor = Math.floor; + var abs = Math.abs; + var min = (a, b) => a < b ? a : b; + var max = (a, b) => a > b ? a : b; + var isNaN = Number.isNaN; + var isNegativeZero = (n) => n !== 0 ? n < 0 : 1 / n < 0; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/binary.js + var BIT1 = 1; + var BIT2 = 2; + var BIT3 = 4; + var BIT4 = 8; + var BIT6 = 32; + var BIT7 = 64; + var BIT8 = 128; + var BIT18 = 1 << 17; + var BIT19 = 1 << 18; + var BIT20 = 1 << 19; + var BIT21 = 1 << 20; + var BIT22 = 1 << 21; + var BIT23 = 1 << 22; + var BIT24 = 1 << 23; + var BIT25 = 1 << 24; + var BIT26 = 1 << 25; + var BIT27 = 1 << 26; + var BIT28 = 1 << 27; + var BIT29 = 1 << 28; + var BIT30 = 1 << 29; + var BIT31 = 1 << 30; + var BIT32 = 1 << 31; + var BITS5 = 31; + var BITS6 = 63; + var BITS7 = 127; + var BITS17 = BIT18 - 1; + var BITS18 = BIT19 - 1; + var BITS19 = BIT20 - 1; + var BITS20 = BIT21 - 1; + var BITS21 = BIT22 - 1; + var BITS22 = BIT23 - 1; + var BITS23 = BIT24 - 1; + var BITS24 = BIT25 - 1; + var BITS25 = BIT26 - 1; + var BITS26 = BIT27 - 1; + var BITS27 = BIT28 - 1; + var BITS28 = BIT29 - 1; + var BITS29 = BIT30 - 1; + var BITS30 = BIT31 - 1; + var BITS31 = 2147483647; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/number.js + var MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; + var MIN_SAFE_INTEGER = Number.MIN_SAFE_INTEGER; + var LOWEST_INT32 = 1 << 31; + var isInteger = Number.isInteger || ((num) => typeof num === "number" && isFinite(num) && floor(num) === num); + var isNaN2 = Number.isNaN; + var parseInt = Number.parseInt; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/string.js + var fromCharCode = String.fromCharCode; + var fromCodePoint = String.fromCodePoint; + var MAX_UTF16_CHARACTER = fromCharCode(65535); + var toLowerCase = (s) => s.toLowerCase(); + var trimLeftRegex = /^\s*/g; + var trimLeft = (s) => s.replace(trimLeftRegex, ""); + var fromCamelCaseRegex = /([A-Z])/g; + var fromCamelCase = (s, separator) => trimLeft(s.replace(fromCamelCaseRegex, (match) => `${separator}${toLowerCase(match)}`)); + var _encodeUtf8Polyfill = (str) => { + const encodedString = unescape(encodeURIComponent(str)); + const len = encodedString.length; + const buf = new Uint8Array(len); + for (let i = 0; i < len; i++) { + buf[i] = /** @type {number} */ + encodedString.codePointAt(i); + } + return buf; + }; + var utf8TextEncoder = ( + /** @type {TextEncoder} */ + typeof TextEncoder !== "undefined" ? new TextEncoder() : null + ); + var _encodeUtf8Native = (str) => utf8TextEncoder.encode(str); + var encodeUtf8 = utf8TextEncoder ? _encodeUtf8Native : _encodeUtf8Polyfill; + var utf8TextDecoder = typeof TextDecoder === "undefined" ? null : new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + if (utf8TextDecoder && utf8TextDecoder.decode(new Uint8Array()).length === 1) { + utf8TextDecoder = null; + } + var repeat = (source, n) => unfold(n, () => source).join(""); + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/encoding.js + var Encoder = class { + constructor() { + this.cpos = 0; + this.cbuf = new Uint8Array(100); + this.bufs = []; + } + }; + var createEncoder = () => new Encoder(); + var length = (encoder) => { + let len = encoder.cpos; + for (let i = 0; i < encoder.bufs.length; i++) { + len += encoder.bufs[i].length; + } + return len; + }; + var toUint8Array = (encoder) => { + const uint8arr = new Uint8Array(length(encoder)); + let curPos = 0; + for (let i = 0; i < encoder.bufs.length; i++) { + const d = encoder.bufs[i]; + uint8arr.set(d, curPos); + curPos += d.length; + } + uint8arr.set(new Uint8Array(encoder.cbuf.buffer, 0, encoder.cpos), curPos); + return uint8arr; + }; + var verifyLen = (encoder, len) => { + const bufferLen = encoder.cbuf.length; + if (bufferLen - encoder.cpos < len) { + encoder.bufs.push(new Uint8Array(encoder.cbuf.buffer, 0, encoder.cpos)); + encoder.cbuf = new Uint8Array(max(bufferLen, len) * 2); + encoder.cpos = 0; + } + }; + var write = (encoder, num) => { + const bufferLen = encoder.cbuf.length; + if (encoder.cpos === bufferLen) { + encoder.bufs.push(encoder.cbuf); + encoder.cbuf = new Uint8Array(bufferLen * 2); + encoder.cpos = 0; + } + encoder.cbuf[encoder.cpos++] = num; + }; + var writeUint8 = write; + var writeVarUint = (encoder, num) => { + while (num > BITS7) { + write(encoder, BIT8 | BITS7 & num); + num = floor(num / 128); + } + write(encoder, BITS7 & num); + }; + var writeVarInt = (encoder, num) => { + const isNegative = isNegativeZero(num); + if (isNegative) { + num = -num; + } + write(encoder, (num > BITS6 ? BIT8 : 0) | (isNegative ? BIT7 : 0) | BITS6 & num); + num = floor(num / 64); + while (num > 0) { + write(encoder, (num > BITS7 ? BIT8 : 0) | BITS7 & num); + num = floor(num / 128); + } + }; + var _strBuffer = new Uint8Array(3e4); + var _maxStrBSize = _strBuffer.length / 3; + var _writeVarStringNative = (encoder, str) => { + if (str.length < _maxStrBSize) { + const written = utf8TextEncoder.encodeInto(str, _strBuffer).written || 0; + writeVarUint(encoder, written); + for (let i = 0; i < written; i++) { + write(encoder, _strBuffer[i]); + } + } else { + writeVarUint8Array(encoder, encodeUtf8(str)); + } + }; + var _writeVarStringPolyfill = (encoder, str) => { + const encodedString = unescape(encodeURIComponent(str)); + const len = encodedString.length; + writeVarUint(encoder, len); + for (let i = 0; i < len; i++) { + write( + encoder, + /** @type {number} */ + encodedString.codePointAt(i) + ); + } + }; + var writeVarString = utf8TextEncoder && /** @type {any} */ + utf8TextEncoder.encodeInto ? _writeVarStringNative : _writeVarStringPolyfill; + var writeBinaryEncoder = (encoder, append2) => writeUint8Array(encoder, toUint8Array(append2)); + var writeUint8Array = (encoder, uint8Array) => { + const bufferLen = encoder.cbuf.length; + const cpos = encoder.cpos; + const leftCopyLen = min(bufferLen - cpos, uint8Array.length); + const rightCopyLen = uint8Array.length - leftCopyLen; + encoder.cbuf.set(uint8Array.subarray(0, leftCopyLen), cpos); + encoder.cpos += leftCopyLen; + if (rightCopyLen > 0) { + encoder.bufs.push(encoder.cbuf); + encoder.cbuf = new Uint8Array(max(bufferLen * 2, rightCopyLen)); + encoder.cbuf.set(uint8Array.subarray(leftCopyLen)); + encoder.cpos = rightCopyLen; + } + }; + var writeVarUint8Array = (encoder, uint8Array) => { + writeVarUint(encoder, uint8Array.byteLength); + writeUint8Array(encoder, uint8Array); + }; + var writeOnDataView = (encoder, len) => { + verifyLen(encoder, len); + const dview = new DataView(encoder.cbuf.buffer, encoder.cpos, len); + encoder.cpos += len; + return dview; + }; + var writeFloat32 = (encoder, num) => writeOnDataView(encoder, 4).setFloat32(0, num, false); + var writeFloat64 = (encoder, num) => writeOnDataView(encoder, 8).setFloat64(0, num, false); + var writeBigInt64 = (encoder, num) => ( + /** @type {any} */ + writeOnDataView(encoder, 8).setBigInt64(0, num, false) + ); + var floatTestBed = new DataView(new ArrayBuffer(4)); + var isFloat32 = (num) => { + floatTestBed.setFloat32(0, num); + return floatTestBed.getFloat32(0) === num; + }; + var writeAny = (encoder, data) => { + switch (typeof data) { + case "string": + write(encoder, 119); + writeVarString(encoder, data); + break; + case "number": + if (isInteger(data) && abs(data) <= BITS31) { + write(encoder, 125); + writeVarInt(encoder, data); + } else if (isFloat32(data)) { + write(encoder, 124); + writeFloat32(encoder, data); + } else { + write(encoder, 123); + writeFloat64(encoder, data); + } + break; + case "bigint": + write(encoder, 122); + writeBigInt64(encoder, data); + break; + case "object": + if (data === null) { + write(encoder, 126); + } else if (isArray(data)) { + write(encoder, 117); + writeVarUint(encoder, data.length); + for (let i = 0; i < data.length; i++) { + writeAny(encoder, data[i]); + } + } else if (data instanceof Uint8Array) { + write(encoder, 116); + writeVarUint8Array(encoder, data); + } else { + write(encoder, 118); + const keys2 = Object.keys(data); + writeVarUint(encoder, keys2.length); + for (let i = 0; i < keys2.length; i++) { + const key = keys2[i]; + writeVarString(encoder, key); + writeAny(encoder, data[key]); + } + } + break; + case "boolean": + write(encoder, data ? 120 : 121); + break; + default: + write(encoder, 127); + } + }; + var RleEncoder = class extends Encoder { + /** + * @param {function(Encoder, T):void} writer + */ + constructor(writer) { + super(); + this.w = writer; + this.s = null; + this.count = 0; + } + /** + * @param {T} v + */ + write(v) { + if (this.s === v) { + this.count++; + } else { + if (this.count > 0) { + writeVarUint(this, this.count - 1); + } + this.count = 1; + this.w(this, v); + this.s = v; + } + } + }; + var flushUintOptRleEncoder = (encoder) => { + if (encoder.count > 0) { + writeVarInt(encoder.encoder, encoder.count === 1 ? encoder.s : -encoder.s); + if (encoder.count > 1) { + writeVarUint(encoder.encoder, encoder.count - 2); + } + } + }; + var UintOptRleEncoder = class { + constructor() { + this.encoder = new Encoder(); + this.s = 0; + this.count = 0; + } + /** + * @param {number} v + */ + write(v) { + if (this.s === v) { + this.count++; + } else { + flushUintOptRleEncoder(this); + this.count = 1; + this.s = v; + } + } + /** + * Flush the encoded state and transform this to a Uint8Array. + * + * Note that this should only be called once. + */ + toUint8Array() { + flushUintOptRleEncoder(this); + return toUint8Array(this.encoder); + } + }; + var flushIntDiffOptRleEncoder = (encoder) => { + if (encoder.count > 0) { + const encodedDiff = encoder.diff * 2 + (encoder.count === 1 ? 0 : 1); + writeVarInt(encoder.encoder, encodedDiff); + if (encoder.count > 1) { + writeVarUint(encoder.encoder, encoder.count - 2); + } + } + }; + var IntDiffOptRleEncoder = class { + constructor() { + this.encoder = new Encoder(); + this.s = 0; + this.count = 0; + this.diff = 0; + } + /** + * @param {number} v + */ + write(v) { + if (this.diff === v - this.s) { + this.s = v; + this.count++; + } else { + flushIntDiffOptRleEncoder(this); + this.count = 1; + this.diff = v - this.s; + this.s = v; + } + } + /** + * Flush the encoded state and transform this to a Uint8Array. + * + * Note that this should only be called once. + */ + toUint8Array() { + flushIntDiffOptRleEncoder(this); + return toUint8Array(this.encoder); + } + }; + var StringEncoder = class { + constructor() { + this.sarr = []; + this.s = ""; + this.lensE = new UintOptRleEncoder(); + } + /** + * @param {string} string + */ + write(string) { + this.s += string; + if (this.s.length > 19) { + this.sarr.push(this.s); + this.s = ""; + } + this.lensE.write(string.length); + } + toUint8Array() { + const encoder = new Encoder(); + this.sarr.push(this.s); + this.s = ""; + writeVarString(encoder, this.sarr.join("")); + writeUint8Array(encoder, this.lensE.toUint8Array()); + return toUint8Array(encoder); + } + }; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/error.js + var create3 = (s) => new Error(s); + var methodUnimplemented = () => { + throw create3("Method unimplemented"); + }; + var unexpectedCase = () => { + throw create3("Unexpected case"); + }; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/decoding.js + var errorUnexpectedEndOfArray = create3("Unexpected end of array"); + var errorIntegerOutOfRange = create3("Integer out of Range"); + var Decoder = class { + /** + * @param {Uint8Array} uint8Array Binary data to decode + */ + constructor(uint8Array) { + this.arr = uint8Array; + this.pos = 0; + } + }; + var createDecoder = (uint8Array) => new Decoder(uint8Array); + var hasContent = (decoder) => decoder.pos !== decoder.arr.length; + var readUint8Array = (decoder, len) => { + const view = new Uint8Array(decoder.arr.buffer, decoder.pos + decoder.arr.byteOffset, len); + decoder.pos += len; + return view; + }; + var readVarUint8Array = (decoder) => readUint8Array(decoder, readVarUint(decoder)); + var readUint8 = (decoder) => decoder.arr[decoder.pos++]; + var readVarUint = (decoder) => { + let num = 0; + let mult = 1; + const len = decoder.arr.length; + while (decoder.pos < len) { + const r = decoder.arr[decoder.pos++]; + num = num + (r & BITS7) * mult; + mult *= 128; + if (r < BIT8) { + return num; + } + if (num > MAX_SAFE_INTEGER) { + throw errorIntegerOutOfRange; + } + } + throw errorUnexpectedEndOfArray; + }; + var readVarInt = (decoder) => { + let r = decoder.arr[decoder.pos++]; + let num = r & BITS6; + let mult = 64; + const sign = (r & BIT7) > 0 ? -1 : 1; + if ((r & BIT8) === 0) { + return sign * num; + } + const len = decoder.arr.length; + while (decoder.pos < len) { + r = decoder.arr[decoder.pos++]; + num = num + (r & BITS7) * mult; + mult *= 128; + if (r < BIT8) { + return sign * num; + } + if (num > MAX_SAFE_INTEGER) { + throw errorIntegerOutOfRange; + } + } + throw errorUnexpectedEndOfArray; + }; + var _readVarStringPolyfill = (decoder) => { + let remainingLen = readVarUint(decoder); + if (remainingLen === 0) { + return ""; + } else { + let encodedString = String.fromCodePoint(readUint8(decoder)); + if (--remainingLen < 100) { + while (remainingLen--) { + encodedString += String.fromCodePoint(readUint8(decoder)); + } + } else { + while (remainingLen > 0) { + const nextLen = remainingLen < 1e4 ? remainingLen : 1e4; + const bytes = decoder.arr.subarray(decoder.pos, decoder.pos + nextLen); + decoder.pos += nextLen; + encodedString += String.fromCodePoint.apply( + null, + /** @type {any} */ + bytes + ); + remainingLen -= nextLen; + } + } + return decodeURIComponent(escape(encodedString)); + } + }; + var _readVarStringNative = (decoder) => ( + /** @type any */ + utf8TextDecoder.decode(readVarUint8Array(decoder)) + ); + var readVarString = utf8TextDecoder ? _readVarStringNative : _readVarStringPolyfill; + var readFromDataView = (decoder, len) => { + const dv = new DataView(decoder.arr.buffer, decoder.arr.byteOffset + decoder.pos, len); + decoder.pos += len; + return dv; + }; + var readFloat32 = (decoder) => readFromDataView(decoder, 4).getFloat32(0, false); + var readFloat64 = (decoder) => readFromDataView(decoder, 8).getFloat64(0, false); + var readBigInt64 = (decoder) => ( + /** @type {any} */ + readFromDataView(decoder, 8).getBigInt64(0, false) + ); + var readAnyLookupTable = [ + (decoder) => void 0, + // CASE 127: undefined + (decoder) => null, + // CASE 126: null + readVarInt, + // CASE 125: integer + readFloat32, + // CASE 124: float32 + readFloat64, + // CASE 123: float64 + readBigInt64, + // CASE 122: bigint + (decoder) => false, + // CASE 121: boolean (false) + (decoder) => true, + // CASE 120: boolean (true) + readVarString, + // CASE 119: string + (decoder) => { + const len = readVarUint(decoder); + const obj = {}; + for (let i = 0; i < len; i++) { + const key = readVarString(decoder); + obj[key] = readAny(decoder); + } + return obj; + }, + (decoder) => { + const len = readVarUint(decoder); + const arr = []; + for (let i = 0; i < len; i++) { + arr.push(readAny(decoder)); + } + return arr; + }, + readVarUint8Array + // CASE 116: Uint8Array + ]; + var readAny = (decoder) => readAnyLookupTable[127 - readUint8(decoder)](decoder); + var RleDecoder = class extends Decoder { + /** + * @param {Uint8Array} uint8Array + * @param {function(Decoder):T} reader + */ + constructor(uint8Array, reader) { + super(uint8Array); + this.reader = reader; + this.s = null; + this.count = 0; + } + read() { + if (this.count === 0) { + this.s = this.reader(this); + if (hasContent(this)) { + this.count = readVarUint(this) + 1; + } else { + this.count = -1; + } + } + this.count--; + return ( + /** @type {T} */ + this.s + ); + } + }; + var UintOptRleDecoder = class extends Decoder { + /** + * @param {Uint8Array} uint8Array + */ + constructor(uint8Array) { + super(uint8Array); + this.s = 0; + this.count = 0; + } + read() { + if (this.count === 0) { + this.s = readVarInt(this); + const isNegative = isNegativeZero(this.s); + this.count = 1; + if (isNegative) { + this.s = -this.s; + this.count = readVarUint(this) + 2; + } + } + this.count--; + return ( + /** @type {number} */ + this.s + ); + } + }; + var IntDiffOptRleDecoder = class extends Decoder { + /** + * @param {Uint8Array} uint8Array + */ + constructor(uint8Array) { + super(uint8Array); + this.s = 0; + this.count = 0; + this.diff = 0; + } + /** + * @return {number} + */ + read() { + if (this.count === 0) { + const diff = readVarInt(this); + const hasCount = diff & 1; + this.diff = floor(diff / 2); + this.count = 1; + if (hasCount) { + this.count = readVarUint(this) + 2; + } + } + this.s += this.diff; + this.count--; + return this.s; + } + }; + var StringDecoder = class { + /** + * @param {Uint8Array} uint8Array + */ + constructor(uint8Array) { + this.decoder = new UintOptRleDecoder(uint8Array); + this.str = readVarString(this.decoder); + this.spos = 0; + } + /** + * @return {string} + */ + read() { + const end = this.spos + this.decoder.read(); + const res = this.str.slice(this.spos, end); + this.spos = end; + return res; + } + }; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/webcrypto.js + var subtle = crypto.subtle; + var getRandomValues = crypto.getRandomValues.bind(crypto); + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/random.js + var uint32 = () => getRandomValues(new Uint32Array(1))[0]; + var uuidv4Template = "10000000-1000-4000-8000" + -1e11; + var uuidv4 = () => uuidv4Template.replace( + /[018]/g, + /** @param {number} c */ + (c) => (c ^ uint32() & 15 >> c / 4).toString(16) + ); + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/time.js + var getUnixTime = Date.now; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/promise.js + var create4 = (f) => ( + /** @type {Promise} */ + new Promise(f) + ); + var all = Promise.all.bind(Promise); + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/conditions.js + var undefinedToNull = (v) => v === void 0 ? null : v; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/storage.js + var VarStoragePolyfill = class { + constructor() { + this.map = /* @__PURE__ */ new Map(); + } + /** + * @param {string} key + * @param {any} newValue + */ + setItem(key, newValue) { + this.map.set(key, newValue); + } + /** + * @param {string} key + */ + getItem(key) { + return this.map.get(key); + } + }; + var _localStorage = new VarStoragePolyfill(); + var usePolyfill = true; + try { + if (typeof localStorage !== "undefined" && localStorage) { + _localStorage = localStorage; + usePolyfill = false; + } + } catch (e) { + } + var varStorage = _localStorage; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/object.js + var assign = Object.assign; + var keys = Object.keys; + var forEach = (obj, f) => { + for (const key in obj) { + f(obj[key], key); + } + }; + var size = (obj) => keys(obj).length; + var isEmpty = (obj) => { + for (const _k in obj) { + return false; + } + return true; + }; + var every = (obj, f) => { + for (const key in obj) { + if (!f(obj[key], key)) { + return false; + } + } + return true; + }; + var hasProperty = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); + var equalFlat = (a, b) => a === b || size(a) === size(b) && every(a, (val, key) => (val !== void 0 || hasProperty(b, key)) && b[key] === val); + var freeze = Object.freeze; + var deepFreeze = (o) => { + for (const key in o) { + const c = o[key]; + if (typeof c === "object" || typeof c === "function") { + deepFreeze(o[key]); + } + } + return freeze(o); + }; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/function.js + var callAll = (fs, args2, i = 0) => { + try { + for (; i < fs.length; i++) { + fs[i](...args2); + } + } finally { + if (i < fs.length) { + callAll(fs, args2, i + 1); + } + } + }; + var id = (a) => a; + var isOneOf = (value, options) => options.includes(value); + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/environment.js + var isNode = typeof process !== "undefined" && process.release && /node|io\.js/.test(process.release.name) && Object.prototype.toString.call(typeof process !== "undefined" ? process : 0) === "[object process]"; + var isMac = typeof navigator !== "undefined" ? /Mac/.test(navigator.platform) : false; + var params; + var args = []; + var computeParams = () => { + if (params === void 0) { + if (isNode) { + params = create(); + const pargs = process.argv; + let currParamName = null; + for (let i = 0; i < pargs.length; i++) { + const parg = pargs[i]; + if (parg[0] === "-") { + if (currParamName !== null) { + params.set(currParamName, ""); + } + currParamName = parg; + } else { + if (currParamName !== null) { + params.set(currParamName, parg); + currParamName = null; + } else { + args.push(parg); + } + } + } + if (currParamName !== null) { + params.set(currParamName, ""); + } + } else if (typeof location === "object") { + params = create(); + (location.search || "?").slice(1).split("&").forEach((kv) => { + if (kv.length !== 0) { + const [key, value] = kv.split("="); + params.set(`--${fromCamelCase(key, "-")}`, value); + params.set(`-${fromCamelCase(key, "-")}`, value); + } + }); + } else { + params = create(); + } + } + return params; + }; + var hasParam = (name) => computeParams().has(name); + var getVariable = (name) => isNode ? undefinedToNull(process.env[name.toUpperCase().replaceAll("-", "_")]) : undefinedToNull(varStorage.getItem(name)); + var hasConf = (name) => hasParam("--" + name) || getVariable(name) !== null; + var production = hasConf("production"); + var forceColor = isNode && isOneOf(process.env.FORCE_COLOR, ["true", "1", "2"]); + var supportsColor = forceColor || !hasParam("--no-colors") && // @todo deprecate --no-colors + !hasConf("no-color") && (!isNode || process.stdout.isTTY) && (!isNode || hasParam("--color") || getVariable("COLORTERM") !== null || (getVariable("TERM") || "").includes("color")); + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/buffer.js + var createUint8ArrayFromLen = (len) => new Uint8Array(len); + var copyUint8Array = (uint8Array) => { + const newBuf = createUint8ArrayFromLen(uint8Array.byteLength); + newBuf.set(uint8Array); + return newBuf; + }; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/pair.js + var Pair = class { + /** + * @param {L} left + * @param {R} right + */ + constructor(left, right) { + this.left = left; + this.right = right; + } + }; + var create5 = (left, right) => new Pair(left, right); + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/dom.js + var doc = ( + /** @type {Document} */ + typeof document !== "undefined" ? document : {} + ); + var domParser = ( + /** @type {DOMParser} */ + typeof DOMParser !== "undefined" ? new DOMParser() : null + ); + var mapToStyleString = (m) => map(m, (value, key) => `${key}:${value};`).join(""); + var ELEMENT_NODE = doc.ELEMENT_NODE; + var TEXT_NODE = doc.TEXT_NODE; + var CDATA_SECTION_NODE = doc.CDATA_SECTION_NODE; + var COMMENT_NODE = doc.COMMENT_NODE; + var DOCUMENT_NODE = doc.DOCUMENT_NODE; + var DOCUMENT_TYPE_NODE = doc.DOCUMENT_TYPE_NODE; + var DOCUMENT_FRAGMENT_NODE = doc.DOCUMENT_FRAGMENT_NODE; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/symbol.js + var create6 = Symbol; + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/logging.common.js + var BOLD = create6(); + var UNBOLD = create6(); + var BLUE = create6(); + var GREY = create6(); + var GREEN = create6(); + var RED = create6(); + var PURPLE = create6(); + var ORANGE = create6(); + var UNCOLOR = create6(); + var computeNoColorLoggingArgs = (args2) => { + if (args2.length === 1 && args2[0]?.constructor === Function) { + args2 = /** @type {Array} */ + /** @type {[function]} */ + args2[0](); + } + const strBuilder = []; + const logArgs = []; + let i = 0; + for (; i < args2.length; i++) { + const arg = args2[i]; + if (arg === void 0) { + break; + } else if (arg.constructor === String || arg.constructor === Number) { + strBuilder.push(arg); + } else if (arg.constructor === Object) { + break; + } + } + if (i > 0) { + logArgs.push(strBuilder.join("")); + } + for (; i < args2.length; i++) { + const arg = args2[i]; + if (!(arg instanceof Symbol)) { + logArgs.push(arg); + } + } + return logArgs; + }; + var lastLoggingTime = getUnixTime(); + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/logging.js + var _browserStyleMap = { + [BOLD]: create5("font-weight", "bold"), + [UNBOLD]: create5("font-weight", "normal"), + [BLUE]: create5("color", "blue"), + [GREEN]: create5("color", "green"), + [GREY]: create5("color", "grey"), + [RED]: create5("color", "red"), + [PURPLE]: create5("color", "purple"), + [ORANGE]: create5("color", "orange"), + // not well supported in chrome when debugging node with inspector - TODO: deprecate + [UNCOLOR]: create5("color", "black") + }; + var computeBrowserLoggingArgs = (args2) => { + if (args2.length === 1 && args2[0]?.constructor === Function) { + args2 = /** @type {Array} */ + /** @type {[function]} */ + args2[0](); + } + const strBuilder = []; + const styles = []; + const currentStyle = create(); + let logArgs = []; + let i = 0; + for (; i < args2.length; i++) { + const arg = args2[i]; + const style = _browserStyleMap[arg]; + if (style !== void 0) { + currentStyle.set(style.left, style.right); + } else { + if (arg === void 0) { + break; + } + if (arg.constructor === String || arg.constructor === Number) { + const style2 = mapToStyleString(currentStyle); + if (i > 0 || style2.length > 0) { + strBuilder.push("%c" + arg); + styles.push(style2); + } else { + strBuilder.push(arg); + } + } else { + break; + } + } + } + if (i > 0) { + logArgs = styles; + logArgs.unshift(strBuilder.join("")); + } + for (; i < args2.length; i++) { + const arg = args2[i]; + if (!(arg instanceof Symbol)) { + logArgs.push(arg); + } + } + return logArgs; + }; + var computeLoggingArgs = supportsColor ? computeBrowserLoggingArgs : computeNoColorLoggingArgs; + var print = (...args2) => { + console.log(...computeLoggingArgs(args2)); + vconsoles.forEach((vc) => vc.print(args2)); + }; + var warn = (...args2) => { + console.warn(...computeLoggingArgs(args2)); + args2.unshift(ORANGE); + vconsoles.forEach((vc) => vc.print(args2)); + }; + var vconsoles = create2(); + + // node_modules/.pnpm/lib0@0.2.114/node_modules/lib0/iterator.js + var createIterator = (next) => ({ + /** + * @return {IterableIterator} + */ + [Symbol.iterator]() { + return this; + }, + // @ts-ignore + next + }); + var iteratorFilter = (iterator, filter) => createIterator(() => { + let res; + do { + res = iterator.next(); + } while (!res.done && !filter(res.value)); + return res; + }); + var iteratorMap = (iterator, fmap) => createIterator(() => { + const { done, value } = iterator.next(); + return { done, value: done ? void 0 : fmap(value) }; + }); + + // node_modules/.pnpm/yjs@13.6.27/node_modules/yjs/dist/yjs.mjs + var AbstractConnector = class extends ObservableV2 { + /** + * @param {Doc} ydoc + * @param {any} awareness + */ + constructor(ydoc, awareness) { + super(); + this.doc = ydoc; + this.awareness = awareness; + } + }; + var DeleteItem = class { + /** + * @param {number} clock + * @param {number} len + */ + constructor(clock, len) { + this.clock = clock; + this.len = len; + } + }; + var DeleteSet = class { + constructor() { + this.clients = /* @__PURE__ */ new Map(); + } + }; + var iterateDeletedStructs = (transaction, ds, f) => ds.clients.forEach((deletes, clientid) => { + const structs = ( + /** @type {Array} */ + transaction.doc.store.clients.get(clientid) + ); + if (structs != null) { + const lastStruct = structs[structs.length - 1]; + const clockState = lastStruct.id.clock + lastStruct.length; + for (let i = 0, del = deletes[i]; i < deletes.length && del.clock < clockState; del = deletes[++i]) { + iterateStructs(transaction, structs, del.clock, del.len, f); + } + } + }); + var findIndexDS = (dis, clock) => { + let left = 0; + let right = dis.length - 1; + while (left <= right) { + const midindex = floor((left + right) / 2); + const mid = dis[midindex]; + const midclock = mid.clock; + if (midclock <= clock) { + if (clock < midclock + mid.len) { + return midindex; + } + left = midindex + 1; + } else { + right = midindex - 1; + } + } + return null; + }; + var isDeleted = (ds, id2) => { + const dis = ds.clients.get(id2.client); + return dis !== void 0 && findIndexDS(dis, id2.clock) !== null; + }; + var sortAndMergeDeleteSet = (ds) => { + ds.clients.forEach((dels) => { + dels.sort((a, b) => a.clock - b.clock); + let i, j; + for (i = 1, j = 1; i < dels.length; i++) { + const left = dels[j - 1]; + const right = dels[i]; + if (left.clock + left.len >= right.clock) { + left.len = max(left.len, right.clock + right.len - left.clock); + } else { + if (j < i) { + dels[j] = right; + } + j++; + } + } + dels.length = j; + }); + }; + var mergeDeleteSets = (dss) => { + const merged = new DeleteSet(); + for (let dssI = 0; dssI < dss.length; dssI++) { + dss[dssI].clients.forEach((delsLeft, client) => { + if (!merged.clients.has(client)) { + const dels = delsLeft.slice(); + for (let i = dssI + 1; i < dss.length; i++) { + appendTo(dels, dss[i].clients.get(client) || []); + } + merged.clients.set(client, dels); + } + }); + } + sortAndMergeDeleteSet(merged); + return merged; + }; + var addToDeleteSet = (ds, client, clock, length2) => { + setIfUndefined(ds.clients, client, () => ( + /** @type {Array} */ + [] + )).push(new DeleteItem(clock, length2)); + }; + var createDeleteSet = () => new DeleteSet(); + var createDeleteSetFromStructStore = (ss) => { + const ds = createDeleteSet(); + ss.clients.forEach((structs, client) => { + const dsitems = []; + for (let i = 0; i < structs.length; i++) { + const struct = structs[i]; + if (struct.deleted) { + const clock = struct.id.clock; + let len = struct.length; + if (i + 1 < structs.length) { + for (let next = structs[i + 1]; i + 1 < structs.length && next.deleted; next = structs[++i + 1]) { + len += next.length; + } + } + dsitems.push(new DeleteItem(clock, len)); + } + } + if (dsitems.length > 0) { + ds.clients.set(client, dsitems); + } + }); + return ds; + }; + var writeDeleteSet = (encoder, ds) => { + writeVarUint(encoder.restEncoder, ds.clients.size); + from(ds.clients.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, dsitems]) => { + encoder.resetDsCurVal(); + writeVarUint(encoder.restEncoder, client); + const len = dsitems.length; + writeVarUint(encoder.restEncoder, len); + for (let i = 0; i < len; i++) { + const item = dsitems[i]; + encoder.writeDsClock(item.clock); + encoder.writeDsLen(item.len); + } + }); + }; + var readDeleteSet = (decoder) => { + const ds = new DeleteSet(); + const numClients = readVarUint(decoder.restDecoder); + for (let i = 0; i < numClients; i++) { + decoder.resetDsCurVal(); + const client = readVarUint(decoder.restDecoder); + const numberOfDeletes = readVarUint(decoder.restDecoder); + if (numberOfDeletes > 0) { + const dsField = setIfUndefined(ds.clients, client, () => ( + /** @type {Array} */ + [] + )); + for (let i2 = 0; i2 < numberOfDeletes; i2++) { + dsField.push(new DeleteItem(decoder.readDsClock(), decoder.readDsLen())); + } + } + } + return ds; + }; + var readAndApplyDeleteSet = (decoder, transaction, store) => { + const unappliedDS = new DeleteSet(); + const numClients = readVarUint(decoder.restDecoder); + for (let i = 0; i < numClients; i++) { + decoder.resetDsCurVal(); + const client = readVarUint(decoder.restDecoder); + const numberOfDeletes = readVarUint(decoder.restDecoder); + const structs = store.clients.get(client) || []; + const state = getState(store, client); + for (let i2 = 0; i2 < numberOfDeletes; i2++) { + const clock = decoder.readDsClock(); + const clockEnd = clock + decoder.readDsLen(); + if (clock < state) { + if (state < clockEnd) { + addToDeleteSet(unappliedDS, client, state, clockEnd - state); + } + let index = findIndexSS(structs, clock); + let struct = structs[index]; + if (!struct.deleted && struct.id.clock < clock) { + structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock)); + index++; + } + while (index < structs.length) { + struct = structs[index++]; + if (struct.id.clock < clockEnd) { + if (!struct.deleted) { + if (clockEnd < struct.id.clock + struct.length) { + structs.splice(index, 0, splitItem(transaction, struct, clockEnd - struct.id.clock)); + } + struct.delete(transaction); + } + } else { + break; + } + } + } else { + addToDeleteSet(unappliedDS, client, clock, clockEnd - clock); + } + } + } + if (unappliedDS.clients.size > 0) { + const ds = new UpdateEncoderV2(); + writeVarUint(ds.restEncoder, 0); + writeDeleteSet(ds, unappliedDS); + return ds.toUint8Array(); + } + return null; + }; + var equalDeleteSets = (ds1, ds2) => { + if (ds1.clients.size !== ds2.clients.size) return false; + for (const [client, deleteItems1] of ds1.clients.entries()) { + const deleteItems2 = ( + /** @type {Array} */ + ds2.clients.get(client) + ); + if (deleteItems2 === void 0 || deleteItems1.length !== deleteItems2.length) return false; + for (let i = 0; i < deleteItems1.length; i++) { + const di1 = deleteItems1[i]; + const di2 = deleteItems2[i]; + if (di1.clock !== di2.clock || di1.len !== di2.len) { + return false; + } + } + } + return true; + }; + var generateNewClientId = uint32; + var Doc = class _Doc extends ObservableV2 { + /** + * @param {DocOpts} opts configuration + */ + constructor({ guid = uuidv4(), collectionid = null, gc = true, gcFilter = () => true, meta = null, autoLoad = false, shouldLoad = true } = {}) { + super(); + this.gc = gc; + this.gcFilter = gcFilter; + this.clientID = generateNewClientId(); + this.guid = guid; + this.collectionid = collectionid; + this.share = /* @__PURE__ */ new Map(); + this.store = new StructStore(); + this._transaction = null; + this._transactionCleanups = []; + this.subdocs = /* @__PURE__ */ new Set(); + this._item = null; + this.shouldLoad = shouldLoad; + this.autoLoad = autoLoad; + this.meta = meta; + this.isLoaded = false; + this.isSynced = false; + this.isDestroyed = false; + this.whenLoaded = create4((resolve) => { + this.on("load", () => { + this.isLoaded = true; + resolve(this); + }); + }); + const provideSyncedPromise = () => create4((resolve) => { + const eventHandler = (isSynced) => { + if (isSynced === void 0 || isSynced === true) { + this.off("sync", eventHandler); + resolve(); + } + }; + this.on("sync", eventHandler); + }); + this.on("sync", (isSynced) => { + if (isSynced === false && this.isSynced) { + this.whenSynced = provideSyncedPromise(); + } + this.isSynced = isSynced === void 0 || isSynced === true; + if (this.isSynced && !this.isLoaded) { + this.emit("load", [this]); + } + }); + this.whenSynced = provideSyncedPromise(); + } + /** + * Notify the parent document that you request to load data into this subdocument (if it is a subdocument). + * + * `load()` might be used in the future to request any provider to load the most current data. + * + * It is safe to call `load()` multiple times. + */ + load() { + const item = this._item; + if (item !== null && !this.shouldLoad) { + transact( + /** @type {any} */ + item.parent.doc, + (transaction) => { + transaction.subdocsLoaded.add(this); + }, + null, + true + ); + } + this.shouldLoad = true; + } + getSubdocs() { + return this.subdocs; + } + getSubdocGuids() { + return new Set(from(this.subdocs).map((doc2) => doc2.guid)); + } + /** + * Changes that happen inside of a transaction are bundled. This means that + * the observer fires _after_ the transaction is finished and that all changes + * that happened inside of the transaction are sent as one message to the + * other peers. + * + * @template T + * @param {function(Transaction):T} f The function that should be executed as a transaction + * @param {any} [origin] Origin of who started the transaction. Will be stored on transaction.origin + * @return T + * + * @public + */ + transact(f, origin = null) { + return transact(this, f, origin); + } + /** + * Define a shared data type. + * + * Multiple calls of `ydoc.get(name, TypeConstructor)` yield the same result + * and do not overwrite each other. I.e. + * `ydoc.get(name, Y.Array) === ydoc.get(name, Y.Array)` + * + * After this method is called, the type is also available on `ydoc.share.get(name)`. + * + * *Best Practices:* + * Define all types right after the Y.Doc instance is created and store them in a separate object. + * Also use the typed methods `getText(name)`, `getArray(name)`, .. + * + * @template {typeof AbstractType} Type + * @example + * const ydoc = new Y.Doc(..) + * const appState = { + * document: ydoc.getText('document') + * comments: ydoc.getArray('comments') + * } + * + * @param {string} name + * @param {Type} TypeConstructor The constructor of the type definition. E.g. Y.Text, Y.Array, Y.Map, ... + * @return {InstanceType} The created type. Constructed with TypeConstructor + * + * @public + */ + get(name, TypeConstructor = ( + /** @type {any} */ + AbstractType + )) { + const type = setIfUndefined(this.share, name, () => { + const t = new TypeConstructor(); + t._integrate(this, null); + return t; + }); + const Constr = type.constructor; + if (TypeConstructor !== AbstractType && Constr !== TypeConstructor) { + if (Constr === AbstractType) { + const t = new TypeConstructor(); + t._map = type._map; + type._map.forEach( + /** @param {Item?} n */ + (n) => { + for (; n !== null; n = n.left) { + n.parent = t; + } + } + ); + t._start = type._start; + for (let n = t._start; n !== null; n = n.right) { + n.parent = t; + } + t._length = type._length; + this.share.set(name, t); + t._integrate(this, null); + return ( + /** @type {InstanceType} */ + t + ); + } else { + throw new Error(`Type with the name ${name} has already been defined with a different constructor`); + } + } + return ( + /** @type {InstanceType} */ + type + ); + } + /** + * @template T + * @param {string} [name] + * @return {YArray} + * + * @public + */ + getArray(name = "") { + return ( + /** @type {YArray} */ + this.get(name, YArray) + ); + } + /** + * @param {string} [name] + * @return {YText} + * + * @public + */ + getText(name = "") { + return this.get(name, YText); + } + /** + * @template T + * @param {string} [name] + * @return {YMap} + * + * @public + */ + getMap(name = "") { + return ( + /** @type {YMap} */ + this.get(name, YMap) + ); + } + /** + * @param {string} [name] + * @return {YXmlElement} + * + * @public + */ + getXmlElement(name = "") { + return ( + /** @type {YXmlElement<{[key:string]:string}>} */ + this.get(name, YXmlElement) + ); + } + /** + * @param {string} [name] + * @return {YXmlFragment} + * + * @public + */ + getXmlFragment(name = "") { + return this.get(name, YXmlFragment); + } + /** + * Converts the entire document into a js object, recursively traversing each yjs type + * Doesn't log types that have not been defined (using ydoc.getType(..)). + * + * @deprecated Do not use this method and rather call toJSON directly on the shared types. + * + * @return {Object} + */ + toJSON() { + const doc2 = {}; + this.share.forEach((value, key) => { + doc2[key] = value.toJSON(); + }); + return doc2; + } + /** + * Emit `destroy` event and unregister all event handlers. + */ + destroy() { + this.isDestroyed = true; + from(this.subdocs).forEach((subdoc) => subdoc.destroy()); + const item = this._item; + if (item !== null) { + this._item = null; + const content = ( + /** @type {ContentDoc} */ + item.content + ); + content.doc = new _Doc({ guid: this.guid, ...content.opts, shouldLoad: false }); + content.doc._item = item; + transact( + /** @type {any} */ + item.parent.doc, + (transaction) => { + const doc2 = content.doc; + if (!item.deleted) { + transaction.subdocsAdded.add(doc2); + } + transaction.subdocsRemoved.add(this); + }, + null, + true + ); + } + this.emit("destroyed", [true]); + this.emit("destroy", [this]); + super.destroy(); + } + }; + var DSDecoderV1 = class { + /** + * @param {decoding.Decoder} decoder + */ + constructor(decoder) { + this.restDecoder = decoder; + } + resetDsCurVal() { + } + /** + * @return {number} + */ + readDsClock() { + return readVarUint(this.restDecoder); + } + /** + * @return {number} + */ + readDsLen() { + return readVarUint(this.restDecoder); + } + }; + var UpdateDecoderV1 = class extends DSDecoderV1 { + /** + * @return {ID} + */ + readLeftID() { + return createID(readVarUint(this.restDecoder), readVarUint(this.restDecoder)); + } + /** + * @return {ID} + */ + readRightID() { + return createID(readVarUint(this.restDecoder), readVarUint(this.restDecoder)); + } + /** + * Read the next client id. + * Use this in favor of readID whenever possible to reduce the number of objects created. + */ + readClient() { + return readVarUint(this.restDecoder); + } + /** + * @return {number} info An unsigned 8-bit integer + */ + readInfo() { + return readUint8(this.restDecoder); + } + /** + * @return {string} + */ + readString() { + return readVarString(this.restDecoder); + } + /** + * @return {boolean} isKey + */ + readParentInfo() { + return readVarUint(this.restDecoder) === 1; + } + /** + * @return {number} info An unsigned 8-bit integer + */ + readTypeRef() { + return readVarUint(this.restDecoder); + } + /** + * Write len of a struct - well suited for Opt RLE encoder. + * + * @return {number} len + */ + readLen() { + return readVarUint(this.restDecoder); + } + /** + * @return {any} + */ + readAny() { + return readAny(this.restDecoder); + } + /** + * @return {Uint8Array} + */ + readBuf() { + return copyUint8Array(readVarUint8Array(this.restDecoder)); + } + /** + * Legacy implementation uses JSON parse. We use any-decoding in v2. + * + * @return {any} + */ + readJSON() { + return JSON.parse(readVarString(this.restDecoder)); + } + /** + * @return {string} + */ + readKey() { + return readVarString(this.restDecoder); + } + }; + var DSDecoderV2 = class { + /** + * @param {decoding.Decoder} decoder + */ + constructor(decoder) { + this.dsCurrVal = 0; + this.restDecoder = decoder; + } + resetDsCurVal() { + this.dsCurrVal = 0; + } + /** + * @return {number} + */ + readDsClock() { + this.dsCurrVal += readVarUint(this.restDecoder); + return this.dsCurrVal; + } + /** + * @return {number} + */ + readDsLen() { + const diff = readVarUint(this.restDecoder) + 1; + this.dsCurrVal += diff; + return diff; + } + }; + var UpdateDecoderV2 = class extends DSDecoderV2 { + /** + * @param {decoding.Decoder} decoder + */ + constructor(decoder) { + super(decoder); + this.keys = []; + readVarUint(decoder); + this.keyClockDecoder = new IntDiffOptRleDecoder(readVarUint8Array(decoder)); + this.clientDecoder = new UintOptRleDecoder(readVarUint8Array(decoder)); + this.leftClockDecoder = new IntDiffOptRleDecoder(readVarUint8Array(decoder)); + this.rightClockDecoder = new IntDiffOptRleDecoder(readVarUint8Array(decoder)); + this.infoDecoder = new RleDecoder(readVarUint8Array(decoder), readUint8); + this.stringDecoder = new StringDecoder(readVarUint8Array(decoder)); + this.parentInfoDecoder = new RleDecoder(readVarUint8Array(decoder), readUint8); + this.typeRefDecoder = new UintOptRleDecoder(readVarUint8Array(decoder)); + this.lenDecoder = new UintOptRleDecoder(readVarUint8Array(decoder)); + } + /** + * @return {ID} + */ + readLeftID() { + return new ID(this.clientDecoder.read(), this.leftClockDecoder.read()); + } + /** + * @return {ID} + */ + readRightID() { + return new ID(this.clientDecoder.read(), this.rightClockDecoder.read()); + } + /** + * Read the next client id. + * Use this in favor of readID whenever possible to reduce the number of objects created. + */ + readClient() { + return this.clientDecoder.read(); + } + /** + * @return {number} info An unsigned 8-bit integer + */ + readInfo() { + return ( + /** @type {number} */ + this.infoDecoder.read() + ); + } + /** + * @return {string} + */ + readString() { + return this.stringDecoder.read(); + } + /** + * @return {boolean} + */ + readParentInfo() { + return this.parentInfoDecoder.read() === 1; + } + /** + * @return {number} An unsigned 8-bit integer + */ + readTypeRef() { + return this.typeRefDecoder.read(); + } + /** + * Write len of a struct - well suited for Opt RLE encoder. + * + * @return {number} + */ + readLen() { + return this.lenDecoder.read(); + } + /** + * @return {any} + */ + readAny() { + return readAny(this.restDecoder); + } + /** + * @return {Uint8Array} + */ + readBuf() { + return readVarUint8Array(this.restDecoder); + } + /** + * This is mainly here for legacy purposes. + * + * Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder. + * + * @return {any} + */ + readJSON() { + return readAny(this.restDecoder); + } + /** + * @return {string} + */ + readKey() { + const keyClock = this.keyClockDecoder.read(); + if (keyClock < this.keys.length) { + return this.keys[keyClock]; + } else { + const key = this.stringDecoder.read(); + this.keys.push(key); + return key; + } + } + }; + var DSEncoderV1 = class { + constructor() { + this.restEncoder = createEncoder(); + } + toUint8Array() { + return toUint8Array(this.restEncoder); + } + resetDsCurVal() { + } + /** + * @param {number} clock + */ + writeDsClock(clock) { + writeVarUint(this.restEncoder, clock); + } + /** + * @param {number} len + */ + writeDsLen(len) { + writeVarUint(this.restEncoder, len); + } + }; + var UpdateEncoderV1 = class extends DSEncoderV1 { + /** + * @param {ID} id + */ + writeLeftID(id2) { + writeVarUint(this.restEncoder, id2.client); + writeVarUint(this.restEncoder, id2.clock); + } + /** + * @param {ID} id + */ + writeRightID(id2) { + writeVarUint(this.restEncoder, id2.client); + writeVarUint(this.restEncoder, id2.clock); + } + /** + * Use writeClient and writeClock instead of writeID if possible. + * @param {number} client + */ + writeClient(client) { + writeVarUint(this.restEncoder, client); + } + /** + * @param {number} info An unsigned 8-bit integer + */ + writeInfo(info) { + writeUint8(this.restEncoder, info); + } + /** + * @param {string} s + */ + writeString(s) { + writeVarString(this.restEncoder, s); + } + /** + * @param {boolean} isYKey + */ + writeParentInfo(isYKey) { + writeVarUint(this.restEncoder, isYKey ? 1 : 0); + } + /** + * @param {number} info An unsigned 8-bit integer + */ + writeTypeRef(info) { + writeVarUint(this.restEncoder, info); + } + /** + * Write len of a struct - well suited for Opt RLE encoder. + * + * @param {number} len + */ + writeLen(len) { + writeVarUint(this.restEncoder, len); + } + /** + * @param {any} any + */ + writeAny(any2) { + writeAny(this.restEncoder, any2); + } + /** + * @param {Uint8Array} buf + */ + writeBuf(buf) { + writeVarUint8Array(this.restEncoder, buf); + } + /** + * @param {any} embed + */ + writeJSON(embed) { + writeVarString(this.restEncoder, JSON.stringify(embed)); + } + /** + * @param {string} key + */ + writeKey(key) { + writeVarString(this.restEncoder, key); + } + }; + var DSEncoderV2 = class { + constructor() { + this.restEncoder = createEncoder(); + this.dsCurrVal = 0; + } + toUint8Array() { + return toUint8Array(this.restEncoder); + } + resetDsCurVal() { + this.dsCurrVal = 0; + } + /** + * @param {number} clock + */ + writeDsClock(clock) { + const diff = clock - this.dsCurrVal; + this.dsCurrVal = clock; + writeVarUint(this.restEncoder, diff); + } + /** + * @param {number} len + */ + writeDsLen(len) { + if (len === 0) { + unexpectedCase(); + } + writeVarUint(this.restEncoder, len - 1); + this.dsCurrVal += len; + } + }; + var UpdateEncoderV2 = class extends DSEncoderV2 { + constructor() { + super(); + this.keyMap = /* @__PURE__ */ new Map(); + this.keyClock = 0; + this.keyClockEncoder = new IntDiffOptRleEncoder(); + this.clientEncoder = new UintOptRleEncoder(); + this.leftClockEncoder = new IntDiffOptRleEncoder(); + this.rightClockEncoder = new IntDiffOptRleEncoder(); + this.infoEncoder = new RleEncoder(writeUint8); + this.stringEncoder = new StringEncoder(); + this.parentInfoEncoder = new RleEncoder(writeUint8); + this.typeRefEncoder = new UintOptRleEncoder(); + this.lenEncoder = new UintOptRleEncoder(); + } + toUint8Array() { + const encoder = createEncoder(); + writeVarUint(encoder, 0); + writeVarUint8Array(encoder, this.keyClockEncoder.toUint8Array()); + writeVarUint8Array(encoder, this.clientEncoder.toUint8Array()); + writeVarUint8Array(encoder, this.leftClockEncoder.toUint8Array()); + writeVarUint8Array(encoder, this.rightClockEncoder.toUint8Array()); + writeVarUint8Array(encoder, toUint8Array(this.infoEncoder)); + writeVarUint8Array(encoder, this.stringEncoder.toUint8Array()); + writeVarUint8Array(encoder, toUint8Array(this.parentInfoEncoder)); + writeVarUint8Array(encoder, this.typeRefEncoder.toUint8Array()); + writeVarUint8Array(encoder, this.lenEncoder.toUint8Array()); + writeUint8Array(encoder, toUint8Array(this.restEncoder)); + return toUint8Array(encoder); + } + /** + * @param {ID} id + */ + writeLeftID(id2) { + this.clientEncoder.write(id2.client); + this.leftClockEncoder.write(id2.clock); + } + /** + * @param {ID} id + */ + writeRightID(id2) { + this.clientEncoder.write(id2.client); + this.rightClockEncoder.write(id2.clock); + } + /** + * @param {number} client + */ + writeClient(client) { + this.clientEncoder.write(client); + } + /** + * @param {number} info An unsigned 8-bit integer + */ + writeInfo(info) { + this.infoEncoder.write(info); + } + /** + * @param {string} s + */ + writeString(s) { + this.stringEncoder.write(s); + } + /** + * @param {boolean} isYKey + */ + writeParentInfo(isYKey) { + this.parentInfoEncoder.write(isYKey ? 1 : 0); + } + /** + * @param {number} info An unsigned 8-bit integer + */ + writeTypeRef(info) { + this.typeRefEncoder.write(info); + } + /** + * Write len of a struct - well suited for Opt RLE encoder. + * + * @param {number} len + */ + writeLen(len) { + this.lenEncoder.write(len); + } + /** + * @param {any} any + */ + writeAny(any2) { + writeAny(this.restEncoder, any2); + } + /** + * @param {Uint8Array} buf + */ + writeBuf(buf) { + writeVarUint8Array(this.restEncoder, buf); + } + /** + * This is mainly here for legacy purposes. + * + * Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder. + * + * @param {any} embed + */ + writeJSON(embed) { + writeAny(this.restEncoder, embed); + } + /** + * Property keys are often reused. For example, in y-prosemirror the key `bold` might + * occur very often. For a 3d application, the key `position` might occur very often. + * + * We cache these keys in a Map and refer to them via a unique number. + * + * @param {string} key + */ + writeKey(key) { + const clock = this.keyMap.get(key); + if (clock === void 0) { + this.keyClockEncoder.write(this.keyClock++); + this.stringEncoder.write(key); + } else { + this.keyClockEncoder.write(clock); + } + } + }; + var writeStructs = (encoder, structs, client, clock) => { + clock = max(clock, structs[0].id.clock); + const startNewStructs = findIndexSS(structs, clock); + writeVarUint(encoder.restEncoder, structs.length - startNewStructs); + encoder.writeClient(client); + writeVarUint(encoder.restEncoder, clock); + const firstStruct = structs[startNewStructs]; + firstStruct.write(encoder, clock - firstStruct.id.clock); + for (let i = startNewStructs + 1; i < structs.length; i++) { + structs[i].write(encoder, 0); + } + }; + var writeClientsStructs = (encoder, store, _sm) => { + const sm = /* @__PURE__ */ new Map(); + _sm.forEach((clock, client) => { + if (getState(store, client) > clock) { + sm.set(client, clock); + } + }); + getStateVector(store).forEach((_clock, client) => { + if (!_sm.has(client)) { + sm.set(client, 0); + } + }); + writeVarUint(encoder.restEncoder, sm.size); + from(sm.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => { + writeStructs( + encoder, + /** @type {Array} */ + store.clients.get(client), + client, + clock + ); + }); + }; + var readClientsStructRefs = (decoder, doc2) => { + const clientRefs = create(); + const numOfStateUpdates = readVarUint(decoder.restDecoder); + for (let i = 0; i < numOfStateUpdates; i++) { + const numberOfStructs = readVarUint(decoder.restDecoder); + const refs = new Array(numberOfStructs); + const client = decoder.readClient(); + let clock = readVarUint(decoder.restDecoder); + clientRefs.set(client, { i: 0, refs }); + for (let i2 = 0; i2 < numberOfStructs; i2++) { + const info = decoder.readInfo(); + switch (BITS5 & info) { + case 0: { + const len = decoder.readLen(); + refs[i2] = new GC(createID(client, clock), len); + clock += len; + break; + } + case 10: { + const len = readVarUint(decoder.restDecoder); + refs[i2] = new Skip(createID(client, clock), len); + clock += len; + break; + } + default: { + const cantCopyParentInfo = (info & (BIT7 | BIT8)) === 0; + const struct = new Item( + createID(client, clock), + null, + // left + (info & BIT8) === BIT8 ? decoder.readLeftID() : null, + // origin + null, + // right + (info & BIT7) === BIT7 ? decoder.readRightID() : null, + // right origin + cantCopyParentInfo ? decoder.readParentInfo() ? doc2.get(decoder.readString()) : decoder.readLeftID() : null, + // parent + cantCopyParentInfo && (info & BIT6) === BIT6 ? decoder.readString() : null, + // parentSub + readItemContent(decoder, info) + // item content + ); + refs[i2] = struct; + clock += struct.length; + } + } + } + } + return clientRefs; + }; + var integrateStructs = (transaction, store, clientsStructRefs) => { + const stack = []; + let clientsStructRefsIds = from(clientsStructRefs.keys()).sort((a, b) => a - b); + if (clientsStructRefsIds.length === 0) { + return null; + } + const getNextStructTarget = () => { + if (clientsStructRefsIds.length === 0) { + return null; + } + let nextStructsTarget = ( + /** @type {{i:number,refs:Array}} */ + clientsStructRefs.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]) + ); + while (nextStructsTarget.refs.length === nextStructsTarget.i) { + clientsStructRefsIds.pop(); + if (clientsStructRefsIds.length > 0) { + nextStructsTarget = /** @type {{i:number,refs:Array}} */ + clientsStructRefs.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]); + } else { + return null; + } + } + return nextStructsTarget; + }; + let curStructsTarget = getNextStructTarget(); + if (curStructsTarget === null) { + return null; + } + const restStructs = new StructStore(); + const missingSV = /* @__PURE__ */ new Map(); + const updateMissingSv = (client, clock) => { + const mclock = missingSV.get(client); + if (mclock == null || mclock > clock) { + missingSV.set(client, clock); + } + }; + let stackHead = ( + /** @type {any} */ + curStructsTarget.refs[ + /** @type {any} */ + curStructsTarget.i++ + ] + ); + const state = /* @__PURE__ */ new Map(); + const addStackToRestSS = () => { + for (const item of stack) { + const client = item.id.client; + const inapplicableItems = clientsStructRefs.get(client); + if (inapplicableItems) { + inapplicableItems.i--; + restStructs.clients.set(client, inapplicableItems.refs.slice(inapplicableItems.i)); + clientsStructRefs.delete(client); + inapplicableItems.i = 0; + inapplicableItems.refs = []; + } else { + restStructs.clients.set(client, [item]); + } + clientsStructRefsIds = clientsStructRefsIds.filter((c) => c !== client); + } + stack.length = 0; + }; + while (true) { + if (stackHead.constructor !== Skip) { + const localClock = setIfUndefined(state, stackHead.id.client, () => getState(store, stackHead.id.client)); + const offset = localClock - stackHead.id.clock; + if (offset < 0) { + stack.push(stackHead); + updateMissingSv(stackHead.id.client, stackHead.id.clock - 1); + addStackToRestSS(); + } else { + const missing = stackHead.getMissing(transaction, store); + if (missing !== null) { + stack.push(stackHead); + const structRefs = clientsStructRefs.get( + /** @type {number} */ + missing + ) || { refs: [], i: 0 }; + if (structRefs.refs.length === structRefs.i) { + updateMissingSv( + /** @type {number} */ + missing, + getState(store, missing) + ); + addStackToRestSS(); + } else { + stackHead = structRefs.refs[structRefs.i++]; + continue; + } + } else if (offset === 0 || offset < stackHead.length) { + stackHead.integrate(transaction, offset); + state.set(stackHead.id.client, stackHead.id.clock + stackHead.length); + } + } + } + if (stack.length > 0) { + stackHead = /** @type {GC|Item} */ + stack.pop(); + } else if (curStructsTarget !== null && curStructsTarget.i < curStructsTarget.refs.length) { + stackHead = /** @type {GC|Item} */ + curStructsTarget.refs[curStructsTarget.i++]; + } else { + curStructsTarget = getNextStructTarget(); + if (curStructsTarget === null) { + break; + } else { + stackHead = /** @type {GC|Item} */ + curStructsTarget.refs[curStructsTarget.i++]; + } + } + } + if (restStructs.clients.size > 0) { + const encoder = new UpdateEncoderV2(); + writeClientsStructs(encoder, restStructs, /* @__PURE__ */ new Map()); + writeVarUint(encoder.restEncoder, 0); + return { missing: missingSV, update: encoder.toUint8Array() }; + } + return null; + }; + var writeStructsFromTransaction = (encoder, transaction) => writeClientsStructs(encoder, transaction.doc.store, transaction.beforeState); + var readUpdateV2 = (decoder, ydoc, transactionOrigin, structDecoder = new UpdateDecoderV2(decoder)) => transact(ydoc, (transaction) => { + transaction.local = false; + let retry = false; + const doc2 = transaction.doc; + const store = doc2.store; + const ss = readClientsStructRefs(structDecoder, doc2); + const restStructs = integrateStructs(transaction, store, ss); + const pending = store.pendingStructs; + if (pending) { + for (const [client, clock] of pending.missing) { + if (clock < getState(store, client)) { + retry = true; + break; + } + } + if (restStructs) { + for (const [client, clock] of restStructs.missing) { + const mclock = pending.missing.get(client); + if (mclock == null || mclock > clock) { + pending.missing.set(client, clock); + } + } + pending.update = mergeUpdatesV2([pending.update, restStructs.update]); + } + } else { + store.pendingStructs = restStructs; + } + const dsRest = readAndApplyDeleteSet(structDecoder, transaction, store); + if (store.pendingDs) { + const pendingDSUpdate = new UpdateDecoderV2(createDecoder(store.pendingDs)); + readVarUint(pendingDSUpdate.restDecoder); + const dsRest2 = readAndApplyDeleteSet(pendingDSUpdate, transaction, store); + if (dsRest && dsRest2) { + store.pendingDs = mergeUpdatesV2([dsRest, dsRest2]); + } else { + store.pendingDs = dsRest || dsRest2; + } + } else { + store.pendingDs = dsRest; + } + if (retry) { + const update = ( + /** @type {{update: Uint8Array}} */ + store.pendingStructs.update + ); + store.pendingStructs = null; + applyUpdateV2(transaction.doc, update); + } + }, transactionOrigin, false); + var readUpdate = (decoder, ydoc, transactionOrigin) => readUpdateV2(decoder, ydoc, transactionOrigin, new UpdateDecoderV1(decoder)); + var applyUpdateV2 = (ydoc, update, transactionOrigin, YDecoder = UpdateDecoderV2) => { + const decoder = createDecoder(update); + readUpdateV2(decoder, ydoc, transactionOrigin, new YDecoder(decoder)); + }; + var applyUpdate = (ydoc, update, transactionOrigin) => applyUpdateV2(ydoc, update, transactionOrigin, UpdateDecoderV1); + var writeStateAsUpdate = (encoder, doc2, targetStateVector = /* @__PURE__ */ new Map()) => { + writeClientsStructs(encoder, doc2.store, targetStateVector); + writeDeleteSet(encoder, createDeleteSetFromStructStore(doc2.store)); + }; + var encodeStateAsUpdateV2 = (doc2, encodedTargetStateVector = new Uint8Array([0]), encoder = new UpdateEncoderV2()) => { + const targetStateVector = decodeStateVector(encodedTargetStateVector); + writeStateAsUpdate(encoder, doc2, targetStateVector); + const updates = [encoder.toUint8Array()]; + if (doc2.store.pendingDs) { + updates.push(doc2.store.pendingDs); + } + if (doc2.store.pendingStructs) { + updates.push(diffUpdateV2(doc2.store.pendingStructs.update, encodedTargetStateVector)); + } + if (updates.length > 1) { + if (encoder.constructor === UpdateEncoderV1) { + return mergeUpdates(updates.map((update, i) => i === 0 ? update : convertUpdateFormatV2ToV1(update))); + } else if (encoder.constructor === UpdateEncoderV2) { + return mergeUpdatesV2(updates); + } + } + return updates[0]; + }; + var encodeStateAsUpdate = (doc2, encodedTargetStateVector) => encodeStateAsUpdateV2(doc2, encodedTargetStateVector, new UpdateEncoderV1()); + var readStateVector = (decoder) => { + const ss = /* @__PURE__ */ new Map(); + const ssLength = readVarUint(decoder.restDecoder); + for (let i = 0; i < ssLength; i++) { + const client = readVarUint(decoder.restDecoder); + const clock = readVarUint(decoder.restDecoder); + ss.set(client, clock); + } + return ss; + }; + var decodeStateVector = (decodedState) => readStateVector(new DSDecoderV1(createDecoder(decodedState))); + var writeStateVector = (encoder, sv) => { + writeVarUint(encoder.restEncoder, sv.size); + from(sv.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => { + writeVarUint(encoder.restEncoder, client); + writeVarUint(encoder.restEncoder, clock); + }); + return encoder; + }; + var writeDocumentStateVector = (encoder, doc2) => writeStateVector(encoder, getStateVector(doc2.store)); + var encodeStateVectorV2 = (doc2, encoder = new DSEncoderV2()) => { + if (doc2 instanceof Map) { + writeStateVector(encoder, doc2); + } else { + writeDocumentStateVector(encoder, doc2); + } + return encoder.toUint8Array(); + }; + var encodeStateVector = (doc2) => encodeStateVectorV2(doc2, new DSEncoderV1()); + var EventHandler = class { + constructor() { + this.l = []; + } + }; + var createEventHandler = () => new EventHandler(); + var addEventHandlerListener = (eventHandler, f) => eventHandler.l.push(f); + var removeEventHandlerListener = (eventHandler, f) => { + const l = eventHandler.l; + const len = l.length; + eventHandler.l = l.filter((g) => f !== g); + if (len === eventHandler.l.length) { + console.error("[yjs] Tried to remove event handler that doesn't exist."); + } + }; + var callEventHandlerListeners = (eventHandler, arg0, arg1) => callAll(eventHandler.l, [arg0, arg1]); + var ID = class { + /** + * @param {number} client client id + * @param {number} clock unique per client id, continuous number + */ + constructor(client, clock) { + this.client = client; + this.clock = clock; + } + }; + var compareIDs = (a, b) => a === b || a !== null && b !== null && a.client === b.client && a.clock === b.clock; + var createID = (client, clock) => new ID(client, clock); + var writeID = (encoder, id2) => { + writeVarUint(encoder, id2.client); + writeVarUint(encoder, id2.clock); + }; + var readID = (decoder) => createID(readVarUint(decoder), readVarUint(decoder)); + var findRootTypeKey = (type) => { + for (const [key, value] of type.doc.share.entries()) { + if (value === type) { + return key; + } + } + throw unexpectedCase(); + }; + var isParentOf = (parent, child) => { + while (child !== null) { + if (child.parent === parent) { + return true; + } + child = /** @type {AbstractType} */ + child.parent._item; + } + return false; + }; + var logType = (type) => { + const res = []; + let n = type._start; + while (n) { + res.push(n); + n = n.right; + } + console.log("Children: ", res); + console.log("Children content: ", res.filter((m) => !m.deleted).map((m) => m.content)); + }; + var PermanentUserData = class { + /** + * @param {Doc} doc + * @param {YMap} [storeType] + */ + constructor(doc2, storeType = doc2.getMap("users")) { + const dss = /* @__PURE__ */ new Map(); + this.yusers = storeType; + this.doc = doc2; + this.clients = /* @__PURE__ */ new Map(); + this.dss = dss; + const initUser = (user, userDescription) => { + const ds = user.get("ds"); + const ids = user.get("ids"); + const addClientId = ( + /** @param {number} clientid */ + (clientid) => this.clients.set(clientid, userDescription) + ); + ds.observe( + /** @param {YArrayEvent} event */ + (event) => { + event.changes.added.forEach((item) => { + item.content.getContent().forEach((encodedDs) => { + if (encodedDs instanceof Uint8Array) { + this.dss.set(userDescription, mergeDeleteSets([this.dss.get(userDescription) || createDeleteSet(), readDeleteSet(new DSDecoderV1(createDecoder(encodedDs)))])); + } + }); + }); + } + ); + this.dss.set(userDescription, mergeDeleteSets(ds.map((encodedDs) => readDeleteSet(new DSDecoderV1(createDecoder(encodedDs)))))); + ids.observe( + /** @param {YArrayEvent} event */ + (event) => event.changes.added.forEach((item) => item.content.getContent().forEach(addClientId)) + ); + ids.forEach(addClientId); + }; + storeType.observe((event) => { + event.keysChanged.forEach( + (userDescription) => initUser(storeType.get(userDescription), userDescription) + ); + }); + storeType.forEach(initUser); + } + /** + * @param {Doc} doc + * @param {number} clientid + * @param {string} userDescription + * @param {Object} conf + * @param {function(Transaction, DeleteSet):boolean} [conf.filter] + */ + setUserMapping(doc2, clientid, userDescription, { filter = () => true } = {}) { + const users = this.yusers; + let user = users.get(userDescription); + if (!user) { + user = new YMap(); + user.set("ids", new YArray()); + user.set("ds", new YArray()); + users.set(userDescription, user); + } + user.get("ids").push([clientid]); + users.observe((_event) => { + setTimeout(() => { + const userOverwrite = users.get(userDescription); + if (userOverwrite !== user) { + user = userOverwrite; + this.clients.forEach((_userDescription, clientid2) => { + if (userDescription === _userDescription) { + user.get("ids").push([clientid2]); + } + }); + const encoder = new DSEncoderV1(); + const ds = this.dss.get(userDescription); + if (ds) { + writeDeleteSet(encoder, ds); + user.get("ds").push([encoder.toUint8Array()]); + } + } + }, 0); + }); + doc2.on( + "afterTransaction", + /** @param {Transaction} transaction */ + (transaction) => { + setTimeout(() => { + const yds = user.get("ds"); + const ds = transaction.deleteSet; + if (transaction.local && ds.clients.size > 0 && filter(transaction, ds)) { + const encoder = new DSEncoderV1(); + writeDeleteSet(encoder, ds); + yds.push([encoder.toUint8Array()]); + } + }); + } + ); + } + /** + * @param {number} clientid + * @return {any} + */ + getUserByClientId(clientid) { + return this.clients.get(clientid) || null; + } + /** + * @param {ID} id + * @return {string | null} + */ + getUserByDeletedId(id2) { + for (const [userDescription, ds] of this.dss.entries()) { + if (isDeleted(ds, id2)) { + return userDescription; + } + } + return null; + } + }; + var RelativePosition = class { + /** + * @param {ID|null} type + * @param {string|null} tname + * @param {ID|null} item + * @param {number} assoc + */ + constructor(type, tname, item, assoc = 0) { + this.type = type; + this.tname = tname; + this.item = item; + this.assoc = assoc; + } + }; + var relativePositionToJSON = (rpos) => { + const json = {}; + if (rpos.type) { + json.type = rpos.type; + } + if (rpos.tname) { + json.tname = rpos.tname; + } + if (rpos.item) { + json.item = rpos.item; + } + if (rpos.assoc != null) { + json.assoc = rpos.assoc; + } + return json; + }; + var createRelativePositionFromJSON = (json) => new RelativePosition(json.type == null ? null : createID(json.type.client, json.type.clock), json.tname ?? null, json.item == null ? null : createID(json.item.client, json.item.clock), json.assoc == null ? 0 : json.assoc); + var AbsolutePosition = class { + /** + * @param {AbstractType} type + * @param {number} index + * @param {number} [assoc] + */ + constructor(type, index, assoc = 0) { + this.type = type; + this.index = index; + this.assoc = assoc; + } + }; + var createAbsolutePosition = (type, index, assoc = 0) => new AbsolutePosition(type, index, assoc); + var createRelativePosition = (type, item, assoc) => { + let typeid = null; + let tname = null; + if (type._item === null) { + tname = findRootTypeKey(type); + } else { + typeid = createID(type._item.id.client, type._item.id.clock); + } + return new RelativePosition(typeid, tname, item, assoc); + }; + var createRelativePositionFromTypeIndex = (type, index, assoc = 0) => { + let t = type._start; + if (assoc < 0) { + if (index === 0) { + return createRelativePosition(type, null, assoc); + } + index--; + } + while (t !== null) { + if (!t.deleted && t.countable) { + if (t.length > index) { + return createRelativePosition(type, createID(t.id.client, t.id.clock + index), assoc); + } + index -= t.length; + } + if (t.right === null && assoc < 0) { + return createRelativePosition(type, t.lastId, assoc); + } + t = t.right; + } + return createRelativePosition(type, null, assoc); + }; + var writeRelativePosition = (encoder, rpos) => { + const { type, tname, item, assoc } = rpos; + if (item !== null) { + writeVarUint(encoder, 0); + writeID(encoder, item); + } else if (tname !== null) { + writeUint8(encoder, 1); + writeVarString(encoder, tname); + } else if (type !== null) { + writeUint8(encoder, 2); + writeID(encoder, type); + } else { + throw unexpectedCase(); + } + writeVarInt(encoder, assoc); + return encoder; + }; + var encodeRelativePosition = (rpos) => { + const encoder = createEncoder(); + writeRelativePosition(encoder, rpos); + return toUint8Array(encoder); + }; + var readRelativePosition = (decoder) => { + let type = null; + let tname = null; + let itemID = null; + switch (readVarUint(decoder)) { + case 0: + itemID = readID(decoder); + break; + case 1: + tname = readVarString(decoder); + break; + case 2: { + type = readID(decoder); + } + } + const assoc = hasContent(decoder) ? readVarInt(decoder) : 0; + return new RelativePosition(type, tname, itemID, assoc); + }; + var decodeRelativePosition = (uint8Array) => readRelativePosition(createDecoder(uint8Array)); + var getItemWithOffset = (store, id2) => { + const item = getItem(store, id2); + const diff = id2.clock - item.id.clock; + return { + item, + diff + }; + }; + var createAbsolutePositionFromRelativePosition = (rpos, doc2, followUndoneDeletions = true) => { + const store = doc2.store; + const rightID = rpos.item; + const typeID = rpos.type; + const tname = rpos.tname; + const assoc = rpos.assoc; + let type = null; + let index = 0; + if (rightID !== null) { + if (getState(store, rightID.client) <= rightID.clock) { + return null; + } + const res = followUndoneDeletions ? followRedone(store, rightID) : getItemWithOffset(store, rightID); + const right = res.item; + if (!(right instanceof Item)) { + return null; + } + type = /** @type {AbstractType} */ + right.parent; + if (type._item === null || !type._item.deleted) { + index = right.deleted || !right.countable ? 0 : res.diff + (assoc >= 0 ? 0 : 1); + let n = right.left; + while (n !== null) { + if (!n.deleted && n.countable) { + index += n.length; + } + n = n.left; + } + } + } else { + if (tname !== null) { + type = doc2.get(tname); + } else if (typeID !== null) { + if (getState(store, typeID.client) <= typeID.clock) { + return null; + } + const { item } = followUndoneDeletions ? followRedone(store, typeID) : { item: getItem(store, typeID) }; + if (item instanceof Item && item.content instanceof ContentType) { + type = item.content.type; + } else { + return null; + } + } else { + throw unexpectedCase(); + } + if (assoc >= 0) { + index = type._length; + } else { + index = 0; + } + } + return createAbsolutePosition(type, index, rpos.assoc); + }; + var compareRelativePositions = (a, b) => a === b || a !== null && b !== null && a.tname === b.tname && compareIDs(a.item, b.item) && compareIDs(a.type, b.type) && a.assoc === b.assoc; + var Snapshot = class { + /** + * @param {DeleteSet} ds + * @param {Map} sv state map + */ + constructor(ds, sv) { + this.ds = ds; + this.sv = sv; + } + }; + var equalSnapshots = (snap1, snap2) => { + const ds1 = snap1.ds.clients; + const ds2 = snap2.ds.clients; + const sv1 = snap1.sv; + const sv2 = snap2.sv; + if (sv1.size !== sv2.size || ds1.size !== ds2.size) { + return false; + } + for (const [key, value] of sv1.entries()) { + if (sv2.get(key) !== value) { + return false; + } + } + for (const [client, dsitems1] of ds1.entries()) { + const dsitems2 = ds2.get(client) || []; + if (dsitems1.length !== dsitems2.length) { + return false; + } + for (let i = 0; i < dsitems1.length; i++) { + const dsitem1 = dsitems1[i]; + const dsitem2 = dsitems2[i]; + if (dsitem1.clock !== dsitem2.clock || dsitem1.len !== dsitem2.len) { + return false; + } + } + } + return true; + }; + var encodeSnapshotV2 = (snapshot2, encoder = new DSEncoderV2()) => { + writeDeleteSet(encoder, snapshot2.ds); + writeStateVector(encoder, snapshot2.sv); + return encoder.toUint8Array(); + }; + var encodeSnapshot = (snapshot2) => encodeSnapshotV2(snapshot2, new DSEncoderV1()); + var decodeSnapshotV2 = (buf, decoder = new DSDecoderV2(createDecoder(buf))) => { + return new Snapshot(readDeleteSet(decoder), readStateVector(decoder)); + }; + var decodeSnapshot = (buf) => decodeSnapshotV2(buf, new DSDecoderV1(createDecoder(buf))); + var createSnapshot = (ds, sm) => new Snapshot(ds, sm); + var emptySnapshot = createSnapshot(createDeleteSet(), /* @__PURE__ */ new Map()); + var snapshot = (doc2) => createSnapshot(createDeleteSetFromStructStore(doc2.store), getStateVector(doc2.store)); + var isVisible = (item, snapshot2) => snapshot2 === void 0 ? !item.deleted : snapshot2.sv.has(item.id.client) && (snapshot2.sv.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot2.ds, item.id); + var splitSnapshotAffectedStructs = (transaction, snapshot2) => { + const meta = setIfUndefined(transaction.meta, splitSnapshotAffectedStructs, create2); + const store = transaction.doc.store; + if (!meta.has(snapshot2)) { + snapshot2.sv.forEach((clock, client) => { + if (clock < getState(store, client)) { + getItemCleanStart(transaction, createID(client, clock)); + } + }); + iterateDeletedStructs(transaction, snapshot2.ds, (_item) => { + }); + meta.add(snapshot2); + } + }; + var createDocFromSnapshot = (originDoc, snapshot2, newDoc = new Doc()) => { + if (originDoc.gc) { + throw new Error("Garbage-collection must be disabled in `originDoc`!"); + } + const { sv, ds } = snapshot2; + const encoder = new UpdateEncoderV2(); + originDoc.transact((transaction) => { + let size2 = 0; + sv.forEach((clock) => { + if (clock > 0) { + size2++; + } + }); + writeVarUint(encoder.restEncoder, size2); + for (const [client, clock] of sv) { + if (clock === 0) { + continue; + } + if (clock < getState(originDoc.store, client)) { + getItemCleanStart(transaction, createID(client, clock)); + } + const structs = originDoc.store.clients.get(client) || []; + const lastStructIndex = findIndexSS(structs, clock - 1); + writeVarUint(encoder.restEncoder, lastStructIndex + 1); + encoder.writeClient(client); + writeVarUint(encoder.restEncoder, 0); + for (let i = 0; i <= lastStructIndex; i++) { + structs[i].write(encoder, 0); + } + } + writeDeleteSet(encoder, ds); + }); + applyUpdateV2(newDoc, encoder.toUint8Array(), "snapshot"); + return newDoc; + }; + var snapshotContainsUpdateV2 = (snapshot2, update, YDecoder = UpdateDecoderV2) => { + const updateDecoder = new YDecoder(createDecoder(update)); + const lazyDecoder = new LazyStructReader(updateDecoder, false); + for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) { + if ((snapshot2.sv.get(curr.id.client) || 0) < curr.id.clock + curr.length) { + return false; + } + } + const mergedDS = mergeDeleteSets([snapshot2.ds, readDeleteSet(updateDecoder)]); + return equalDeleteSets(snapshot2.ds, mergedDS); + }; + var snapshotContainsUpdate = (snapshot2, update) => snapshotContainsUpdateV2(snapshot2, update, UpdateDecoderV1); + var StructStore = class { + constructor() { + this.clients = /* @__PURE__ */ new Map(); + this.pendingStructs = null; + this.pendingDs = null; + } + }; + var getStateVector = (store) => { + const sm = /* @__PURE__ */ new Map(); + store.clients.forEach((structs, client) => { + const struct = structs[structs.length - 1]; + sm.set(client, struct.id.clock + struct.length); + }); + return sm; + }; + var getState = (store, client) => { + const structs = store.clients.get(client); + if (structs === void 0) { + return 0; + } + const lastStruct = structs[structs.length - 1]; + return lastStruct.id.clock + lastStruct.length; + }; + var addStruct = (store, struct) => { + let structs = store.clients.get(struct.id.client); + if (structs === void 0) { + structs = []; + store.clients.set(struct.id.client, structs); + } else { + const lastStruct = structs[structs.length - 1]; + if (lastStruct.id.clock + lastStruct.length !== struct.id.clock) { + throw unexpectedCase(); + } + } + structs.push(struct); + }; + var findIndexSS = (structs, clock) => { + let left = 0; + let right = structs.length - 1; + let mid = structs[right]; + let midclock = mid.id.clock; + if (midclock === clock) { + return right; + } + let midindex = floor(clock / (midclock + mid.length - 1) * right); + while (left <= right) { + mid = structs[midindex]; + midclock = mid.id.clock; + if (midclock <= clock) { + if (clock < midclock + mid.length) { + return midindex; + } + left = midindex + 1; + } else { + right = midindex - 1; + } + midindex = floor((left + right) / 2); + } + throw unexpectedCase(); + }; + var find = (store, id2) => { + const structs = store.clients.get(id2.client); + return structs[findIndexSS(structs, id2.clock)]; + }; + var getItem = ( + /** @type {function(StructStore,ID):Item} */ + find + ); + var findIndexCleanStart = (transaction, structs, clock) => { + const index = findIndexSS(structs, clock); + const struct = structs[index]; + if (struct.id.clock < clock && struct instanceof Item) { + structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock)); + return index + 1; + } + return index; + }; + var getItemCleanStart = (transaction, id2) => { + const structs = ( + /** @type {Array} */ + transaction.doc.store.clients.get(id2.client) + ); + return structs[findIndexCleanStart(transaction, structs, id2.clock)]; + }; + var getItemCleanEnd = (transaction, store, id2) => { + const structs = store.clients.get(id2.client); + const index = findIndexSS(structs, id2.clock); + const struct = structs[index]; + if (id2.clock !== struct.id.clock + struct.length - 1 && struct.constructor !== GC) { + structs.splice(index + 1, 0, splitItem(transaction, struct, id2.clock - struct.id.clock + 1)); + } + return struct; + }; + var replaceStruct = (store, struct, newStruct) => { + const structs = ( + /** @type {Array} */ + store.clients.get(struct.id.client) + ); + structs[findIndexSS(structs, struct.id.clock)] = newStruct; + }; + var iterateStructs = (transaction, structs, clockStart, len, f) => { + if (len === 0) { + return; + } + const clockEnd = clockStart + len; + let index = findIndexCleanStart(transaction, structs, clockStart); + let struct; + do { + struct = structs[index++]; + if (clockEnd < struct.id.clock + struct.length) { + findIndexCleanStart(transaction, structs, clockEnd); + } + f(struct); + } while (index < structs.length && structs[index].id.clock < clockEnd); + }; + var Transaction = class { + /** + * @param {Doc} doc + * @param {any} origin + * @param {boolean} local + */ + constructor(doc2, origin, local) { + this.doc = doc2; + this.deleteSet = new DeleteSet(); + this.beforeState = getStateVector(doc2.store); + this.afterState = /* @__PURE__ */ new Map(); + this.changed = /* @__PURE__ */ new Map(); + this.changedParentTypes = /* @__PURE__ */ new Map(); + this._mergeStructs = []; + this.origin = origin; + this.meta = /* @__PURE__ */ new Map(); + this.local = local; + this.subdocsAdded = /* @__PURE__ */ new Set(); + this.subdocsRemoved = /* @__PURE__ */ new Set(); + this.subdocsLoaded = /* @__PURE__ */ new Set(); + this._needFormattingCleanup = false; + } + }; + var writeUpdateMessageFromTransaction = (encoder, transaction) => { + if (transaction.deleteSet.clients.size === 0 && !any(transaction.afterState, (clock, client) => transaction.beforeState.get(client) !== clock)) { + return false; + } + sortAndMergeDeleteSet(transaction.deleteSet); + writeStructsFromTransaction(encoder, transaction); + writeDeleteSet(encoder, transaction.deleteSet); + return true; + }; + var addChangedTypeToTransaction = (transaction, type, parentSub) => { + const item = type._item; + if (item === null || item.id.clock < (transaction.beforeState.get(item.id.client) || 0) && !item.deleted) { + setIfUndefined(transaction.changed, type, create2).add(parentSub); + } + }; + var tryToMergeWithLefts = (structs, pos) => { + let right = structs[pos]; + let left = structs[pos - 1]; + let i = pos; + for (; i > 0; right = left, left = structs[--i - 1]) { + if (left.deleted === right.deleted && left.constructor === right.constructor) { + if (left.mergeWith(right)) { + if (right instanceof Item && right.parentSub !== null && /** @type {AbstractType} */ + right.parent._map.get(right.parentSub) === right) { + right.parent._map.set( + right.parentSub, + /** @type {Item} */ + left + ); + } + continue; + } + } + break; + } + const merged = pos - i; + if (merged) { + structs.splice(pos + 1 - merged, merged); + } + return merged; + }; + var tryGcDeleteSet = (ds, store, gcFilter) => { + for (const [client, deleteItems] of ds.clients.entries()) { + const structs = ( + /** @type {Array} */ + store.clients.get(client) + ); + for (let di = deleteItems.length - 1; di >= 0; di--) { + const deleteItem = deleteItems[di]; + const endDeleteItemClock = deleteItem.clock + deleteItem.len; + for (let si = findIndexSS(structs, deleteItem.clock), struct = structs[si]; si < structs.length && struct.id.clock < endDeleteItemClock; struct = structs[++si]) { + const struct2 = structs[si]; + if (deleteItem.clock + deleteItem.len <= struct2.id.clock) { + break; + } + if (struct2 instanceof Item && struct2.deleted && !struct2.keep && gcFilter(struct2)) { + struct2.gc(store, false); + } + } + } + } + }; + var tryMergeDeleteSet = (ds, store) => { + ds.clients.forEach((deleteItems, client) => { + const structs = ( + /** @type {Array} */ + store.clients.get(client) + ); + for (let di = deleteItems.length - 1; di >= 0; di--) { + const deleteItem = deleteItems[di]; + const mostRightIndexToCheck = min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1)); + for (let si = mostRightIndexToCheck, struct = structs[si]; si > 0 && struct.id.clock >= deleteItem.clock; struct = structs[si]) { + si -= 1 + tryToMergeWithLefts(structs, si); + } + } + }); + }; + var tryGc = (ds, store, gcFilter) => { + tryGcDeleteSet(ds, store, gcFilter); + tryMergeDeleteSet(ds, store); + }; + var cleanupTransactions = (transactionCleanups, i) => { + if (i < transactionCleanups.length) { + const transaction = transactionCleanups[i]; + const doc2 = transaction.doc; + const store = doc2.store; + const ds = transaction.deleteSet; + const mergeStructs = transaction._mergeStructs; + try { + sortAndMergeDeleteSet(ds); + transaction.afterState = getStateVector(transaction.doc.store); + doc2.emit("beforeObserverCalls", [transaction, doc2]); + const fs = []; + transaction.changed.forEach( + (subs, itemtype) => fs.push(() => { + if (itemtype._item === null || !itemtype._item.deleted) { + itemtype._callObserver(transaction, subs); + } + }) + ); + fs.push(() => { + transaction.changedParentTypes.forEach((events, type) => { + if (type._dEH.l.length > 0 && (type._item === null || !type._item.deleted)) { + events = events.filter( + (event) => event.target._item === null || !event.target._item.deleted + ); + events.forEach((event) => { + event.currentTarget = type; + event._path = null; + }); + events.sort((event1, event2) => event1.path.length - event2.path.length); + callEventHandlerListeners(type._dEH, events, transaction); + } + }); + }); + fs.push(() => doc2.emit("afterTransaction", [transaction, doc2])); + callAll(fs, []); + if (transaction._needFormattingCleanup) { + cleanupYTextAfterTransaction(transaction); + } + } finally { + if (doc2.gc) { + tryGcDeleteSet(ds, store, doc2.gcFilter); + } + tryMergeDeleteSet(ds, store); + transaction.afterState.forEach((clock, client) => { + const beforeClock = transaction.beforeState.get(client) || 0; + if (beforeClock !== clock) { + const structs = ( + /** @type {Array} */ + store.clients.get(client) + ); + const firstChangePos = max(findIndexSS(structs, beforeClock), 1); + for (let i2 = structs.length - 1; i2 >= firstChangePos; ) { + i2 -= 1 + tryToMergeWithLefts(structs, i2); + } + } + }); + for (let i2 = mergeStructs.length - 1; i2 >= 0; i2--) { + const { client, clock } = mergeStructs[i2].id; + const structs = ( + /** @type {Array} */ + store.clients.get(client) + ); + const replacedStructPos = findIndexSS(structs, clock); + if (replacedStructPos + 1 < structs.length) { + if (tryToMergeWithLefts(structs, replacedStructPos + 1) > 1) { + continue; + } + } + if (replacedStructPos > 0) { + tryToMergeWithLefts(structs, replacedStructPos); + } + } + if (!transaction.local && transaction.afterState.get(doc2.clientID) !== transaction.beforeState.get(doc2.clientID)) { + print(ORANGE, BOLD, "[yjs] ", UNBOLD, RED, "Changed the client-id because another client seems to be using it."); + doc2.clientID = generateNewClientId(); + } + doc2.emit("afterTransactionCleanup", [transaction, doc2]); + if (doc2._observers.has("update")) { + const encoder = new UpdateEncoderV1(); + const hasContent2 = writeUpdateMessageFromTransaction(encoder, transaction); + if (hasContent2) { + doc2.emit("update", [encoder.toUint8Array(), transaction.origin, doc2, transaction]); + } + } + if (doc2._observers.has("updateV2")) { + const encoder = new UpdateEncoderV2(); + const hasContent2 = writeUpdateMessageFromTransaction(encoder, transaction); + if (hasContent2) { + doc2.emit("updateV2", [encoder.toUint8Array(), transaction.origin, doc2, transaction]); + } + } + const { subdocsAdded, subdocsLoaded, subdocsRemoved } = transaction; + if (subdocsAdded.size > 0 || subdocsRemoved.size > 0 || subdocsLoaded.size > 0) { + subdocsAdded.forEach((subdoc) => { + subdoc.clientID = doc2.clientID; + if (subdoc.collectionid == null) { + subdoc.collectionid = doc2.collectionid; + } + doc2.subdocs.add(subdoc); + }); + subdocsRemoved.forEach((subdoc) => doc2.subdocs.delete(subdoc)); + doc2.emit("subdocs", [{ loaded: subdocsLoaded, added: subdocsAdded, removed: subdocsRemoved }, doc2, transaction]); + subdocsRemoved.forEach((subdoc) => subdoc.destroy()); + } + if (transactionCleanups.length <= i + 1) { + doc2._transactionCleanups = []; + doc2.emit("afterAllTransactions", [doc2, transactionCleanups]); + } else { + cleanupTransactions(transactionCleanups, i + 1); + } + } + } + }; + var transact = (doc2, f, origin = null, local = true) => { + const transactionCleanups = doc2._transactionCleanups; + let initialCall = false; + let result = null; + if (doc2._transaction === null) { + initialCall = true; + doc2._transaction = new Transaction(doc2, origin, local); + transactionCleanups.push(doc2._transaction); + if (transactionCleanups.length === 1) { + doc2.emit("beforeAllTransactions", [doc2]); + } + doc2.emit("beforeTransaction", [doc2._transaction, doc2]); + } + try { + result = f(doc2._transaction); + } finally { + if (initialCall) { + const finishCleanup = doc2._transaction === transactionCleanups[0]; + doc2._transaction = null; + if (finishCleanup) { + cleanupTransactions(transactionCleanups, 0); + } + } + } + return result; + }; + var StackItem = class { + /** + * @param {DeleteSet} deletions + * @param {DeleteSet} insertions + */ + constructor(deletions, insertions) { + this.insertions = insertions; + this.deletions = deletions; + this.meta = /* @__PURE__ */ new Map(); + } + }; + var clearUndoManagerStackItem = (tr, um, stackItem) => { + iterateDeletedStructs(tr, stackItem.deletions, (item) => { + if (item instanceof Item && um.scope.some((type) => type === tr.doc || isParentOf( + /** @type {AbstractType} */ + type, + item + ))) { + keepItem(item, false); + } + }); + }; + var popStackItem = (undoManager, stack, eventType) => { + let _tr = null; + const doc2 = undoManager.doc; + const scope = undoManager.scope; + transact(doc2, (transaction) => { + while (stack.length > 0 && undoManager.currStackItem === null) { + const store = doc2.store; + const stackItem = ( + /** @type {StackItem} */ + stack.pop() + ); + const itemsToRedo = /* @__PURE__ */ new Set(); + const itemsToDelete = []; + let performedChange = false; + iterateDeletedStructs(transaction, stackItem.insertions, (struct) => { + if (struct instanceof Item) { + if (struct.redone !== null) { + let { item, diff } = followRedone(store, struct.id); + if (diff > 0) { + item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff)); + } + struct = item; + } + if (!struct.deleted && scope.some((type) => type === transaction.doc || isParentOf( + /** @type {AbstractType} */ + type, + /** @type {Item} */ + struct + ))) { + itemsToDelete.push(struct); + } + } + }); + iterateDeletedStructs(transaction, stackItem.deletions, (struct) => { + if (struct instanceof Item && scope.some((type) => type === transaction.doc || isParentOf( + /** @type {AbstractType} */ + type, + struct + )) && // Never redo structs in stackItem.insertions because they were created and deleted in the same capture interval. + !isDeleted(stackItem.insertions, struct.id)) { + itemsToRedo.add(struct); + } + }); + itemsToRedo.forEach((struct) => { + performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.insertions, undoManager.ignoreRemoteMapChanges, undoManager) !== null || performedChange; + }); + for (let i = itemsToDelete.length - 1; i >= 0; i--) { + const item = itemsToDelete[i]; + if (undoManager.deleteFilter(item)) { + item.delete(transaction); + performedChange = true; + } + } + undoManager.currStackItem = performedChange ? stackItem : null; + } + transaction.changed.forEach((subProps, type) => { + if (subProps.has(null) && type._searchMarker) { + type._searchMarker.length = 0; + } + }); + _tr = transaction; + }, undoManager); + const res = undoManager.currStackItem; + if (res != null) { + const changedParentTypes = _tr.changedParentTypes; + undoManager.emit("stack-item-popped", [{ stackItem: res, type: eventType, changedParentTypes, origin: undoManager }, undoManager]); + undoManager.currStackItem = null; + } + return res; + }; + var UndoManager = class extends ObservableV2 { + /** + * @param {Doc|AbstractType|Array>} typeScope Limits the scope of the UndoManager. If this is set to a ydoc instance, all changes on that ydoc will be undone. If set to a specific type, only changes on that type or its children will be undone. Also accepts an array of types. + * @param {UndoManagerOptions} options + */ + constructor(typeScope, { + captureTimeout = 500, + captureTransaction = (_tr) => true, + deleteFilter = () => true, + trackedOrigins = /* @__PURE__ */ new Set([null]), + ignoreRemoteMapChanges = false, + doc: doc2 = ( + /** @type {Doc} */ + isArray(typeScope) ? typeScope[0].doc : typeScope instanceof Doc ? typeScope : typeScope.doc + ) + } = {}) { + super(); + this.scope = []; + this.doc = doc2; + this.addToScope(typeScope); + this.deleteFilter = deleteFilter; + trackedOrigins.add(this); + this.trackedOrigins = trackedOrigins; + this.captureTransaction = captureTransaction; + this.undoStack = []; + this.redoStack = []; + this.undoing = false; + this.redoing = false; + this.currStackItem = null; + this.lastChange = 0; + this.ignoreRemoteMapChanges = ignoreRemoteMapChanges; + this.captureTimeout = captureTimeout; + this.afterTransactionHandler = (transaction) => { + if (!this.captureTransaction(transaction) || !this.scope.some((type) => transaction.changedParentTypes.has( + /** @type {AbstractType} */ + type + ) || type === this.doc) || !this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor))) { + return; + } + const undoing = this.undoing; + const redoing = this.redoing; + const stack = undoing ? this.redoStack : this.undoStack; + if (undoing) { + this.stopCapturing(); + } else if (!redoing) { + this.clear(false, true); + } + const insertions = new DeleteSet(); + transaction.afterState.forEach((endClock, client) => { + const startClock = transaction.beforeState.get(client) || 0; + const len = endClock - startClock; + if (len > 0) { + addToDeleteSet(insertions, client, startClock, len); + } + }); + const now = getUnixTime(); + let didAdd = false; + if (this.lastChange > 0 && now - this.lastChange < this.captureTimeout && stack.length > 0 && !undoing && !redoing) { + const lastOp = stack[stack.length - 1]; + lastOp.deletions = mergeDeleteSets([lastOp.deletions, transaction.deleteSet]); + lastOp.insertions = mergeDeleteSets([lastOp.insertions, insertions]); + } else { + stack.push(new StackItem(transaction.deleteSet, insertions)); + didAdd = true; + } + if (!undoing && !redoing) { + this.lastChange = now; + } + iterateDeletedStructs( + transaction, + transaction.deleteSet, + /** @param {Item|GC} item */ + (item) => { + if (item instanceof Item && this.scope.some((type) => type === transaction.doc || isParentOf( + /** @type {AbstractType} */ + type, + item + ))) { + keepItem(item, true); + } + } + ); + const changeEvent = [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? "redo" : "undo", changedParentTypes: transaction.changedParentTypes }, this]; + if (didAdd) { + this.emit("stack-item-added", changeEvent); + } else { + this.emit("stack-item-updated", changeEvent); + } + }; + this.doc.on("afterTransaction", this.afterTransactionHandler); + this.doc.on("destroy", () => { + this.destroy(); + }); + } + /** + * Extend the scope. + * + * @param {Array | Doc> | AbstractType | Doc} ytypes + */ + addToScope(ytypes) { + const tmpSet = new Set(this.scope); + ytypes = isArray(ytypes) ? ytypes : [ytypes]; + ytypes.forEach((ytype) => { + if (!tmpSet.has(ytype)) { + tmpSet.add(ytype); + if (ytype instanceof AbstractType ? ytype.doc !== this.doc : ytype !== this.doc) warn("[yjs#509] Not same Y.Doc"); + this.scope.push(ytype); + } + }); + } + /** + * @param {any} origin + */ + addTrackedOrigin(origin) { + this.trackedOrigins.add(origin); + } + /** + * @param {any} origin + */ + removeTrackedOrigin(origin) { + this.trackedOrigins.delete(origin); + } + clear(clearUndoStack = true, clearRedoStack = true) { + if (clearUndoStack && this.canUndo() || clearRedoStack && this.canRedo()) { + this.doc.transact((tr) => { + if (clearUndoStack) { + this.undoStack.forEach((item) => clearUndoManagerStackItem(tr, this, item)); + this.undoStack = []; + } + if (clearRedoStack) { + this.redoStack.forEach((item) => clearUndoManagerStackItem(tr, this, item)); + this.redoStack = []; + } + this.emit("stack-cleared", [{ undoStackCleared: clearUndoStack, redoStackCleared: clearRedoStack }]); + }); + } + } + /** + * UndoManager merges Undo-StackItem if they are created within time-gap + * smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next + * StackItem won't be merged. + * + * + * @example + * // without stopCapturing + * ytext.insert(0, 'a') + * ytext.insert(1, 'b') + * um.undo() + * ytext.toString() // => '' (note that 'ab' was removed) + * // with stopCapturing + * ytext.insert(0, 'a') + * um.stopCapturing() + * ytext.insert(0, 'b') + * um.undo() + * ytext.toString() // => 'a' (note that only 'b' was removed) + * + */ + stopCapturing() { + this.lastChange = 0; + } + /** + * Undo last changes on type. + * + * @return {StackItem?} Returns StackItem if a change was applied + */ + undo() { + this.undoing = true; + let res; + try { + res = popStackItem(this, this.undoStack, "undo"); + } finally { + this.undoing = false; + } + return res; + } + /** + * Redo last undo operation. + * + * @return {StackItem?} Returns StackItem if a change was applied + */ + redo() { + this.redoing = true; + let res; + try { + res = popStackItem(this, this.redoStack, "redo"); + } finally { + this.redoing = false; + } + return res; + } + /** + * Are undo steps available? + * + * @return {boolean} `true` if undo is possible + */ + canUndo() { + return this.undoStack.length > 0; + } + /** + * Are redo steps available? + * + * @return {boolean} `true` if redo is possible + */ + canRedo() { + return this.redoStack.length > 0; + } + destroy() { + this.trackedOrigins.delete(this); + this.doc.off("afterTransaction", this.afterTransactionHandler); + super.destroy(); + } + }; + function* lazyStructReaderGenerator(decoder) { + const numOfStateUpdates = readVarUint(decoder.restDecoder); + for (let i = 0; i < numOfStateUpdates; i++) { + const numberOfStructs = readVarUint(decoder.restDecoder); + const client = decoder.readClient(); + let clock = readVarUint(decoder.restDecoder); + for (let i2 = 0; i2 < numberOfStructs; i2++) { + const info = decoder.readInfo(); + if (info === 10) { + const len = readVarUint(decoder.restDecoder); + yield new Skip(createID(client, clock), len); + clock += len; + } else if ((BITS5 & info) !== 0) { + const cantCopyParentInfo = (info & (BIT7 | BIT8)) === 0; + const struct = new Item( + createID(client, clock), + null, + // left + (info & BIT8) === BIT8 ? decoder.readLeftID() : null, + // origin + null, + // right + (info & BIT7) === BIT7 ? decoder.readRightID() : null, + // right origin + // @ts-ignore Force writing a string here. + cantCopyParentInfo ? decoder.readParentInfo() ? decoder.readString() : decoder.readLeftID() : null, + // parent + cantCopyParentInfo && (info & BIT6) === BIT6 ? decoder.readString() : null, + // parentSub + readItemContent(decoder, info) + // item content + ); + yield struct; + clock += struct.length; + } else { + const len = decoder.readLen(); + yield new GC(createID(client, clock), len); + clock += len; + } + } + } + } + var LazyStructReader = class { + /** + * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder + * @param {boolean} filterSkips + */ + constructor(decoder, filterSkips) { + this.gen = lazyStructReaderGenerator(decoder); + this.curr = null; + this.done = false; + this.filterSkips = filterSkips; + this.next(); + } + /** + * @return {Item | GC | Skip |null} + */ + next() { + do { + this.curr = this.gen.next().value || null; + } while (this.filterSkips && this.curr !== null && this.curr.constructor === Skip); + return this.curr; + } + }; + var logUpdate = (update) => logUpdateV2(update, UpdateDecoderV1); + var logUpdateV2 = (update, YDecoder = UpdateDecoderV2) => { + const structs = []; + const updateDecoder = new YDecoder(createDecoder(update)); + const lazyDecoder = new LazyStructReader(updateDecoder, false); + for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) { + structs.push(curr); + } + print("Structs: ", structs); + const ds = readDeleteSet(updateDecoder); + print("DeleteSet: ", ds); + }; + var decodeUpdate = (update) => decodeUpdateV2(update, UpdateDecoderV1); + var decodeUpdateV2 = (update, YDecoder = UpdateDecoderV2) => { + const structs = []; + const updateDecoder = new YDecoder(createDecoder(update)); + const lazyDecoder = new LazyStructReader(updateDecoder, false); + for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) { + structs.push(curr); + } + return { + structs, + ds: readDeleteSet(updateDecoder) + }; + }; + var LazyStructWriter = class { + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + */ + constructor(encoder) { + this.currClient = 0; + this.startClock = 0; + this.written = 0; + this.encoder = encoder; + this.clientStructs = []; + } + }; + var mergeUpdates = (updates) => mergeUpdatesV2(updates, UpdateDecoderV1, UpdateEncoderV1); + var encodeStateVectorFromUpdateV2 = (update, YEncoder = DSEncoderV2, YDecoder = UpdateDecoderV2) => { + const encoder = new YEncoder(); + const updateDecoder = new LazyStructReader(new YDecoder(createDecoder(update)), false); + let curr = updateDecoder.curr; + if (curr !== null) { + let size2 = 0; + let currClient = curr.id.client; + let stopCounting = curr.id.clock !== 0; + let currClock = stopCounting ? 0 : curr.id.clock + curr.length; + for (; curr !== null; curr = updateDecoder.next()) { + if (currClient !== curr.id.client) { + if (currClock !== 0) { + size2++; + writeVarUint(encoder.restEncoder, currClient); + writeVarUint(encoder.restEncoder, currClock); + } + currClient = curr.id.client; + currClock = 0; + stopCounting = curr.id.clock !== 0; + } + if (curr.constructor === Skip) { + stopCounting = true; + } + if (!stopCounting) { + currClock = curr.id.clock + curr.length; + } + } + if (currClock !== 0) { + size2++; + writeVarUint(encoder.restEncoder, currClient); + writeVarUint(encoder.restEncoder, currClock); + } + const enc = createEncoder(); + writeVarUint(enc, size2); + writeBinaryEncoder(enc, encoder.restEncoder); + encoder.restEncoder = enc; + return encoder.toUint8Array(); + } else { + writeVarUint(encoder.restEncoder, 0); + return encoder.toUint8Array(); + } + }; + var encodeStateVectorFromUpdate = (update) => encodeStateVectorFromUpdateV2(update, DSEncoderV1, UpdateDecoderV1); + var parseUpdateMetaV2 = (update, YDecoder = UpdateDecoderV2) => { + const from2 = /* @__PURE__ */ new Map(); + const to = /* @__PURE__ */ new Map(); + const updateDecoder = new LazyStructReader(new YDecoder(createDecoder(update)), false); + let curr = updateDecoder.curr; + if (curr !== null) { + let currClient = curr.id.client; + let currClock = curr.id.clock; + from2.set(currClient, currClock); + for (; curr !== null; curr = updateDecoder.next()) { + if (currClient !== curr.id.client) { + to.set(currClient, currClock); + from2.set(curr.id.client, curr.id.clock); + currClient = curr.id.client; + } + currClock = curr.id.clock + curr.length; + } + to.set(currClient, currClock); + } + return { from: from2, to }; + }; + var parseUpdateMeta = (update) => parseUpdateMetaV2(update, UpdateDecoderV1); + var sliceStruct = (left, diff) => { + if (left.constructor === GC) { + const { client, clock } = left.id; + return new GC(createID(client, clock + diff), left.length - diff); + } else if (left.constructor === Skip) { + const { client, clock } = left.id; + return new Skip(createID(client, clock + diff), left.length - diff); + } else { + const leftItem = ( + /** @type {Item} */ + left + ); + const { client, clock } = leftItem.id; + return new Item( + createID(client, clock + diff), + null, + createID(client, clock + diff - 1), + null, + leftItem.rightOrigin, + leftItem.parent, + leftItem.parentSub, + leftItem.content.splice(diff) + ); + } + }; + var mergeUpdatesV2 = (updates, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => { + if (updates.length === 1) { + return updates[0]; + } + const updateDecoders = updates.map((update) => new YDecoder(createDecoder(update))); + let lazyStructDecoders = updateDecoders.map((decoder) => new LazyStructReader(decoder, true)); + let currWrite = null; + const updateEncoder = new YEncoder(); + const lazyStructEncoder = new LazyStructWriter(updateEncoder); + while (true) { + lazyStructDecoders = lazyStructDecoders.filter((dec) => dec.curr !== null); + lazyStructDecoders.sort( + /** @type {function(any,any):number} */ + (dec1, dec2) => { + if (dec1.curr.id.client === dec2.curr.id.client) { + const clockDiff = dec1.curr.id.clock - dec2.curr.id.clock; + if (clockDiff === 0) { + return dec1.curr.constructor === dec2.curr.constructor ? 0 : dec1.curr.constructor === Skip ? 1 : -1; + } else { + return clockDiff; + } + } else { + return dec2.curr.id.client - dec1.curr.id.client; + } + } + ); + if (lazyStructDecoders.length === 0) { + break; + } + const currDecoder = lazyStructDecoders[0]; + const firstClient = ( + /** @type {Item | GC} */ + currDecoder.curr.id.client + ); + if (currWrite !== null) { + let curr = ( + /** @type {Item | GC | null} */ + currDecoder.curr + ); + let iterated = false; + while (curr !== null && curr.id.clock + curr.length <= currWrite.struct.id.clock + currWrite.struct.length && curr.id.client >= currWrite.struct.id.client) { + curr = currDecoder.next(); + iterated = true; + } + if (curr === null || // current decoder is empty + curr.id.client !== firstClient || // check whether there is another decoder that has has updates from `firstClient` + iterated && curr.id.clock > currWrite.struct.id.clock + currWrite.struct.length) { + continue; + } + if (firstClient !== currWrite.struct.id.client) { + writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset); + currWrite = { struct: curr, offset: 0 }; + currDecoder.next(); + } else { + if (currWrite.struct.id.clock + currWrite.struct.length < curr.id.clock) { + if (currWrite.struct.constructor === Skip) { + currWrite.struct.length = curr.id.clock + curr.length - currWrite.struct.id.clock; + } else { + writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset); + const diff = curr.id.clock - currWrite.struct.id.clock - currWrite.struct.length; + const struct = new Skip(createID(firstClient, currWrite.struct.id.clock + currWrite.struct.length), diff); + currWrite = { struct, offset: 0 }; + } + } else { + const diff = currWrite.struct.id.clock + currWrite.struct.length - curr.id.clock; + if (diff > 0) { + if (currWrite.struct.constructor === Skip) { + currWrite.struct.length -= diff; + } else { + curr = sliceStruct(curr, diff); + } + } + if (!currWrite.struct.mergeWith( + /** @type {any} */ + curr + )) { + writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset); + currWrite = { struct: curr, offset: 0 }; + currDecoder.next(); + } + } + } + } else { + currWrite = { struct: ( + /** @type {Item | GC} */ + currDecoder.curr + ), offset: 0 }; + currDecoder.next(); + } + for (let next = currDecoder.curr; next !== null && next.id.client === firstClient && next.id.clock === currWrite.struct.id.clock + currWrite.struct.length && next.constructor !== Skip; next = currDecoder.next()) { + writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset); + currWrite = { struct: next, offset: 0 }; + } + } + if (currWrite !== null) { + writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset); + currWrite = null; + } + finishLazyStructWriting(lazyStructEncoder); + const dss = updateDecoders.map((decoder) => readDeleteSet(decoder)); + const ds = mergeDeleteSets(dss); + writeDeleteSet(updateEncoder, ds); + return updateEncoder.toUint8Array(); + }; + var diffUpdateV2 = (update, sv, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => { + const state = decodeStateVector(sv); + const encoder = new YEncoder(); + const lazyStructWriter = new LazyStructWriter(encoder); + const decoder = new YDecoder(createDecoder(update)); + const reader = new LazyStructReader(decoder, false); + while (reader.curr) { + const curr = reader.curr; + const currClient = curr.id.client; + const svClock = state.get(currClient) || 0; + if (reader.curr.constructor === Skip) { + reader.next(); + continue; + } + if (curr.id.clock + curr.length > svClock) { + writeStructToLazyStructWriter(lazyStructWriter, curr, max(svClock - curr.id.clock, 0)); + reader.next(); + while (reader.curr && reader.curr.id.client === currClient) { + writeStructToLazyStructWriter(lazyStructWriter, reader.curr, 0); + reader.next(); + } + } else { + while (reader.curr && reader.curr.id.client === currClient && reader.curr.id.clock + reader.curr.length <= svClock) { + reader.next(); + } + } + } + finishLazyStructWriting(lazyStructWriter); + const ds = readDeleteSet(decoder); + writeDeleteSet(encoder, ds); + return encoder.toUint8Array(); + }; + var diffUpdate = (update, sv) => diffUpdateV2(update, sv, UpdateDecoderV1, UpdateEncoderV1); + var flushLazyStructWriter = (lazyWriter) => { + if (lazyWriter.written > 0) { + lazyWriter.clientStructs.push({ written: lazyWriter.written, restEncoder: toUint8Array(lazyWriter.encoder.restEncoder) }); + lazyWriter.encoder.restEncoder = createEncoder(); + lazyWriter.written = 0; + } + }; + var writeStructToLazyStructWriter = (lazyWriter, struct, offset) => { + if (lazyWriter.written > 0 && lazyWriter.currClient !== struct.id.client) { + flushLazyStructWriter(lazyWriter); + } + if (lazyWriter.written === 0) { + lazyWriter.currClient = struct.id.client; + lazyWriter.encoder.writeClient(struct.id.client); + writeVarUint(lazyWriter.encoder.restEncoder, struct.id.clock + offset); + } + struct.write(lazyWriter.encoder, offset); + lazyWriter.written++; + }; + var finishLazyStructWriting = (lazyWriter) => { + flushLazyStructWriter(lazyWriter); + const restEncoder = lazyWriter.encoder.restEncoder; + writeVarUint(restEncoder, lazyWriter.clientStructs.length); + for (let i = 0; i < lazyWriter.clientStructs.length; i++) { + const partStructs = lazyWriter.clientStructs[i]; + writeVarUint(restEncoder, partStructs.written); + writeUint8Array(restEncoder, partStructs.restEncoder); + } + }; + var convertUpdateFormat = (update, blockTransformer, YDecoder, YEncoder) => { + const updateDecoder = new YDecoder(createDecoder(update)); + const lazyDecoder = new LazyStructReader(updateDecoder, false); + const updateEncoder = new YEncoder(); + const lazyWriter = new LazyStructWriter(updateEncoder); + for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) { + writeStructToLazyStructWriter(lazyWriter, blockTransformer(curr), 0); + } + finishLazyStructWriting(lazyWriter); + const ds = readDeleteSet(updateDecoder); + writeDeleteSet(updateEncoder, ds); + return updateEncoder.toUint8Array(); + }; + var createObfuscator = ({ formatting = true, subdocs = true, yxml = true } = {}) => { + let i = 0; + const mapKeyCache = create(); + const nodeNameCache = create(); + const formattingKeyCache = create(); + const formattingValueCache = create(); + formattingValueCache.set(null, null); + return (block) => { + switch (block.constructor) { + case GC: + case Skip: + return block; + case Item: { + const item = ( + /** @type {Item} */ + block + ); + const content = item.content; + switch (content.constructor) { + case ContentDeleted: + break; + case ContentType: { + if (yxml) { + const type = ( + /** @type {ContentType} */ + content.type + ); + if (type instanceof YXmlElement) { + type.nodeName = setIfUndefined(nodeNameCache, type.nodeName, () => "node-" + i); + } + if (type instanceof YXmlHook) { + type.hookName = setIfUndefined(nodeNameCache, type.hookName, () => "hook-" + i); + } + } + break; + } + case ContentAny: { + const c = ( + /** @type {ContentAny} */ + content + ); + c.arr = c.arr.map(() => i); + break; + } + case ContentBinary: { + const c = ( + /** @type {ContentBinary} */ + content + ); + c.content = new Uint8Array([i]); + break; + } + case ContentDoc: { + const c = ( + /** @type {ContentDoc} */ + content + ); + if (subdocs) { + c.opts = {}; + c.doc.guid = i + ""; + } + break; + } + case ContentEmbed: { + const c = ( + /** @type {ContentEmbed} */ + content + ); + c.embed = {}; + break; + } + case ContentFormat: { + const c = ( + /** @type {ContentFormat} */ + content + ); + if (formatting) { + c.key = setIfUndefined(formattingKeyCache, c.key, () => i + ""); + c.value = setIfUndefined(formattingValueCache, c.value, () => ({ i })); + } + break; + } + case ContentJSON: { + const c = ( + /** @type {ContentJSON} */ + content + ); + c.arr = c.arr.map(() => i); + break; + } + case ContentString: { + const c = ( + /** @type {ContentString} */ + content + ); + c.str = repeat(i % 10 + "", c.str.length); + break; + } + default: + unexpectedCase(); + } + if (item.parentSub) { + item.parentSub = setIfUndefined(mapKeyCache, item.parentSub, () => i + ""); + } + i++; + return block; + } + default: + unexpectedCase(); + } + }; + }; + var obfuscateUpdate = (update, opts) => convertUpdateFormat(update, createObfuscator(opts), UpdateDecoderV1, UpdateEncoderV1); + var obfuscateUpdateV2 = (update, opts) => convertUpdateFormat(update, createObfuscator(opts), UpdateDecoderV2, UpdateEncoderV2); + var convertUpdateFormatV1ToV2 = (update) => convertUpdateFormat(update, id, UpdateDecoderV1, UpdateEncoderV2); + var convertUpdateFormatV2ToV1 = (update) => convertUpdateFormat(update, id, UpdateDecoderV2, UpdateEncoderV1); + var errorComputeChanges = "You must not compute changes after the event-handler fired."; + var YEvent = class { + /** + * @param {T} target The changed type. + * @param {Transaction} transaction + */ + constructor(target, transaction) { + this.target = target; + this.currentTarget = target; + this.transaction = transaction; + this._changes = null; + this._keys = null; + this._delta = null; + this._path = null; + } + /** + * Computes the path from `y` to the changed type. + * + * @todo v14 should standardize on path: Array<{parent, index}> because that is easier to work with. + * + * The following property holds: + * @example + * let type = y + * event.path.forEach(dir => { + * type = type.get(dir) + * }) + * type === event.target // => true + */ + get path() { + return this._path || (this._path = getPathTo(this.currentTarget, this.target)); + } + /** + * Check if a struct is deleted by this event. + * + * In contrast to change.deleted, this method also returns true if the struct was added and then deleted. + * + * @param {AbstractStruct} struct + * @return {boolean} + */ + deletes(struct) { + return isDeleted(this.transaction.deleteSet, struct.id); + } + /** + * @type {Map} + */ + get keys() { + if (this._keys === null) { + if (this.transaction.doc._transactionCleanups.length === 0) { + throw create3(errorComputeChanges); + } + const keys2 = /* @__PURE__ */ new Map(); + const target = this.target; + const changed = ( + /** @type Set */ + this.transaction.changed.get(target) + ); + changed.forEach((key) => { + if (key !== null) { + const item = ( + /** @type {Item} */ + target._map.get(key) + ); + let action; + let oldValue; + if (this.adds(item)) { + let prev = item.left; + while (prev !== null && this.adds(prev)) { + prev = prev.left; + } + if (this.deletes(item)) { + if (prev !== null && this.deletes(prev)) { + action = "delete"; + oldValue = last(prev.content.getContent()); + } else { + return; + } + } else { + if (prev !== null && this.deletes(prev)) { + action = "update"; + oldValue = last(prev.content.getContent()); + } else { + action = "add"; + oldValue = void 0; + } + } + } else { + if (this.deletes(item)) { + action = "delete"; + oldValue = last( + /** @type {Item} */ + item.content.getContent() + ); + } else { + return; + } + } + keys2.set(key, { action, oldValue }); + } + }); + this._keys = keys2; + } + return this._keys; + } + /** + * This is a computed property. Note that this can only be safely computed during the + * event call. Computing this property after other changes happened might result in + * unexpected behavior (incorrect computation of deltas). A safe way to collect changes + * is to store the `changes` or the `delta` object. Avoid storing the `transaction` object. + * + * @type {Array<{insert?: string | Array | object | AbstractType, retain?: number, delete?: number, attributes?: Object}>} + */ + get delta() { + return this.changes.delta; + } + /** + * Check if a struct is added by this event. + * + * In contrast to change.deleted, this method also returns true if the struct was added and then deleted. + * + * @param {AbstractStruct} struct + * @return {boolean} + */ + adds(struct) { + return struct.id.clock >= (this.transaction.beforeState.get(struct.id.client) || 0); + } + /** + * This is a computed property. Note that this can only be safely computed during the + * event call. Computing this property after other changes happened might result in + * unexpected behavior (incorrect computation of deltas). A safe way to collect changes + * is to store the `changes` or the `delta` object. Avoid storing the `transaction` object. + * + * @type {{added:Set,deleted:Set,keys:Map,delta:Array<{insert?:Array|string, delete?:number, retain?:number}>}} + */ + get changes() { + let changes = this._changes; + if (changes === null) { + if (this.transaction.doc._transactionCleanups.length === 0) { + throw create3(errorComputeChanges); + } + const target = this.target; + const added = create2(); + const deleted = create2(); + const delta = []; + changes = { + added, + deleted, + delta, + keys: this.keys + }; + const changed = ( + /** @type Set */ + this.transaction.changed.get(target) + ); + if (changed.has(null)) { + let lastOp = null; + const packOp = () => { + if (lastOp) { + delta.push(lastOp); + } + }; + for (let item = target._start; item !== null; item = item.right) { + if (item.deleted) { + if (this.deletes(item) && !this.adds(item)) { + if (lastOp === null || lastOp.delete === void 0) { + packOp(); + lastOp = { delete: 0 }; + } + lastOp.delete += item.length; + deleted.add(item); + } + } else { + if (this.adds(item)) { + if (lastOp === null || lastOp.insert === void 0) { + packOp(); + lastOp = { insert: [] }; + } + lastOp.insert = lastOp.insert.concat(item.content.getContent()); + added.add(item); + } else { + if (lastOp === null || lastOp.retain === void 0) { + packOp(); + lastOp = { retain: 0 }; + } + lastOp.retain += item.length; + } + } + } + if (lastOp !== null && lastOp.retain === void 0) { + packOp(); + } + } + this._changes = changes; + } + return ( + /** @type {any} */ + changes + ); + } + }; + var getPathTo = (parent, child) => { + const path = []; + while (child._item !== null && child !== parent) { + if (child._item.parentSub !== null) { + path.unshift(child._item.parentSub); + } else { + let i = 0; + let c = ( + /** @type {AbstractType} */ + child._item.parent._start + ); + while (c !== child._item && c !== null) { + if (!c.deleted && c.countable) { + i += c.length; + } + c = c.right; + } + path.unshift(i); + } + child = /** @type {AbstractType} */ + child._item.parent; + } + return path; + }; + var warnPrematureAccess = () => { + warn("Invalid access: Add Yjs type to a document before reading data."); + }; + var maxSearchMarker = 80; + var globalSearchMarkerTimestamp = 0; + var ArraySearchMarker = class { + /** + * @param {Item} p + * @param {number} index + */ + constructor(p, index) { + p.marker = true; + this.p = p; + this.index = index; + this.timestamp = globalSearchMarkerTimestamp++; + } + }; + var refreshMarkerTimestamp = (marker) => { + marker.timestamp = globalSearchMarkerTimestamp++; + }; + var overwriteMarker = (marker, p, index) => { + marker.p.marker = false; + marker.p = p; + p.marker = true; + marker.index = index; + marker.timestamp = globalSearchMarkerTimestamp++; + }; + var markPosition = (searchMarker, p, index) => { + if (searchMarker.length >= maxSearchMarker) { + const marker = searchMarker.reduce((a, b) => a.timestamp < b.timestamp ? a : b); + overwriteMarker(marker, p, index); + return marker; + } else { + const pm = new ArraySearchMarker(p, index); + searchMarker.push(pm); + return pm; + } + }; + var findMarker = (yarray, index) => { + if (yarray._start === null || index === 0 || yarray._searchMarker === null) { + return null; + } + const marker = yarray._searchMarker.length === 0 ? null : yarray._searchMarker.reduce((a, b) => abs(index - a.index) < abs(index - b.index) ? a : b); + let p = yarray._start; + let pindex = 0; + if (marker !== null) { + p = marker.p; + pindex = marker.index; + refreshMarkerTimestamp(marker); + } + while (p.right !== null && pindex < index) { + if (!p.deleted && p.countable) { + if (index < pindex + p.length) { + break; + } + pindex += p.length; + } + p = p.right; + } + while (p.left !== null && pindex > index) { + p = p.left; + if (!p.deleted && p.countable) { + pindex -= p.length; + } + } + while (p.left !== null && p.left.id.client === p.id.client && p.left.id.clock + p.left.length === p.id.clock) { + p = p.left; + if (!p.deleted && p.countable) { + pindex -= p.length; + } + } + if (marker !== null && abs(marker.index - pindex) < /** @type {YText|YArray} */ + p.parent.length / maxSearchMarker) { + overwriteMarker(marker, p, pindex); + return marker; + } else { + return markPosition(yarray._searchMarker, p, pindex); + } + }; + var updateMarkerChanges = (searchMarker, index, len) => { + for (let i = searchMarker.length - 1; i >= 0; i--) { + const m = searchMarker[i]; + if (len > 0) { + let p = m.p; + p.marker = false; + while (p && (p.deleted || !p.countable)) { + p = p.left; + if (p && !p.deleted && p.countable) { + m.index -= p.length; + } + } + if (p === null || p.marker === true) { + searchMarker.splice(i, 1); + continue; + } + m.p = p; + p.marker = true; + } + if (index < m.index || len > 0 && index === m.index) { + m.index = max(index, m.index + len); + } + } + }; + var getTypeChildren = (t) => { + t.doc ?? warnPrematureAccess(); + let s = t._start; + const arr = []; + while (s) { + arr.push(s); + s = s.right; + } + return arr; + }; + var callTypeObservers = (type, transaction, event) => { + const changedType = type; + const changedParentTypes = transaction.changedParentTypes; + while (true) { + setIfUndefined(changedParentTypes, type, () => []).push(event); + if (type._item === null) { + break; + } + type = /** @type {AbstractType} */ + type._item.parent; + } + callEventHandlerListeners(changedType._eH, event, transaction); + }; + var AbstractType = class { + constructor() { + this._item = null; + this._map = /* @__PURE__ */ new Map(); + this._start = null; + this.doc = null; + this._length = 0; + this._eH = createEventHandler(); + this._dEH = createEventHandler(); + this._searchMarker = null; + } + /** + * @return {AbstractType|null} + */ + get parent() { + return this._item ? ( + /** @type {AbstractType} */ + this._item.parent + ) : null; + } + /** + * Integrate this type into the Yjs instance. + * + * * Save this struct in the os + * * This type is sent to other client + * * Observer functions are fired + * + * @param {Doc} y The Yjs instance + * @param {Item|null} item + */ + _integrate(y, item) { + this.doc = y; + this._item = item; + } + /** + * @return {AbstractType} + */ + _copy() { + throw methodUnimplemented(); + } + /** + * Makes a copy of this data type that can be included somewhere else. + * + * Note that the content is only readable _after_ it has been included somewhere in the Ydoc. + * + * @return {AbstractType} + */ + clone() { + throw methodUnimplemented(); + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} _encoder + */ + _write(_encoder) { + } + /** + * The first non-deleted item + */ + get _first() { + let n = this._start; + while (n !== null && n.deleted) { + n = n.right; + } + return n; + } + /** + * Creates YEvent and calls all type observers. + * Must be implemented by each type. + * + * @param {Transaction} transaction + * @param {Set} _parentSubs Keys changed on this type. `null` if list was modified. + */ + _callObserver(transaction, _parentSubs) { + if (!transaction.local && this._searchMarker) { + this._searchMarker.length = 0; + } + } + /** + * Observe all events that are created on this type. + * + * @param {function(EventType, Transaction):void} f Observer function + */ + observe(f) { + addEventHandlerListener(this._eH, f); + } + /** + * Observe all events that are created by this type and its children. + * + * @param {function(Array>,Transaction):void} f Observer function + */ + observeDeep(f) { + addEventHandlerListener(this._dEH, f); + } + /** + * Unregister an observer function. + * + * @param {function(EventType,Transaction):void} f Observer function + */ + unobserve(f) { + removeEventHandlerListener(this._eH, f); + } + /** + * Unregister an observer function. + * + * @param {function(Array>,Transaction):void} f Observer function + */ + unobserveDeep(f) { + removeEventHandlerListener(this._dEH, f); + } + /** + * @abstract + * @return {any} + */ + toJSON() { + } + }; + var typeListSlice = (type, start, end) => { + type.doc ?? warnPrematureAccess(); + if (start < 0) { + start = type._length + start; + } + if (end < 0) { + end = type._length + end; + } + let len = end - start; + const cs = []; + let n = type._start; + while (n !== null && len > 0) { + if (n.countable && !n.deleted) { + const c = n.content.getContent(); + if (c.length <= start) { + start -= c.length; + } else { + for (let i = start; i < c.length && len > 0; i++) { + cs.push(c[i]); + len--; + } + start = 0; + } + } + n = n.right; + } + return cs; + }; + var typeListToArray = (type) => { + type.doc ?? warnPrematureAccess(); + const cs = []; + let n = type._start; + while (n !== null) { + if (n.countable && !n.deleted) { + const c = n.content.getContent(); + for (let i = 0; i < c.length; i++) { + cs.push(c[i]); + } + } + n = n.right; + } + return cs; + }; + var typeListToArraySnapshot = (type, snapshot2) => { + const cs = []; + let n = type._start; + while (n !== null) { + if (n.countable && isVisible(n, snapshot2)) { + const c = n.content.getContent(); + for (let i = 0; i < c.length; i++) { + cs.push(c[i]); + } + } + n = n.right; + } + return cs; + }; + var typeListForEach = (type, f) => { + let index = 0; + let n = type._start; + type.doc ?? warnPrematureAccess(); + while (n !== null) { + if (n.countable && !n.deleted) { + const c = n.content.getContent(); + for (let i = 0; i < c.length; i++) { + f(c[i], index++, type); + } + } + n = n.right; + } + }; + var typeListMap = (type, f) => { + const result = []; + typeListForEach(type, (c, i) => { + result.push(f(c, i, type)); + }); + return result; + }; + var typeListCreateIterator = (type) => { + let n = type._start; + let currentContent = null; + let currentContentIndex = 0; + return { + [Symbol.iterator]() { + return this; + }, + next: () => { + if (currentContent === null) { + while (n !== null && n.deleted) { + n = n.right; + } + if (n === null) { + return { + done: true, + value: void 0 + }; + } + currentContent = n.content.getContent(); + currentContentIndex = 0; + n = n.right; + } + const value = currentContent[currentContentIndex++]; + if (currentContent.length <= currentContentIndex) { + currentContent = null; + } + return { + done: false, + value + }; + } + }; + }; + var typeListGet = (type, index) => { + type.doc ?? warnPrematureAccess(); + const marker = findMarker(type, index); + let n = type._start; + if (marker !== null) { + n = marker.p; + index -= marker.index; + } + for (; n !== null; n = n.right) { + if (!n.deleted && n.countable) { + if (index < n.length) { + return n.content.getContent()[index]; + } + index -= n.length; + } + } + }; + var typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => { + let left = referenceItem; + const doc2 = transaction.doc; + const ownClientId = doc2.clientID; + const store = doc2.store; + const right = referenceItem === null ? parent._start : referenceItem.right; + let jsonContent = []; + const packJsonContent = () => { + if (jsonContent.length > 0) { + left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentAny(jsonContent)); + left.integrate(transaction, 0); + jsonContent = []; + } + }; + content.forEach((c) => { + if (c === null) { + jsonContent.push(c); + } else { + switch (c.constructor) { + case Number: + case Object: + case Boolean: + case Array: + case String: + jsonContent.push(c); + break; + default: + packJsonContent(); + switch (c.constructor) { + case Uint8Array: + case ArrayBuffer: + left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array( + /** @type {Uint8Array} */ + c + ))); + left.integrate(transaction, 0); + break; + case Doc: + left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentDoc( + /** @type {Doc} */ + c + )); + left.integrate(transaction, 0); + break; + default: + if (c instanceof AbstractType) { + left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(c)); + left.integrate(transaction, 0); + } else { + throw new Error("Unexpected content type in insert operation"); + } + } + } + } + }); + packJsonContent(); + }; + var lengthExceeded = () => create3("Length exceeded!"); + var typeListInsertGenerics = (transaction, parent, index, content) => { + if (index > parent._length) { + throw lengthExceeded(); + } + if (index === 0) { + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, index, content.length); + } + return typeListInsertGenericsAfter(transaction, parent, null, content); + } + const startIndex = index; + const marker = findMarker(parent, index); + let n = parent._start; + if (marker !== null) { + n = marker.p; + index -= marker.index; + if (index === 0) { + n = n.prev; + index += n && n.countable && !n.deleted ? n.length : 0; + } + } + for (; n !== null; n = n.right) { + if (!n.deleted && n.countable) { + if (index <= n.length) { + if (index < n.length) { + getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index)); + } + break; + } + index -= n.length; + } + } + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, startIndex, content.length); + } + return typeListInsertGenericsAfter(transaction, parent, n, content); + }; + var typeListPushGenerics = (transaction, parent, content) => { + const marker = (parent._searchMarker || []).reduce((maxMarker, currMarker) => currMarker.index > maxMarker.index ? currMarker : maxMarker, { index: 0, p: parent._start }); + let n = marker.p; + if (n) { + while (n.right) { + n = n.right; + } + } + return typeListInsertGenericsAfter(transaction, parent, n, content); + }; + var typeListDelete = (transaction, parent, index, length2) => { + if (length2 === 0) { + return; + } + const startIndex = index; + const startLength = length2; + const marker = findMarker(parent, index); + let n = parent._start; + if (marker !== null) { + n = marker.p; + index -= marker.index; + } + for (; n !== null && index > 0; n = n.right) { + if (!n.deleted && n.countable) { + if (index < n.length) { + getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index)); + } + index -= n.length; + } + } + while (length2 > 0 && n !== null) { + if (!n.deleted) { + if (length2 < n.length) { + getItemCleanStart(transaction, createID(n.id.client, n.id.clock + length2)); + } + n.delete(transaction); + length2 -= n.length; + } + n = n.right; + } + if (length2 > 0) { + throw lengthExceeded(); + } + if (parent._searchMarker) { + updateMarkerChanges( + parent._searchMarker, + startIndex, + -startLength + length2 + /* in case we remove the above exception */ + ); + } + }; + var typeMapDelete = (transaction, parent, key) => { + const c = parent._map.get(key); + if (c !== void 0) { + c.delete(transaction); + } + }; + var typeMapSet = (transaction, parent, key, value) => { + const left = parent._map.get(key) || null; + const doc2 = transaction.doc; + const ownClientId = doc2.clientID; + let content; + if (value == null) { + content = new ContentAny([value]); + } else { + switch (value.constructor) { + case Number: + case Object: + case Boolean: + case Array: + case String: + case Date: + case BigInt: + content = new ContentAny([value]); + break; + case Uint8Array: + content = new ContentBinary( + /** @type {Uint8Array} */ + value + ); + break; + case Doc: + content = new ContentDoc( + /** @type {Doc} */ + value + ); + break; + default: + if (value instanceof AbstractType) { + content = new ContentType(value); + } else { + throw new Error("Unexpected content type"); + } + } + } + new Item(createID(ownClientId, getState(doc2.store, ownClientId)), left, left && left.lastId, null, null, parent, key, content).integrate(transaction, 0); + }; + var typeMapGet = (parent, key) => { + parent.doc ?? warnPrematureAccess(); + const val = parent._map.get(key); + return val !== void 0 && !val.deleted ? val.content.getContent()[val.length - 1] : void 0; + }; + var typeMapGetAll = (parent) => { + const res = {}; + parent.doc ?? warnPrematureAccess(); + parent._map.forEach((value, key) => { + if (!value.deleted) { + res[key] = value.content.getContent()[value.length - 1]; + } + }); + return res; + }; + var typeMapHas = (parent, key) => { + parent.doc ?? warnPrematureAccess(); + const val = parent._map.get(key); + return val !== void 0 && !val.deleted; + }; + var typeMapGetSnapshot = (parent, key, snapshot2) => { + let v = parent._map.get(key) || null; + while (v !== null && (!snapshot2.sv.has(v.id.client) || v.id.clock >= (snapshot2.sv.get(v.id.client) || 0))) { + v = v.left; + } + return v !== null && isVisible(v, snapshot2) ? v.content.getContent()[v.length - 1] : void 0; + }; + var typeMapGetAllSnapshot = (parent, snapshot2) => { + const res = {}; + parent._map.forEach((value, key) => { + let v = value; + while (v !== null && (!snapshot2.sv.has(v.id.client) || v.id.clock >= (snapshot2.sv.get(v.id.client) || 0))) { + v = v.left; + } + if (v !== null && isVisible(v, snapshot2)) { + res[key] = v.content.getContent()[v.length - 1]; + } + }); + return res; + }; + var createMapIterator = (type) => { + type.doc ?? warnPrematureAccess(); + return iteratorFilter( + type._map.entries(), + /** @param {any} entry */ + (entry) => !entry[1].deleted + ); + }; + var YArrayEvent = class extends YEvent { + }; + var YArray = class _YArray extends AbstractType { + constructor() { + super(); + this._prelimContent = []; + this._searchMarker = []; + } + /** + * Construct a new YArray containing the specified items. + * @template {Object|Array|number|null|string|Uint8Array} T + * @param {Array} items + * @return {YArray} + */ + static from(items) { + const a = new _YArray(); + a.push(items); + return a; + } + /** + * Integrate this type into the Yjs instance. + * + * * Save this struct in the os + * * This type is sent to other client + * * Observer functions are fired + * + * @param {Doc} y The Yjs instance + * @param {Item} item + */ + _integrate(y, item) { + super._integrate(y, item); + this.insert( + 0, + /** @type {Array} */ + this._prelimContent + ); + this._prelimContent = null; + } + /** + * @return {YArray} + */ + _copy() { + return new _YArray(); + } + /** + * Makes a copy of this data type that can be included somewhere else. + * + * Note that the content is only readable _after_ it has been included somewhere in the Ydoc. + * + * @return {YArray} + */ + clone() { + const arr = new _YArray(); + arr.insert(0, this.toArray().map( + (el) => el instanceof AbstractType ? ( + /** @type {typeof el} */ + el.clone() + ) : el + )); + return arr; + } + get length() { + this.doc ?? warnPrematureAccess(); + return this._length; + } + /** + * Creates YArrayEvent and calls observers. + * + * @param {Transaction} transaction + * @param {Set} parentSubs Keys changed on this type. `null` if list was modified. + */ + _callObserver(transaction, parentSubs) { + super._callObserver(transaction, parentSubs); + callTypeObservers(this, transaction, new YArrayEvent(this, transaction)); + } + /** + * Inserts new content at an index. + * + * Important: This function expects an array of content. Not just a content + * object. The reason for this "weirdness" is that inserting several elements + * is very efficient when it is done as a single operation. + * + * @example + * // Insert character 'a' at position 0 + * yarray.insert(0, ['a']) + * // Insert numbers 1, 2 at position 1 + * yarray.insert(1, [1, 2]) + * + * @param {number} index The index to insert content at. + * @param {Array} content The array of content + */ + insert(index, content) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + typeListInsertGenerics( + transaction, + this, + index, + /** @type {any} */ + content + ); + }); + } else { + this._prelimContent.splice(index, 0, ...content); + } + } + /** + * Appends content to this YArray. + * + * @param {Array} content Array of content to append. + * + * @todo Use the following implementation in all types. + */ + push(content) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + typeListPushGenerics( + transaction, + this, + /** @type {any} */ + content + ); + }); + } else { + this._prelimContent.push(...content); + } + } + /** + * Prepends content to this YArray. + * + * @param {Array} content Array of content to prepend. + */ + unshift(content) { + this.insert(0, content); + } + /** + * Deletes elements starting from an index. + * + * @param {number} index Index at which to start deleting elements + * @param {number} length The number of elements to remove. Defaults to 1. + */ + delete(index, length2 = 1) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + typeListDelete(transaction, this, index, length2); + }); + } else { + this._prelimContent.splice(index, length2); + } + } + /** + * Returns the i-th element from a YArray. + * + * @param {number} index The index of the element to return from the YArray + * @return {T} + */ + get(index) { + return typeListGet(this, index); + } + /** + * Transforms this YArray to a JavaScript Array. + * + * @return {Array} + */ + toArray() { + return typeListToArray(this); + } + /** + * Returns a portion of this YArray into a JavaScript Array selected + * from start to end (end not included). + * + * @param {number} [start] + * @param {number} [end] + * @return {Array} + */ + slice(start = 0, end = this.length) { + return typeListSlice(this, start, end); + } + /** + * Transforms this Shared Type to a JSON object. + * + * @return {Array} + */ + toJSON() { + return this.map((c) => c instanceof AbstractType ? c.toJSON() : c); + } + /** + * Returns an Array with the result of calling a provided function on every + * element of this YArray. + * + * @template M + * @param {function(T,number,YArray):M} f Function that produces an element of the new Array + * @return {Array} A new array with each element being the result of the + * callback function + */ + map(f) { + return typeListMap( + this, + /** @type {any} */ + f + ); + } + /** + * Executes a provided function once on every element of this YArray. + * + * @param {function(T,number,YArray):void} f A function to execute on every element of this YArray. + */ + forEach(f) { + typeListForEach(this, f); + } + /** + * @return {IterableIterator} + */ + [Symbol.iterator]() { + return typeListCreateIterator(this); + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + */ + _write(encoder) { + encoder.writeTypeRef(YArrayRefID); + } + }; + var readYArray = (_decoder) => new YArray(); + var YMapEvent = class extends YEvent { + /** + * @param {YMap} ymap The YArray that changed. + * @param {Transaction} transaction + * @param {Set} subs The keys that changed. + */ + constructor(ymap, transaction, subs) { + super(ymap, transaction); + this.keysChanged = subs; + } + }; + var YMap = class _YMap extends AbstractType { + /** + * + * @param {Iterable=} entries - an optional iterable to initialize the YMap + */ + constructor(entries) { + super(); + this._prelimContent = null; + if (entries === void 0) { + this._prelimContent = /* @__PURE__ */ new Map(); + } else { + this._prelimContent = new Map(entries); + } + } + /** + * Integrate this type into the Yjs instance. + * + * * Save this struct in the os + * * This type is sent to other client + * * Observer functions are fired + * + * @param {Doc} y The Yjs instance + * @param {Item} item + */ + _integrate(y, item) { + super._integrate(y, item); + this._prelimContent.forEach((value, key) => { + this.set(key, value); + }); + this._prelimContent = null; + } + /** + * @return {YMap} + */ + _copy() { + return new _YMap(); + } + /** + * Makes a copy of this data type that can be included somewhere else. + * + * Note that the content is only readable _after_ it has been included somewhere in the Ydoc. + * + * @return {YMap} + */ + clone() { + const map2 = new _YMap(); + this.forEach((value, key) => { + map2.set(key, value instanceof AbstractType ? ( + /** @type {typeof value} */ + value.clone() + ) : value); + }); + return map2; + } + /** + * Creates YMapEvent and calls observers. + * + * @param {Transaction} transaction + * @param {Set} parentSubs Keys changed on this type. `null` if list was modified. + */ + _callObserver(transaction, parentSubs) { + callTypeObservers(this, transaction, new YMapEvent(this, transaction, parentSubs)); + } + /** + * Transforms this Shared Type to a JSON object. + * + * @return {Object} + */ + toJSON() { + this.doc ?? warnPrematureAccess(); + const map2 = {}; + this._map.forEach((item, key) => { + if (!item.deleted) { + const v = item.content.getContent()[item.length - 1]; + map2[key] = v instanceof AbstractType ? v.toJSON() : v; + } + }); + return map2; + } + /** + * Returns the size of the YMap (count of key/value pairs) + * + * @return {number} + */ + get size() { + return [...createMapIterator(this)].length; + } + /** + * Returns the keys for each element in the YMap Type. + * + * @return {IterableIterator} + */ + keys() { + return iteratorMap( + createMapIterator(this), + /** @param {any} v */ + (v) => v[0] + ); + } + /** + * Returns the values for each element in the YMap Type. + * + * @return {IterableIterator} + */ + values() { + return iteratorMap( + createMapIterator(this), + /** @param {any} v */ + (v) => v[1].content.getContent()[v[1].length - 1] + ); + } + /** + * Returns an Iterator of [key, value] pairs + * + * @return {IterableIterator<[string, MapType]>} + */ + entries() { + return iteratorMap( + createMapIterator(this), + /** @param {any} v */ + (v) => ( + /** @type {any} */ + [v[0], v[1].content.getContent()[v[1].length - 1]] + ) + ); + } + /** + * Executes a provided function on once on every key-value pair. + * + * @param {function(MapType,string,YMap):void} f A function to execute on every element of this YArray. + */ + forEach(f) { + this.doc ?? warnPrematureAccess(); + this._map.forEach((item, key) => { + if (!item.deleted) { + f(item.content.getContent()[item.length - 1], key, this); + } + }); + } + /** + * Returns an Iterator of [key, value] pairs + * + * @return {IterableIterator<[string, MapType]>} + */ + [Symbol.iterator]() { + return this.entries(); + } + /** + * Remove a specified element from this YMap. + * + * @param {string} key The key of the element to remove. + */ + delete(key) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + typeMapDelete(transaction, this, key); + }); + } else { + this._prelimContent.delete(key); + } + } + /** + * Adds or updates an element with a specified key and value. + * @template {MapType} VAL + * + * @param {string} key The key of the element to add to this YMap + * @param {VAL} value The value of the element to add + * @return {VAL} + */ + set(key, value) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + typeMapSet( + transaction, + this, + key, + /** @type {any} */ + value + ); + }); + } else { + this._prelimContent.set(key, value); + } + return value; + } + /** + * Returns a specified element from this YMap. + * + * @param {string} key + * @return {MapType|undefined} + */ + get(key) { + return ( + /** @type {any} */ + typeMapGet(this, key) + ); + } + /** + * Returns a boolean indicating whether the specified key exists or not. + * + * @param {string} key The key to test. + * @return {boolean} + */ + has(key) { + return typeMapHas(this, key); + } + /** + * Removes all elements from this YMap. + */ + clear() { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + this.forEach(function(_value, key, map2) { + typeMapDelete(transaction, map2, key); + }); + }); + } else { + this._prelimContent.clear(); + } + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + */ + _write(encoder) { + encoder.writeTypeRef(YMapRefID); + } + }; + var readYMap = (_decoder) => new YMap(); + var equalAttrs = (a, b) => a === b || typeof a === "object" && typeof b === "object" && a && b && equalFlat(a, b); + var ItemTextListPosition = class { + /** + * @param {Item|null} left + * @param {Item|null} right + * @param {number} index + * @param {Map} currentAttributes + */ + constructor(left, right, index, currentAttributes) { + this.left = left; + this.right = right; + this.index = index; + this.currentAttributes = currentAttributes; + } + /** + * Only call this if you know that this.right is defined + */ + forward() { + if (this.right === null) { + unexpectedCase(); + } + switch (this.right.content.constructor) { + case ContentFormat: + if (!this.right.deleted) { + updateCurrentAttributes( + this.currentAttributes, + /** @type {ContentFormat} */ + this.right.content + ); + } + break; + default: + if (!this.right.deleted) { + this.index += this.right.length; + } + break; + } + this.left = this.right; + this.right = this.right.right; + } + }; + var findNextPosition = (transaction, pos, count) => { + while (pos.right !== null && count > 0) { + switch (pos.right.content.constructor) { + case ContentFormat: + if (!pos.right.deleted) { + updateCurrentAttributes( + pos.currentAttributes, + /** @type {ContentFormat} */ + pos.right.content + ); + } + break; + default: + if (!pos.right.deleted) { + if (count < pos.right.length) { + getItemCleanStart(transaction, createID(pos.right.id.client, pos.right.id.clock + count)); + } + pos.index += pos.right.length; + count -= pos.right.length; + } + break; + } + pos.left = pos.right; + pos.right = pos.right.right; + } + return pos; + }; + var findPosition = (transaction, parent, index, useSearchMarker) => { + const currentAttributes = /* @__PURE__ */ new Map(); + const marker = useSearchMarker ? findMarker(parent, index) : null; + if (marker) { + const pos = new ItemTextListPosition(marker.p.left, marker.p, marker.index, currentAttributes); + return findNextPosition(transaction, pos, index - marker.index); + } else { + const pos = new ItemTextListPosition(null, parent._start, 0, currentAttributes); + return findNextPosition(transaction, pos, index); + } + }; + var insertNegatedAttributes = (transaction, parent, currPos, negatedAttributes) => { + while (currPos.right !== null && (currPos.right.deleted === true || currPos.right.content.constructor === ContentFormat && equalAttrs( + negatedAttributes.get( + /** @type {ContentFormat} */ + currPos.right.content.key + ), + /** @type {ContentFormat} */ + currPos.right.content.value + ))) { + if (!currPos.right.deleted) { + negatedAttributes.delete( + /** @type {ContentFormat} */ + currPos.right.content.key + ); + } + currPos.forward(); + } + const doc2 = transaction.doc; + const ownClientId = doc2.clientID; + negatedAttributes.forEach((val, key) => { + const left = currPos.left; + const right = currPos.right; + const nextFormat = new Item(createID(ownClientId, getState(doc2.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val)); + nextFormat.integrate(transaction, 0); + currPos.right = nextFormat; + currPos.forward(); + }); + }; + var updateCurrentAttributes = (currentAttributes, format) => { + const { key, value } = format; + if (value === null) { + currentAttributes.delete(key); + } else { + currentAttributes.set(key, value); + } + }; + var minimizeAttributeChanges = (currPos, attributes) => { + while (true) { + if (currPos.right === null) { + break; + } else if (currPos.right.deleted || currPos.right.content.constructor === ContentFormat && equalAttrs( + attributes[ + /** @type {ContentFormat} */ + currPos.right.content.key + ] ?? null, + /** @type {ContentFormat} */ + currPos.right.content.value + )) ; + else { + break; + } + currPos.forward(); + } + }; + var insertAttributes = (transaction, parent, currPos, attributes) => { + const doc2 = transaction.doc; + const ownClientId = doc2.clientID; + const negatedAttributes = /* @__PURE__ */ new Map(); + for (const key in attributes) { + const val = attributes[key]; + const currentVal = currPos.currentAttributes.get(key) ?? null; + if (!equalAttrs(currentVal, val)) { + negatedAttributes.set(key, currentVal); + const { left, right } = currPos; + currPos.right = new Item(createID(ownClientId, getState(doc2.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val)); + currPos.right.integrate(transaction, 0); + currPos.forward(); + } + } + return negatedAttributes; + }; + var insertText = (transaction, parent, currPos, text2, attributes) => { + currPos.currentAttributes.forEach((_val, key) => { + if (attributes[key] === void 0) { + attributes[key] = null; + } + }); + const doc2 = transaction.doc; + const ownClientId = doc2.clientID; + minimizeAttributeChanges(currPos, attributes); + const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes); + const content = text2.constructor === String ? new ContentString( + /** @type {string} */ + text2 + ) : text2 instanceof AbstractType ? new ContentType(text2) : new ContentEmbed(text2); + let { left, right, index } = currPos; + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, currPos.index, content.getLength()); + } + right = new Item(createID(ownClientId, getState(doc2.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content); + right.integrate(transaction, 0); + currPos.right = right; + currPos.index = index; + currPos.forward(); + insertNegatedAttributes(transaction, parent, currPos, negatedAttributes); + }; + var formatText = (transaction, parent, currPos, length2, attributes) => { + const doc2 = transaction.doc; + const ownClientId = doc2.clientID; + minimizeAttributeChanges(currPos, attributes); + const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes); + iterationLoop: while (currPos.right !== null && (length2 > 0 || negatedAttributes.size > 0 && (currPos.right.deleted || currPos.right.content.constructor === ContentFormat))) { + if (!currPos.right.deleted) { + switch (currPos.right.content.constructor) { + case ContentFormat: { + const { key, value } = ( + /** @type {ContentFormat} */ + currPos.right.content + ); + const attr = attributes[key]; + if (attr !== void 0) { + if (equalAttrs(attr, value)) { + negatedAttributes.delete(key); + } else { + if (length2 === 0) { + break iterationLoop; + } + negatedAttributes.set(key, value); + } + currPos.right.delete(transaction); + } else { + currPos.currentAttributes.set(key, value); + } + break; + } + default: + if (length2 < currPos.right.length) { + getItemCleanStart(transaction, createID(currPos.right.id.client, currPos.right.id.clock + length2)); + } + length2 -= currPos.right.length; + break; + } + } + currPos.forward(); + } + if (length2 > 0) { + let newlines = ""; + for (; length2 > 0; length2--) { + newlines += "\n"; + } + currPos.right = new Item(createID(ownClientId, getState(doc2.store, ownClientId)), currPos.left, currPos.left && currPos.left.lastId, currPos.right, currPos.right && currPos.right.id, parent, null, new ContentString(newlines)); + currPos.right.integrate(transaction, 0); + currPos.forward(); + } + insertNegatedAttributes(transaction, parent, currPos, negatedAttributes); + }; + var cleanupFormattingGap = (transaction, start, curr, startAttributes, currAttributes) => { + let end = start; + const endFormats = create(); + while (end && (!end.countable || end.deleted)) { + if (!end.deleted && end.content.constructor === ContentFormat) { + const cf = ( + /** @type {ContentFormat} */ + end.content + ); + endFormats.set(cf.key, cf); + } + end = end.right; + } + let cleanups = 0; + let reachedCurr = false; + while (start !== end) { + if (curr === start) { + reachedCurr = true; + } + if (!start.deleted) { + const content = start.content; + switch (content.constructor) { + case ContentFormat: { + const { key, value } = ( + /** @type {ContentFormat} */ + content + ); + const startAttrValue = startAttributes.get(key) ?? null; + if (endFormats.get(key) !== content || startAttrValue === value) { + start.delete(transaction); + cleanups++; + if (!reachedCurr && (currAttributes.get(key) ?? null) === value && startAttrValue !== value) { + if (startAttrValue === null) { + currAttributes.delete(key); + } else { + currAttributes.set(key, startAttrValue); + } + } + } + if (!reachedCurr && !start.deleted) { + updateCurrentAttributes( + currAttributes, + /** @type {ContentFormat} */ + content + ); + } + break; + } + } + } + start = /** @type {Item} */ + start.right; + } + return cleanups; + }; + var cleanupContextlessFormattingGap = (transaction, item) => { + while (item && item.right && (item.right.deleted || !item.right.countable)) { + item = item.right; + } + const attrs = /* @__PURE__ */ new Set(); + while (item && (item.deleted || !item.countable)) { + if (!item.deleted && item.content.constructor === ContentFormat) { + const key = ( + /** @type {ContentFormat} */ + item.content.key + ); + if (attrs.has(key)) { + item.delete(transaction); + } else { + attrs.add(key); + } + } + item = item.left; + } + }; + var cleanupYTextFormatting = (type) => { + let res = 0; + transact( + /** @type {Doc} */ + type.doc, + (transaction) => { + let start = ( + /** @type {Item} */ + type._start + ); + let end = type._start; + let startAttributes = create(); + const currentAttributes = copy(startAttributes); + while (end) { + if (end.deleted === false) { + switch (end.content.constructor) { + case ContentFormat: + updateCurrentAttributes( + currentAttributes, + /** @type {ContentFormat} */ + end.content + ); + break; + default: + res += cleanupFormattingGap(transaction, start, end, startAttributes, currentAttributes); + startAttributes = copy(currentAttributes); + start = end; + break; + } + } + end = end.right; + } + } + ); + return res; + }; + var cleanupYTextAfterTransaction = (transaction) => { + const needFullCleanup = /* @__PURE__ */ new Set(); + const doc2 = transaction.doc; + for (const [client, afterClock] of transaction.afterState.entries()) { + const clock = transaction.beforeState.get(client) || 0; + if (afterClock === clock) { + continue; + } + iterateStructs( + transaction, + /** @type {Array} */ + doc2.store.clients.get(client), + clock, + afterClock, + (item) => { + if (!item.deleted && /** @type {Item} */ + item.content.constructor === ContentFormat && item.constructor !== GC) { + needFullCleanup.add( + /** @type {any} */ + item.parent + ); + } + } + ); + } + transact(doc2, (t) => { + iterateDeletedStructs(transaction, transaction.deleteSet, (item) => { + if (item instanceof GC || !/** @type {YText} */ + item.parent._hasFormatting || needFullCleanup.has( + /** @type {YText} */ + item.parent + )) { + return; + } + const parent = ( + /** @type {YText} */ + item.parent + ); + if (item.content.constructor === ContentFormat) { + needFullCleanup.add(parent); + } else { + cleanupContextlessFormattingGap(t, item); + } + }); + for (const yText of needFullCleanup) { + cleanupYTextFormatting(yText); + } + }); + }; + var deleteText = (transaction, currPos, length2) => { + const startLength = length2; + const startAttrs = copy(currPos.currentAttributes); + const start = currPos.right; + while (length2 > 0 && currPos.right !== null) { + if (currPos.right.deleted === false) { + switch (currPos.right.content.constructor) { + case ContentType: + case ContentEmbed: + case ContentString: + if (length2 < currPos.right.length) { + getItemCleanStart(transaction, createID(currPos.right.id.client, currPos.right.id.clock + length2)); + } + length2 -= currPos.right.length; + currPos.right.delete(transaction); + break; + } + } + currPos.forward(); + } + if (start) { + cleanupFormattingGap(transaction, start, currPos.right, startAttrs, currPos.currentAttributes); + } + const parent = ( + /** @type {AbstractType} */ + /** @type {Item} */ + (currPos.left || currPos.right).parent + ); + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, currPos.index, -startLength + length2); + } + return currPos; + }; + var YTextEvent = class extends YEvent { + /** + * @param {YText} ytext + * @param {Transaction} transaction + * @param {Set} subs The keys that changed + */ + constructor(ytext, transaction, subs) { + super(ytext, transaction); + this.childListChanged = false; + this.keysChanged = /* @__PURE__ */ new Set(); + subs.forEach((sub) => { + if (sub === null) { + this.childListChanged = true; + } else { + this.keysChanged.add(sub); + } + }); + } + /** + * @type {{added:Set,deleted:Set,keys:Map,delta:Array<{insert?:Array|string, delete?:number, retain?:number}>}} + */ + get changes() { + if (this._changes === null) { + const changes = { + keys: this.keys, + delta: this.delta, + added: /* @__PURE__ */ new Set(), + deleted: /* @__PURE__ */ new Set() + }; + this._changes = changes; + } + return ( + /** @type {any} */ + this._changes + ); + } + /** + * Compute the changes in the delta format. + * A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document. + * + * @type {Array<{insert?:string|object|AbstractType, delete?:number, retain?:number, attributes?: Object}>} + * + * @public + */ + get delta() { + if (this._delta === null) { + const y = ( + /** @type {Doc} */ + this.target.doc + ); + const delta = []; + transact(y, (transaction) => { + const currentAttributes = /* @__PURE__ */ new Map(); + const oldAttributes = /* @__PURE__ */ new Map(); + let item = this.target._start; + let action = null; + const attributes = {}; + let insert = ""; + let retain = 0; + let deleteLen = 0; + const addOp = () => { + if (action !== null) { + let op = null; + switch (action) { + case "delete": + if (deleteLen > 0) { + op = { delete: deleteLen }; + } + deleteLen = 0; + break; + case "insert": + if (typeof insert === "object" || insert.length > 0) { + op = { insert }; + if (currentAttributes.size > 0) { + op.attributes = {}; + currentAttributes.forEach((value, key) => { + if (value !== null) { + op.attributes[key] = value; + } + }); + } + } + insert = ""; + break; + case "retain": + if (retain > 0) { + op = { retain }; + if (!isEmpty(attributes)) { + op.attributes = assign({}, attributes); + } + } + retain = 0; + break; + } + if (op) delta.push(op); + action = null; + } + }; + while (item !== null) { + switch (item.content.constructor) { + case ContentType: + case ContentEmbed: + if (this.adds(item)) { + if (!this.deletes(item)) { + addOp(); + action = "insert"; + insert = item.content.getContent()[0]; + addOp(); + } + } else if (this.deletes(item)) { + if (action !== "delete") { + addOp(); + action = "delete"; + } + deleteLen += 1; + } else if (!item.deleted) { + if (action !== "retain") { + addOp(); + action = "retain"; + } + retain += 1; + } + break; + case ContentString: + if (this.adds(item)) { + if (!this.deletes(item)) { + if (action !== "insert") { + addOp(); + action = "insert"; + } + insert += /** @type {ContentString} */ + item.content.str; + } + } else if (this.deletes(item)) { + if (action !== "delete") { + addOp(); + action = "delete"; + } + deleteLen += item.length; + } else if (!item.deleted) { + if (action !== "retain") { + addOp(); + action = "retain"; + } + retain += item.length; + } + break; + case ContentFormat: { + const { key, value } = ( + /** @type {ContentFormat} */ + item.content + ); + if (this.adds(item)) { + if (!this.deletes(item)) { + const curVal = currentAttributes.get(key) ?? null; + if (!equalAttrs(curVal, value)) { + if (action === "retain") { + addOp(); + } + if (equalAttrs(value, oldAttributes.get(key) ?? null)) { + delete attributes[key]; + } else { + attributes[key] = value; + } + } else if (value !== null) { + item.delete(transaction); + } + } + } else if (this.deletes(item)) { + oldAttributes.set(key, value); + const curVal = currentAttributes.get(key) ?? null; + if (!equalAttrs(curVal, value)) { + if (action === "retain") { + addOp(); + } + attributes[key] = curVal; + } + } else if (!item.deleted) { + oldAttributes.set(key, value); + const attr = attributes[key]; + if (attr !== void 0) { + if (!equalAttrs(attr, value)) { + if (action === "retain") { + addOp(); + } + if (value === null) { + delete attributes[key]; + } else { + attributes[key] = value; + } + } else if (attr !== null) { + item.delete(transaction); + } + } + } + if (!item.deleted) { + if (action === "insert") { + addOp(); + } + updateCurrentAttributes( + currentAttributes, + /** @type {ContentFormat} */ + item.content + ); + } + break; + } + } + item = item.right; + } + addOp(); + while (delta.length > 0) { + const lastOp = delta[delta.length - 1]; + if (lastOp.retain !== void 0 && lastOp.attributes === void 0) { + delta.pop(); + } else { + break; + } + } + }); + this._delta = delta; + } + return ( + /** @type {any} */ + this._delta + ); + } + }; + var YText = class _YText extends AbstractType { + /** + * @param {String} [string] The initial value of the YText. + */ + constructor(string) { + super(); + this._pending = string !== void 0 ? [() => this.insert(0, string)] : []; + this._searchMarker = []; + this._hasFormatting = false; + } + /** + * Number of characters of this text type. + * + * @type {number} + */ + get length() { + this.doc ?? warnPrematureAccess(); + return this._length; + } + /** + * @param {Doc} y + * @param {Item} item + */ + _integrate(y, item) { + super._integrate(y, item); + try { + this._pending.forEach((f) => f()); + } catch (e) { + console.error(e); + } + this._pending = null; + } + _copy() { + return new _YText(); + } + /** + * Makes a copy of this data type that can be included somewhere else. + * + * Note that the content is only readable _after_ it has been included somewhere in the Ydoc. + * + * @return {YText} + */ + clone() { + const text2 = new _YText(); + text2.applyDelta(this.toDelta()); + return text2; + } + /** + * Creates YTextEvent and calls observers. + * + * @param {Transaction} transaction + * @param {Set} parentSubs Keys changed on this type. `null` if list was modified. + */ + _callObserver(transaction, parentSubs) { + super._callObserver(transaction, parentSubs); + const event = new YTextEvent(this, transaction, parentSubs); + callTypeObservers(this, transaction, event); + if (!transaction.local && this._hasFormatting) { + transaction._needFormattingCleanup = true; + } + } + /** + * Returns the unformatted string representation of this YText type. + * + * @public + */ + toString() { + this.doc ?? warnPrematureAccess(); + let str = ""; + let n = this._start; + while (n !== null) { + if (!n.deleted && n.countable && n.content.constructor === ContentString) { + str += /** @type {ContentString} */ + n.content.str; + } + n = n.right; + } + return str; + } + /** + * Returns the unformatted string representation of this YText type. + * + * @return {string} + * @public + */ + toJSON() { + return this.toString(); + } + /** + * Apply a {@link Delta} on this shared YText type. + * + * @param {Array} delta The changes to apply on this element. + * @param {object} opts + * @param {boolean} [opts.sanitize] Sanitize input delta. Removes ending newlines if set to true. + * + * + * @public + */ + applyDelta(delta, { sanitize = true } = {}) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + const currPos = new ItemTextListPosition(null, this._start, 0, /* @__PURE__ */ new Map()); + for (let i = 0; i < delta.length; i++) { + const op = delta[i]; + if (op.insert !== void 0) { + const ins = !sanitize && typeof op.insert === "string" && i === delta.length - 1 && currPos.right === null && op.insert.slice(-1) === "\n" ? op.insert.slice(0, -1) : op.insert; + if (typeof ins !== "string" || ins.length > 0) { + insertText(transaction, this, currPos, ins, op.attributes || {}); + } + } else if (op.retain !== void 0) { + formatText(transaction, this, currPos, op.retain, op.attributes || {}); + } else if (op.delete !== void 0) { + deleteText(transaction, currPos, op.delete); + } + } + }); + } else { + this._pending.push(() => this.applyDelta(delta)); + } + } + /** + * Returns the Delta representation of this YText type. + * + * @param {Snapshot} [snapshot] + * @param {Snapshot} [prevSnapshot] + * @param {function('removed' | 'added', ID):any} [computeYChange] + * @return {any} The Delta representation of this type. + * + * @public + */ + toDelta(snapshot2, prevSnapshot, computeYChange) { + this.doc ?? warnPrematureAccess(); + const ops = []; + const currentAttributes = /* @__PURE__ */ new Map(); + const doc2 = ( + /** @type {Doc} */ + this.doc + ); + let str = ""; + let n = this._start; + function packStr() { + if (str.length > 0) { + const attributes = {}; + let addAttributes = false; + currentAttributes.forEach((value, key) => { + addAttributes = true; + attributes[key] = value; + }); + const op = { insert: str }; + if (addAttributes) { + op.attributes = attributes; + } + ops.push(op); + str = ""; + } + } + const computeDelta = () => { + while (n !== null) { + if (isVisible(n, snapshot2) || prevSnapshot !== void 0 && isVisible(n, prevSnapshot)) { + switch (n.content.constructor) { + case ContentString: { + const cur = currentAttributes.get("ychange"); + if (snapshot2 !== void 0 && !isVisible(n, snapshot2)) { + if (cur === void 0 || cur.user !== n.id.client || cur.type !== "removed") { + packStr(); + currentAttributes.set("ychange", computeYChange ? computeYChange("removed", n.id) : { type: "removed" }); + } + } else if (prevSnapshot !== void 0 && !isVisible(n, prevSnapshot)) { + if (cur === void 0 || cur.user !== n.id.client || cur.type !== "added") { + packStr(); + currentAttributes.set("ychange", computeYChange ? computeYChange("added", n.id) : { type: "added" }); + } + } else if (cur !== void 0) { + packStr(); + currentAttributes.delete("ychange"); + } + str += /** @type {ContentString} */ + n.content.str; + break; + } + case ContentType: + case ContentEmbed: { + packStr(); + const op = { + insert: n.content.getContent()[0] + }; + if (currentAttributes.size > 0) { + const attrs = ( + /** @type {Object} */ + {} + ); + op.attributes = attrs; + currentAttributes.forEach((value, key) => { + attrs[key] = value; + }); + } + ops.push(op); + break; + } + case ContentFormat: + if (isVisible(n, snapshot2)) { + packStr(); + updateCurrentAttributes( + currentAttributes, + /** @type {ContentFormat} */ + n.content + ); + } + break; + } + } + n = n.right; + } + packStr(); + }; + if (snapshot2 || prevSnapshot) { + transact(doc2, (transaction) => { + if (snapshot2) { + splitSnapshotAffectedStructs(transaction, snapshot2); + } + if (prevSnapshot) { + splitSnapshotAffectedStructs(transaction, prevSnapshot); + } + computeDelta(); + }, "cleanup"); + } else { + computeDelta(); + } + return ops; + } + /** + * Insert text at a given index. + * + * @param {number} index The index at which to start inserting. + * @param {String} text The text to insert at the specified position. + * @param {TextAttributes} [attributes] Optionally define some formatting + * information to apply on the inserted + * Text. + * @public + */ + insert(index, text2, attributes) { + if (text2.length <= 0) { + return; + } + const y = this.doc; + if (y !== null) { + transact(y, (transaction) => { + const pos = findPosition(transaction, this, index, !attributes); + if (!attributes) { + attributes = {}; + pos.currentAttributes.forEach((v, k) => { + attributes[k] = v; + }); + } + insertText(transaction, this, pos, text2, attributes); + }); + } else { + this._pending.push(() => this.insert(index, text2, attributes)); + } + } + /** + * Inserts an embed at a index. + * + * @param {number} index The index to insert the embed at. + * @param {Object | AbstractType} embed The Object that represents the embed. + * @param {TextAttributes} [attributes] Attribute information to apply on the + * embed + * + * @public + */ + insertEmbed(index, embed, attributes) { + const y = this.doc; + if (y !== null) { + transact(y, (transaction) => { + const pos = findPosition(transaction, this, index, !attributes); + insertText(transaction, this, pos, embed, attributes || {}); + }); + } else { + this._pending.push(() => this.insertEmbed(index, embed, attributes || {})); + } + } + /** + * Deletes text starting from an index. + * + * @param {number} index Index at which to start deleting. + * @param {number} length The number of characters to remove. Defaults to 1. + * + * @public + */ + delete(index, length2) { + if (length2 === 0) { + return; + } + const y = this.doc; + if (y !== null) { + transact(y, (transaction) => { + deleteText(transaction, findPosition(transaction, this, index, true), length2); + }); + } else { + this._pending.push(() => this.delete(index, length2)); + } + } + /** + * Assigns properties to a range of text. + * + * @param {number} index The position where to start formatting. + * @param {number} length The amount of characters to assign properties to. + * @param {TextAttributes} attributes Attribute information to apply on the + * text. + * + * @public + */ + format(index, length2, attributes) { + if (length2 === 0) { + return; + } + const y = this.doc; + if (y !== null) { + transact(y, (transaction) => { + const pos = findPosition(transaction, this, index, false); + if (pos.right === null) { + return; + } + formatText(transaction, this, pos, length2, attributes); + }); + } else { + this._pending.push(() => this.format(index, length2, attributes)); + } + } + /** + * Removes an attribute. + * + * @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks. + * + * @param {String} attributeName The attribute name that is to be removed. + * + * @public + */ + removeAttribute(attributeName) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + typeMapDelete(transaction, this, attributeName); + }); + } else { + this._pending.push(() => this.removeAttribute(attributeName)); + } + } + /** + * Sets or updates an attribute. + * + * @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks. + * + * @param {String} attributeName The attribute name that is to be set. + * @param {any} attributeValue The attribute value that is to be set. + * + * @public + */ + setAttribute(attributeName, attributeValue) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + typeMapSet(transaction, this, attributeName, attributeValue); + }); + } else { + this._pending.push(() => this.setAttribute(attributeName, attributeValue)); + } + } + /** + * Returns an attribute value that belongs to the attribute name. + * + * @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks. + * + * @param {String} attributeName The attribute name that identifies the + * queried value. + * @return {any} The queried attribute value. + * + * @public + */ + getAttribute(attributeName) { + return ( + /** @type {any} */ + typeMapGet(this, attributeName) + ); + } + /** + * Returns all attribute name/value pairs in a JSON Object. + * + * @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks. + * + * @return {Object} A JSON Object that describes the attributes. + * + * @public + */ + getAttributes() { + return typeMapGetAll(this); + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + */ + _write(encoder) { + encoder.writeTypeRef(YTextRefID); + } + }; + var readYText = (_decoder) => new YText(); + var YXmlTreeWalker = class { + /** + * @param {YXmlFragment | YXmlElement} root + * @param {function(AbstractType):boolean} [f] + */ + constructor(root, f = () => true) { + this._filter = f; + this._root = root; + this._currentNode = /** @type {Item} */ + root._start; + this._firstCall = true; + root.doc ?? warnPrematureAccess(); + } + [Symbol.iterator]() { + return this; + } + /** + * Get the next node. + * + * @return {IteratorResult} The next node. + * + * @public + */ + next() { + let n = this._currentNode; + let type = n && n.content && /** @type {any} */ + n.content.type; + if (n !== null && (!this._firstCall || n.deleted || !this._filter(type))) { + do { + type = /** @type {any} */ + n.content.type; + if (!n.deleted && (type.constructor === YXmlElement || type.constructor === YXmlFragment) && type._start !== null) { + n = type._start; + } else { + while (n !== null) { + const nxt = n.next; + if (nxt !== null) { + n = nxt; + break; + } else if (n.parent === this._root) { + n = null; + } else { + n = /** @type {AbstractType} */ + n.parent._item; + } + } + } + } while (n !== null && (n.deleted || !this._filter( + /** @type {ContentType} */ + n.content.type + ))); + } + this._firstCall = false; + if (n === null) { + return { value: void 0, done: true }; + } + this._currentNode = n; + return { value: ( + /** @type {any} */ + n.content.type + ), done: false }; + } + }; + var YXmlFragment = class _YXmlFragment extends AbstractType { + constructor() { + super(); + this._prelimContent = []; + } + /** + * @type {YXmlElement|YXmlText|null} + */ + get firstChild() { + const first = this._first; + return first ? first.content.getContent()[0] : null; + } + /** + * Integrate this type into the Yjs instance. + * + * * Save this struct in the os + * * This type is sent to other client + * * Observer functions are fired + * + * @param {Doc} y The Yjs instance + * @param {Item} item + */ + _integrate(y, item) { + super._integrate(y, item); + this.insert( + 0, + /** @type {Array} */ + this._prelimContent + ); + this._prelimContent = null; + } + _copy() { + return new _YXmlFragment(); + } + /** + * Makes a copy of this data type that can be included somewhere else. + * + * Note that the content is only readable _after_ it has been included somewhere in the Ydoc. + * + * @return {YXmlFragment} + */ + clone() { + const el = new _YXmlFragment(); + el.insert(0, this.toArray().map((item) => item instanceof AbstractType ? item.clone() : item)); + return el; + } + get length() { + this.doc ?? warnPrematureAccess(); + return this._prelimContent === null ? this._length : this._prelimContent.length; + } + /** + * Create a subtree of childNodes. + * + * @example + * const walker = elem.createTreeWalker(dom => dom.nodeName === 'div') + * for (let node in walker) { + * // `node` is a div node + * nop(node) + * } + * + * @param {function(AbstractType):boolean} filter Function that is called on each child element and + * returns a Boolean indicating whether the child + * is to be included in the subtree. + * @return {YXmlTreeWalker} A subtree and a position within it. + * + * @public + */ + createTreeWalker(filter) { + return new YXmlTreeWalker(this, filter); + } + /** + * Returns the first YXmlElement that matches the query. + * Similar to DOM's {@link querySelector}. + * + * Query support: + * - tagname + * TODO: + * - id + * - attribute + * + * @param {CSS_Selector} query The query on the children. + * @return {YXmlElement|YXmlText|YXmlHook|null} The first element that matches the query or null. + * + * @public + */ + querySelector(query) { + query = query.toUpperCase(); + const iterator = new YXmlTreeWalker(this, (element2) => element2.nodeName && element2.nodeName.toUpperCase() === query); + const next = iterator.next(); + if (next.done) { + return null; + } else { + return next.value; + } + } + /** + * Returns all YXmlElements that match the query. + * Similar to Dom's {@link querySelectorAll}. + * + * @todo Does not yet support all queries. Currently only query by tagName. + * + * @param {CSS_Selector} query The query on the children + * @return {Array} The elements that match this query. + * + * @public + */ + querySelectorAll(query) { + query = query.toUpperCase(); + return from(new YXmlTreeWalker(this, (element2) => element2.nodeName && element2.nodeName.toUpperCase() === query)); + } + /** + * Creates YXmlEvent and calls observers. + * + * @param {Transaction} transaction + * @param {Set} parentSubs Keys changed on this type. `null` if list was modified. + */ + _callObserver(transaction, parentSubs) { + callTypeObservers(this, transaction, new YXmlEvent(this, parentSubs, transaction)); + } + /** + * Get the string representation of all the children of this YXmlFragment. + * + * @return {string} The string representation of all children. + */ + toString() { + return typeListMap(this, (xml) => xml.toString()).join(""); + } + /** + * @return {string} + */ + toJSON() { + return this.toString(); + } + /** + * Creates a Dom Element that mirrors this YXmlElement. + * + * @param {Document} [_document=document] The document object (you must define + * this when calling this method in + * nodejs) + * @param {Object} [hooks={}] Optional property to customize how hooks + * are presented in the DOM + * @param {any} [binding] You should not set this property. This is + * used if DomBinding wants to create a + * association to the created DOM type. + * @return {Node} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element} + * + * @public + */ + toDOM(_document = document, hooks = {}, binding) { + const fragment = _document.createDocumentFragment(); + if (binding !== void 0) { + binding._createAssociation(fragment, this); + } + typeListForEach(this, (xmlType) => { + fragment.insertBefore(xmlType.toDOM(_document, hooks, binding), null); + }); + return fragment; + } + /** + * Inserts new content at an index. + * + * @example + * // Insert character 'a' at position 0 + * xml.insert(0, [new Y.XmlText('text')]) + * + * @param {number} index The index to insert content at + * @param {Array} content The array of content + */ + insert(index, content) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + typeListInsertGenerics(transaction, this, index, content); + }); + } else { + this._prelimContent.splice(index, 0, ...content); + } + } + /** + * Inserts new content at an index. + * + * @example + * // Insert character 'a' at position 0 + * xml.insert(0, [new Y.XmlText('text')]) + * + * @param {null|Item|YXmlElement|YXmlText} ref The index to insert content at + * @param {Array} content The array of content + */ + insertAfter(ref, content) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + const refItem = ref && ref instanceof AbstractType ? ref._item : ref; + typeListInsertGenericsAfter(transaction, this, refItem, content); + }); + } else { + const pc = ( + /** @type {Array} */ + this._prelimContent + ); + const index = ref === null ? 0 : pc.findIndex((el) => el === ref) + 1; + if (index === 0 && ref !== null) { + throw create3("Reference item not found"); + } + pc.splice(index, 0, ...content); + } + } + /** + * Deletes elements starting from an index. + * + * @param {number} index Index at which to start deleting elements + * @param {number} [length=1] The number of elements to remove. Defaults to 1. + */ + delete(index, length2 = 1) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + typeListDelete(transaction, this, index, length2); + }); + } else { + this._prelimContent.splice(index, length2); + } + } + /** + * Transforms this YArray to a JavaScript Array. + * + * @return {Array} + */ + toArray() { + return typeListToArray(this); + } + /** + * Appends content to this YArray. + * + * @param {Array} content Array of content to append. + */ + push(content) { + this.insert(this.length, content); + } + /** + * Prepends content to this YArray. + * + * @param {Array} content Array of content to prepend. + */ + unshift(content) { + this.insert(0, content); + } + /** + * Returns the i-th element from a YArray. + * + * @param {number} index The index of the element to return from the YArray + * @return {YXmlElement|YXmlText} + */ + get(index) { + return typeListGet(this, index); + } + /** + * Returns a portion of this YXmlFragment into a JavaScript Array selected + * from start to end (end not included). + * + * @param {number} [start] + * @param {number} [end] + * @return {Array} + */ + slice(start = 0, end = this.length) { + return typeListSlice(this, start, end); + } + /** + * Executes a provided function on once on every child element. + * + * @param {function(YXmlElement|YXmlText,number, typeof self):void} f A function to execute on every element of this YArray. + */ + forEach(f) { + typeListForEach(this, f); + } + /** + * Transform the properties of this type to binary and write it to an + * BinaryEncoder. + * + * This is called when this Item is sent to a remote peer. + * + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to. + */ + _write(encoder) { + encoder.writeTypeRef(YXmlFragmentRefID); + } + }; + var readYXmlFragment = (_decoder) => new YXmlFragment(); + var YXmlElement = class _YXmlElement extends YXmlFragment { + constructor(nodeName = "UNDEFINED") { + super(); + this.nodeName = nodeName; + this._prelimAttrs = /* @__PURE__ */ new Map(); + } + /** + * @type {YXmlElement|YXmlText|null} + */ + get nextSibling() { + const n = this._item ? this._item.next : null; + return n ? ( + /** @type {YXmlElement|YXmlText} */ + /** @type {ContentType} */ + n.content.type + ) : null; + } + /** + * @type {YXmlElement|YXmlText|null} + */ + get prevSibling() { + const n = this._item ? this._item.prev : null; + return n ? ( + /** @type {YXmlElement|YXmlText} */ + /** @type {ContentType} */ + n.content.type + ) : null; + } + /** + * Integrate this type into the Yjs instance. + * + * * Save this struct in the os + * * This type is sent to other client + * * Observer functions are fired + * + * @param {Doc} y The Yjs instance + * @param {Item} item + */ + _integrate(y, item) { + super._integrate(y, item); + /** @type {Map} */ + this._prelimAttrs.forEach((value, key) => { + this.setAttribute(key, value); + }); + this._prelimAttrs = null; + } + /** + * Creates an Item with the same effect as this Item (without position effect) + * + * @return {YXmlElement} + */ + _copy() { + return new _YXmlElement(this.nodeName); + } + /** + * Makes a copy of this data type that can be included somewhere else. + * + * Note that the content is only readable _after_ it has been included somewhere in the Ydoc. + * + * @return {YXmlElement} + */ + clone() { + const el = new _YXmlElement(this.nodeName); + const attrs = this.getAttributes(); + forEach(attrs, (value, key) => { + if (typeof value === "string") { + el.setAttribute(key, value); + } + }); + el.insert(0, this.toArray().map((item) => item instanceof AbstractType ? item.clone() : item)); + return el; + } + /** + * Returns the XML serialization of this YXmlElement. + * The attributes are ordered by attribute-name, so you can easily use this + * method to compare YXmlElements + * + * @return {string} The string representation of this type. + * + * @public + */ + toString() { + const attrs = this.getAttributes(); + const stringBuilder = []; + const keys2 = []; + for (const key in attrs) { + keys2.push(key); + } + keys2.sort(); + const keysLen = keys2.length; + for (let i = 0; i < keysLen; i++) { + const key = keys2[i]; + stringBuilder.push(key + '="' + attrs[key] + '"'); + } + const nodeName = this.nodeName.toLocaleLowerCase(); + const attrsString = stringBuilder.length > 0 ? " " + stringBuilder.join(" ") : ""; + return `<${nodeName}${attrsString}>${super.toString()}`; + } + /** + * Removes an attribute from this YXmlElement. + * + * @param {string} attributeName The attribute name that is to be removed. + * + * @public + */ + removeAttribute(attributeName) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + typeMapDelete(transaction, this, attributeName); + }); + } else { + this._prelimAttrs.delete(attributeName); + } + } + /** + * Sets or updates an attribute. + * + * @template {keyof KV & string} KEY + * + * @param {KEY} attributeName The attribute name that is to be set. + * @param {KV[KEY]} attributeValue The attribute value that is to be set. + * + * @public + */ + setAttribute(attributeName, attributeValue) { + if (this.doc !== null) { + transact(this.doc, (transaction) => { + typeMapSet(transaction, this, attributeName, attributeValue); + }); + } else { + this._prelimAttrs.set(attributeName, attributeValue); + } + } + /** + * Returns an attribute value that belongs to the attribute name. + * + * @template {keyof KV & string} KEY + * + * @param {KEY} attributeName The attribute name that identifies the + * queried value. + * @return {KV[KEY]|undefined} The queried attribute value. + * + * @public + */ + getAttribute(attributeName) { + return ( + /** @type {any} */ + typeMapGet(this, attributeName) + ); + } + /** + * Returns whether an attribute exists + * + * @param {string} attributeName The attribute name to check for existence. + * @return {boolean} whether the attribute exists. + * + * @public + */ + hasAttribute(attributeName) { + return ( + /** @type {any} */ + typeMapHas(this, attributeName) + ); + } + /** + * Returns all attribute name/value pairs in a JSON Object. + * + * @param {Snapshot} [snapshot] + * @return {{ [Key in Extract]?: KV[Key]}} A JSON Object that describes the attributes. + * + * @public + */ + getAttributes(snapshot2) { + return ( + /** @type {any} */ + snapshot2 ? typeMapGetAllSnapshot(this, snapshot2) : typeMapGetAll(this) + ); + } + /** + * Creates a Dom Element that mirrors this YXmlElement. + * + * @param {Document} [_document=document] The document object (you must define + * this when calling this method in + * nodejs) + * @param {Object} [hooks={}] Optional property to customize how hooks + * are presented in the DOM + * @param {any} [binding] You should not set this property. This is + * used if DomBinding wants to create a + * association to the created DOM type. + * @return {Node} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element} + * + * @public + */ + toDOM(_document = document, hooks = {}, binding) { + const dom = _document.createElement(this.nodeName); + const attrs = this.getAttributes(); + for (const key in attrs) { + const value = attrs[key]; + if (typeof value === "string") { + dom.setAttribute(key, value); + } + } + typeListForEach(this, (yxml) => { + dom.appendChild(yxml.toDOM(_document, hooks, binding)); + }); + if (binding !== void 0) { + binding._createAssociation(dom, this); + } + return dom; + } + /** + * Transform the properties of this type to binary and write it to an + * BinaryEncoder. + * + * This is called when this Item is sent to a remote peer. + * + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to. + */ + _write(encoder) { + encoder.writeTypeRef(YXmlElementRefID); + encoder.writeKey(this.nodeName); + } + }; + var readYXmlElement = (decoder) => new YXmlElement(decoder.readKey()); + var YXmlEvent = class extends YEvent { + /** + * @param {YXmlElement|YXmlText|YXmlFragment} target The target on which the event is created. + * @param {Set} subs The set of changed attributes. `null` is included if the + * child list changed. + * @param {Transaction} transaction The transaction instance with which the + * change was created. + */ + constructor(target, subs, transaction) { + super(target, transaction); + this.childListChanged = false; + this.attributesChanged = /* @__PURE__ */ new Set(); + subs.forEach((sub) => { + if (sub === null) { + this.childListChanged = true; + } else { + this.attributesChanged.add(sub); + } + }); + } + }; + var YXmlHook = class _YXmlHook extends YMap { + /** + * @param {string} hookName nodeName of the Dom Node. + */ + constructor(hookName) { + super(); + this.hookName = hookName; + } + /** + * Creates an Item with the same effect as this Item (without position effect) + */ + _copy() { + return new _YXmlHook(this.hookName); + } + /** + * Makes a copy of this data type that can be included somewhere else. + * + * Note that the content is only readable _after_ it has been included somewhere in the Ydoc. + * + * @return {YXmlHook} + */ + clone() { + const el = new _YXmlHook(this.hookName); + this.forEach((value, key) => { + el.set(key, value); + }); + return el; + } + /** + * Creates a Dom Element that mirrors this YXmlElement. + * + * @param {Document} [_document=document] The document object (you must define + * this when calling this method in + * nodejs) + * @param {Object.} [hooks] Optional property to customize how hooks + * are presented in the DOM + * @param {any} [binding] You should not set this property. This is + * used if DomBinding wants to create a + * association to the created DOM type + * @return {Element} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element} + * + * @public + */ + toDOM(_document = document, hooks = {}, binding) { + const hook = hooks[this.hookName]; + let dom; + if (hook !== void 0) { + dom = hook.createDom(this); + } else { + dom = document.createElement(this.hookName); + } + dom.setAttribute("data-yjs-hook", this.hookName); + if (binding !== void 0) { + binding._createAssociation(dom, this); + } + return dom; + } + /** + * Transform the properties of this type to binary and write it to an + * BinaryEncoder. + * + * This is called when this Item is sent to a remote peer. + * + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to. + */ + _write(encoder) { + encoder.writeTypeRef(YXmlHookRefID); + encoder.writeKey(this.hookName); + } + }; + var readYXmlHook = (decoder) => new YXmlHook(decoder.readKey()); + var YXmlText = class _YXmlText extends YText { + /** + * @type {YXmlElement|YXmlText|null} + */ + get nextSibling() { + const n = this._item ? this._item.next : null; + return n ? ( + /** @type {YXmlElement|YXmlText} */ + /** @type {ContentType} */ + n.content.type + ) : null; + } + /** + * @type {YXmlElement|YXmlText|null} + */ + get prevSibling() { + const n = this._item ? this._item.prev : null; + return n ? ( + /** @type {YXmlElement|YXmlText} */ + /** @type {ContentType} */ + n.content.type + ) : null; + } + _copy() { + return new _YXmlText(); + } + /** + * Makes a copy of this data type that can be included somewhere else. + * + * Note that the content is only readable _after_ it has been included somewhere in the Ydoc. + * + * @return {YXmlText} + */ + clone() { + const text2 = new _YXmlText(); + text2.applyDelta(this.toDelta()); + return text2; + } + /** + * Creates a Dom Element that mirrors this YXmlText. + * + * @param {Document} [_document=document] The document object (you must define + * this when calling this method in + * nodejs) + * @param {Object} [hooks] Optional property to customize how hooks + * are presented in the DOM + * @param {any} [binding] You should not set this property. This is + * used if DomBinding wants to create a + * association to the created DOM type. + * @return {Text} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element} + * + * @public + */ + toDOM(_document = document, hooks, binding) { + const dom = _document.createTextNode(this.toString()); + if (binding !== void 0) { + binding._createAssociation(dom, this); + } + return dom; + } + toString() { + return this.toDelta().map((delta) => { + const nestedNodes = []; + for (const nodeName in delta.attributes) { + const attrs = []; + for (const key in delta.attributes[nodeName]) { + attrs.push({ key, value: delta.attributes[nodeName][key] }); + } + attrs.sort((a, b) => a.key < b.key ? -1 : 1); + nestedNodes.push({ nodeName, attrs }); + } + nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1); + let str = ""; + for (let i = 0; i < nestedNodes.length; i++) { + const node = nestedNodes[i]; + str += `<${node.nodeName}`; + for (let j = 0; j < node.attrs.length; j++) { + const attr = node.attrs[j]; + str += ` ${attr.key}="${attr.value}"`; + } + str += ">"; + } + str += delta.insert; + for (let i = nestedNodes.length - 1; i >= 0; i--) { + str += ``; + } + return str; + }).join(""); + } + /** + * @return {string} + */ + toJSON() { + return this.toString(); + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + */ + _write(encoder) { + encoder.writeTypeRef(YXmlTextRefID); + } + }; + var readYXmlText = (decoder) => new YXmlText(); + var AbstractStruct = class { + /** + * @param {ID} id + * @param {number} length + */ + constructor(id2, length2) { + this.id = id2; + this.length = length2; + } + /** + * @type {boolean} + */ + get deleted() { + throw methodUnimplemented(); + } + /** + * Merge this struct with the item to the right. + * This method is already assuming that `this.id.clock + this.length === this.id.clock`. + * Also this method does *not* remove right from StructStore! + * @param {AbstractStruct} right + * @return {boolean} whether this merged with right + */ + mergeWith(right) { + return false; + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to. + * @param {number} offset + * @param {number} encodingRef + */ + write(encoder, offset, encodingRef) { + throw methodUnimplemented(); + } + /** + * @param {Transaction} transaction + * @param {number} offset + */ + integrate(transaction, offset) { + throw methodUnimplemented(); + } + }; + var structGCRefNumber = 0; + var GC = class extends AbstractStruct { + get deleted() { + return true; + } + delete() { + } + /** + * @param {GC} right + * @return {boolean} + */ + mergeWith(right) { + if (this.constructor !== right.constructor) { + return false; + } + this.length += right.length; + return true; + } + /** + * @param {Transaction} transaction + * @param {number} offset + */ + integrate(transaction, offset) { + if (offset > 0) { + this.id.clock += offset; + this.length -= offset; + } + addStruct(transaction.doc.store, this); + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + */ + write(encoder, offset) { + encoder.writeInfo(structGCRefNumber); + encoder.writeLen(this.length - offset); + } + /** + * @param {Transaction} transaction + * @param {StructStore} store + * @return {null | number} + */ + getMissing(transaction, store) { + return null; + } + }; + var ContentBinary = class _ContentBinary { + /** + * @param {Uint8Array} content + */ + constructor(content) { + this.content = content; + } + /** + * @return {number} + */ + getLength() { + return 1; + } + /** + * @return {Array} + */ + getContent() { + return [this.content]; + } + /** + * @return {boolean} + */ + isCountable() { + return true; + } + /** + * @return {ContentBinary} + */ + copy() { + return new _ContentBinary(this.content); + } + /** + * @param {number} offset + * @return {ContentBinary} + */ + splice(offset) { + throw methodUnimplemented(); + } + /** + * @param {ContentBinary} right + * @return {boolean} + */ + mergeWith(right) { + return false; + } + /** + * @param {Transaction} transaction + * @param {Item} item + */ + integrate(transaction, item) { + } + /** + * @param {Transaction} transaction + */ + delete(transaction) { + } + /** + * @param {StructStore} store + */ + gc(store) { + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + */ + write(encoder, offset) { + encoder.writeBuf(this.content); + } + /** + * @return {number} + */ + getRef() { + return 3; + } + }; + var readContentBinary = (decoder) => new ContentBinary(decoder.readBuf()); + var ContentDeleted = class _ContentDeleted { + /** + * @param {number} len + */ + constructor(len) { + this.len = len; + } + /** + * @return {number} + */ + getLength() { + return this.len; + } + /** + * @return {Array} + */ + getContent() { + return []; + } + /** + * @return {boolean} + */ + isCountable() { + return false; + } + /** + * @return {ContentDeleted} + */ + copy() { + return new _ContentDeleted(this.len); + } + /** + * @param {number} offset + * @return {ContentDeleted} + */ + splice(offset) { + const right = new _ContentDeleted(this.len - offset); + this.len = offset; + return right; + } + /** + * @param {ContentDeleted} right + * @return {boolean} + */ + mergeWith(right) { + this.len += right.len; + return true; + } + /** + * @param {Transaction} transaction + * @param {Item} item + */ + integrate(transaction, item) { + addToDeleteSet(transaction.deleteSet, item.id.client, item.id.clock, this.len); + item.markDeleted(); + } + /** + * @param {Transaction} transaction + */ + delete(transaction) { + } + /** + * @param {StructStore} store + */ + gc(store) { + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + */ + write(encoder, offset) { + encoder.writeLen(this.len - offset); + } + /** + * @return {number} + */ + getRef() { + return 1; + } + }; + var readContentDeleted = (decoder) => new ContentDeleted(decoder.readLen()); + var createDocFromOpts = (guid, opts) => new Doc({ guid, ...opts, shouldLoad: opts.shouldLoad || opts.autoLoad || false }); + var ContentDoc = class _ContentDoc { + /** + * @param {Doc} doc + */ + constructor(doc2) { + if (doc2._item) { + console.error("This document was already integrated as a sub-document. You should create a second instance instead with the same guid."); + } + this.doc = doc2; + const opts = {}; + this.opts = opts; + if (!doc2.gc) { + opts.gc = false; + } + if (doc2.autoLoad) { + opts.autoLoad = true; + } + if (doc2.meta !== null) { + opts.meta = doc2.meta; + } + } + /** + * @return {number} + */ + getLength() { + return 1; + } + /** + * @return {Array} + */ + getContent() { + return [this.doc]; + } + /** + * @return {boolean} + */ + isCountable() { + return true; + } + /** + * @return {ContentDoc} + */ + copy() { + return new _ContentDoc(createDocFromOpts(this.doc.guid, this.opts)); + } + /** + * @param {number} offset + * @return {ContentDoc} + */ + splice(offset) { + throw methodUnimplemented(); + } + /** + * @param {ContentDoc} right + * @return {boolean} + */ + mergeWith(right) { + return false; + } + /** + * @param {Transaction} transaction + * @param {Item} item + */ + integrate(transaction, item) { + this.doc._item = item; + transaction.subdocsAdded.add(this.doc); + if (this.doc.shouldLoad) { + transaction.subdocsLoaded.add(this.doc); + } + } + /** + * @param {Transaction} transaction + */ + delete(transaction) { + if (transaction.subdocsAdded.has(this.doc)) { + transaction.subdocsAdded.delete(this.doc); + } else { + transaction.subdocsRemoved.add(this.doc); + } + } + /** + * @param {StructStore} store + */ + gc(store) { + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + */ + write(encoder, offset) { + encoder.writeString(this.doc.guid); + encoder.writeAny(this.opts); + } + /** + * @return {number} + */ + getRef() { + return 9; + } + }; + var readContentDoc = (decoder) => new ContentDoc(createDocFromOpts(decoder.readString(), decoder.readAny())); + var ContentEmbed = class _ContentEmbed { + /** + * @param {Object} embed + */ + constructor(embed) { + this.embed = embed; + } + /** + * @return {number} + */ + getLength() { + return 1; + } + /** + * @return {Array} + */ + getContent() { + return [this.embed]; + } + /** + * @return {boolean} + */ + isCountable() { + return true; + } + /** + * @return {ContentEmbed} + */ + copy() { + return new _ContentEmbed(this.embed); + } + /** + * @param {number} offset + * @return {ContentEmbed} + */ + splice(offset) { + throw methodUnimplemented(); + } + /** + * @param {ContentEmbed} right + * @return {boolean} + */ + mergeWith(right) { + return false; + } + /** + * @param {Transaction} transaction + * @param {Item} item + */ + integrate(transaction, item) { + } + /** + * @param {Transaction} transaction + */ + delete(transaction) { + } + /** + * @param {StructStore} store + */ + gc(store) { + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + */ + write(encoder, offset) { + encoder.writeJSON(this.embed); + } + /** + * @return {number} + */ + getRef() { + return 5; + } + }; + var readContentEmbed = (decoder) => new ContentEmbed(decoder.readJSON()); + var ContentFormat = class _ContentFormat { + /** + * @param {string} key + * @param {Object} value + */ + constructor(key, value) { + this.key = key; + this.value = value; + } + /** + * @return {number} + */ + getLength() { + return 1; + } + /** + * @return {Array} + */ + getContent() { + return []; + } + /** + * @return {boolean} + */ + isCountable() { + return false; + } + /** + * @return {ContentFormat} + */ + copy() { + return new _ContentFormat(this.key, this.value); + } + /** + * @param {number} _offset + * @return {ContentFormat} + */ + splice(_offset) { + throw methodUnimplemented(); + } + /** + * @param {ContentFormat} _right + * @return {boolean} + */ + mergeWith(_right) { + return false; + } + /** + * @param {Transaction} _transaction + * @param {Item} item + */ + integrate(_transaction, item) { + const p = ( + /** @type {YText} */ + item.parent + ); + p._searchMarker = null; + p._hasFormatting = true; + } + /** + * @param {Transaction} transaction + */ + delete(transaction) { + } + /** + * @param {StructStore} store + */ + gc(store) { + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + */ + write(encoder, offset) { + encoder.writeKey(this.key); + encoder.writeJSON(this.value); + } + /** + * @return {number} + */ + getRef() { + return 6; + } + }; + var readContentFormat = (decoder) => new ContentFormat(decoder.readKey(), decoder.readJSON()); + var ContentJSON = class _ContentJSON { + /** + * @param {Array} arr + */ + constructor(arr) { + this.arr = arr; + } + /** + * @return {number} + */ + getLength() { + return this.arr.length; + } + /** + * @return {Array} + */ + getContent() { + return this.arr; + } + /** + * @return {boolean} + */ + isCountable() { + return true; + } + /** + * @return {ContentJSON} + */ + copy() { + return new _ContentJSON(this.arr); + } + /** + * @param {number} offset + * @return {ContentJSON} + */ + splice(offset) { + const right = new _ContentJSON(this.arr.slice(offset)); + this.arr = this.arr.slice(0, offset); + return right; + } + /** + * @param {ContentJSON} right + * @return {boolean} + */ + mergeWith(right) { + this.arr = this.arr.concat(right.arr); + return true; + } + /** + * @param {Transaction} transaction + * @param {Item} item + */ + integrate(transaction, item) { + } + /** + * @param {Transaction} transaction + */ + delete(transaction) { + } + /** + * @param {StructStore} store + */ + gc(store) { + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + */ + write(encoder, offset) { + const len = this.arr.length; + encoder.writeLen(len - offset); + for (let i = offset; i < len; i++) { + const c = this.arr[i]; + encoder.writeString(c === void 0 ? "undefined" : JSON.stringify(c)); + } + } + /** + * @return {number} + */ + getRef() { + return 2; + } + }; + var readContentJSON = (decoder) => { + const len = decoder.readLen(); + const cs = []; + for (let i = 0; i < len; i++) { + const c = decoder.readString(); + if (c === "undefined") { + cs.push(void 0); + } else { + cs.push(JSON.parse(c)); + } + } + return new ContentJSON(cs); + }; + var isDevMode = getVariable("node_env") === "development"; + var ContentAny = class _ContentAny { + /** + * @param {Array} arr + */ + constructor(arr) { + this.arr = arr; + isDevMode && deepFreeze(arr); + } + /** + * @return {number} + */ + getLength() { + return this.arr.length; + } + /** + * @return {Array} + */ + getContent() { + return this.arr; + } + /** + * @return {boolean} + */ + isCountable() { + return true; + } + /** + * @return {ContentAny} + */ + copy() { + return new _ContentAny(this.arr); + } + /** + * @param {number} offset + * @return {ContentAny} + */ + splice(offset) { + const right = new _ContentAny(this.arr.slice(offset)); + this.arr = this.arr.slice(0, offset); + return right; + } + /** + * @param {ContentAny} right + * @return {boolean} + */ + mergeWith(right) { + this.arr = this.arr.concat(right.arr); + return true; + } + /** + * @param {Transaction} transaction + * @param {Item} item + */ + integrate(transaction, item) { + } + /** + * @param {Transaction} transaction + */ + delete(transaction) { + } + /** + * @param {StructStore} store + */ + gc(store) { + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + */ + write(encoder, offset) { + const len = this.arr.length; + encoder.writeLen(len - offset); + for (let i = offset; i < len; i++) { + const c = this.arr[i]; + encoder.writeAny(c); + } + } + /** + * @return {number} + */ + getRef() { + return 8; + } + }; + var readContentAny = (decoder) => { + const len = decoder.readLen(); + const cs = []; + for (let i = 0; i < len; i++) { + cs.push(decoder.readAny()); + } + return new ContentAny(cs); + }; + var ContentString = class _ContentString { + /** + * @param {string} str + */ + constructor(str) { + this.str = str; + } + /** + * @return {number} + */ + getLength() { + return this.str.length; + } + /** + * @return {Array} + */ + getContent() { + return this.str.split(""); + } + /** + * @return {boolean} + */ + isCountable() { + return true; + } + /** + * @return {ContentString} + */ + copy() { + return new _ContentString(this.str); + } + /** + * @param {number} offset + * @return {ContentString} + */ + splice(offset) { + const right = new _ContentString(this.str.slice(offset)); + this.str = this.str.slice(0, offset); + const firstCharCode = this.str.charCodeAt(offset - 1); + if (firstCharCode >= 55296 && firstCharCode <= 56319) { + this.str = this.str.slice(0, offset - 1) + "\uFFFD"; + right.str = "\uFFFD" + right.str.slice(1); + } + return right; + } + /** + * @param {ContentString} right + * @return {boolean} + */ + mergeWith(right) { + this.str += right.str; + return true; + } + /** + * @param {Transaction} transaction + * @param {Item} item + */ + integrate(transaction, item) { + } + /** + * @param {Transaction} transaction + */ + delete(transaction) { + } + /** + * @param {StructStore} store + */ + gc(store) { + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + */ + write(encoder, offset) { + encoder.writeString(offset === 0 ? this.str : this.str.slice(offset)); + } + /** + * @return {number} + */ + getRef() { + return 4; + } + }; + var readContentString = (decoder) => new ContentString(decoder.readString()); + var typeRefs = [ + readYArray, + readYMap, + readYText, + readYXmlElement, + readYXmlFragment, + readYXmlHook, + readYXmlText + ]; + var YArrayRefID = 0; + var YMapRefID = 1; + var YTextRefID = 2; + var YXmlElementRefID = 3; + var YXmlFragmentRefID = 4; + var YXmlHookRefID = 5; + var YXmlTextRefID = 6; + var ContentType = class _ContentType { + /** + * @param {AbstractType} type + */ + constructor(type) { + this.type = type; + } + /** + * @return {number} + */ + getLength() { + return 1; + } + /** + * @return {Array} + */ + getContent() { + return [this.type]; + } + /** + * @return {boolean} + */ + isCountable() { + return true; + } + /** + * @return {ContentType} + */ + copy() { + return new _ContentType(this.type._copy()); + } + /** + * @param {number} offset + * @return {ContentType} + */ + splice(offset) { + throw methodUnimplemented(); + } + /** + * @param {ContentType} right + * @return {boolean} + */ + mergeWith(right) { + return false; + } + /** + * @param {Transaction} transaction + * @param {Item} item + */ + integrate(transaction, item) { + this.type._integrate(transaction.doc, item); + } + /** + * @param {Transaction} transaction + */ + delete(transaction) { + let item = this.type._start; + while (item !== null) { + if (!item.deleted) { + item.delete(transaction); + } else if (item.id.clock < (transaction.beforeState.get(item.id.client) || 0)) { + transaction._mergeStructs.push(item); + } + item = item.right; + } + this.type._map.forEach((item2) => { + if (!item2.deleted) { + item2.delete(transaction); + } else if (item2.id.clock < (transaction.beforeState.get(item2.id.client) || 0)) { + transaction._mergeStructs.push(item2); + } + }); + transaction.changed.delete(this.type); + } + /** + * @param {StructStore} store + */ + gc(store) { + let item = this.type._start; + while (item !== null) { + item.gc(store, true); + item = item.right; + } + this.type._start = null; + this.type._map.forEach( + /** @param {Item | null} item */ + (item2) => { + while (item2 !== null) { + item2.gc(store, true); + item2 = item2.left; + } + } + ); + this.type._map = /* @__PURE__ */ new Map(); + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + */ + write(encoder, offset) { + this.type._write(encoder); + } + /** + * @return {number} + */ + getRef() { + return 7; + } + }; + var readContentType = (decoder) => new ContentType(typeRefs[decoder.readTypeRef()](decoder)); + var followRedone = (store, id2) => { + let nextID = id2; + let diff = 0; + let item; + do { + if (diff > 0) { + nextID = createID(nextID.client, nextID.clock + diff); + } + item = getItem(store, nextID); + diff = nextID.clock - item.id.clock; + nextID = item.redone; + } while (nextID !== null && item instanceof Item); + return { + item, + diff + }; + }; + var keepItem = (item, keep) => { + while (item !== null && item.keep !== keep) { + item.keep = keep; + item = /** @type {AbstractType} */ + item.parent._item; + } + }; + var splitItem = (transaction, leftItem, diff) => { + const { client, clock } = leftItem.id; + const rightItem = new Item( + createID(client, clock + diff), + leftItem, + createID(client, clock + diff - 1), + leftItem.right, + leftItem.rightOrigin, + leftItem.parent, + leftItem.parentSub, + leftItem.content.splice(diff) + ); + if (leftItem.deleted) { + rightItem.markDeleted(); + } + if (leftItem.keep) { + rightItem.keep = true; + } + if (leftItem.redone !== null) { + rightItem.redone = createID(leftItem.redone.client, leftItem.redone.clock + diff); + } + leftItem.right = rightItem; + if (rightItem.right !== null) { + rightItem.right.left = rightItem; + } + transaction._mergeStructs.push(rightItem); + if (rightItem.parentSub !== null && rightItem.right === null) { + rightItem.parent._map.set(rightItem.parentSub, rightItem); + } + leftItem.length = diff; + return rightItem; + }; + var isDeletedByUndoStack = (stack, id2) => some( + stack, + /** @param {StackItem} s */ + (s) => isDeleted(s.deletions, id2) + ); + var redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemoteMapChanges, um) => { + const doc2 = transaction.doc; + const store = doc2.store; + const ownClientID = doc2.clientID; + const redone = item.redone; + if (redone !== null) { + return getItemCleanStart(transaction, redone); + } + let parentItem = ( + /** @type {AbstractType} */ + item.parent._item + ); + let left = null; + let right; + if (parentItem !== null && parentItem.deleted === true) { + if (parentItem.redone === null && (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete, ignoreRemoteMapChanges, um) === null)) { + return null; + } + while (parentItem.redone !== null) { + parentItem = getItemCleanStart(transaction, parentItem.redone); + } + } + const parentType = parentItem === null ? ( + /** @type {AbstractType} */ + item.parent + ) : ( + /** @type {ContentType} */ + parentItem.content.type + ); + if (item.parentSub === null) { + left = item.left; + right = item; + while (left !== null) { + let leftTrace = left; + while (leftTrace !== null && /** @type {AbstractType} */ + leftTrace.parent._item !== parentItem) { + leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, leftTrace.redone); + } + if (leftTrace !== null && /** @type {AbstractType} */ + leftTrace.parent._item === parentItem) { + left = leftTrace; + break; + } + left = left.left; + } + while (right !== null) { + let rightTrace = right; + while (rightTrace !== null && /** @type {AbstractType} */ + rightTrace.parent._item !== parentItem) { + rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, rightTrace.redone); + } + if (rightTrace !== null && /** @type {AbstractType} */ + rightTrace.parent._item === parentItem) { + right = rightTrace; + break; + } + right = right.right; + } + } else { + right = null; + if (item.right && !ignoreRemoteMapChanges) { + left = item; + while (left !== null && left.right !== null && (left.right.redone || isDeleted(itemsToDelete, left.right.id) || isDeletedByUndoStack(um.undoStack, left.right.id) || isDeletedByUndoStack(um.redoStack, left.right.id))) { + left = left.right; + while (left.redone) left = getItemCleanStart(transaction, left.redone); + } + if (left && left.right !== null) { + return null; + } + } else { + left = parentType._map.get(item.parentSub) || null; + } + } + const nextClock = getState(store, ownClientID); + const nextId = createID(ownClientID, nextClock); + const redoneItem = new Item( + nextId, + left, + left && left.lastId, + right, + right && right.id, + parentType, + item.parentSub, + item.content.copy() + ); + item.redone = nextId; + keepItem(redoneItem, true); + redoneItem.integrate(transaction, 0); + return redoneItem; + }; + var Item = class _Item extends AbstractStruct { + /** + * @param {ID} id + * @param {Item | null} left + * @param {ID | null} origin + * @param {Item | null} right + * @param {ID | null} rightOrigin + * @param {AbstractType|ID|null} parent Is a type if integrated, is null if it is possible to copy parent from left or right, is ID before integration to search for it. + * @param {string | null} parentSub + * @param {AbstractContent} content + */ + constructor(id2, left, origin, right, rightOrigin, parent, parentSub, content) { + super(id2, content.getLength()); + this.origin = origin; + this.left = left; + this.right = right; + this.rightOrigin = rightOrigin; + this.parent = parent; + this.parentSub = parentSub; + this.redone = null; + this.content = content; + this.info = this.content.isCountable() ? BIT2 : 0; + } + /** + * This is used to mark the item as an indexed fast-search marker + * + * @type {boolean} + */ + set marker(isMarked) { + if ((this.info & BIT4) > 0 !== isMarked) { + this.info ^= BIT4; + } + } + get marker() { + return (this.info & BIT4) > 0; + } + /** + * If true, do not garbage collect this Item. + */ + get keep() { + return (this.info & BIT1) > 0; + } + set keep(doKeep) { + if (this.keep !== doKeep) { + this.info ^= BIT1; + } + } + get countable() { + return (this.info & BIT2) > 0; + } + /** + * Whether this item was deleted or not. + * @type {Boolean} + */ + get deleted() { + return (this.info & BIT3) > 0; + } + set deleted(doDelete) { + if (this.deleted !== doDelete) { + this.info ^= BIT3; + } + } + markDeleted() { + this.info |= BIT3; + } + /** + * Return the creator clientID of the missing op or define missing items and return null. + * + * @param {Transaction} transaction + * @param {StructStore} store + * @return {null | number} + */ + getMissing(transaction, store) { + if (this.origin && this.origin.client !== this.id.client && this.origin.clock >= getState(store, this.origin.client)) { + return this.origin.client; + } + if (this.rightOrigin && this.rightOrigin.client !== this.id.client && this.rightOrigin.clock >= getState(store, this.rightOrigin.client)) { + return this.rightOrigin.client; + } + if (this.parent && this.parent.constructor === ID && this.id.client !== this.parent.client && this.parent.clock >= getState(store, this.parent.client)) { + return this.parent.client; + } + if (this.origin) { + this.left = getItemCleanEnd(transaction, store, this.origin); + this.origin = this.left.lastId; + } + if (this.rightOrigin) { + this.right = getItemCleanStart(transaction, this.rightOrigin); + this.rightOrigin = this.right.id; + } + if (this.left && this.left.constructor === GC || this.right && this.right.constructor === GC) { + this.parent = null; + } else if (!this.parent) { + if (this.left && this.left.constructor === _Item) { + this.parent = this.left.parent; + this.parentSub = this.left.parentSub; + } else if (this.right && this.right.constructor === _Item) { + this.parent = this.right.parent; + this.parentSub = this.right.parentSub; + } + } else if (this.parent.constructor === ID) { + const parentItem = getItem(store, this.parent); + if (parentItem.constructor === GC) { + this.parent = null; + } else { + this.parent = /** @type {ContentType} */ + parentItem.content.type; + } + } + return null; + } + /** + * @param {Transaction} transaction + * @param {number} offset + */ + integrate(transaction, offset) { + if (offset > 0) { + this.id.clock += offset; + this.left = getItemCleanEnd(transaction, transaction.doc.store, createID(this.id.client, this.id.clock - 1)); + this.origin = this.left.lastId; + this.content = this.content.splice(offset); + this.length -= offset; + } + if (this.parent) { + if (!this.left && (!this.right || this.right.left !== null) || this.left && this.left.right !== this.right) { + let left = this.left; + let o; + if (left !== null) { + o = left.right; + } else if (this.parentSub !== null) { + o = /** @type {AbstractType} */ + this.parent._map.get(this.parentSub) || null; + while (o !== null && o.left !== null) { + o = o.left; + } + } else { + o = /** @type {AbstractType} */ + this.parent._start; + } + const conflictingItems = /* @__PURE__ */ new Set(); + const itemsBeforeOrigin = /* @__PURE__ */ new Set(); + while (o !== null && o !== this.right) { + itemsBeforeOrigin.add(o); + conflictingItems.add(o); + if (compareIDs(this.origin, o.origin)) { + if (o.id.client < this.id.client) { + left = o; + conflictingItems.clear(); + } else if (compareIDs(this.rightOrigin, o.rightOrigin)) { + break; + } + } else if (o.origin !== null && itemsBeforeOrigin.has(getItem(transaction.doc.store, o.origin))) { + if (!conflictingItems.has(getItem(transaction.doc.store, o.origin))) { + left = o; + conflictingItems.clear(); + } + } else { + break; + } + o = o.right; + } + this.left = left; + } + if (this.left !== null) { + const right = this.left.right; + this.right = right; + this.left.right = this; + } else { + let r; + if (this.parentSub !== null) { + r = /** @type {AbstractType} */ + this.parent._map.get(this.parentSub) || null; + while (r !== null && r.left !== null) { + r = r.left; + } + } else { + r = /** @type {AbstractType} */ + this.parent._start; + this.parent._start = this; + } + this.right = r; + } + if (this.right !== null) { + this.right.left = this; + } else if (this.parentSub !== null) { + this.parent._map.set(this.parentSub, this); + if (this.left !== null) { + this.left.delete(transaction); + } + } + if (this.parentSub === null && this.countable && !this.deleted) { + this.parent._length += this.length; + } + addStruct(transaction.doc.store, this); + this.content.integrate(transaction, this); + addChangedTypeToTransaction( + transaction, + /** @type {AbstractType} */ + this.parent, + this.parentSub + ); + if ( + /** @type {AbstractType} */ + this.parent._item !== null && /** @type {AbstractType} */ + this.parent._item.deleted || this.parentSub !== null && this.right !== null + ) { + this.delete(transaction); + } + } else { + new GC(this.id, this.length).integrate(transaction, 0); + } + } + /** + * Returns the next non-deleted item + */ + get next() { + let n = this.right; + while (n !== null && n.deleted) { + n = n.right; + } + return n; + } + /** + * Returns the previous non-deleted item + */ + get prev() { + let n = this.left; + while (n !== null && n.deleted) { + n = n.left; + } + return n; + } + /** + * Computes the last content address of this Item. + */ + get lastId() { + return this.length === 1 ? this.id : createID(this.id.client, this.id.clock + this.length - 1); + } + /** + * Try to merge two items + * + * @param {Item} right + * @return {boolean} + */ + mergeWith(right) { + if (this.constructor === right.constructor && compareIDs(right.origin, this.lastId) && this.right === right && compareIDs(this.rightOrigin, right.rightOrigin) && this.id.client === right.id.client && this.id.clock + this.length === right.id.clock && this.deleted === right.deleted && this.redone === null && right.redone === null && this.content.constructor === right.content.constructor && this.content.mergeWith(right.content)) { + const searchMarker = ( + /** @type {AbstractType} */ + this.parent._searchMarker + ); + if (searchMarker) { + searchMarker.forEach((marker) => { + if (marker.p === right) { + marker.p = this; + if (!this.deleted && this.countable) { + marker.index -= this.length; + } + } + }); + } + if (right.keep) { + this.keep = true; + } + this.right = right.right; + if (this.right !== null) { + this.right.left = this; + } + this.length += right.length; + return true; + } + return false; + } + /** + * Mark this Item as deleted. + * + * @param {Transaction} transaction + */ + delete(transaction) { + if (!this.deleted) { + const parent = ( + /** @type {AbstractType} */ + this.parent + ); + if (this.countable && this.parentSub === null) { + parent._length -= this.length; + } + this.markDeleted(); + addToDeleteSet(transaction.deleteSet, this.id.client, this.id.clock, this.length); + addChangedTypeToTransaction(transaction, parent, this.parentSub); + this.content.delete(transaction); + } + } + /** + * @param {StructStore} store + * @param {boolean} parentGCd + */ + gc(store, parentGCd) { + if (!this.deleted) { + throw unexpectedCase(); + } + this.content.gc(store); + if (parentGCd) { + replaceStruct(store, this, new GC(this.id, this.length)); + } else { + this.content = new ContentDeleted(this.length); + } + } + /** + * Transform the properties of this type to binary and write it to an + * BinaryEncoder. + * + * This is called when this Item is sent to a remote peer. + * + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to. + * @param {number} offset + */ + write(encoder, offset) { + const origin = offset > 0 ? createID(this.id.client, this.id.clock + offset - 1) : this.origin; + const rightOrigin = this.rightOrigin; + const parentSub = this.parentSub; + const info = this.content.getRef() & BITS5 | (origin === null ? 0 : BIT8) | // origin is defined + (rightOrigin === null ? 0 : BIT7) | // right origin is defined + (parentSub === null ? 0 : BIT6); + encoder.writeInfo(info); + if (origin !== null) { + encoder.writeLeftID(origin); + } + if (rightOrigin !== null) { + encoder.writeRightID(rightOrigin); + } + if (origin === null && rightOrigin === null) { + const parent = ( + /** @type {AbstractType} */ + this.parent + ); + if (parent._item !== void 0) { + const parentItem = parent._item; + if (parentItem === null) { + const ykey = findRootTypeKey(parent); + encoder.writeParentInfo(true); + encoder.writeString(ykey); + } else { + encoder.writeParentInfo(false); + encoder.writeLeftID(parentItem.id); + } + } else if (parent.constructor === String) { + encoder.writeParentInfo(true); + encoder.writeString(parent); + } else if (parent.constructor === ID) { + encoder.writeParentInfo(false); + encoder.writeLeftID(parent); + } else { + unexpectedCase(); + } + if (parentSub !== null) { + encoder.writeString(parentSub); + } + } + this.content.write(encoder, offset); + } + }; + var readItemContent = (decoder, info) => contentRefs[info & BITS5](decoder); + var contentRefs = [ + () => { + unexpectedCase(); + }, + // GC is not ItemContent + readContentDeleted, + // 1 + readContentJSON, + // 2 + readContentBinary, + // 3 + readContentString, + // 4 + readContentEmbed, + // 5 + readContentFormat, + // 6 + readContentType, + // 7 + readContentAny, + // 8 + readContentDoc, + // 9 + () => { + unexpectedCase(); + } + // 10 - Skip is not ItemContent + ]; + var structSkipRefNumber = 10; + var Skip = class extends AbstractStruct { + get deleted() { + return true; + } + delete() { + } + /** + * @param {Skip} right + * @return {boolean} + */ + mergeWith(right) { + if (this.constructor !== right.constructor) { + return false; + } + this.length += right.length; + return true; + } + /** + * @param {Transaction} transaction + * @param {number} offset + */ + integrate(transaction, offset) { + unexpectedCase(); + } + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + */ + write(encoder, offset) { + encoder.writeInfo(structSkipRefNumber); + writeVarUint(encoder.restEncoder, this.length - offset); + } + /** + * @param {Transaction} transaction + * @param {StructStore} store + * @return {null | number} + */ + getMissing(transaction, store) { + return null; + } + }; + var glo = ( + /** @type {any} */ + typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {} + ); + var importIdentifier = "__ $YJS$ __"; + if (glo[importIdentifier] === true) { + console.error("Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438"); + } + glo[importIdentifier] = true; + return __toCommonJS(yjs_exports); +})(); diff --git a/spec/lib/ot_text_unicode_spec.rb b/spec/lib/ot_text_unicode_spec.rb deleted file mode 100644 index 800c30a..0000000 --- a/spec/lib/ot_text_unicode_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe OtTextUnicode do - it "can apply operations to text" do - result = OtTextUnicode.apply("😎hello world", [7, { d: 9 }, "hello"]) - expect(result).to eq("😎hello hello") - end - - it "what happens when stuff is stacked" do - text = "I like bananas" - op1 = [2, { d: 4 }, "eat"] - op2 = [7, { d: 7 }, "apples"] - - op1a = OtTextUnicode.transform(op1, op2) - - result = OtTextUnicode.apply(text, op2) - result = OtTextUnicode.apply(result, op1a) - - expect(result).to eq("I eat apples") - end -end diff --git a/spec/lib/yjs_spec.rb b/spec/lib/yjs_spec.rb new file mode 100644 index 0000000..d4b63ed --- /dev/null +++ b/spec/lib/yjs_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" +require_dependency Rails.root.join( + "plugins", + "discourse-shared-edits", + "lib", + "discourse_shared_edits", + "yjs", + ) + +RSpec.describe DiscourseSharedEdits::Yjs do + it "encodes text into a Yjs state and returns the same content" do + result = described_class.state_from_text("😎hello world") + + expect(result[:text]).to eq("😎hello world") + expect(result[:state]).to be_present + end + + it "applies an update to produce new content" do + initial = described_class.state_from_text("I like bananas") + update = described_class.update_from_state(initial[:state], "I eat apples") + + applied = described_class.apply_update(initial[:state], update) + + expect(applied[:text]).to eq("I eat apples") + expect(applied[:state]).to be_present + end +end diff --git a/spec/models/shared_edit_revision_spec.rb b/spec/models/shared_edit_revision_spec.rb index 10a2a10..29d0ea8 100644 --- a/spec/models/shared_edit_revision_spec.rb +++ b/spec/models/shared_edit_revision_spec.rb @@ -1,13 +1,49 @@ # frozen_string_literal: true +require "rails_helper" +require_dependency Rails.root.join( + "plugins", + "discourse-shared-edits", + "app", + "models", + "shared_edit_revision", + ) +require_dependency Rails.root.join( + "plugins", + "discourse-shared-edits", + "db", + "migrate", + "20200721001123_migrate_shared_edits", + ) +require_dependency Rails.root.join( + "plugins", + "discourse-shared-edits", + "db", + "migrate", + "20251124000000_resize_shared_edit_columns", + ) + RSpec.describe SharedEditRevision do - def fake_edit(post, user_id, data, version:) + before do + unless ActiveRecord::Base.connection.data_source_exists?(:shared_edit_revisions) + MigrateSharedEdits.new.up + ResizeSharedEditColumns.new.up + end + end + + def latest_state(post) + SharedEditRevision.where(post_id: post.id).order("version desc").limit(1).pluck(:raw).first + end + + def fake_edit(post, user_id, new_text) + state = latest_state(post) + update = DiscourseSharedEdits::Yjs.update_from_state(state, new_text) + SharedEditRevision.revise!( post_id: post.id, user_id: user_id, client_id: user_id, - revision: data.to_json, - version: version, + update: update, ) end @@ -20,66 +56,58 @@ def fake_edit(post, user_id, data, version:) user1 = Fabricate(:user) user2 = Fabricate(:user) + user3 = Fabricate(:user) post = Fabricate(:post, raw: raw) SharedEditRevision.init!(post) - version, revision = nil + text_after_user1 = <<~RAW + 0123456 + mister + 0123456 + RAW + + text_after_user2 = <<~RAW + Hello + mister + 0123456 + RAW + + final_text = <<~RAW + Hello + mister + world + RAW messages = MessageBus.track_publish("/shared_edits/#{post.id}") do - version, revision = fake_edit(post, user1.id, [8, { d: 7 }, "mister"], version: 1) + fake_edit(post, user1.id, text_after_user1) end - expected_rev = [8, { d: 7 }, "mister"].to_json expect(messages.length).to eq(1) expect(messages.first.data[:version]).to eq(2) - expect(messages.first.data[:revision]).to eq(expected_rev) - - expect(version).to eq(2) - expect(revision).to eq(expected_rev) + expect(messages.first.data[:update]).to be_present SharedEditRevision.commit!(post.id) - new_raw = (<<~RAW).strip - 0123456 - mister - 0123456 - RAW - post.reload - expect(post.raw).to eq(new_raw) - - version, revision = fake_edit(post, user2.id, [{ d: 7 }, "hello"], version: 1) + expect(post.raw.strip).to eq(text_after_user1.strip) - expect(version).to eq(3) - expect(revision).to eq("[{\"d\":7},\"hello\"]") - - version, revision = fake_edit(post, user1.id, [16, { d: 7 }, "world"], version: 1) - - expect(version).to eq(4) - expect(revision).to eq("[13,{\"d\":7},\"world\"]") - - fake_edit(post, 3, [{ d: 1 }, "H"], version: 3) + fake_edit(post, user2.id, text_after_user2) + fake_edit(post, user3.id, final_text) SharedEditRevision.commit!(post.id) - new_raw = (<<~RAW).strip - Hello - mister - world - RAW - post.reload - expect(post.raw).to eq(new_raw) + expect(post.raw.strip).to eq(final_text.strip) rev = post.revisions.order(:number).first reason = rev.modifications["edit_reason"][1].to_s expect(reason).to include(user1.username) expect(reason).to include(user2.username) - expect(reason).not_to include("d,") + expect(reason).to include(user3.username) edit_rev = SharedEditRevision.where(post_id: post.id).order("version desc").first @@ -95,8 +123,7 @@ def fake_edit(post, user_id, data, version:) post_id: post.id, user_id: user.id, client_id: user.id, - revision: [{ d: 11 }, "Test"], - version: 1, + update: DiscourseSharedEdits::Yjs.update_from_text_change("Hello world", "Test"), ) expect(post.reload.raw).to eq("Hello world") diff --git a/spec/requests/revision_controller_spec.rb b/spec/requests/revision_controller_spec.rb index 4d73352..54ecaf2 100644 --- a/spec/requests/revision_controller_spec.rb +++ b/spec/requests/revision_controller_spec.rb @@ -35,12 +35,18 @@ SharedEditRevision.toggle_shared_edits!(post1.id, true) end + def latest_state_for(post) + SharedEditRevision.where(post_id: post.id).order("version desc").limit(1).pluck(:raw).first + end + it "can submit edits on a post" do + new_text = "1234" + post1.raw[4..] + latest_state = latest_state_for(post1) + put "/shared_edits/p/#{post1.id}", params: { client_id: "abc", - version: 1, - revision: [{ d: 4 }, "1234"].to_json, + update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text), } expect(response.status).to eq(200) @@ -51,11 +57,13 @@ end it "can get the latest version" do + new_text = "1234" + post1.raw[4..] + latest_state = latest_state_for(post1) + put "/shared_edits/p/#{post1.id}", params: { client_id: "abc", - version: 1, - revision: [{ d: 4 }, "1234"].to_json, + update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text), } get "/shared_edits/p/#{post1.id}" @@ -63,20 +71,24 @@ raw = response.parsed_body["raw"] version = response.parsed_body["version"] + state = response.parsed_body["state"] expect(raw[0..3]).to eq("1234") expect(version).to eq(2) + expect(state).to be_present end it "will defer commit" do Discourse.redis.del SharedEditRevision.will_commit_key(post1.id) + new_text = "1234" + post1.raw[4..] + latest_state = latest_state_for(post1) + Sidekiq::Testing.inline! do put "/shared_edits/p/#{post1.id}", params: { client_id: "abc", - version: 1, - revision: [{ d: 4 }, "1234"].to_json, + update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text), } get "/shared_edits/p/#{post1.id}" @@ -90,26 +102,31 @@ end end - it "can submit old edits to a post and get sane info" do + it "accepts multiple updates without client-side version tracking" do + first_text = "abcd" + post1.raw[4..] + second_text = "wxyz" + post1.raw[4..] + latest_state = latest_state_for(post1) + put "/shared_edits/p/#{post1.id}", params: { client_id: "abc", - version: 1, - revision: [{ d: 4 }, "1234"].to_json, + update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, first_text), } + latest_state = latest_state_for(post1) + put "/shared_edits/p/#{post1.id}", params: { client_id: "123", - version: 1, - revision: [4, { d: 4 }, "abcd"].to_json, + update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, second_text), } + expect(response.status).to eq(200) SharedEditRevision.commit!(post1.id) post1.reload - expect(post1.raw[4..7]).to eq("abcd") + expect(post1.raw[0..3]).to eq("wxyz") end it "can not enable revisions as normal user" do diff --git a/spec/system/edit_spec.rb b/spec/system/edit_spec.rb index 082f295..6d57db8 100644 --- a/spec/system/edit_spec.rb +++ b/spec/system/edit_spec.rb @@ -17,8 +17,10 @@ try_until_success do revision = SharedEditRevision.find_by(post_id: post.id, version: 1) expect(revision).to be_present - expect(revision.raw).to eq("lorem ipsum\n") - expect(revision.revision).to eq("[]") + expect(DiscourseSharedEdits::Yjs.text_from_state(revision.raw)).to eq( + "lorem ipsum\n", + ) + expect(revision.revision).to eq("") expect(SharedEditRevision.count).to eq(1) end @@ -29,7 +31,9 @@ try_until_success do revision = SharedEditRevision.find_by(post_id: post.id, version: 2) expect(revision).to be_present - expect(revision.revision).to eq("[12,\"foo\"]") + expect( + DiscourseSharedEdits::Yjs.text_from_state(revision.raw), + ).to eq("lorem ipsum\nfoo") expect(SharedEditRevision.count).to eq(2) end @@ -37,7 +41,9 @@ try_until_success do revision = SharedEditRevision.find_by(post_id: post.id, version: 3) expect(revision).to be_present - expect(revision.revision).to eq("[15,\" bar\"]") + expect( + DiscourseSharedEdits::Yjs.text_from_state(revision.raw), + ).to eq("lorem ipsum\nfoo bar") expect(SharedEditRevision.count).to eq(3) end diff --git a/support/text-unicode-webpack/package.json b/support/text-unicode-webpack/package.json deleted file mode 100644 index 9040172..0000000 --- a/support/text-unicode-webpack/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "text-unicode-webpack", - "private": true, - "main": "index.js", - "dependencies": { - "ot-diff": "^1.1.1", - "ot-text-unicode": "^4.0.0" - }, - "devDependencies": { - "@babel/core": "^7.24.0", - "@babel/preset-env": "^7.24.0", - "babel-loader": "^9.1.3", - "webpack": "^5.94.0", - "webpack-cli": "^5.1.4" - } -} diff --git a/support/text-unicode-webpack/src/index.js b/support/text-unicode-webpack/src/index.js deleted file mode 100644 index bbd4024..0000000 --- a/support/text-unicode-webpack/src/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import OtDiff from "ot-diff"; -import { type } from "ot-text-unicode"; - -export default { OtDiff, OtUnicode: type }; diff --git a/support/text-unicode-webpack/webpack.config.js b/support/text-unicode-webpack/webpack.config.js deleted file mode 100644 index 2dae8b0..0000000 --- a/support/text-unicode-webpack/webpack.config.js +++ /dev/null @@ -1,20 +0,0 @@ -const path = require("path"); - -module.exports = { - mode: "production", - output: { - path: path.resolve(__dirname, "..", "..", "public", "javascripts"), - filename: "text-unicode-dist.js", - library: "otLib", - libraryTarget: "umd", - globalObject: "window", - }, - module: { - rules: [ - { - test: /\.js$/, - use: { loader: "babel-loader" }, - }, - ], - }, -}; diff --git a/support/text-unicode-webpack/yarn.lock b/support/text-unicode-webpack/yarn.lock deleted file mode 100644 index 972a68e..0000000 --- a/support/text-unicode-webpack/yarn.lock +++ /dev/null @@ -1,2086 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@ampproject/remapping@^2.2.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" - integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== - dependencies: - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.24" - -"@babel/code-frame@^7.23.5": - version "7.23.5" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244" - integrity sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA== - dependencies: - "@babel/highlight" "^7.23.4" - chalk "^2.4.2" - -"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.23.5": - version "7.23.5" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.23.5.tgz#ffb878728bb6bdcb6f4510aa51b1be9afb8cfd98" - integrity sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw== - -"@babel/core@^7.24.0": - version "7.24.0" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.0.tgz#56cbda6b185ae9d9bed369816a8f4423c5f2ff1b" - integrity sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw== - dependencies: - "@ampproject/remapping" "^2.2.0" - "@babel/code-frame" "^7.23.5" - "@babel/generator" "^7.23.6" - "@babel/helper-compilation-targets" "^7.23.6" - "@babel/helper-module-transforms" "^7.23.3" - "@babel/helpers" "^7.24.0" - "@babel/parser" "^7.24.0" - "@babel/template" "^7.24.0" - "@babel/traverse" "^7.24.0" - "@babel/types" "^7.24.0" - convert-source-map "^2.0.0" - debug "^4.1.0" - gensync "^1.0.0-beta.2" - json5 "^2.2.3" - semver "^6.3.1" - -"@babel/generator@^7.23.6": - version "7.23.6" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.6.tgz#9e1fca4811c77a10580d17d26b57b036133f3c2e" - integrity sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw== - dependencies: - "@babel/types" "^7.23.6" - "@jridgewell/gen-mapping" "^0.3.2" - "@jridgewell/trace-mapping" "^0.3.17" - jsesc "^2.5.1" - -"@babel/helper-annotate-as-pure@^7.22.5": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz#e7f06737b197d580a01edf75d97e2c8be99d3882" - integrity sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg== - dependencies: - "@babel/types" "^7.22.5" - -"@babel/helper-builder-binary-assignment-operator-visitor@^7.22.15": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz#5426b109cf3ad47b91120f8328d8ab1be8b0b956" - integrity sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw== - dependencies: - "@babel/types" "^7.22.15" - -"@babel/helper-compilation-targets@^7.22.15", "@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.23.6": - version "7.23.6" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz#4d79069b16cbcf1461289eccfbbd81501ae39991" - integrity sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ== - dependencies: - "@babel/compat-data" "^7.23.5" - "@babel/helper-validator-option" "^7.23.5" - browserslist "^4.22.2" - lru-cache "^5.1.1" - semver "^6.3.1" - -"@babel/helper-create-class-features-plugin@^7.22.15": - version "7.24.0" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.0.tgz#fc7554141bdbfa2d17f7b4b80153b9b090e5d158" - integrity sha512-QAH+vfvts51BCsNZ2PhY6HAggnlS6omLLFTsIpeqZk/MmJ6cW7tgz5yRv0fMJThcr6FmbMrENh1RgrWPTYA76g== - dependencies: - "@babel/helper-annotate-as-pure" "^7.22.5" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-member-expression-to-functions" "^7.23.0" - "@babel/helper-optimise-call-expression" "^7.22.5" - "@babel/helper-replace-supers" "^7.22.20" - "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.22.6" - semver "^6.3.1" - -"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.22.15", "@babel/helper-create-regexp-features-plugin@^7.22.5": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz#5ee90093914ea09639b01c711db0d6775e558be1" - integrity sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w== - dependencies: - "@babel/helper-annotate-as-pure" "^7.22.5" - regexpu-core "^5.3.1" - semver "^6.3.1" - -"@babel/helper-define-polyfill-provider@^0.5.0": - version "0.5.0" - resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz#465805b7361f461e86c680f1de21eaf88c25901b" - integrity sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q== - dependencies: - "@babel/helper-compilation-targets" "^7.22.6" - "@babel/helper-plugin-utils" "^7.22.5" - debug "^4.1.1" - lodash.debounce "^4.0.8" - resolve "^1.14.2" - -"@babel/helper-define-polyfill-provider@^0.6.0": - version "0.6.0" - resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.0.tgz#4d1a8b898c8299a2fcf295d7d356d2648471ab31" - integrity sha512-efwOM90nCG6YeT8o3PCyBVSxRfmILxCNL+TNI8CGQl7a62M0Wd9VkV+XHwIlkOz1r4b+lxu6gBjdWiOMdUCrCQ== - dependencies: - "@babel/helper-compilation-targets" "^7.22.6" - "@babel/helper-plugin-utils" "^7.22.5" - debug "^4.1.1" - lodash.debounce "^4.0.8" - resolve "^1.14.2" - -"@babel/helper-environment-visitor@^7.22.20": - version "7.22.20" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" - integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== - -"@babel/helper-function-name@^7.22.5", "@babel/helper-function-name@^7.23.0": - version "7.23.0" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759" - integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw== - dependencies: - "@babel/template" "^7.22.15" - "@babel/types" "^7.23.0" - -"@babel/helper-hoist-variables@^7.22.5": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" - integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw== - dependencies: - "@babel/types" "^7.22.5" - -"@babel/helper-member-expression-to-functions@^7.22.15", "@babel/helper-member-expression-to-functions@^7.23.0": - version "7.23.0" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz#9263e88cc5e41d39ec18c9a3e0eced59a3e7d366" - integrity sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA== - dependencies: - "@babel/types" "^7.23.0" - -"@babel/helper-module-imports@^7.22.15": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0" - integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w== - dependencies: - "@babel/types" "^7.22.15" - -"@babel/helper-module-transforms@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz#d7d12c3c5d30af5b3c0fcab2a6d5217773e2d0f1" - integrity sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ== - dependencies: - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-module-imports" "^7.22.15" - "@babel/helper-simple-access" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/helper-validator-identifier" "^7.22.20" - -"@babel/helper-optimise-call-expression@^7.22.5": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz#f21531a9ccbff644fdd156b4077c16ff0c3f609e" - integrity sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw== - dependencies: - "@babel/types" "^7.22.5" - -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.24.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": - version "7.24.0" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz#945681931a52f15ce879fd5b86ce2dae6d3d7f2a" - integrity sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w== - -"@babel/helper-remap-async-to-generator@^7.22.20": - version "7.22.20" - resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz#7b68e1cb4fa964d2996fd063723fb48eca8498e0" - integrity sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.22.5" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-wrap-function" "^7.22.20" - -"@babel/helper-replace-supers@^7.22.20": - version "7.22.20" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz#e37d367123ca98fe455a9887734ed2e16eb7a793" - integrity sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw== - dependencies: - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-member-expression-to-functions" "^7.22.15" - "@babel/helper-optimise-call-expression" "^7.22.5" - -"@babel/helper-simple-access@^7.22.5": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz#4938357dc7d782b80ed6dbb03a0fba3d22b1d5de" - integrity sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w== - dependencies: - "@babel/types" "^7.22.5" - -"@babel/helper-skip-transparent-expression-wrappers@^7.22.5": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz#007f15240b5751c537c40e77abb4e89eeaaa8847" - integrity sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q== - dependencies: - "@babel/types" "^7.22.5" - -"@babel/helper-split-export-declaration@^7.22.6": - version "7.22.6" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz#322c61b7310c0997fe4c323955667f18fcefb91c" - integrity sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g== - dependencies: - "@babel/types" "^7.22.5" - -"@babel/helper-string-parser@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83" - integrity sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ== - -"@babel/helper-validator-identifier@^7.22.20": - version "7.22.20" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" - integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A== - -"@babel/helper-validator-option@^7.23.5": - version "7.23.5" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz#907a3fbd4523426285365d1206c423c4c5520307" - integrity sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw== - -"@babel/helper-wrap-function@^7.22.20": - version "7.22.20" - resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz#15352b0b9bfb10fc9c76f79f6342c00e3411a569" - integrity sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw== - dependencies: - "@babel/helper-function-name" "^7.22.5" - "@babel/template" "^7.22.15" - "@babel/types" "^7.22.19" - -"@babel/helpers@^7.24.0": - version "7.24.0" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.0.tgz#a3dd462b41769c95db8091e49cfe019389a9409b" - integrity sha512-ulDZdc0Aj5uLc5nETsa7EPx2L7rM0YJM8r7ck7U73AXi7qOV44IHHRAYZHY6iU1rr3C5N4NtTmMRUJP6kwCWeA== - dependencies: - "@babel/template" "^7.24.0" - "@babel/traverse" "^7.24.0" - "@babel/types" "^7.24.0" - -"@babel/highlight@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz#edaadf4d8232e1a961432db785091207ead0621b" - integrity sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A== - dependencies: - "@babel/helper-validator-identifier" "^7.22.20" - chalk "^2.4.2" - js-tokens "^4.0.0" - -"@babel/parser@^7.24.0": - version "7.24.0" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.0.tgz#26a3d1ff49031c53a97d03b604375f028746a9ac" - integrity sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg== - -"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.23.3.tgz#5cd1c87ba9380d0afb78469292c954fee5d2411a" - integrity sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.23.3.tgz#f6652bb16b94f8f9c20c50941e16e9756898dc5d" - integrity sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5" - "@babel/plugin-transform-optional-chaining" "^7.23.3" - -"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@^7.23.7": - version "7.23.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.23.7.tgz#516462a95d10a9618f197d39ad291a9b47ae1d7b" - integrity sha512-LlRT7HgaifEpQA1ZgLVOIJZZFVPWN5iReq/7/JixwBtwcoeVGDBD53ZV28rrsLYOZs1Y/EHhA8N/Z6aazHR8cw== - dependencies: - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2": - version "7.21.0-placeholder-for-preset-env.2" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz#7844f9289546efa9febac2de4cfe358a050bd703" - integrity sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w== - -"@babel/plugin-syntax-async-generators@^7.8.4": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" - integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-class-properties@^7.12.13": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" - integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== - dependencies: - "@babel/helper-plugin-utils" "^7.12.13" - -"@babel/plugin-syntax-class-static-block@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" - integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-dynamic-import@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" - integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-export-namespace-from@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz#028964a9ba80dbc094c915c487ad7c4e7a66465a" - integrity sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q== - dependencies: - "@babel/helper-plugin-utils" "^7.8.3" - -"@babel/plugin-syntax-import-assertions@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.23.3.tgz#9c05a7f592982aff1a2768260ad84bcd3f0c77fc" - integrity sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-syntax-import-attributes@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.23.3.tgz#992aee922cf04512461d7dae3ff6951b90a2dc06" - integrity sha512-pawnE0P9g10xgoP7yKr6CK63K2FMsTE+FZidZO/1PwRdzmAPVs+HS1mAURUsgaoxammTJvULUdIkEK0gOcU2tA== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-syntax-import-meta@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" - integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-json-strings@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" - integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" - integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" - integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-numeric-separator@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" - integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-object-rest-spread@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" - integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-optional-catch-binding@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" - integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-optional-chaining@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" - integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-private-property-in-object@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" - integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-top-level-await@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" - integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-unicode-sets-regex@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357" - integrity sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-arrow-functions@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.23.3.tgz#94c6dcfd731af90f27a79509f9ab7fb2120fc38b" - integrity sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-async-generator-functions@^7.23.9": - version "7.23.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.9.tgz#9adaeb66fc9634a586c5df139c6240d41ed801ce" - integrity sha512-8Q3veQEDGe14dTYuwagbRtwxQDnytyg1JFu4/HwEMETeofocrB0U0ejBJIXoeG/t2oXZ8kzCyI0ZZfbT80VFNQ== - dependencies: - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/helper-remap-async-to-generator" "^7.22.20" - "@babel/plugin-syntax-async-generators" "^7.8.4" - -"@babel/plugin-transform-async-to-generator@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.23.3.tgz#d1f513c7a8a506d43f47df2bf25f9254b0b051fa" - integrity sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw== - dependencies: - "@babel/helper-module-imports" "^7.22.15" - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/helper-remap-async-to-generator" "^7.22.20" - -"@babel/plugin-transform-block-scoped-functions@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.23.3.tgz#fe1177d715fb569663095e04f3598525d98e8c77" - integrity sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-block-scoping@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz#b2d38589531c6c80fbe25e6b58e763622d2d3cf5" - integrity sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-class-properties@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.23.3.tgz#35c377db11ca92a785a718b6aa4e3ed1eb65dc48" - integrity sha512-uM+AN8yCIjDPccsKGlw271xjJtGii+xQIF/uMPS8H15L12jZTsLfF4o5vNO7d/oUguOyfdikHGc/yi9ge4SGIg== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.22.15" - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-class-static-block@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.4.tgz#2a202c8787a8964dd11dfcedf994d36bfc844ab5" - integrity sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.22.15" - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/plugin-syntax-class-static-block" "^7.14.5" - -"@babel/plugin-transform-classes@^7.23.8": - version "7.23.8" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.8.tgz#d08ae096c240347badd68cdf1b6d1624a6435d92" - integrity sha512-yAYslGsY1bX6Knmg46RjiCiNSwJKv2IUC8qOdYKqMMr0491SXFhcHqOdRDeCRohOOIzwN/90C6mQ9qAKgrP7dg== - dependencies: - "@babel/helper-annotate-as-pure" "^7.22.5" - "@babel/helper-compilation-targets" "^7.23.6" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/helper-replace-supers" "^7.22.20" - "@babel/helper-split-export-declaration" "^7.22.6" - globals "^11.1.0" - -"@babel/plugin-transform-computed-properties@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz#652e69561fcc9d2b50ba4f7ac7f60dcf65e86474" - integrity sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/template" "^7.22.15" - -"@babel/plugin-transform-destructuring@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.3.tgz#8c9ee68228b12ae3dff986e56ed1ba4f3c446311" - integrity sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-dotall-regex@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.23.3.tgz#3f7af6054882ede89c378d0cf889b854a993da50" - integrity sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.22.15" - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-duplicate-keys@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.23.3.tgz#664706ca0a5dfe8d066537f99032fc1dc8b720ce" - integrity sha512-RrqQ+BQmU3Oyav3J+7/myfvRCq7Tbz+kKLLshUmMwNlDHExbGL7ARhajvoBJEvc+fCguPPu887N+3RRXBVKZUA== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-dynamic-import@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.4.tgz#c7629e7254011ac3630d47d7f34ddd40ca535143" - integrity sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/plugin-syntax-dynamic-import" "^7.8.3" - -"@babel/plugin-transform-exponentiation-operator@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.23.3.tgz#ea0d978f6b9232ba4722f3dbecdd18f450babd18" - integrity sha512-5fhCsl1odX96u7ILKHBj4/Y8vipoqwsJMh4csSA8qFfxrZDEA4Ssku2DyNvMJSmZNOEBT750LfFPbtrnTP90BQ== - dependencies: - "@babel/helper-builder-binary-assignment-operator-visitor" "^7.22.15" - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-export-namespace-from@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.4.tgz#084c7b25e9a5c8271e987a08cf85807b80283191" - integrity sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/plugin-syntax-export-namespace-from" "^7.8.3" - -"@babel/plugin-transform-for-of@^7.23.6": - version "7.23.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.23.6.tgz#81c37e24171b37b370ba6aaffa7ac86bcb46f94e" - integrity sha512-aYH4ytZ0qSuBbpfhuofbg/e96oQ7U2w1Aw/UQmKT+1l39uEhUPoFS3fHevDc1G0OvewyDudfMKY1OulczHzWIw== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5" - -"@babel/plugin-transform-function-name@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.23.3.tgz#8f424fcd862bf84cb9a1a6b42bc2f47ed630f8dc" - integrity sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw== - dependencies: - "@babel/helper-compilation-targets" "^7.22.15" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-json-strings@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.4.tgz#a871d9b6bd171976efad2e43e694c961ffa3714d" - integrity sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/plugin-syntax-json-strings" "^7.8.3" - -"@babel/plugin-transform-literals@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.23.3.tgz#8214665f00506ead73de157eba233e7381f3beb4" - integrity sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-logical-assignment-operators@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.4.tgz#e599f82c51d55fac725f62ce55d3a0886279ecb5" - integrity sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" - -"@babel/plugin-transform-member-expression-literals@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.23.3.tgz#e37b3f0502289f477ac0e776b05a833d853cabcc" - integrity sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-modules-amd@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.3.tgz#e19b55436a1416829df0a1afc495deedfae17f7d" - integrity sha512-vJYQGxeKM4t8hYCKVBlZX/gtIY2I7mRGFNcm85sgXGMTBcoV3QdVtdpbcWEbzbfUIUZKwvgFT82mRvaQIebZzw== - dependencies: - "@babel/helper-module-transforms" "^7.23.3" - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-modules-commonjs@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.3.tgz#661ae831b9577e52be57dd8356b734f9700b53b4" - integrity sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA== - dependencies: - "@babel/helper-module-transforms" "^7.23.3" - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/helper-simple-access" "^7.22.5" - -"@babel/plugin-transform-modules-systemjs@^7.23.9": - version "7.23.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.9.tgz#105d3ed46e4a21d257f83a2f9e2ee4203ceda6be" - integrity sha512-KDlPRM6sLo4o1FkiSlXoAa8edLXFsKKIda779fbLrvmeuc3itnjCtaO6RrtoaANsIJANj+Vk1zqbZIMhkCAHVw== - dependencies: - "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-module-transforms" "^7.23.3" - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/helper-validator-identifier" "^7.22.20" - -"@babel/plugin-transform-modules-umd@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.23.3.tgz#5d4395fccd071dfefe6585a4411aa7d6b7d769e9" - integrity sha512-zHsy9iXX2nIsCBFPud3jKn1IRPWg3Ing1qOZgeKV39m1ZgIdpJqvlWVeiHBZC6ITRG0MfskhYe9cLgntfSFPIg== - dependencies: - "@babel/helper-module-transforms" "^7.23.3" - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-named-capturing-groups-regex@^7.22.5": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz#67fe18ee8ce02d57c855185e27e3dc959b2e991f" - integrity sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.22.5" - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-new-target@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.23.3.tgz#5491bb78ed6ac87e990957cea367eab781c4d980" - integrity sha512-YJ3xKqtJMAT5/TIZnpAR3I+K+WaDowYbN3xyxI8zxx/Gsypwf9B9h0VB+1Nh6ACAAPRS5NSRje0uVv5i79HYGQ== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-nullish-coalescing-operator@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.4.tgz#45556aad123fc6e52189ea749e33ce090637346e" - integrity sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - -"@babel/plugin-transform-numeric-separator@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.4.tgz#03d08e3691e405804ecdd19dd278a40cca531f29" - integrity sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/plugin-syntax-numeric-separator" "^7.10.4" - -"@babel/plugin-transform-object-rest-spread@^7.24.0": - version "7.24.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.0.tgz#7b836ad0088fdded2420ce96d4e1d3ed78b71df1" - integrity sha512-y/yKMm7buHpFFXfxVFS4Vk1ToRJDilIa6fKRioB9Vjichv58TDGXTvqV0dN7plobAmTW5eSEGXDngE+Mm+uO+w== - dependencies: - "@babel/compat-data" "^7.23.5" - "@babel/helper-compilation-targets" "^7.23.6" - "@babel/helper-plugin-utils" "^7.24.0" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-transform-parameters" "^7.23.3" - -"@babel/plugin-transform-object-super@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.23.3.tgz#81fdb636dcb306dd2e4e8fd80db5b2362ed2ebcd" - integrity sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/helper-replace-supers" "^7.22.20" - -"@babel/plugin-transform-optional-catch-binding@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.4.tgz#318066de6dacce7d92fa244ae475aa8d91778017" - integrity sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" - -"@babel/plugin-transform-optional-chaining@^7.23.3", "@babel/plugin-transform-optional-chaining@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.4.tgz#6acf61203bdfc4de9d4e52e64490aeb3e52bd017" - integrity sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - -"@babel/plugin-transform-parameters@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.23.3.tgz#83ef5d1baf4b1072fa6e54b2b0999a7b2527e2af" - integrity sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-private-methods@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.23.3.tgz#b2d7a3c97e278bfe59137a978d53b2c2e038c0e4" - integrity sha512-UzqRcRtWsDMTLrRWFvUBDwmw06tCQH9Rl1uAjfh6ijMSmGYQ+fpdB+cnqRC8EMh5tuuxSv0/TejGL+7vyj+50g== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.22.15" - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-private-property-in-object@^7.23.4": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.4.tgz#3ec711d05d6608fd173d9b8de39872d8dbf68bf5" - integrity sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A== - dependencies: - "@babel/helper-annotate-as-pure" "^7.22.5" - "@babel/helper-create-class-features-plugin" "^7.22.15" - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/plugin-syntax-private-property-in-object" "^7.14.5" - -"@babel/plugin-transform-property-literals@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.23.3.tgz#54518f14ac4755d22b92162e4a852d308a560875" - integrity sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-regenerator@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.23.3.tgz#141afd4a2057298602069fce7f2dc5173e6c561c" - integrity sha512-KP+75h0KghBMcVpuKisx3XTu9Ncut8Q8TuvGO4IhY+9D5DFEckQefOuIsB/gQ2tG71lCke4NMrtIPS8pOj18BQ== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - regenerator-transform "^0.15.2" - -"@babel/plugin-transform-reserved-words@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.23.3.tgz#4130dcee12bd3dd5705c587947eb715da12efac8" - integrity sha512-QnNTazY54YqgGxwIexMZva9gqbPa15t/x9VS+0fsEFWplwVpXYZivtgl43Z1vMpc1bdPP2PP8siFeVcnFvA3Cg== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-shorthand-properties@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.23.3.tgz#97d82a39b0e0c24f8a981568a8ed851745f59210" - integrity sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-spread@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.23.3.tgz#41d17aacb12bde55168403c6f2d6bdca563d362c" - integrity sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5" - -"@babel/plugin-transform-sticky-regex@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.23.3.tgz#dec45588ab4a723cb579c609b294a3d1bd22ff04" - integrity sha512-HZOyN9g+rtvnOU3Yh7kSxXrKbzgrm5X4GncPY1QOquu7epga5MxKHVpYu2hvQnry/H+JjckSYRb93iNfsioAGg== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-template-literals@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.23.3.tgz#5f0f028eb14e50b5d0f76be57f90045757539d07" - integrity sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-typeof-symbol@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.23.3.tgz#9dfab97acc87495c0c449014eb9c547d8966bca4" - integrity sha512-4t15ViVnaFdrPC74be1gXBSMzXk3B4Us9lP7uLRQHTFpV5Dvt33pn+2MyyNxmN3VTTm3oTrZVMUmuw3oBnQ2oQ== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-unicode-escapes@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.23.3.tgz#1f66d16cab01fab98d784867d24f70c1ca65b925" - integrity sha512-OMCUx/bU6ChE3r4+ZdylEqAjaQgHAgipgW8nsCfu5pGqDcFytVd91AwRvUJSBZDz0exPGgnjoqhgRYLRjFZc9Q== - dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-unicode-property-regex@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.23.3.tgz#19e234129e5ffa7205010feec0d94c251083d7ad" - integrity sha512-KcLIm+pDZkWZQAFJ9pdfmh89EwVfmNovFBcXko8szpBeF8z68kWIPeKlmSOkT9BXJxs2C0uk+5LxoxIv62MROA== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.22.15" - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-unicode-regex@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.23.3.tgz#26897708d8f42654ca4ce1b73e96140fbad879dc" - integrity sha512-wMHpNA4x2cIA32b/ci3AfwNgheiva2W0WUKWTK7vBHBhDKfPsc5cFGNWm69WBqpwd86u1qwZ9PWevKqm1A3yAw== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.22.15" - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/plugin-transform-unicode-sets-regex@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.23.3.tgz#4fb6f0a719c2c5859d11f6b55a050cc987f3799e" - integrity sha512-W7lliA/v9bNR83Qc3q1ip9CQMZ09CcHDbHfbLRDNuAhn1Mvkr1ZNF7hPmztMQvtTGVLJ9m8IZqWsTkXOml8dbw== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.22.15" - "@babel/helper-plugin-utils" "^7.22.5" - -"@babel/preset-env@^7.24.0": - version "7.24.0" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.24.0.tgz#11536a7f4b977294f0bdfad780f01a8ac8e183fc" - integrity sha512-ZxPEzV9IgvGn73iK0E6VB9/95Nd7aMFpbE0l8KQFDG70cOV9IxRP7Y2FUPmlK0v6ImlLqYX50iuZ3ZTVhOF2lA== - dependencies: - "@babel/compat-data" "^7.23.5" - "@babel/helper-compilation-targets" "^7.23.6" - "@babel/helper-plugin-utils" "^7.24.0" - "@babel/helper-validator-option" "^7.23.5" - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.23.3" - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.23.3" - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly" "^7.23.7" - "@babel/plugin-proposal-private-property-in-object" "7.21.0-placeholder-for-preset-env.2" - "@babel/plugin-syntax-async-generators" "^7.8.4" - "@babel/plugin-syntax-class-properties" "^7.12.13" - "@babel/plugin-syntax-class-static-block" "^7.14.5" - "@babel/plugin-syntax-dynamic-import" "^7.8.3" - "@babel/plugin-syntax-export-namespace-from" "^7.8.3" - "@babel/plugin-syntax-import-assertions" "^7.23.3" - "@babel/plugin-syntax-import-attributes" "^7.23.3" - "@babel/plugin-syntax-import-meta" "^7.10.4" - "@babel/plugin-syntax-json-strings" "^7.8.3" - "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - "@babel/plugin-syntax-numeric-separator" "^7.10.4" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - "@babel/plugin-syntax-private-property-in-object" "^7.14.5" - "@babel/plugin-syntax-top-level-await" "^7.14.5" - "@babel/plugin-syntax-unicode-sets-regex" "^7.18.6" - "@babel/plugin-transform-arrow-functions" "^7.23.3" - "@babel/plugin-transform-async-generator-functions" "^7.23.9" - "@babel/plugin-transform-async-to-generator" "^7.23.3" - "@babel/plugin-transform-block-scoped-functions" "^7.23.3" - "@babel/plugin-transform-block-scoping" "^7.23.4" - "@babel/plugin-transform-class-properties" "^7.23.3" - "@babel/plugin-transform-class-static-block" "^7.23.4" - "@babel/plugin-transform-classes" "^7.23.8" - "@babel/plugin-transform-computed-properties" "^7.23.3" - "@babel/plugin-transform-destructuring" "^7.23.3" - "@babel/plugin-transform-dotall-regex" "^7.23.3" - "@babel/plugin-transform-duplicate-keys" "^7.23.3" - "@babel/plugin-transform-dynamic-import" "^7.23.4" - "@babel/plugin-transform-exponentiation-operator" "^7.23.3" - "@babel/plugin-transform-export-namespace-from" "^7.23.4" - "@babel/plugin-transform-for-of" "^7.23.6" - "@babel/plugin-transform-function-name" "^7.23.3" - "@babel/plugin-transform-json-strings" "^7.23.4" - "@babel/plugin-transform-literals" "^7.23.3" - "@babel/plugin-transform-logical-assignment-operators" "^7.23.4" - "@babel/plugin-transform-member-expression-literals" "^7.23.3" - "@babel/plugin-transform-modules-amd" "^7.23.3" - "@babel/plugin-transform-modules-commonjs" "^7.23.3" - "@babel/plugin-transform-modules-systemjs" "^7.23.9" - "@babel/plugin-transform-modules-umd" "^7.23.3" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.22.5" - "@babel/plugin-transform-new-target" "^7.23.3" - "@babel/plugin-transform-nullish-coalescing-operator" "^7.23.4" - "@babel/plugin-transform-numeric-separator" "^7.23.4" - "@babel/plugin-transform-object-rest-spread" "^7.24.0" - "@babel/plugin-transform-object-super" "^7.23.3" - "@babel/plugin-transform-optional-catch-binding" "^7.23.4" - "@babel/plugin-transform-optional-chaining" "^7.23.4" - "@babel/plugin-transform-parameters" "^7.23.3" - "@babel/plugin-transform-private-methods" "^7.23.3" - "@babel/plugin-transform-private-property-in-object" "^7.23.4" - "@babel/plugin-transform-property-literals" "^7.23.3" - "@babel/plugin-transform-regenerator" "^7.23.3" - "@babel/plugin-transform-reserved-words" "^7.23.3" - "@babel/plugin-transform-shorthand-properties" "^7.23.3" - "@babel/plugin-transform-spread" "^7.23.3" - "@babel/plugin-transform-sticky-regex" "^7.23.3" - "@babel/plugin-transform-template-literals" "^7.23.3" - "@babel/plugin-transform-typeof-symbol" "^7.23.3" - "@babel/plugin-transform-unicode-escapes" "^7.23.3" - "@babel/plugin-transform-unicode-property-regex" "^7.23.3" - "@babel/plugin-transform-unicode-regex" "^7.23.3" - "@babel/plugin-transform-unicode-sets-regex" "^7.23.3" - "@babel/preset-modules" "0.1.6-no-external-plugins" - babel-plugin-polyfill-corejs2 "^0.4.8" - babel-plugin-polyfill-corejs3 "^0.9.0" - babel-plugin-polyfill-regenerator "^0.5.5" - core-js-compat "^3.31.0" - semver "^6.3.1" - -"@babel/preset-modules@0.1.6-no-external-plugins": - version "0.1.6-no-external-plugins" - resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz#ccb88a2c49c817236861fee7826080573b8a923a" - integrity sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/types" "^7.4.4" - esutils "^2.0.2" - -"@babel/regjsgen@^0.8.0": - version "0.8.0" - resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" - integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== - -"@babel/runtime@^7.8.4": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.7.tgz#fcb41a5a70550e04a7b708037c7c32f7f356d8fd" - integrity sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ== - dependencies: - regenerator-runtime "^0.13.11" - -"@babel/template@^7.22.15", "@babel/template@^7.24.0": - version "7.24.0" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.0.tgz#c6a524aa93a4a05d66aaf31654258fae69d87d50" - integrity sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA== - dependencies: - "@babel/code-frame" "^7.23.5" - "@babel/parser" "^7.24.0" - "@babel/types" "^7.24.0" - -"@babel/traverse@^7.24.0": - version "7.24.0" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.0.tgz#4a408fbf364ff73135c714a2ab46a5eab2831b1e" - integrity sha512-HfuJlI8qq3dEDmNU5ChzzpZRWq+oxCZQyMzIMEqLho+AQnhMnKQUzH6ydo3RBl/YjPCuk68Y6s0Gx0AeyULiWw== - dependencies: - "@babel/code-frame" "^7.23.5" - "@babel/generator" "^7.23.6" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/parser" "^7.24.0" - "@babel/types" "^7.24.0" - debug "^4.3.1" - globals "^11.1.0" - -"@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.23.6", "@babel/types@^7.24.0", "@babel/types@^7.4.4": - version "7.24.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.0.tgz#3b951f435a92e7333eba05b7566fd297960ea1bf" - integrity sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w== - dependencies: - "@babel/helper-string-parser" "^7.23.4" - "@babel/helper-validator-identifier" "^7.22.20" - to-fast-properties "^2.0.0" - -"@discoveryjs/json-ext@^0.5.0": - version "0.5.7" - resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" - integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== - -"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5": - version "0.3.5" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" - integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== - dependencies: - "@jridgewell/set-array" "^1.2.1" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping" "^0.3.24" - -"@jridgewell/resolve-uri@^3.1.0": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721" - integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA== - -"@jridgewell/set-array@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" - integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== - -"@jridgewell/source-map@^0.3.3": - version "0.3.5" - resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.5.tgz#a3bb4d5c6825aab0d281268f47f6ad5853431e91" - integrity sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ== - dependencies: - "@jridgewell/gen-mapping" "^0.3.0" - "@jridgewell/trace-mapping" "^0.3.9" - -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": - version "1.4.15" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" - integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== - -"@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.20", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.9": - version "0.3.25" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" - integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - -"@types/estree@^1.0.5": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" - integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== - -"@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": - version "7.0.15" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" - integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== - -"@types/node@*": - version "20.11.25" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.25.tgz#0f50d62f274e54dd7a49f7704cc16bfbcccaf49f" - integrity sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw== - dependencies: - undici-types "~5.26.4" - -"@webassemblyjs/ast@1.12.1", "@webassemblyjs/ast@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.12.1.tgz#bb16a0e8b1914f979f45864c23819cc3e3f0d4bb" - integrity sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg== - dependencies: - "@webassemblyjs/helper-numbers" "1.11.6" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - -"@webassemblyjs/floating-point-hex-parser@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431" - integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw== - -"@webassemblyjs/helper-api-error@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" - integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== - -"@webassemblyjs/helper-buffer@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz#6df20d272ea5439bf20ab3492b7fb70e9bfcb3f6" - integrity sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw== - -"@webassemblyjs/helper-numbers@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5" - integrity sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g== - dependencies: - "@webassemblyjs/floating-point-hex-parser" "1.11.6" - "@webassemblyjs/helper-api-error" "1.11.6" - "@xtuc/long" "4.2.2" - -"@webassemblyjs/helper-wasm-bytecode@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" - integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== - -"@webassemblyjs/helper-wasm-section@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz#3da623233ae1a60409b509a52ade9bc22a37f7bf" - integrity sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/wasm-gen" "1.12.1" - -"@webassemblyjs/ieee754@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a" - integrity sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg== - dependencies: - "@xtuc/ieee754" "^1.2.0" - -"@webassemblyjs/leb128@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7" - integrity sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ== - dependencies: - "@xtuc/long" "4.2.2" - -"@webassemblyjs/utf8@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" - integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== - -"@webassemblyjs/wasm-edit@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz#9f9f3ff52a14c980939be0ef9d5df9ebc678ae3b" - integrity sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/helper-wasm-section" "1.12.1" - "@webassemblyjs/wasm-gen" "1.12.1" - "@webassemblyjs/wasm-opt" "1.12.1" - "@webassemblyjs/wasm-parser" "1.12.1" - "@webassemblyjs/wast-printer" "1.12.1" - -"@webassemblyjs/wasm-gen@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz#a6520601da1b5700448273666a71ad0a45d78547" - integrity sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/ieee754" "1.11.6" - "@webassemblyjs/leb128" "1.11.6" - "@webassemblyjs/utf8" "1.11.6" - -"@webassemblyjs/wasm-opt@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz#9e6e81475dfcfb62dab574ac2dda38226c232bc5" - integrity sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/wasm-gen" "1.12.1" - "@webassemblyjs/wasm-parser" "1.12.1" - -"@webassemblyjs/wasm-parser@1.12.1", "@webassemblyjs/wasm-parser@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz#c47acb90e6f083391e3fa61d113650eea1e95937" - integrity sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-api-error" "1.11.6" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/ieee754" "1.11.6" - "@webassemblyjs/leb128" "1.11.6" - "@webassemblyjs/utf8" "1.11.6" - -"@webassemblyjs/wast-printer@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz#bcecf661d7d1abdaf989d8341a4833e33e2b31ac" - integrity sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@xtuc/long" "4.2.2" - -"@webpack-cli/configtest@^2.1.1": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-2.1.1.tgz#3b2f852e91dac6e3b85fb2a314fb8bef46d94646" - integrity sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw== - -"@webpack-cli/info@^2.0.2": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-2.0.2.tgz#cc3fbf22efeb88ff62310cf885c5b09f44ae0fdd" - integrity sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A== - -"@webpack-cli/serve@^2.0.5": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.5.tgz#325db42395cd49fe6c14057f9a900e427df8810e" - integrity sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ== - -"@xtuc/ieee754@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" - integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== - -"@xtuc/long@4.2.2": - version "4.2.2" - resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" - integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== - -acorn-import-attributes@^1.9.5: - version "1.9.5" - resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" - integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== - -acorn@^8.7.1, acorn@^8.8.2: - version "8.11.3" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" - integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== - -ajv-formats@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" - integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== - dependencies: - ajv "^8.0.0" - -ajv-keywords@^3.5.2: - version "3.5.2" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" - integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== - -ajv-keywords@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz#69d4d385a4733cdbeab44964a1170a88f87f0e16" - integrity sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw== - dependencies: - fast-deep-equal "^3.1.3" - -ajv@^6.12.5: - version "6.12.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ajv@^8.0.0, ajv@^8.9.0: - version "8.12.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1" - integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== - dependencies: - fast-deep-equal "^3.1.1" - json-schema-traverse "^1.0.0" - require-from-string "^2.0.2" - uri-js "^4.2.2" - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -babel-loader@^9.1.3: - version "9.1.3" - resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-9.1.3.tgz#3d0e01b4e69760cc694ee306fe16d358aa1c6f9a" - integrity sha512-xG3ST4DglodGf8qSwv0MdeWLhrDsw/32QMdTO5T1ZIp9gQur0HkCyFs7Awskr10JKXFXwpAhiCuYX5oGXnRGbw== - dependencies: - find-cache-dir "^4.0.0" - schema-utils "^4.0.0" - -babel-plugin-polyfill-corejs2@^0.4.8: - version "0.4.9" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.9.tgz#15a285f681e1c5495093d85f1cf72bd1cbed41ce" - integrity sha512-BXIWIaO3MewbXWdJdIGDWZurv5OGJlFNo7oy20DpB3kWDVJLcY2NRypRsRUbRe5KMqSNLuOGnWTFQQtY5MAsRw== - dependencies: - "@babel/compat-data" "^7.22.6" - "@babel/helper-define-polyfill-provider" "^0.6.0" - semver "^6.3.1" - -babel-plugin-polyfill-corejs3@^0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.9.0.tgz#9eea32349d94556c2ad3ab9b82ebb27d4bf04a81" - integrity sha512-7nZPG1uzK2Ymhy/NbaOWTg3uibM2BmGASS4vHS4szRZAIR8R6GwA/xAujpdrXU5iyklrimWnLWU+BLF9suPTqg== - dependencies: - "@babel/helper-define-polyfill-provider" "^0.5.0" - core-js-compat "^3.34.0" - -babel-plugin-polyfill-regenerator@^0.5.5: - version "0.5.5" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.5.tgz#8b0c8fc6434239e5d7b8a9d1f832bb2b0310f06a" - integrity sha512-OJGYZlhLqBh2DDHeqAxWB1XIvr49CxiJ2gIt61/PU55CQK4Z58OzMqjDe1zwQdQk+rBYsRc+1rJmdajM3gimHg== - dependencies: - "@babel/helper-define-polyfill-provider" "^0.5.0" - -browserslist@^4.21.10, browserslist@^4.22.2, browserslist@^4.22.3: - version "4.23.0" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.0.tgz#8f3acc2bbe73af7213399430890f86c63a5674ab" - integrity sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ== - dependencies: - caniuse-lite "^1.0.30001587" - electron-to-chromium "^1.4.668" - node-releases "^2.0.14" - update-browserslist-db "^1.0.13" - -buffer-from@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" - integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== - -caniuse-lite@^1.0.30001587: - version "1.0.30001596" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz#da06b79c3d9c3d9958eb307aa832ac68ead79bee" - integrity sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ== - -chalk@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chrome-trace-event@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" - integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== - -clone-deep@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" - integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== - dependencies: - is-plain-object "^2.0.4" - kind-of "^6.0.2" - shallow-clone "^3.0.0" - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - -colorette@^2.0.14: - version "2.0.20" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" - integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== - -commander@^10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" - integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== - -commander@^2.20.0: - version "2.20.3" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" - integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== - -common-path-prefix@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/common-path-prefix/-/common-path-prefix-3.0.0.tgz#7d007a7e07c58c4b4d5f433131a19141b29f11e0" - integrity sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w== - -convert-source-map@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" - integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== - -core-js-compat@^3.31.0, core-js-compat@^3.34.0: - version "3.36.0" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.36.0.tgz#087679119bc2fdbdefad0d45d8e5d307d45ba190" - integrity sha512-iV9Pd/PsgjNWBXeq8XRtWVSgz2tKAfhfvBs7qxYty+RlRd+OCksaWmOnc4JKrTc1cToXL1N0s3l/vwlxPtdElw== - dependencies: - browserslist "^4.22.3" - -cross-spawn@^7.0.3: - version "7.0.6" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" - integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - -electron-to-chromium@^1.4.668: - version "1.4.699" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.699.tgz#dd53c939e13da64e94b341e563f0a3011b4ef0e9" - integrity sha512-I7q3BbQi6e4tJJN5CRcyvxhK0iJb34TV8eJQcgh+fR2fQ8miMgZcEInckCo1U9exDHbfz7DLDnFn8oqH/VcRKw== - -enhanced-resolve@^5.17.1: - version "5.17.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" - integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg== - dependencies: - graceful-fs "^4.2.4" - tapable "^2.2.0" - -envinfo@^7.7.3: - version "7.11.1" - resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.11.1.tgz#2ffef77591057081b0129a8fd8cf6118da1b94e1" - integrity sha512-8PiZgZNIB4q/Lw4AhOvAfB/ityHAd2bli3lESSWmWSzSsl5dKpy5N1d1Rfkd2teq/g9xN90lc6o98DOjMeYHpg== - -es-module-lexer@^1.2.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.4.1.tgz#41ea21b43908fe6a287ffcbe4300f790555331f5" - integrity sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w== - -escalade@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" - integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== - -eslint-scope@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" - integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== - dependencies: - esrecurse "^4.3.0" - estraverse "^4.1.1" - -esrecurse@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" - integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== - dependencies: - estraverse "^5.2.0" - -estraverse@^4.1.1: - version "4.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" - integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== - -estraverse@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" - integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== - -esutils@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" - integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== - -events@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" - integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== - -fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - -fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - -fastest-levenshtein@^1.0.12: - version "1.0.16" - resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" - integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== - -find-cache-dir@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-4.0.0.tgz#a30ee0448f81a3990708f6453633c733e2f6eec2" - integrity sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg== - dependencies: - common-path-prefix "^3.0.0" - pkg-dir "^7.0.0" - -find-up@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== - dependencies: - locate-path "^5.0.0" - path-exists "^4.0.0" - -find-up@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-6.3.0.tgz#2abab3d3280b2dc7ac10199ef324c4e002c8c790" - integrity sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw== - dependencies: - locate-path "^7.1.0" - path-exists "^5.0.0" - -flat@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" - integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== - -function-bind@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" - integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== - -gensync@^1.0.0-beta.2: - version "1.0.0-beta.2" - resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" - integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== - -glob-to-regexp@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" - integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== - -globals@^11.1.0: - version "11.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" - integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== - -graceful-fs@^4.1.2, graceful-fs@^4.2.11, graceful-fs@^4.2.4: - version "4.2.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" - integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -hasown@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== - dependencies: - function-bind "^1.1.2" - -import-local@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" - integrity sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg== - dependencies: - pkg-dir "^4.2.0" - resolve-cwd "^3.0.0" - -interpret@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4" - integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ== - -is-core-module@^2.13.0: - version "2.13.1" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" - integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== - dependencies: - hasown "^2.0.0" - -is-plain-object@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" - integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== - dependencies: - isobject "^3.0.1" - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - -isobject@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== - -jest-worker@^27.4.5: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" - integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== - dependencies: - "@types/node" "*" - merge-stream "^2.0.0" - supports-color "^8.0.0" - -js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -jsesc@^2.5.1: - version "2.5.2" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" - integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== - -jsesc@~0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" - integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA== - -json-parse-even-better-errors@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" - integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== - -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-schema-traverse@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" - integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== - -json5@^2.2.3: - version "2.2.3" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" - integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== - -kind-of@^6.0.2: - version "6.0.3" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" - integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== - -loader-runner@^4.2.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" - integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== - -locate-path@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" - integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== - dependencies: - p-locate "^4.1.0" - -locate-path@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-7.2.0.tgz#69cb1779bd90b35ab1e771e1f2f89a202c2a8a8a" - integrity sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA== - dependencies: - p-locate "^6.0.0" - -lodash.debounce@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" - integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== - -lru-cache@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" - integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== - dependencies: - yallist "^3.0.2" - -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.27: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -neo-async@^2.6.2: - version "2.6.2" - resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" - integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== - -node-releases@^2.0.14: - version "2.0.14" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" - integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== - -ot-diff@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ot-diff/-/ot-diff-1.1.1.tgz#c587f17d15efd0d9170a5d38e606368a57643ad4" - integrity sha512-Nf6vUblD0oKsMJA+dKbQ1njBjU/BHZjj9A00IdHdk7AjvXk42U/7uhiCZMuUr/OIbft5YZn9cUwFXG4E8oLjIg== - -ot-text-unicode@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/ot-text-unicode/-/ot-text-unicode-4.0.0.tgz#778a327535c81ed265b36ebe1bd677f31bae1e32" - integrity sha512-W7ZLU8QXesY2wagYFv47zErXud3E93FGImmSGJsQnBzE+idcPPyo2u2KMilIrTwBh4pbCizy71qRjmmV6aDhcQ== - dependencies: - unicount "1.1" - -p-limit@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - -p-limit@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-4.0.0.tgz#914af6544ed32bfa54670b061cafcbd04984b644" - integrity sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ== - dependencies: - yocto-queue "^1.0.0" - -p-locate@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" - integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== - dependencies: - p-limit "^2.2.0" - -p-locate@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-6.0.0.tgz#3da9a49d4934b901089dca3302fa65dc5a05c04f" - integrity sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw== - dependencies: - p-limit "^4.0.0" - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - -path-exists@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-5.0.0.tgz#a6aad9489200b21fab31e49cf09277e5116fb9e7" - integrity sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ== - -path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -picocolors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" - integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== - -pkg-dir@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" - integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== - dependencies: - find-up "^4.0.0" - -pkg-dir@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-7.0.0.tgz#8f0c08d6df4476756c5ff29b3282d0bab7517d11" - integrity sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA== - dependencies: - find-up "^6.3.0" - -punycode@^2.1.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" - integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== - -randombytes@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" - -rechoir@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22" - integrity sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ== - dependencies: - resolve "^1.20.0" - -regenerate-unicode-properties@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz#7c3192cab6dd24e21cb4461e5ddd7dd24fa8374c" - integrity sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ== - dependencies: - regenerate "^1.4.2" - -regenerate@^1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" - integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== - -regenerator-runtime@^0.13.11: - version "0.13.11" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" - integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== - -regenerator-transform@^0.15.2: - version "0.15.2" - resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.2.tgz#5bbae58b522098ebdf09bca2f83838929001c7a4" - integrity sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg== - dependencies: - "@babel/runtime" "^7.8.4" - -regexpu-core@^5.3.1: - version "5.3.2" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.3.2.tgz#11a2b06884f3527aec3e93dbbf4a3b958a95546b" - integrity sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ== - dependencies: - "@babel/regjsgen" "^0.8.0" - regenerate "^1.4.2" - regenerate-unicode-properties "^10.1.0" - regjsparser "^0.9.1" - unicode-match-property-ecmascript "^2.0.0" - unicode-match-property-value-ecmascript "^2.1.0" - -regjsparser@^0.9.1: - version "0.9.1" - resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.9.1.tgz#272d05aa10c7c1f67095b1ff0addae8442fc5709" - integrity sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ== - dependencies: - jsesc "~0.5.0" - -require-from-string@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== - -resolve-cwd@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" - integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== - dependencies: - resolve-from "^5.0.0" - -resolve-from@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" - integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== - -resolve@^1.14.2, resolve@^1.20.0: - version "1.22.8" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" - integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== - dependencies: - is-core-module "^2.13.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - -safe-buffer@^5.1.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -schema-utils@^3.1.1, schema-utils@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" - integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== - dependencies: - "@types/json-schema" "^7.0.8" - ajv "^6.12.5" - ajv-keywords "^3.5.2" - -schema-utils@^4.0.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.2.0.tgz#70d7c93e153a273a805801882ebd3bff20d89c8b" - integrity sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw== - dependencies: - "@types/json-schema" "^7.0.9" - ajv "^8.9.0" - ajv-formats "^2.1.1" - ajv-keywords "^5.1.0" - -semver@^6.3.1: - version "6.3.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -serialize-javascript@^6.0.1: - version "6.0.2" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" - integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== - dependencies: - randombytes "^2.1.0" - -shallow-clone@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" - integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== - dependencies: - kind-of "^6.0.2" - -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -source-map-support@~0.5.20: - version "0.5.21" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" - integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - -source-map@^0.6.0: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== - -tapable@^2.1.1, tapable@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" - integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== - -terser-webpack-plugin@^5.3.10: - version "5.3.10" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz#904f4c9193c6fd2a03f693a2150c62a92f40d199" - integrity sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w== - dependencies: - "@jridgewell/trace-mapping" "^0.3.20" - jest-worker "^27.4.5" - schema-utils "^3.1.1" - serialize-javascript "^6.0.1" - terser "^5.26.0" - -terser@^5.26.0: - version "5.29.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.29.1.tgz#44e58045b70c09792ba14bfb7b4e14ca8755b9fa" - integrity sha512-lZQ/fyaIGxsbGxApKmoPTODIzELy3++mXhS5hOqaAWZjQtpq/hFHAc+rm29NND1rYRxRWKcjuARNwULNXa5RtQ== - dependencies: - "@jridgewell/source-map" "^0.3.3" - acorn "^8.8.2" - commander "^2.20.0" - source-map-support "~0.5.20" - -to-fast-properties@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" - integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== - -undici-types@~5.26.4: - version "5.26.5" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" - integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== - -unicode-canonical-property-names-ecmascript@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" - integrity sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ== - -unicode-match-property-ecmascript@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz#54fd16e0ecb167cf04cf1f756bdcc92eba7976c3" - integrity sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q== - dependencies: - unicode-canonical-property-names-ecmascript "^2.0.0" - unicode-property-aliases-ecmascript "^2.0.0" - -unicode-match-property-value-ecmascript@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz#cb5fffdcd16a05124f5a4b0bf7c3770208acbbe0" - integrity sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA== - -unicode-property-aliases-ecmascript@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" - integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== - -unicount@1.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/unicount/-/unicount-1.1.0.tgz#396a3df661c19675a93861ac878c2c9c0042abf0" - integrity sha512-RlwWt1ywVW4WErPGAVHw/rIuJ2+MxvTME0siJ6lk9zBhpDfExDbspe6SRlWT3qU6AucNjotPl9qAJRVjP7guCQ== - -update-browserslist-db@^1.0.13: - version "1.0.13" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4" - integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg== - dependencies: - escalade "^3.1.1" - picocolors "^1.0.0" - -uri-js@^4.2.2: - version "4.4.1" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" - integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== - dependencies: - punycode "^2.1.0" - -watchpack@^2.4.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.2.tgz#2feeaed67412e7c33184e5a79ca738fbd38564da" - integrity sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw== - dependencies: - glob-to-regexp "^0.4.1" - graceful-fs "^4.1.2" - -webpack-cli@^5.1.4: - version "5.1.4" - resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.1.4.tgz#c8e046ba7eaae4911d7e71e2b25b776fcc35759b" - integrity sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg== - dependencies: - "@discoveryjs/json-ext" "^0.5.0" - "@webpack-cli/configtest" "^2.1.1" - "@webpack-cli/info" "^2.0.2" - "@webpack-cli/serve" "^2.0.5" - colorette "^2.0.14" - commander "^10.0.1" - cross-spawn "^7.0.3" - envinfo "^7.7.3" - fastest-levenshtein "^1.0.12" - import-local "^3.0.2" - interpret "^3.1.1" - rechoir "^0.8.0" - webpack-merge "^5.7.3" - -webpack-merge@^5.7.3: - version "5.10.0" - resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.10.0.tgz#a3ad5d773241e9c682803abf628d4cd62b8a4177" - integrity sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA== - dependencies: - clone-deep "^4.0.1" - flat "^5.0.2" - wildcard "^2.0.0" - -webpack-sources@^3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" - integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== - -webpack@^5.94.0: - version "5.94.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.94.0.tgz#77a6089c716e7ab90c1c67574a28da518a20970f" - integrity sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg== - dependencies: - "@types/estree" "^1.0.5" - "@webassemblyjs/ast" "^1.12.1" - "@webassemblyjs/wasm-edit" "^1.12.1" - "@webassemblyjs/wasm-parser" "^1.12.1" - acorn "^8.7.1" - acorn-import-attributes "^1.9.5" - browserslist "^4.21.10" - chrome-trace-event "^1.0.2" - enhanced-resolve "^5.17.1" - es-module-lexer "^1.2.1" - eslint-scope "5.1.1" - events "^3.2.0" - glob-to-regexp "^0.4.1" - graceful-fs "^4.2.11" - json-parse-even-better-errors "^2.3.1" - loader-runner "^4.2.0" - mime-types "^2.1.27" - neo-async "^2.6.2" - schema-utils "^3.2.0" - tapable "^2.1.1" - terser-webpack-plugin "^5.3.10" - watchpack "^2.4.1" - webpack-sources "^3.2.3" - -which@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" - -wildcard@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" - integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ== - -yallist@^3.0.2: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" - integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== - -yocto-queue@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" - integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== diff --git a/test/javascripts/acceptance/composer-test.js b/test/javascripts/acceptance/composer-test.js index f9caf4d..71238aa 100644 --- a/test/javascripts/acceptance/composer-test.js +++ b/test/javascripts/acceptance/composer-test.js @@ -6,18 +6,21 @@ acceptance(`Discourse Shared Edits | Composer`, function (needs) { needs.user(); needs.pretender((server, helper) => { - server.put("/shared_edits/p/398/enable.json", () => + server.put("/shared_edits/p/:id/enable.json", () => helper.response({ success: "OK" }) ); - server.get("/shared_edits/p/398", () => + server.get("/shared_edits/p/:id", () => helper.response({ + state: "", raw: "the latest iteration of the post", version: 2, }) ); - server.put("/shared_edits/p/398/commit", () => + server.put("/shared_edits/p/:id", () => helper.response({ success: "OK" })); + + server.put("/shared_edits/p/:id/commit", () => helper.response({ success: "OK" }) ); }); From 83910893c2184bbcbad547198cb8e4adf2fc3904 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Tue, 25 Nov 2025 07:08:07 +1100 Subject: [PATCH 02/19] improve implementation --- .../revision_controller.rb | 10 +- app/models/shared_edit_revision.rb | 89 +++--- lib/discourse_shared_edits/yjs.rb | 114 +++----- package.json | 1 + pnpm-lock.yaml | 261 ++++++++++++++++++ spec/lib/guardian_spec.rb | 58 +++- spec/lib/yjs_spec.rb | 84 +++++- spec/models/shared_edit_revision_spec.rb | 142 ++++++++++ spec/requests/revision_controller_spec.rb | 166 ++++++++--- 9 files changed, 748 insertions(+), 177 deletions(-) diff --git a/app/controllers/discourse_shared_edits/revision_controller.rb b/app/controllers/discourse_shared_edits/revision_controller.rb index a7def52..07dbb43 100644 --- a/app/controllers/discourse_shared_edits/revision_controller.rb +++ b/app/controllers/discourse_shared_edits/revision_controller.rb @@ -24,12 +24,14 @@ def latest post = Post.find(params[:post_id].to_i) guardian.ensure_can_see!(post) SharedEditRevision.commit!(post.id, apply_to_post: false) - latest = SharedEditRevision.where(post_id: post.id).order("version desc").first + revision = SharedEditRevision.where(post_id: post.id).order("version desc").first + + raise Discourse::NotFound if revision.nil? render json: { - raw: DiscourseSharedEdits::Yjs.text_from_state(latest.raw), - version: latest.version, - state: latest.raw, + raw: DiscourseSharedEdits::Yjs.text_from_state(revision.raw), + version: revision.version, + state: revision.raw, } end diff --git a/app/models/shared_edit_revision.rb b/app/models/shared_edit_revision.rb index 9891c63..aa7c00e 100644 --- a/app/models/shared_edit_revision.rb +++ b/app/models/shared_edit_revision.rb @@ -53,65 +53,80 @@ def self.init!(post) def self.commit!(post_id, apply_to_post: true) latest = SharedEditRevision.where(post_id: post_id).order("version desc").first - return if !latest&.raw - return if latest.post_revision_id && !apply_to_post + return if latest.nil? || latest.raw.nil? + return if latest.post_revision_id.present? && !apply_to_post raw = DiscourseSharedEdits::Yjs.text_from_state(latest.raw) - return raw if latest.post_revision_id || !apply_to_post + return raw if latest.post_revision_id.present? || !apply_to_post post = Post.find(post_id) revisor = PostRevisor.new(post) - opts = { bypass_rate_limiter: true, bypass_bump: true, skip_staff_log: true } + revised = revisor.revise!(Discourse.system_user, { raw: raw }, opts) - done = revisor.revise!(Discourse.system_user, { raw: raw }, opts) - - return raw if !done + return raw unless revised - last_post_revision = PostRevision.where(post: post).limit(1).order("number desc").first + post_revision = PostRevision.where(post: post).order("number desc").first + return raw if post_revision.nil? SharedEditRevision.transaction do - last_committed_version = - SharedEditRevision - .where(post_id: post_id) - .where.not(post_revision_id: nil) - .maximum(:version) || 0 + editor_usernames = collect_editor_usernames(post_id) + update_edit_reason(post, post_revision, editor_usernames) + latest.update!(post_revision_id: post_revision.id) + end + + raw + end + + def self.collect_editor_usernames(post_id) + last_committed_version = + SharedEditRevision + .where(post_id: post_id) + .where.not(post_revision_id: nil) + .maximum(:version) || 0 + + editor_ids = + SharedEditRevision + .where(post_id: post_id) + .where("version > ?", last_committed_version) + .distinct + .pluck(:user_id) - editors = - SharedEditRevision - .where(post_id: post_id) - .where("version > ?", last_committed_version) - .pluck(:user_id) - .uniq + User.where(id: editor_ids).pluck(:username) + end + private_class_method :collect_editor_usernames - reason = last_post_revision.modifications["edit_reason"] || "" - reason = reason[1] if Array === reason + def self.update_edit_reason(post, post_revision, new_usernames) + return if new_usernames.empty? - usernames = reason&.split(",")&.map(&:strip) || [] + existing_reason = post_revision.modifications["edit_reason"] + existing_reason = existing_reason[1] if existing_reason.is_a?(Array) + existing_reason ||= "" - if usernames.length > 0 - reason_length = I18n.t("shared_edits.reason", users: "").length - usernames[0] = usernames[0][reason_length..-1] - end + existing_usernames = parse_usernames_from_reason(existing_reason) + combined_usernames = (existing_usernames + new_usernames).uniq - User.where(id: editors).pluck(:username).each { |name| usernames << name } + new_reason = I18n.t("shared_edits.reason", users: combined_usernames.join(", ")) - usernames.uniq! + return if new_reason == existing_reason - new_reason = I18n.t("shared_edits.reason", users: usernames.join(", ")) + post_revision.modifications["edit_reason"] = [nil, new_reason] + post_revision.save! + post.update!(edit_reason: new_reason) + end + private_class_method :update_edit_reason - if new_reason != reason - last_post_revision.modifications["edit_reason"] = [nil, new_reason] - last_post_revision.save! - post.update!(edit_reason: new_reason) - end + def self.parse_usernames_from_reason(reason) + return [] if reason.blank? - latest.update!(post_revision_id: last_post_revision.id) - end + prefix = I18n.t("shared_edits.reason", users: "") + return [] unless reason.start_with?(prefix) - raw + users_part = reason[prefix.length..] + users_part.split(",").map(&:strip).reject(&:blank?) end + private_class_method :parse_usernames_from_reason def self.latest_raw(post_id) latest = diff --git a/lib/discourse_shared_edits/yjs.rb b/lib/discourse_shared_edits/yjs.rb index 9dd9770..32bb80e 100644 --- a/lib/discourse_shared_edits/yjs.rb +++ b/lib/discourse_shared_edits/yjs.rb @@ -38,14 +38,15 @@ def context ctx.eval(<<~JS) const YRef = global.Y; + const TEXT_KEY = "post"; - function docFromText(text) { + function createDocWithText(text) { const doc = new YRef.Doc(); - doc.getText("post").insert(0, text || ""); + doc.getText(TEXT_KEY).insert(0, text || ""); return doc; } - function yDocFromState(state) { + function createDocFromState(state) { const doc = new YRef.Doc(); if (state && state.length) { YRef.applyUpdate(doc, new Uint8Array(state)); @@ -53,78 +54,15 @@ def context return doc; } - function encodeState(doc) { + function encodeDocState(doc) { return Array.from(YRef.encodeStateAsUpdate(doc)); } - function docText(doc) { - return doc.getText("post").toString(); + function getDocText(doc) { + return doc.getText(TEXT_KEY).toString(); } - function applyUpdateToState(state, update) { - const doc = yDocFromState(state); - if (update && update.length) { - YRef.applyUpdate(doc, new Uint8Array(update)); - } - - return { state: encodeState(doc), text: docText(doc) }; - } - - function stateFromText(text) { - const doc = docFromText(text); - return { state: encodeState(doc), text: docText(doc) }; - } - - function updateFromTextChange(oldText, newText) { - const doc = docFromText(oldText); - const before = YRef.encodeStateVector(doc); - const text = doc.getText("post"); - const oldVal = oldText || ""; - const newVal = newText || ""; - - let start = 0; - while ( - start < oldVal.length && - start < newVal.length && - oldVal[start] === newVal[start] - ) { - start++; - } - - let endOld = oldVal.length - 1; - let endNew = newVal.length - 1; - - while ( - endOld >= start && - endNew >= start && - oldVal[endOld] === newVal[endNew] - ) { - endOld--; - endNew--; - } - - const removeCount = Math.max(0, endOld - start + 1); - const insertText = - endNew >= start ? newVal.slice(start, endNew + 1) : ""; - - if (removeCount > 0) { - text.delete(start, removeCount); - } - - if (insertText.length > 0) { - text.insert(start, insertText); - } - - return Array.from(YRef.encodeStateAsUpdate(doc, before)); - } - - function updateFromState(state, newText) { - const doc = yDocFromState(state); - const before = YRef.encodeStateVector(doc); - const text = doc.getText("post"); - const current = text.toString(); - const desired = newText || ""; - + function applyDiffToYText(yText, current, desired) { let start = 0; while ( start < current.length && @@ -147,18 +85,42 @@ def context } const removeCount = Math.max(0, endCurrent - start + 1); - const insertText = - endDesired >= start ? desired.slice(start, endDesired + 1) : ""; - if (removeCount > 0) { - text.delete(start, removeCount); + yText.delete(start, removeCount); } + const insertText = endDesired >= start ? desired.slice(start, endDesired + 1) : ""; if (insertText.length > 0) { - text.insert(start, insertText); + yText.insert(start, insertText); + } + } + + function applyUpdateToState(state, update) { + const doc = createDocFromState(state); + if (update && update.length) { + YRef.applyUpdate(doc, new Uint8Array(update)); } + return { state: encodeDocState(doc), text: getDocText(doc) }; + } + + function stateFromText(text) { + const doc = createDocWithText(text); + return { state: encodeDocState(doc), text: getDocText(doc) }; + } + + function updateFromTextChange(oldText, newText) { + const doc = createDocWithText(oldText); + const stateVector = YRef.encodeStateVector(doc); + applyDiffToYText(doc.getText(TEXT_KEY), oldText || "", newText || ""); + return Array.from(YRef.encodeStateAsUpdate(doc, stateVector)); + } - return Array.from(YRef.encodeStateAsUpdate(doc, before)); + function updateFromState(state, newText) { + const doc = createDocFromState(state); + const stateVector = YRef.encodeStateVector(doc); + const yText = doc.getText(TEXT_KEY); + applyDiffToYText(yText, yText.toString(), newText || ""); + return Array.from(YRef.encodeStateAsUpdate(doc, stateVector)); } JS diff --git a/package.json b/package.json index a9b3047..dcd6efe 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "devDependencies": { "@discourse/lint-configs": "2.32.0", "ember-template-lint": "7.9.1", + "esbuild": "0.25.5", "eslint": "9.37.0", "prettier": "3.6.2", "stylelint": "16.25.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f85687..3585687 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: ember-template-lint: specifier: 7.9.1 version: 7.9.1 + esbuild: + specifier: 0.25.5 + version: 0.25.5 eslint: specifier: 9.37.0 version: 9.37.0 @@ -194,6 +197,156 @@ packages: '@ember-data/rfc395-data@0.0.4': resolution: {integrity: sha512-tGRdvgC9/QMQSuSuJV45xoyhI0Pzjm7A9o/MVVA3HakXIImJbbzx/k/6dO9CUEQXIyS2y0fW6C1XaYOG7rY0FQ==} + '@esbuild/aix-ppc64@0.25.5': + resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.5': + resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.5': + resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.5': + resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.5': + resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.5': + resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.5': + resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.5': + resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.5': + resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.5': + resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.5': + resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.5': + resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.5': + resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.5': + resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.5': + resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.5': + resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.5': + resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.5': + resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.5': + resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.5': + resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.5': + resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.5': + resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.5': + resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.5': + resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.5': + resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -614,6 +767,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.25.5: + resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1896,6 +2054,81 @@ snapshots: '@ember-data/rfc395-data@0.0.4': {} + '@esbuild/aix-ppc64@0.25.5': + optional: true + + '@esbuild/android-arm64@0.25.5': + optional: true + + '@esbuild/android-arm@0.25.5': + optional: true + + '@esbuild/android-x64@0.25.5': + optional: true + + '@esbuild/darwin-arm64@0.25.5': + optional: true + + '@esbuild/darwin-x64@0.25.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.5': + optional: true + + '@esbuild/freebsd-x64@0.25.5': + optional: true + + '@esbuild/linux-arm64@0.25.5': + optional: true + + '@esbuild/linux-arm@0.25.5': + optional: true + + '@esbuild/linux-ia32@0.25.5': + optional: true + + '@esbuild/linux-loong64@0.25.5': + optional: true + + '@esbuild/linux-mips64el@0.25.5': + optional: true + + '@esbuild/linux-ppc64@0.25.5': + optional: true + + '@esbuild/linux-riscv64@0.25.5': + optional: true + + '@esbuild/linux-s390x@0.25.5': + optional: true + + '@esbuild/linux-x64@0.25.5': + optional: true + + '@esbuild/netbsd-arm64@0.25.5': + optional: true + + '@esbuild/netbsd-x64@0.25.5': + optional: true + + '@esbuild/openbsd-arm64@0.25.5': + optional: true + + '@esbuild/openbsd-x64@0.25.5': + optional: true + + '@esbuild/sunos-x64@0.25.5': + optional: true + + '@esbuild/win32-arm64@0.25.5': + optional: true + + '@esbuild/win32-ia32@0.25.5': + optional: true + + '@esbuild/win32-x64@0.25.5': + optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@9.37.0)': dependencies: eslint: 9.37.0 @@ -2409,6 +2642,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.25.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.5 + '@esbuild/android-arm': 0.25.5 + '@esbuild/android-arm64': 0.25.5 + '@esbuild/android-x64': 0.25.5 + '@esbuild/darwin-arm64': 0.25.5 + '@esbuild/darwin-x64': 0.25.5 + '@esbuild/freebsd-arm64': 0.25.5 + '@esbuild/freebsd-x64': 0.25.5 + '@esbuild/linux-arm': 0.25.5 + '@esbuild/linux-arm64': 0.25.5 + '@esbuild/linux-ia32': 0.25.5 + '@esbuild/linux-loong64': 0.25.5 + '@esbuild/linux-mips64el': 0.25.5 + '@esbuild/linux-ppc64': 0.25.5 + '@esbuild/linux-riscv64': 0.25.5 + '@esbuild/linux-s390x': 0.25.5 + '@esbuild/linux-x64': 0.25.5 + '@esbuild/netbsd-arm64': 0.25.5 + '@esbuild/netbsd-x64': 0.25.5 + '@esbuild/openbsd-arm64': 0.25.5 + '@esbuild/openbsd-x64': 0.25.5 + '@esbuild/sunos-x64': 0.25.5 + '@esbuild/win32-arm64': 0.25.5 + '@esbuild/win32-ia32': 0.25.5 + '@esbuild/win32-x64': 0.25.5 + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} diff --git a/spec/lib/guardian_spec.rb b/spec/lib/guardian_spec.rb index ecd1109..6e0e81e 100644 --- a/spec/lib/guardian_spec.rb +++ b/spec/lib/guardian_spec.rb @@ -2,23 +2,55 @@ RSpec.describe Guardian do fab!(:moderator) + fab!(:admin) fab!(:user) - it "disallows shared edits from anon" do - expect(Guardian.new.can_toggle_shared_edits?).to eq(false) - end + describe "#can_toggle_shared_edits?" do + context "when shared_edits_enabled is true" do + before { SiteSetting.shared_edits_enabled = true } - it "disallows shared edits for tl3 users" do - user.trust_level = 3 - expect(Guardian.new(user).can_toggle_shared_edits?).to eq(false) - end + it "disallows shared edits from anon" do + expect(Guardian.new.can_toggle_shared_edits?).to eq(false) + end - it "allows shared edits for staff" do - expect(Guardian.new(moderator).can_toggle_shared_edits?).to eq(true) - end + it "disallows shared edits for tl3 users" do + user.trust_level = 3 + expect(Guardian.new(user).can_toggle_shared_edits?).to eq(false) + end + + it "disallows shared edits for regular users" do + expect(Guardian.new(user).can_toggle_shared_edits?).to eq(false) + end + + it "allows shared edits for moderators" do + expect(Guardian.new(moderator).can_toggle_shared_edits?).to eq(true) + end + + it "allows shared edits for admins" do + expect(Guardian.new(admin).can_toggle_shared_edits?).to eq(true) + end + + it "allows shared edits for tl4" do + user.trust_level = 4 + expect(Guardian.new(user).can_toggle_shared_edits?).to eq(true) + end + end + + context "when shared_edits_enabled is false" do + before { SiteSetting.shared_edits_enabled = false } + + it "disallows shared edits for admins" do + expect(Guardian.new(admin).can_toggle_shared_edits?).to eq(false) + end + + it "disallows shared edits for moderators" do + expect(Guardian.new(moderator).can_toggle_shared_edits?).to eq(false) + end - it "allows shared edits for tl4" do - user.trust_level = 4 - expect(Guardian.new(user).can_toggle_shared_edits?).to eq(true) + it "disallows shared edits for tl4" do + user.trust_level = 4 + expect(Guardian.new(user).can_toggle_shared_edits?).to eq(false) + end + end end end diff --git a/spec/lib/yjs_spec.rb b/spec/lib/yjs_spec.rb index d4b63ed..70a2a0c 100644 --- a/spec/lib/yjs_spec.rb +++ b/spec/lib/yjs_spec.rb @@ -10,20 +10,84 @@ ) RSpec.describe DiscourseSharedEdits::Yjs do - it "encodes text into a Yjs state and returns the same content" do - result = described_class.state_from_text("😎hello world") + describe ".state_from_text" do + it "encodes text into a Yjs state and returns the same content" do + result = described_class.state_from_text("😎hello world") - expect(result[:text]).to eq("😎hello world") - expect(result[:state]).to be_present + expect(result[:text]).to eq("😎hello world") + expect(result[:state]).to be_present + end + + it "handles empty text" do + result = described_class.state_from_text("") + + expect(result[:text]).to eq("") + expect(result[:state]).to be_present + end + + it "handles nil text" do + result = described_class.state_from_text(nil) + + expect(result[:text]).to eq("") + expect(result[:state]).to be_present + end + end + + describe ".apply_update" do + it "applies an update to produce new content" do + initial = described_class.state_from_text("I like bananas") + update = described_class.update_from_state(initial[:state], "I eat apples") + + applied = described_class.apply_update(initial[:state], update) + + expect(applied[:text]).to eq("I eat apples") + expect(applied[:state]).to be_present + end + end + + describe ".text_from_state" do + it "extracts text from an encoded state" do + original = "Hello World" + state = described_class.state_from_text(original)[:state] + + expect(described_class.text_from_state(state)).to eq(original) + end + end + + describe ".update_from_text_change" do + it "creates an update representing the diff between two texts" do + old_text = "Hello World" + new_text = "Hello Universe" + + update = described_class.update_from_text_change(old_text, new_text) + expect(update).to be_present + end + + it "handles insertion at the beginning" do + old_text = "World" + new_text = "Hello World" + + update = described_class.update_from_text_change(old_text, new_text) + expect(update).to be_present + end + + it "handles deletion" do + old_text = "Hello World" + new_text = "World" + + update = described_class.update_from_text_change(old_text, new_text) + expect(update).to be_present + end end - it "applies an update to produce new content" do - initial = described_class.state_from_text("I like bananas") - update = described_class.update_from_state(initial[:state], "I eat apples") + describe ".update_from_state" do + it "creates an update from existing state to new text" do + initial = described_class.state_from_text("First version") + update = described_class.update_from_state(initial[:state], "Second version") - applied = described_class.apply_update(initial[:state], update) + applied = described_class.apply_update(initial[:state], update) - expect(applied[:text]).to eq("I eat apples") - expect(applied[:state]).to be_present + expect(applied[:text]).to eq("Second version") + end end end diff --git a/spec/models/shared_edit_revision_spec.rb b/spec/models/shared_edit_revision_spec.rb index 29d0ea8..1478e8c 100644 --- a/spec/models/shared_edit_revision_spec.rb +++ b/spec/models/shared_edit_revision_spec.rb @@ -47,6 +47,148 @@ def fake_edit(post, user_id, new_text) ) end + describe ".init!" do + fab!(:post) + + it "creates an initial revision with the post content" do + SharedEditRevision.init!(post) + + revision = SharedEditRevision.find_by(post_id: post.id) + expect(revision).to be_present + expect(revision.version).to eq(1) + expect(revision.client_id).to eq("system") + expect(revision.user_id).to eq(Discourse.system_user.id) + expect(DiscourseSharedEdits::Yjs.text_from_state(revision.raw)).to eq(post.raw) + end + + it "does not create duplicate revisions if already initialized" do + SharedEditRevision.init!(post) + SharedEditRevision.init!(post) + + expect(SharedEditRevision.where(post_id: post.id).count).to eq(1) + end + end + + describe ".toggle_shared_edits!" do + fab!(:post) + + it "enables shared edits and creates initial revision" do + SharedEditRevision.toggle_shared_edits!(post.id, true) + + post.reload + expect(post.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to eq(true) + expect(SharedEditRevision.where(post_id: post.id).count).to eq(1) + end + + it "disables shared edits and removes revisions" do + SharedEditRevision.toggle_shared_edits!(post.id, true) + SharedEditRevision.toggle_shared_edits!(post.id, false) + + post.reload + expect(post.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to be_nil + expect(SharedEditRevision.where(post_id: post.id).count).to eq(0) + end + + it "commits pending changes when disabling" do + SharedEditRevision.toggle_shared_edits!(post.id, true) + user = Fabricate(:user) + new_raw = "#{post.raw} edited content" + fake_edit(post, user.id, new_raw) + + SharedEditRevision.toggle_shared_edits!(post.id, false) + + post.reload + expect(post.raw).to eq(new_raw) + end + end + + describe ".latest_raw" do + fab!(:post) + + it "returns nil when no revisions exist" do + result = SharedEditRevision.latest_raw(post.id) + expect(result).to be_nil + end + + it "returns the version and text of the latest revision" do + SharedEditRevision.init!(post) + user = Fabricate(:user) + new_text = "Updated content" + fake_edit(post, user.id, new_text) + + version, text = SharedEditRevision.latest_raw(post.id) + + expect(version).to eq(2) + expect(text).to eq(new_text) + end + end + + describe ".revise!" do + fab!(:post) + fab!(:user) + + before { SharedEditRevision.init!(post) } + + it "raises when shared edits not initialized" do + SharedEditRevision.where(post_id: post.id).delete_all + + expect { + SharedEditRevision.revise!( + post_id: post.id, + user_id: user.id, + client_id: "test", + update: "some_update", + ) + }.to raise_error(StandardError, "shared edits not initialized") + end + + it "publishes to message bus" do + messages = + MessageBus.track_publish("/shared_edits/#{post.id}") do + fake_edit(post, user.id, "new content") + end + + expect(messages.length).to eq(1) + expect(messages.first.data[:version]).to eq(2) + expect(messages.first.data[:user_id]).to eq(user.id) + end + end + + describe ".commit!" do + fab!(:post) { Fabricate(:post, raw: "Original content that is long enough") } + + it "returns nil when no revisions exist" do + result = SharedEditRevision.commit!(post.id) + expect(result).to be_nil + end + + it "does nothing when already committed" do + SharedEditRevision.init!(post) + user = Fabricate(:user) + fake_edit(post, user.id, "Modified content that is long enough") + SharedEditRevision.commit!(post.id) + + post.reload + original_raw = post.raw + revision_count = PostRevision.where(post: post).count + + SharedEditRevision.commit!(post.id) + + expect(PostRevision.where(post: post).count).to eq(revision_count) + expect(post.reload.raw).to eq(original_raw) + end + + it "does not apply to post when apply_to_post is false" do + SharedEditRevision.init!(post) + user = Fabricate(:user) + fake_edit(post, user.id, "Modified content that is long enough") + + SharedEditRevision.commit!(post.id, apply_to_post: false) + + expect(post.reload.raw).to eq("Original content that is long enough") + end + end + it "can resolve complex edits and notify" do raw = <<~RAW 0123456 diff --git a/spec/requests/revision_controller_spec.rb b/spec/requests/revision_controller_spec.rb index 54ecaf2..c03c813 100644 --- a/spec/requests/revision_controller_spec.rb +++ b/spec/requests/revision_controller_spec.rb @@ -1,35 +1,72 @@ # frozen_string_literal: true RSpec.describe DiscourseSharedEdits::RevisionController do - fab!(:post1, :post) + fab!(:post1) { Fabricate(:post, raw: "Hello World, testing shared edits") } fab!(:admin) fab!(:user) - context :admin do - before { sign_in admin } + describe "#enable" do + context "when admin" do + before { sign_in admin } - it "is hard disabled when plugin is disabled" do - SiteSetting.shared_edits_enabled = false - put "/shared_edits/p/#{post1.id}/enable" - expect(response.status).to eq(404) + it "returns 404 when plugin is disabled" do + SiteSetting.shared_edits_enabled = false + put "/shared_edits/p/#{post1.id}/enable" + expect(response.status).to eq(404) + end + + it "enables shared edits on a post" do + put "/shared_edits/p/#{post1.id}/enable" + expect(response.status).to eq(200) + + post1.reload + expect(post1.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to eq(true) + end end - it "is able to enable revisions on a post" do - put "/shared_edits/p/#{post1.id}/enable" - expect(response.status).to eq(200) + context "when regular user" do + before { sign_in user } - post1.reload - expect(post1.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to eq(true) + it "returns 403" do + put "/shared_edits/p/#{post1.id}/enable" + expect(response.status).to eq(403) + end + end - put "/shared_edits/p/#{post1.id}/disable" - expect(response.status).to eq(200) + context "when anonymous" do + it "returns 403" do + put "/shared_edits/p/#{post1.id}/enable" + expect(response.status).to eq(403) + end + end + end - post1.reload - expect(post1.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to eq(nil) + describe "#disable" do + context "when admin" do + before { sign_in admin } + + it "disables shared edits on a post" do + SharedEditRevision.toggle_shared_edits!(post1.id, true) + + put "/shared_edits/p/#{post1.id}/disable" + expect(response.status).to eq(200) + + post1.reload + expect(post1.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to be_nil + end + end + + context "when regular user" do + before { sign_in user } + + it "returns 403" do + put "/shared_edits/p/#{post1.id}/disable" + expect(response.status).to eq(403) + end end end - context :user do + describe "#latest" do before do sign_in user SharedEditRevision.toggle_shared_edits!(post1.id, true) @@ -39,7 +76,7 @@ def latest_state_for(post) SharedEditRevision.where(post_id: post.id).order("version desc").limit(1).pluck(:raw).first end - it "can submit edits on a post" do + it "returns the latest version" do new_text = "1234" + post1.raw[4..] latest_state = latest_state_for(post1) @@ -48,15 +85,72 @@ def latest_state_for(post) client_id: "abc", update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text), } + + get "/shared_edits/p/#{post1.id}" expect(response.status).to eq(200) - SharedEditRevision.commit!(post1.id) + raw = response.parsed_body["raw"] + version = response.parsed_body["version"] + state = response.parsed_body["state"] + + expect(raw[0..3]).to eq("1234") + expect(version).to eq(2) + expect(state).to be_present + end + + it "returns 404 when no revisions exist" do + SharedEditRevision.where(post_id: post1.id).delete_all + + get "/shared_edits/p/#{post1.id}" + expect(response.status).to eq(404) + end + + it "returns 404 for non-existent post" do + get "/shared_edits/p/999999" + expect(response.status).to eq(404) + end + end + + describe "#commit" do + before { sign_in user } + + it "commits pending changes to the post" do + SharedEditRevision.toggle_shared_edits!(post1.id, true) + new_text = "committed content " + post1.raw + state = SharedEditRevision.where(post_id: post1.id).order("version desc").first.raw + update = DiscourseSharedEdits::Yjs.update_from_state(state, new_text) + + SharedEditRevision.revise!( + post_id: post1.id, + user_id: user.id, + client_id: "test", + update: update, + ) + + put "/shared_edits/p/#{post1.id}/commit" + expect(response.status).to eq(200) post1.reload - expect(post1.raw[0..3]).to eq("1234") + expect(post1.raw).to eq(new_text) + end + + it "returns 404 for non-existent post" do + put "/shared_edits/p/999999/commit" + expect(response.status).to eq(404) + end + end + + describe "#revise" do + before do + sign_in user + SharedEditRevision.toggle_shared_edits!(post1.id, true) + end + + def latest_state_for(post) + SharedEditRevision.where(post_id: post.id).order("version desc").limit(1).pluck(:raw).first end - it "can get the latest version" do + it "can submit edits on a post" do new_text = "1234" + post1.raw[4..] latest_state = latest_state_for(post1) @@ -65,20 +159,25 @@ def latest_state_for(post) client_id: "abc", update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text), } - - get "/shared_edits/p/#{post1.id}" expect(response.status).to eq(200) - raw = response.parsed_body["raw"] - version = response.parsed_body["version"] - state = response.parsed_body["state"] + SharedEditRevision.commit!(post1.id) - expect(raw[0..3]).to eq("1234") - expect(version).to eq(2) - expect(state).to be_present + post1.reload + expect(post1.raw[0..3]).to eq("1234") + end + + it "requires client_id parameter" do + put "/shared_edits/p/#{post1.id}", params: { update: "test" } + expect(response.status).to eq(400) + end + + it "requires update parameter" do + put "/shared_edits/p/#{post1.id}", params: { client_id: "abc" } + expect(response.status).to eq(400) end - it "will defer commit" do + it "schedules a deferred commit" do Discourse.redis.del SharedEditRevision.will_commit_key(post1.id) new_text = "1234" + post1.raw[4..] @@ -128,12 +227,5 @@ def latest_state_for(post) post1.reload expect(post1.raw[0..3]).to eq("wxyz") end - - it "can not enable revisions as normal user" do - put "/shared_edits/p/#{post1.id}/enable" - expect(response.status).to eq(403) - put "/shared_edits/p/#{post1.id}/disable" - expect(response.status).to eq(403) - end end end From 16a8153169bfdd022f2311a058b03d3c73b199ce Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Wed, 26 Nov 2025 14:12:50 +1100 Subject: [PATCH 03/19] remove selection, fix migration name --- .../discourse/initializers/shared-edits-init.js | 13 +++++++++++++ ...=> 20251124000123_resize_shared_edit_columns.rb} | 0 2 files changed, 13 insertions(+) rename db/migrate/{20251124000000_resize_shared_edit_columns.rb => 20251124000123_resize_shared_edit_columns.rb} (100%) diff --git a/assets/javascripts/discourse/initializers/shared-edits-init.js b/assets/javascripts/discourse/initializers/shared-edits-init.js index 72f5a0c..7b4832c 100644 --- a/assets/javascripts/discourse/initializers/shared-edits-init.js +++ b/assets/javascripts/discourse/initializers/shared-edits-init.js @@ -2,6 +2,7 @@ import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { withPluginApi } from "discourse/lib/plugin-api"; import { SAVE_ICONS, SAVE_LABELS } from "discourse/models/composer"; +import { USER_OPTION_COMPOSITION_MODES } from "discourse/lib/constants"; import SharedEditButton from "../components/shared-edit-button"; const SHARED_EDIT_ACTION = "sharedEdit"; @@ -10,6 +11,18 @@ function initWithApi(api) { SAVE_LABELS[SHARED_EDIT_ACTION] = "composer.save_edit"; SAVE_ICONS[SHARED_EDIT_ACTION] = "pencil"; + // Force markdown mode when in shared edit mode + // This disables the rich text editor and hides the toggle + api.registerValueTransformer( + "composer-force-editor-mode", + ({ value, context }) => { + if (context.model?.action === SHARED_EDIT_ACTION) { + return USER_OPTION_COMPOSITION_MODES.markdown; + } + return value; + } + ); + customizePostMenu(api); const currentUser = api.getCurrentUser(); diff --git a/db/migrate/20251124000000_resize_shared_edit_columns.rb b/db/migrate/20251124000123_resize_shared_edit_columns.rb similarity index 100% rename from db/migrate/20251124000000_resize_shared_edit_columns.rb rename to db/migrate/20251124000123_resize_shared_edit_columns.rb From 6f47335c39ef6e6a18be2b1a2f01ceccd3b6a56d Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Wed, 26 Nov 2025 14:35:29 +1100 Subject: [PATCH 04/19] clean up display on shared edit title when composing show shared edit button unconditionally --- .../components/shared-edit-button.gjs | 4 +++ .../initializers/shared-edits-init.js | 32 +++++++++++++++++-- .../common/discourse-shared-edits.scss | 4 +++ config/locales/client.en.yml | 1 + 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/assets/javascripts/discourse/components/shared-edit-button.gjs b/assets/javascripts/discourse/components/shared-edit-button.gjs index 38fa3d2..030cfc0 100644 --- a/assets/javascripts/discourse/components/shared-edit-button.gjs +++ b/assets/javascripts/discourse/components/shared-edit-button.gjs @@ -5,6 +5,10 @@ import DButton from "discourse/components/d-button"; import concatClass from "discourse/helpers/concat-class"; export default class SharedEditButton extends Component { + static hidden() { + return false; + } + static shouldRender(args) { return args.post.can_edit; } diff --git a/assets/javascripts/discourse/initializers/shared-edits-init.js b/assets/javascripts/discourse/initializers/shared-edits-init.js index 7b4832c..2ede053 100644 --- a/assets/javascripts/discourse/initializers/shared-edits-init.js +++ b/assets/javascripts/discourse/initializers/shared-edits-init.js @@ -1,16 +1,44 @@ +import { htmlSafe } from "@ember/template"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import { withPluginApi } from "discourse/lib/plugin-api"; -import { SAVE_ICONS, SAVE_LABELS } from "discourse/models/composer"; import { USER_OPTION_COMPOSITION_MODES } from "discourse/lib/constants"; +import { iconHTML } from "discourse/lib/icon-library"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { + registerCustomizationCallback, + SAVE_ICONS, + SAVE_LABELS, +} from "discourse/models/composer"; import SharedEditButton from "../components/shared-edit-button"; const SHARED_EDIT_ACTION = "sharedEdit"; +function formatSharedEditActionTitle(model) { + if (model.action !== SHARED_EDIT_ACTION) { + return; + } + + const opts = model.replyOptions; + if (!opts?.userAvatar || !opts?.userLink || !opts?.postLink) { + return; + } + + return htmlSafe(` + ${iconHTML("far-pen-to-square", { title: "shared_edits.composer_title" })} + ${opts.postLink.anchor} + ${opts.userAvatar} + ${opts.userLink.anchor} + `); +} + function initWithApi(api) { SAVE_LABELS[SHARED_EDIT_ACTION] = "composer.save_edit"; SAVE_ICONS[SHARED_EDIT_ACTION] = "pencil"; + registerCustomizationCallback({ + actionTitle: formatSharedEditActionTitle, + }); + // Force markdown mode when in shared edit mode // This disables the rich text editor and hides the toggle api.registerValueTransformer( diff --git a/assets/stylesheets/common/discourse-shared-edits.scss b/assets/stylesheets/common/discourse-shared-edits.scss index 4f34d0b..ab900f4 100644 --- a/assets/stylesheets/common/discourse-shared-edits.scss +++ b/assets/stylesheets/common/discourse-shared-edits.scss @@ -14,4 +14,8 @@ .d-editor-preview-wrapper { margin-top: 0; } + + .action-title .svg-icon-title { + margin-right: 0.5em; + } } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 93d899b..a7d6d31 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -5,4 +5,5 @@ en: disable_shared_edits: "Disable Shared Edits" edit: "Edit" button_title: "Edit Post Collaboratively" + composer_title: "Post is being edited collaboratively" done: "Done" From c35a13ba8b456c76ac797b8b8953fcc7a3c0025d Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Wed, 26 Nov 2025 15:11:51 +1100 Subject: [PATCH 05/19] Add much better reliability to the system and handle boundary constraints --- .../revision_controller.rb | 76 +++ app/models/shared_edit_revision.rb | 20 +- .../discourse/services/shared-edit-manager.js | 39 +- config/locales/server.en.yml | 3 + lib/discourse_shared_edits/state_validator.rb | 179 +++++++ plugin.rb | 4 + spec/lib/state_validator_spec.rb | 454 ++++++++++++++++++ spec/models/shared_edit_revision_spec.rb | 2 +- spec/requests/revision_controller_spec.rb | 216 +++++++++ 9 files changed, 990 insertions(+), 3 deletions(-) create mode 100644 lib/discourse_shared_edits/state_validator.rb create mode 100644 spec/lib/state_validator_spec.rb diff --git a/app/controllers/discourse_shared_edits/revision_controller.rb b/app/controllers/discourse_shared_edits/revision_controller.rb index 07dbb43..ebd53a6 100644 --- a/app/controllers/discourse_shared_edits/revision_controller.rb +++ b/app/controllers/discourse_shared_edits/revision_controller.rb @@ -28,6 +28,22 @@ def latest raise Discourse::NotFound if revision.nil? + # Validate state before sending to client + health = StateValidator.health_check(post.id) + unless health[:healthy] + Rails.logger.warn( + "[SharedEdits] Unhealthy state detected for post #{post.id}, attempting recovery", + ) + recovery = StateValidator.recover_from_post_raw(post.id) + unless recovery[:success] + raise Discourse::InvalidAccess.new( + I18n.t("shared_edits.errors.state_corrupted"), + custom_message: "shared_edits.errors.state_corrupted", + ) + end + revision = SharedEditRevision.where(post_id: post.id).order("version desc").first + end + render json: { raw: DiscourseSharedEdits::Yjs.text_from_state(revision.raw), version: revision.version, @@ -63,6 +79,66 @@ def revise SharedEditRevision.ensure_will_commit(post.id) render json: { version: version, update: update } + rescue StateValidator::StateCorruptionError => e + Rails.logger.error( + "[SharedEdits] State corruption in revise for post #{params[:post_id]}: #{e.message}", + ) + + # Attempt automatic recovery + recovery = StateValidator.recover_from_post_raw(params[:post_id].to_i) + if recovery[:success] + render json: { + error: "state_recovered", + message: I18n.t("shared_edits.errors.state_recovered"), + recovered_version: recovery[:new_version], + }, + status: :conflict + else + render json: { + error: "state_corrupted", + message: I18n.t("shared_edits.errors.state_corrupted"), + }, + status: :unprocessable_entity + end + end + + def health + guardian.ensure_can_toggle_shared_edits! + + post = Post.find(params[:post_id].to_i) + health = StateValidator.health_check(post.id) + + render json: health + end + + def recover + guardian.ensure_can_toggle_shared_edits! + + post = Post.find(params[:post_id].to_i) + result = StateValidator.recover_from_post_raw(post.id, force: params[:force] == "true") + + if result[:success] + # Notify connected clients to resync + post.publish_message!( + "/shared_edits/#{post.id}", + { action: "resync", version: result[:new_version] }, + ) + render json: result + else + render json: result, status: :unprocessable_entity + end + end + + def reset + guardian.ensure_can_toggle_shared_edits! + + post = Post.find(params[:post_id].to_i) + new_version = SharedEditRevision.reset_history!(post.id) + + # Notify connected clients to resync + post.publish_message!("/shared_edits/#{post.id}", { action: "resync", version: new_version }) + + render json: { success: true, version: new_version } end protected diff --git a/app/models/shared_edit_revision.rb b/app/models/shared_edit_revision.rb index aa7c00e..53efaeb 100644 --- a/app/models/shared_edit_revision.rb +++ b/app/models/shared_edit_revision.rb @@ -128,6 +128,19 @@ def self.parse_usernames_from_reason(reason) end private_class_method :parse_usernames_from_reason + def self.reset_history!(post_id) + post = Post.find(post_id) + + SharedEditRevision.transaction do + commit!(post_id) + SharedEditRevision.where(post_id: post_id).delete_all + init!(post) + end + + revision = SharedEditRevision.where(post_id: post_id).order("version desc").first + revision&.version + end + def self.latest_raw(post_id) latest = SharedEditRevision @@ -147,7 +160,7 @@ def self.revise!(post_id:, user_id:, client_id:, update:) latest = SharedEditRevision.where(post_id: post_id).lock.order("version desc").first raise StandardError, "shared edits not initialized" if !latest - applied = DiscourseSharedEdits::Yjs.apply_update(latest.raw, update) + applied = DiscourseSharedEdits::StateValidator.safe_apply_update(post_id, latest.raw, update) revision = SharedEditRevision.create!( @@ -170,6 +183,11 @@ def self.revise!(post_id:, user_id:, client_id:, update:) [revision.version, update] end + rescue MiniRacer::RuntimeError, MiniRacer::ParseError => e + raise DiscourseSharedEdits::StateValidator::StateCorruptionError.new( + "Yjs operation failed: #{e.message}", + post_id: post_id, + ) end end diff --git a/assets/javascripts/discourse/services/shared-edit-manager.js b/assets/javascripts/discourse/services/shared-edit-manager.js index 96f4cfe..7f39b0a 100644 --- a/assets/javascripts/discourse/services/shared-edit-manager.js +++ b/assets/javascripts/discourse/services/shared-edit-manager.js @@ -131,10 +131,16 @@ export default class SharedEditManager extends Service { /** * Apply updates received from the message bus. - * @param {{client_id: string, update: string}} message + * @param {{client_id: string, update: string, action?: string, version?: number}} message * @returns {void} */ #onRemoteMessage = (message) => { + // Handle resync command from server (e.g., after recovery) + if (message.action === "resync") { + this.#handleResync(); + return; + } + if (!this.doc || message.client_id === this.messageBus.clientId) { return; } @@ -160,6 +166,27 @@ export default class SharedEditManager extends Service { this.#sendUpdatesThrottled(); }; + /** + * Handle a resync command by reloading the document state from the server. + * @returns {Promise} + */ + async #handleResync() { + const postId = this.currentPostId || this.#postId; + if (!postId) { + return; + } + + try { + const data = await ajax(`/shared_edits/p/${postId}`); + if (!this.composer.model || this.isDestroying || this.isDestroyed) { + return; + } + this.#setupDoc(data.state, data.raw); + } catch (e) { + popupAjaxError(e); + } + } + /** * Start syncing the current composer with the shared Yjs document for the post. * @returns {Promise} @@ -488,6 +515,16 @@ export default class SharedEditManager extends Service { }); await this.inFlightRequest; + } catch (e) { + // Handle state recovery response (409 Conflict) + if ( + e.jqXHR?.status === 409 && + e.jqXHR?.responseJSON?.error === "state_recovered" + ) { + await this.#handleResync(); + return; + } + throw e; } finally { this.inFlightRequest = null; this.ajaxInProgress = false; diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 0cff4df..05d5820 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1,5 +1,8 @@ en: shared_edits: reason: "Edited By: %{users}" + errors: + state_corrupted: "The collaborative editing state is corrupted and could not be recovered. Please try again." + state_recovered: "The collaborative editing state was corrupted but has been recovered. Please refresh to continue editing." site_settings: shared_edits_enabled: Enable shared, collaborative, editing on designated posts. diff --git a/lib/discourse_shared_edits/state_validator.rb b/lib/discourse_shared_edits/state_validator.rb new file mode 100644 index 0000000..629cd56 --- /dev/null +++ b/lib/discourse_shared_edits/state_validator.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +module DiscourseSharedEdits + module StateValidator + class StateCorruptionError < StandardError + attr_reader :post_id, :version, :recovery_attempted + + def initialize(message, post_id: nil, version: nil, recovery_attempted: false) + @post_id = post_id + @version = version + @recovery_attempted = recovery_attempted + super(message) + end + end + + class << self + def validate_state(state_b64) + return { valid: false, text: nil, error: "State is nil" } if state_b64.nil? + return { valid: false, text: nil, error: "State is empty" } if state_b64.empty? + + begin + decoded = Base64.strict_decode64(state_b64) + return { valid: false, text: nil, error: "Decoded state is empty" } if decoded.empty? + rescue ArgumentError => e + return { valid: false, text: nil, error: "Invalid base64: #{e.message}" } + end + + begin + text = DiscourseSharedEdits::Yjs.text_from_state(state_b64) + { valid: true, text: text, error: nil } + rescue MiniRacer::RuntimeError, MiniRacer::ParseError => e + { valid: false, text: nil, error: "Yjs extraction failed: #{e.message}" } + rescue StandardError => e + { valid: false, text: nil, error: "Unexpected error: #{e.message}" } + end + end + + def validate_update(update_b64) + return { valid: false, error: "Update is nil" } if update_b64.nil? + return { valid: false, error: "Update is empty" } if update_b64.empty? + + begin + decoded = Base64.strict_decode64(update_b64) + return { valid: false, error: "Decoded update is empty" } if decoded.empty? + { valid: true, error: nil } + rescue ArgumentError => e + { valid: false, error: "Invalid base64: #{e.message}" } + end + end + + def health_check(post_id) + report = { post_id: post_id, healthy: true, errors: [], warnings: [], state: nil } + + revisions = SharedEditRevision.where(post_id: post_id).order(:version) + + if revisions.empty? + report[:state] = :not_initialized + return report + end + + report[:state] = :initialized + report[:revision_count] = revisions.count + report[:version_range] = [revisions.first.version, revisions.last.version] + + expected_version = revisions.first.version + version_gaps = [] + revisions.each do |rev| + if rev.version != expected_version + version_gaps << { expected: expected_version, got: rev.version } + end + expected_version = rev.version + 1 + end + report[:version_gaps] = version_gaps if version_gaps.any? + + latest = revisions.last + if latest.raw.present? + validation = validate_state(latest.raw) + if validation[:valid] + report[:current_text] = validation[:text] + report[:text_length] = validation[:text]&.length + else + report[:healthy] = false + report[ + :errors + ] << "Latest state (v#{latest.version}) is corrupted: #{validation[:error]}" + end + else + report[:healthy] = false + report[:errors] << "Latest revision has nil state" + end + + report + end + + def recover_from_post_raw(post_id, force: false) + post = Post.find_by(id: post_id) + return { success: false, message: "Post not found" } if post.nil? + + unless force + health = health_check(post_id) + if health[:healthy] + return { success: false, message: "State is healthy, use force: true to override" } + end + end + + SharedEditRevision.transaction do + SharedEditRevision.where(post_id: post_id).delete_all + + initial_state = DiscourseSharedEdits::Yjs.state_from_text(post.raw) + + revision = + SharedEditRevision.create!( + post_id: post_id, + client_id: "recovery", + user_id: Discourse.system_user.id, + version: 1, + revision: "", + raw: initial_state[:state], + post_revision_id: SharedEditRevision.last_revision_id_for_post(post), + ) + + validation = validate_state(revision.raw) + unless validation[:valid] + raise StateCorruptionError.new( + "Recovery failed: new state is also invalid", + post_id: post_id, + recovery_attempted: true, + ) + end + + Rails.logger.info("[SharedEdits] Recovered state for post #{post_id} from post.raw") + + { success: true, message: "State recovered from post.raw", new_version: revision.version } + end + rescue ActiveRecord::RecordInvalid => e + { success: false, message: "Database error: #{e.message}" } + rescue StateCorruptionError => e + { success: false, message: e.message } + end + + def safe_apply_update(post_id, current_state, update) + update_validation = validate_update(update) + unless update_validation[:valid] + Rails.logger.warn( + "[SharedEdits] Invalid update for post #{post_id}: #{update_validation[:error]}", + ) + raise StateCorruptionError.new( + "Invalid update: #{update_validation[:error]}", + post_id: post_id, + ) + end + + result = DiscourseSharedEdits::Yjs.apply_update(current_state, update) + + state_validation = validate_state(result[:state]) + unless state_validation[:valid] + Rails.logger.error( + "[SharedEdits] State corruption after update for post #{post_id}: #{state_validation[:error]}", + ) + raise StateCorruptionError.new( + "State corrupted after update: #{state_validation[:error]}", + post_id: post_id, + ) + end + + text_length = result[:text]&.length || 0 + max_length = SiteSetting.max_post_length + if text_length > max_length + raise StateCorruptionError.new( + "Post length #{text_length} exceeds maximum allowed #{max_length}", + post_id: post_id, + ) + end + + result + end + end + end +end diff --git a/plugin.rb b/plugin.rb index f4bada6..6509c15 100644 --- a/plugin.rb +++ b/plugin.rb @@ -23,6 +23,7 @@ class Engine < ::Rails::Engine end require_relative "lib/discourse_shared_edits/yjs" + require_relative "lib/discourse_shared_edits/state_validator" require_relative "app/models/shared_edit_revision" require_relative "app/controllers/discourse_shared_edits/revision_controller" require_relative "app/jobs/commit_shared_revision" @@ -34,6 +35,9 @@ class Engine < ::Rails::Engine put "/p/:post_id" => "revision#revise" get "/p/:post_id" => "revision#latest" put "/p/:post_id/commit" => "revision#commit" + get "/p/:post_id/health" => "revision#health" + post "/p/:post_id/recover" => "revision#recover" + post "/p/:post_id/reset" => "revision#reset" end Discourse::Application.routes.append { mount ::DiscourseSharedEdits::Engine, at: "/shared_edits" } diff --git a/spec/lib/state_validator_spec.rb b/spec/lib/state_validator_spec.rb new file mode 100644 index 0000000..1210570 --- /dev/null +++ b/spec/lib/state_validator_spec.rb @@ -0,0 +1,454 @@ +# frozen_string_literal: true + +require "rails_helper" +require_dependency Rails.root.join( + "plugins", + "discourse-shared-edits", + "lib", + "discourse_shared_edits", + "yjs", + ) +require_dependency Rails.root.join( + "plugins", + "discourse-shared-edits", + "lib", + "discourse_shared_edits", + "state_validator", + ) +require_dependency Rails.root.join( + "plugins", + "discourse-shared-edits", + "app", + "models", + "shared_edit_revision", + ) +require_dependency Rails.root.join( + "plugins", + "discourse-shared-edits", + "db", + "migrate", + "20200721001123_migrate_shared_edits", + ) +require_dependency Rails.root.join( + "plugins", + "discourse-shared-edits", + "db", + "migrate", + "20251124000123_resize_shared_edit_columns", + ) + +RSpec.describe DiscourseSharedEdits::StateValidator do + before do + unless ActiveRecord::Base.connection.data_source_exists?(:shared_edit_revisions) + MigrateSharedEdits.new.up + ResizeSharedEditColumns.new.up + end + end + + describe ".validate_state" do + it "returns valid for a properly encoded state" do + state = DiscourseSharedEdits::Yjs.state_from_text("Hello world")[:state] + result = described_class.validate_state(state) + + expect(result[:valid]).to eq(true) + expect(result[:text]).to eq("Hello world") + expect(result[:error]).to be_nil + end + + it "returns invalid for nil state" do + result = described_class.validate_state(nil) + + expect(result[:valid]).to eq(false) + expect(result[:error]).to eq("State is nil") + end + + it "returns invalid for empty state" do + result = described_class.validate_state("") + + expect(result[:valid]).to eq(false) + expect(result[:error]).to eq("State is empty") + end + + it "returns invalid for malformed base64" do + result = described_class.validate_state("not-valid-base64!!!") + + expect(result[:valid]).to eq(false) + expect(result[:error]).to include("Invalid base64") + end + + it "returns invalid for corrupted Yjs state" do + # Valid base64 but not a valid Yjs document + corrupted = Base64.strict_encode64("random garbage data that is not yjs") + result = described_class.validate_state(corrupted) + + expect(result[:valid]).to eq(false) + expect(result[:error]).to include("Yjs extraction failed") + end + + it "handles unicode content correctly" do + state = DiscourseSharedEdits::Yjs.state_from_text("Hello 🌍 世界")[:state] + result = described_class.validate_state(state) + + expect(result[:valid]).to eq(true) + expect(result[:text]).to eq("Hello 🌍 世界") + end + + it "handles very large content" do + large_text = "x" * 100_000 + state = DiscourseSharedEdits::Yjs.state_from_text(large_text)[:state] + result = described_class.validate_state(state) + + expect(result[:valid]).to eq(true) + expect(result[:text].length).to eq(100_000) + end + end + + describe ".validate_update" do + it "returns valid for a properly encoded update" do + old_text = "Hello" + new_text = "Hello world" + update = DiscourseSharedEdits::Yjs.update_from_text_change(old_text, new_text) + + result = described_class.validate_update(update) + + expect(result[:valid]).to eq(true) + expect(result[:error]).to be_nil + end + + it "returns invalid for nil update" do + result = described_class.validate_update(nil) + + expect(result[:valid]).to eq(false) + expect(result[:error]).to eq("Update is nil") + end + + it "returns invalid for empty update" do + result = described_class.validate_update("") + + expect(result[:valid]).to eq(false) + expect(result[:error]).to eq("Update is empty") + end + + it "returns invalid for malformed base64" do + result = described_class.validate_update("not-valid-base64!!!") + + expect(result[:valid]).to eq(false) + expect(result[:error]).to include("Invalid base64") + end + end + + describe ".health_check" do + fab!(:post) { Fabricate(:post, raw: "Original content") } + + it "returns not_initialized when no revisions exist" do + report = described_class.health_check(post.id) + + expect(report[:state]).to eq(:not_initialized) + expect(report[:healthy]).to eq(true) + end + + it "returns healthy for properly initialized state" do + SharedEditRevision.init!(post) + + report = described_class.health_check(post.id) + + expect(report[:state]).to eq(:initialized) + expect(report[:healthy]).to eq(true) + expect(report[:errors]).to be_empty + expect(report[:current_text]).to eq(post.raw) + end + + it "detects corrupted state" do + SharedEditRevision.init!(post) + revision = SharedEditRevision.find_by(post_id: post.id) + revision.update_column(:raw, Base64.strict_encode64("corrupted data")) + + report = described_class.health_check(post.id) + + expect(report[:healthy]).to eq(false) + expect(report[:errors]).not_to be_empty + expect(report[:errors].first).to include("corrupted") + end + + it "detects nil state in revision" do + SharedEditRevision.init!(post) + revision = SharedEditRevision.find_by(post_id: post.id) + revision.update_column(:raw, nil) + + report = described_class.health_check(post.id) + + expect(report[:healthy]).to eq(false) + expect(report[:errors]).to include("Latest revision has nil state") + end + + it "reports version gaps as informational" do + SharedEditRevision.init!(post) + + # Manually create a revision with a version gap + state = DiscourseSharedEdits::Yjs.state_from_text("v5 content")[:state] + SharedEditRevision.create!( + post_id: post.id, + client_id: "test", + user_id: Discourse.system_user.id, + version: 5, # Gap from version 1 + revision: "", + raw: state, + ) + + report = described_class.health_check(post.id) + + # Version gaps are informational, not warnings - they don't affect functionality + expect(report[:healthy]).to eq(true) + expect(report[:version_gaps]).to be_present + expect(report[:version_gaps].first[:expected]).to eq(2) + expect(report[:version_gaps].first[:got]).to eq(5) + end + end + + describe ".recover_from_post_raw" do + fab!(:post) { Fabricate(:post, raw: "Recovery test content") } + + it "recovers from corrupted state" do + SharedEditRevision.init!(post) + revision = SharedEditRevision.find_by(post_id: post.id) + revision.update_column(:raw, Base64.strict_encode64("corrupted")) + + result = described_class.recover_from_post_raw(post.id) + + expect(result[:success]).to eq(true) + expect(result[:message]).to include("recovered") + expect(result[:new_version]).to eq(1) + + # Verify the new state is valid + new_revision = SharedEditRevision.find_by(post_id: post.id) + text = DiscourseSharedEdits::Yjs.text_from_state(new_revision.raw) + expect(text).to eq(post.raw) + end + + it "refuses to recover healthy state without force" do + SharedEditRevision.init!(post) + + result = described_class.recover_from_post_raw(post.id) + + expect(result[:success]).to eq(false) + expect(result[:message]).to include("healthy") + end + + it "recovers healthy state with force" do + SharedEditRevision.init!(post) + + result = described_class.recover_from_post_raw(post.id, force: true) + + expect(result[:success]).to eq(true) + end + + it "returns error for non-existent post" do + result = described_class.recover_from_post_raw(999_999) + + expect(result[:success]).to eq(false) + expect(result[:message]).to eq("Post not found") + end + + it "deletes all existing revisions during recovery" do + SharedEditRevision.init!(post) + user = Fabricate(:user) + + # Add more revisions + state = SharedEditRevision.where(post_id: post.id).order("version desc").first.raw + update = DiscourseSharedEdits::Yjs.update_from_state(state, "Modified content") + SharedEditRevision.revise!( + post_id: post.id, + user_id: user.id, + client_id: "test", + update: update, + ) + + expect(SharedEditRevision.where(post_id: post.id).count).to eq(2) + + # Corrupt the state + SharedEditRevision + .where(post_id: post.id) + .order("version desc") + .first + .update_column(:raw, Base64.strict_encode64("corrupted")) + + result = described_class.recover_from_post_raw(post.id) + + expect(result[:success]).to eq(true) + expect(SharedEditRevision.where(post_id: post.id).count).to eq(1) + end + end + + describe ".safe_apply_update" do + fab!(:post) + + it "applies valid update successfully" do + initial_state = DiscourseSharedEdits::Yjs.state_from_text("Hello")[:state] + update = DiscourseSharedEdits::Yjs.update_from_state(initial_state, "Hello world") + + result = described_class.safe_apply_update(post.id, initial_state, update) + + expect(result[:text]).to eq("Hello world") + expect(result[:state]).to be_present + end + + it "raises StateCorruptionError for invalid update" do + initial_state = DiscourseSharedEdits::Yjs.state_from_text("Hello")[:state] + + expect { + described_class.safe_apply_update(post.id, initial_state, "not-valid-base64!!!") + }.to raise_error(DiscourseSharedEdits::StateValidator::StateCorruptionError) + end + + it "raises StateCorruptionError for nil update" do + initial_state = DiscourseSharedEdits::Yjs.state_from_text("Hello")[:state] + + expect { described_class.safe_apply_update(post.id, initial_state, nil) }.to raise_error( + DiscourseSharedEdits::StateValidator::StateCorruptionError, + ) + end + + it "raises StateCorruptionError when result exceeds max_post_length" do + SiteSetting.max_post_length = 100 + + initial_state = DiscourseSharedEdits::Yjs.state_from_text("Hello")[:state] + long_text = "x" * 200 + update = DiscourseSharedEdits::Yjs.update_from_state(initial_state, long_text) + + expect { described_class.safe_apply_update(post.id, initial_state, update) }.to raise_error( + DiscourseSharedEdits::StateValidator::StateCorruptionError, + /exceeds maximum allowed/, + ) + end + + it "allows updates within max_post_length" do + SiteSetting.max_post_length = 100 + + initial_state = DiscourseSharedEdits::Yjs.state_from_text("Hello")[:state] + valid_text = "x" * 50 + update = DiscourseSharedEdits::Yjs.update_from_state(initial_state, valid_text) + + result = described_class.safe_apply_update(post.id, initial_state, update) + expect(result[:text]).to eq(valid_text) + end + end + + describe "concurrent edit simulation" do + fab!(:post) { Fabricate(:post, raw: "Initial content here") } + fab!(:user1, :user) + fab!(:user2, :user) + fab!(:user3, :user) + + def simulate_edit(post, user, new_text) + state = SharedEditRevision.where(post_id: post.id).order("version desc").first.raw + update = DiscourseSharedEdits::Yjs.update_from_state(state, new_text) + SharedEditRevision.revise!( + post_id: post.id, + user_id: user.id, + client_id: "client-#{user.id}", + update: update, + ) + end + + it "handles rapid sequential edits without corruption" do + SharedEditRevision.init!(post) + + 10.times { |i| simulate_edit(post, user1, "Content v#{i + 1}") } + + health = described_class.health_check(post.id) + + expect(health[:healthy]).to eq(true) + expect(health[:current_text]).to eq("Content v10") + expect(health[:revision_count]).to eq(11) + end + + it "handles alternating user edits" do + SharedEditRevision.init!(post) + users = [user1, user2, user3] + + 10.times do |i| + user = users[i % 3] + simulate_edit(post, user, "Edit #{i + 1} by #{user.username}") + end + + health = described_class.health_check(post.id) + + expect(health[:healthy]).to eq(true) + expect(health[:current_text]).to include("Edit 10") + end + + it "maintains state integrity after commit" do + SharedEditRevision.init!(post) + + simulate_edit(post, user1, "First edit") + simulate_edit(post, user2, "Second edit") + + SharedEditRevision.commit!(post.id) + + post.reload + expect(post.raw).to eq("Second edit") + + simulate_edit(post, user3, "Third edit after commit") + + health = described_class.health_check(post.id) + expect(health[:healthy]).to eq(true) + expect(health[:current_text]).to eq("Third edit after commit") + end + end + + describe "edge case recovery scenarios" do + fab!(:post) { Fabricate(:post, raw: "Edge case content") } + + it "recovers when all revisions have nil raw" do + SharedEditRevision.init!(post) + SharedEditRevision.where(post_id: post.id).update_all(raw: nil) + + health = described_class.health_check(post.id) + expect(health[:healthy]).to eq(false) + + result = described_class.recover_from_post_raw(post.id) + expect(result[:success]).to eq(true) + + health = described_class.health_check(post.id) + expect(health[:healthy]).to eq(true) + end + + it "recovers when revisions table is empty for post" do + SharedEditRevision.where(post_id: post.id).delete_all + + result = described_class.recover_from_post_raw(post.id, force: true) + expect(result[:success]).to eq(true) + + health = described_class.health_check(post.id) + expect(health[:healthy]).to eq(true) + end + + it "handles empty post.raw during recovery" do + post.update_column(:raw, "") + SharedEditRevision.init!(post) + + # Corrupt the state + SharedEditRevision + .where(post_id: post.id) + .first + .update_column(:raw, Base64.strict_encode64("corrupted")) + + result = described_class.recover_from_post_raw(post.id) + expect(result[:success]).to eq(true) + + new_revision = SharedEditRevision.find_by(post_id: post.id) + expect(DiscourseSharedEdits::Yjs.text_from_state(new_revision.raw)).to eq("") + end + + it "handles unicode post.raw during recovery" do + post.update_column(:raw, "Hello 🌍🎉 世界 مرحبا") + SharedEditRevision.init!(post) + + result = described_class.recover_from_post_raw(post.id, force: true) + expect(result[:success]).to eq(true) + + new_revision = SharedEditRevision.find_by(post_id: post.id) + expect(DiscourseSharedEdits::Yjs.text_from_state(new_revision.raw)).to eq("Hello 🌍🎉 世界 مرحبا") + end + end +end diff --git a/spec/models/shared_edit_revision_spec.rb b/spec/models/shared_edit_revision_spec.rb index 1478e8c..5f5ef80 100644 --- a/spec/models/shared_edit_revision_spec.rb +++ b/spec/models/shared_edit_revision_spec.rb @@ -20,7 +20,7 @@ "discourse-shared-edits", "db", "migrate", - "20251124000000_resize_shared_edit_columns", + "20251124000123_resize_shared_edit_columns", ) RSpec.describe SharedEditRevision do diff --git a/spec/requests/revision_controller_spec.rb b/spec/requests/revision_controller_spec.rb index c03c813..76777e2 100644 --- a/spec/requests/revision_controller_spec.rb +++ b/spec/requests/revision_controller_spec.rb @@ -227,5 +227,221 @@ def latest_state_for(post) post1.reload expect(post1.raw[0..3]).to eq("wxyz") end + + it "handles corrupted state with automatic recovery" do + # Corrupt the state + revision = SharedEditRevision.where(post_id: post1.id).first + revision.update_column(:raw, Base64.strict_encode64("corrupted data")) + + put "/shared_edits/p/#{post1.id}", params: { client_id: "abc", update: "some_update" } + + expect(response.status).to eq(409) + expect(response.parsed_body["error"]).to eq("state_recovered") + expect(response.parsed_body["recovered_version"]).to eq(1) + end + end + + describe "#health" do + context "when admin" do + before do + sign_in admin + SharedEditRevision.toggle_shared_edits!(post1.id, true) + end + + it "returns health status for a post" do + get "/shared_edits/p/#{post1.id}/health" + + expect(response.status).to eq(200) + body = response.parsed_body + expect(body["healthy"]).to eq(true) + expect(body["state"]).to eq("initialized") + expect(body["current_text"]).to eq(post1.raw) + end + + it "detects corrupted state" do + revision = SharedEditRevision.where(post_id: post1.id).first + revision.update_column(:raw, Base64.strict_encode64("corrupted")) + + get "/shared_edits/p/#{post1.id}/health" + + expect(response.status).to eq(200) + body = response.parsed_body + expect(body["healthy"]).to eq(false) + expect(body["errors"]).not_to be_empty + end + + it "returns not_initialized for posts without shared edits" do + post2 = Fabricate(:post) + + get "/shared_edits/p/#{post2.id}/health" + + expect(response.status).to eq(200) + body = response.parsed_body + expect(body["state"]).to eq("not_initialized") + end + end + + context "when regular user" do + before { sign_in user } + + it "returns 403" do + get "/shared_edits/p/#{post1.id}/health" + expect(response.status).to eq(403) + end + end + end + + describe "#recover" do + context "when admin" do + before do + sign_in admin + SharedEditRevision.toggle_shared_edits!(post1.id, true) + end + + it "recovers corrupted state" do + revision = SharedEditRevision.where(post_id: post1.id).first + revision.update_column(:raw, Base64.strict_encode64("corrupted")) + + messages = + MessageBus.track_publish("/shared_edits/#{post1.id}") do + post "/shared_edits/p/#{post1.id}/recover" + end + + expect(response.status).to eq(200) + body = response.parsed_body + expect(body["success"]).to eq(true) + + expect(messages.length).to eq(1) + expect(messages.first.data[:action]).to eq("resync") + end + + it "refuses to recover healthy state without force" do + post "/shared_edits/p/#{post1.id}/recover" + + expect(response.status).to eq(422) + body = response.parsed_body + expect(body["success"]).to eq(false) + expect(body["message"]).to include("healthy") + end + + it "recovers healthy state with force parameter" do + post "/shared_edits/p/#{post1.id}/recover", params: { force: "true" } + + expect(response.status).to eq(200) + body = response.parsed_body + expect(body["success"]).to eq(true) + end + + it "returns 404 for non-existent post" do + post "/shared_edits/p/999999/recover" + expect(response.status).to eq(404) + end + end + + context "when regular user" do + before { sign_in user } + + it "returns 403" do + post "/shared_edits/p/#{post1.id}/recover" + expect(response.status).to eq(403) + end + end + end + + describe "#latest with automatic recovery" do + before do + sign_in user + SharedEditRevision.toggle_shared_edits!(post1.id, true) + end + + it "automatically recovers corrupted state on access" do + revision = SharedEditRevision.where(post_id: post1.id).first + revision.update_column(:raw, Base64.strict_encode64("corrupted")) + + get "/shared_edits/p/#{post1.id}" + + expect(response.status).to eq(200) + body = response.parsed_body + expect(body["raw"]).to eq(post1.raw) + expect(body["version"]).to eq(1) + end + end + + describe "#reset" do + def latest_state_for(post) + SharedEditRevision.where(post_id: post.id).order("version desc").limit(1).pluck(:raw).first + end + + context "when admin" do + before do + sign_in admin + SharedEditRevision.toggle_shared_edits!(post1.id, true) + end + + it "resets history and notifies clients" do + # Make some edits to build up history + state = latest_state_for(post1) + update = DiscourseSharedEdits::Yjs.update_from_state(state, "Edit 1") + SharedEditRevision.revise!( + post_id: post1.id, + user_id: admin.id, + client_id: "test", + update: update, + ) + + state = latest_state_for(post1) + update = DiscourseSharedEdits::Yjs.update_from_state(state, "Edit 2") + SharedEditRevision.revise!( + post_id: post1.id, + user_id: admin.id, + client_id: "test", + update: update, + ) + + expect(SharedEditRevision.where(post_id: post1.id).count).to eq(3) + + messages = + MessageBus.track_publish("/shared_edits/#{post1.id}") do + post "/shared_edits/p/#{post1.id}/reset" + end + + expect(response.status).to eq(200) + body = response.parsed_body + expect(body["success"]).to eq(true) + expect(body["version"]).to eq(1) + + expect(SharedEditRevision.where(post_id: post1.id).count).to eq(1) + + expect(messages.length).to eq(1) + expect(messages.first.data[:action]).to eq("resync") + end + + it "commits pending changes before reset" do + state = latest_state_for(post1) + new_content = "New content from edit" + update = DiscourseSharedEdits::Yjs.update_from_state(state, new_content) + SharedEditRevision.revise!( + post_id: post1.id, + user_id: admin.id, + client_id: "test", + update: update, + ) + + post "/shared_edits/p/#{post1.id}/reset" + + expect(response.status).to eq(200) + post1.reload + expect(post1.raw).to eq(new_content) + end + end + + context "when regular user" do + before { sign_in user } + + it "returns 403" do + post "/shared_edits/p/#{post1.id}/reset" + expect(response.status).to eq(403) + end + end end end From ed65f858f29e3a7d50c3f03e375cb9c8b73e3c8f Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Wed, 26 Nov 2025 15:14:44 +1100 Subject: [PATCH 06/19] remove junk code --- spec/lib/state_validator_spec.rb | 36 ------------------------ spec/lib/yjs_spec.rb | 7 ----- spec/models/shared_edit_revision_spec.rb | 21 -------------- 3 files changed, 64 deletions(-) diff --git a/spec/lib/state_validator_spec.rb b/spec/lib/state_validator_spec.rb index 1210570..4ee29d6 100644 --- a/spec/lib/state_validator_spec.rb +++ b/spec/lib/state_validator_spec.rb @@ -1,42 +1,6 @@ # frozen_string_literal: true require "rails_helper" -require_dependency Rails.root.join( - "plugins", - "discourse-shared-edits", - "lib", - "discourse_shared_edits", - "yjs", - ) -require_dependency Rails.root.join( - "plugins", - "discourse-shared-edits", - "lib", - "discourse_shared_edits", - "state_validator", - ) -require_dependency Rails.root.join( - "plugins", - "discourse-shared-edits", - "app", - "models", - "shared_edit_revision", - ) -require_dependency Rails.root.join( - "plugins", - "discourse-shared-edits", - "db", - "migrate", - "20200721001123_migrate_shared_edits", - ) -require_dependency Rails.root.join( - "plugins", - "discourse-shared-edits", - "db", - "migrate", - "20251124000123_resize_shared_edit_columns", - ) - RSpec.describe DiscourseSharedEdits::StateValidator do before do unless ActiveRecord::Base.connection.data_source_exists?(:shared_edit_revisions) diff --git a/spec/lib/yjs_spec.rb b/spec/lib/yjs_spec.rb index 70a2a0c..35630a1 100644 --- a/spec/lib/yjs_spec.rb +++ b/spec/lib/yjs_spec.rb @@ -1,13 +1,6 @@ # frozen_string_literal: true require "rails_helper" -require_dependency Rails.root.join( - "plugins", - "discourse-shared-edits", - "lib", - "discourse_shared_edits", - "yjs", - ) RSpec.describe DiscourseSharedEdits::Yjs do describe ".state_from_text" do diff --git a/spec/models/shared_edit_revision_spec.rb b/spec/models/shared_edit_revision_spec.rb index 5f5ef80..8b84ca1 100644 --- a/spec/models/shared_edit_revision_spec.rb +++ b/spec/models/shared_edit_revision_spec.rb @@ -1,27 +1,6 @@ # frozen_string_literal: true require "rails_helper" -require_dependency Rails.root.join( - "plugins", - "discourse-shared-edits", - "app", - "models", - "shared_edit_revision", - ) -require_dependency Rails.root.join( - "plugins", - "discourse-shared-edits", - "db", - "migrate", - "20200721001123_migrate_shared_edits", - ) -require_dependency Rails.root.join( - "plugins", - "discourse-shared-edits", - "db", - "migrate", - "20251124000123_resize_shared_edit_columns", - ) RSpec.describe SharedEditRevision do before do From 900bcd2898d5a7912695dc4397a5896422b5416c Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Wed, 26 Nov 2025 16:50:34 +1100 Subject: [PATCH 07/19] more fixes --- app/models/shared_edit_revision.rb | 64 ++- .../discourse/services/shared-edit-manager.js | 203 +++++++- lib/discourse_shared_edits/yjs.rb | 6 +- spec/lib/state_validator_spec.rb | 9 +- spec/lib/yjs_spec.rb | 78 ++- spec/models/shared_edit_revision_spec.rb | 85 +++- support/fake_writer | 460 ++++++++++++++++++ support/fake_writer.rb | 37 -- 8 files changed, 865 insertions(+), 77 deletions(-) create mode 100755 support/fake_writer delete mode 100644 support/fake_writer.rb diff --git a/app/models/shared_edit_revision.rb b/app/models/shared_edit_revision.rb index 53efaeb..60b80b7 100644 --- a/app/models/shared_edit_revision.rb +++ b/app/models/shared_edit_revision.rb @@ -155,33 +155,53 @@ def self.latest_raw(post_id) [latest.version, DiscourseSharedEdits::Yjs.text_from_state(latest.raw)] end + MAX_REVISION_RETRIES = 3 + def self.revise!(post_id:, user_id:, client_id:, update:) - SharedEditRevision.transaction do - latest = SharedEditRevision.where(post_id: post_id).lock.order("version desc").first - raise StandardError, "shared edits not initialized" if !latest + retries = 0 - applied = DiscourseSharedEdits::StateValidator.safe_apply_update(post_id, latest.raw, update) + begin + SharedEditRevision.transaction do + latest = SharedEditRevision.where(post_id: post_id).lock.order("version desc").first + raise StandardError, "shared edits not initialized" if !latest - revision = - SharedEditRevision.create!( - post_id: post_id, - user_id: user_id, - client_id: client_id, - revision: update, - raw: applied[:state], - version: latest.version + 1, - ) + applied = + DiscourseSharedEdits::StateValidator.safe_apply_update(post_id, latest.raw, update) - post = Post.find(post_id) - message = { - version: revision.version, - update: update, - client_id: client_id, - user_id: user_id, - } - post.publish_message!("/shared_edits/#{post.id}", message) + revision = + SharedEditRevision.create!( + post_id: post_id, + user_id: user_id, + client_id: client_id, + revision: update, + raw: applied[:state], + version: latest.version + 1, + ) - [revision.version, update] + post = Post.find(post_id) + message = { + version: revision.version, + update: update, + client_id: client_id, + user_id: user_id, + } + post.publish_message!("/shared_edits/#{post.id}", message) + + [revision.version, update] + end + rescue ActiveRecord::RecordNotUnique => e + retries += 1 + if retries < MAX_REVISION_RETRIES + Rails.logger.warn( + "[SharedEdits] Version conflict for post #{post_id}, retry #{retries}/#{MAX_REVISION_RETRIES}", + ) + retry + else + Rails.logger.error( + "[SharedEdits] Version conflict for post #{post_id} after #{MAX_REVISION_RETRIES} retries: #{e.message}", + ) + raise + end end rescue MiniRacer::RuntimeError, MiniRacer::ParseError => e raise DiscourseSharedEdits::StateValidator::StateCorruptionError.new( diff --git a/assets/javascripts/discourse/services/shared-edit-manager.js b/assets/javascripts/discourse/services/shared-edit-manager.js index 7f39b0a..2aebafd 100644 --- a/assets/javascripts/discourse/services/shared-edit-manager.js +++ b/assets/javascripts/discourse/services/shared-edit-manager.js @@ -128,6 +128,9 @@ export default class SharedEditManager extends Service { textObserver = null; inFlightRequest = null; #pendingRelativeSelection = null; + #isSelecting = false; + #selectionListenersAttached = false; + #skippedUpdatesDuringSelection = false; /** * Apply updates received from the message bus. @@ -145,7 +148,11 @@ export default class SharedEditManager extends Service { return; } - this.#pendingRelativeSelection = this.#captureRelativeSelection(); + // Only capture relative selection if not actively selecting. + // During selection, we use #selectionStartRelative captured on mousedown. + if (!this.#isSelecting) { + this.#pendingRelativeSelection = this.#captureRelativeSelection(); + } const update = base64ToUint8Array(message.update); window.Y.applyUpdate(this.doc, update, "remote"); @@ -166,6 +173,150 @@ export default class SharedEditManager extends Service { this.#sendUpdatesThrottled(); }; + /** + * Handle mousedown on textarea - user may be starting a selection drag. + * @returns {void} + */ + #onTextareaMouseDown = () => { + this.#isSelecting = true; + this.#skippedUpdatesDuringSelection = false; + }; + + /** + * Handle mouseup - user finished selecting. Sync textarea if updates were skipped. + * @returns {void} + */ + #onTextareaMouseUp = () => { + const wasSelecting = this.#isSelecting; + const hadSkippedUpdates = this.#skippedUpdatesDuringSelection; + const textareaSelection = this.#getTextareaSelection(); + + this.#isSelecting = false; + this.#skippedUpdatesDuringSelection = false; + + // If we skipped updates while selecting, sync the textarea now + if (wasSelecting && hadSkippedUpdates) { + this.#syncTextareaAfterSelection(textareaSelection); + } + }; + + /** + * Get current textarea selection. + * @returns {{start: number, end: number}|null} + */ + #getTextareaSelection() { + const textarea = document.querySelector(TEXTAREA_SELECTOR); + if (!textarea) { + return null; + } + return { start: textarea.selectionStart, end: textarea.selectionEnd }; + } + + /** + * Sync textarea content after selection is complete, preserving selection position. + * @param {{start: number, end: number}|null} oldSelection - Selection in old content coordinates + * @returns {void} + */ + #syncTextareaAfterSelection(oldSelection) { + const textarea = document.querySelector(TEXTAREA_SELECTOR); + if (!textarea || !this.text) { + return; + } + + const oldText = textarea.value; + const newText = this.text.toString(); + const scrollTop = textarea.scrollTop; + + // Transform the selection from old content coordinates to new content coordinates + let adjustedSelection = null; + + if (oldSelection && oldText !== newText) { + adjustedSelection = this.#transformSelectionThroughDiff( + oldText, + newText, + oldSelection + ); + } else if (oldSelection) { + adjustedSelection = oldSelection; + } + + this.suppressComposerChange = true; + this.composer.model?.set("reply", newText); + this.suppressComposerChange = false; + + textarea.value = newText; + + if (adjustedSelection) { + // Clamp selection to valid range + const maxPos = newText.length; + textarea.selectionStart = Math.min( + Math.max(0, adjustedSelection.start), + maxPos + ); + textarea.selectionEnd = Math.min( + Math.max(0, adjustedSelection.end), + maxPos + ); + } + + if (scrollTop !== undefined) { + window.requestAnimationFrame(() => { + textarea.scrollTop = scrollTop; + }); + } + } + + /** + * Transform selection coordinates from old text to new text based on diff. + * @param {string} oldText + * @param {string} newText + * @param {{start: number, end: number}} selection + * @returns {{start: number, end: number}} + */ + #transformSelectionThroughDiff(oldText, newText, selection) { + // Find common prefix length + let prefixLen = 0; + const minLen = Math.min(oldText.length, newText.length); + while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) { + prefixLen++; + } + + // Find common suffix length (but don't overlap with prefix) + let suffixLen = 0; + while ( + suffixLen < oldText.length - prefixLen && + suffixLen < newText.length - prefixLen && + oldText[oldText.length - 1 - suffixLen] === + newText[newText.length - 1 - suffixLen] + ) { + suffixLen++; + } + + // The change region in old text is [prefixLen, oldText.length - suffixLen) + // The change region in new text is [prefixLen, newText.length - suffixLen) + const oldChangeEnd = oldText.length - suffixLen; + const newChangeEnd = newText.length - suffixLen; + + const transformPos = (pos) => { + if (pos <= prefixLen) { + // Before the change region - no adjustment needed + return pos; + } else if (pos >= oldChangeEnd) { + // After the change region - shift by the length difference + return pos + (newChangeEnd - oldChangeEnd); + } else { + // Inside the change region - map to end of new change region + // (best guess - the old position no longer exists) + return newChangeEnd; + } + }; + + return { + start: transformPos(selection.start), + end: transformPos(selection.end), + }; + } + /** * Handle a resync command by reloading the document state from the server. * @returns {Promise} @@ -273,6 +424,8 @@ export default class SharedEditManager extends Service { this.text.observe(this.textObserver); this.doc.on("update", this.#handleDocUpdate); + this.#attachSelectionListeners(); + this.suppressComposerChange = true; this.composer.model.set("reply", this.text.toString()); this.suppressComposerChange = false; @@ -291,11 +444,51 @@ export default class SharedEditManager extends Service { this.doc.off("update", this.#handleDocUpdate); } + this.#detachSelectionListeners(); + this.doc = null; this.text = null; this.textObserver = null; } + /** + * Attach listeners to track active selection state on the textarea. + * @returns {void} + */ + #attachSelectionListeners() { + if (this.#selectionListenersAttached) { + return; + } + + const textarea = document.querySelector(TEXTAREA_SELECTOR); + if (!textarea) { + return; + } + + textarea.addEventListener("mousedown", this.#onTextareaMouseDown); + // Use document for mouseup to catch releases outside the textarea + document.addEventListener("mouseup", this.#onTextareaMouseUp); + this.#selectionListenersAttached = true; + } + + /** + * Remove selection tracking listeners. + * @returns {void} + */ + #detachSelectionListeners() { + if (!this.#selectionListenersAttached) { + return; + } + + const textarea = document.querySelector(TEXTAREA_SELECTOR); + if (textarea) { + textarea.removeEventListener("mousedown", this.#onTextareaMouseDown); + } + document.removeEventListener("mouseup", this.#onTextareaMouseUp); + this.#selectionListenersAttached = false; + this.#isSelecting = false; + } + /** * @returns {number|undefined} id of the post currently being edited */ @@ -333,6 +526,14 @@ export default class SharedEditManager extends Service { return; } + // If user is actively selecting, skip the textarea update to avoid interrupting + // the native selection. The Yjs doc already has the update, and we'll sync + // the textarea on mouseup. + if (this.#isSelecting) { + this.#skippedUpdatesDuringSelection = true; + return; + } + const textarea = document.querySelector(TEXTAREA_SELECTOR); const selection = textarea && typeof textarea.selectionStart === "number" diff --git a/lib/discourse_shared_edits/yjs.rb b/lib/discourse_shared_edits/yjs.rb index 32bb80e..aeaaeeb 100644 --- a/lib/discourse_shared_edits/yjs.rb +++ b/lib/discourse_shared_edits/yjs.rb @@ -110,9 +110,10 @@ def context function updateFromTextChange(oldText, newText) { const doc = createDocWithText(oldText); + const initialState = encodeDocState(doc); const stateVector = YRef.encodeStateVector(doc); applyDiffToYText(doc.getText(TEXT_KEY), oldText || "", newText || ""); - return Array.from(YRef.encodeStateAsUpdate(doc, stateVector)); + return { state: initialState, update: Array.from(YRef.encodeStateAsUpdate(doc, stateVector)) }; } function updateFromState(state, newText) { @@ -144,7 +145,8 @@ def text_from_state(state_b64) end def update_from_text_change(old_text, new_text) - encode(context.call("updateFromTextChange", old_text, new_text)) + result = context.call("updateFromTextChange", old_text, new_text) + { state: encode(result["state"]), update: encode(result["update"]) } end def update_from_state(state_b64, new_text) diff --git a/spec/lib/state_validator_spec.rb b/spec/lib/state_validator_spec.rb index 4ee29d6..1701244 100644 --- a/spec/lib/state_validator_spec.rb +++ b/spec/lib/state_validator_spec.rb @@ -2,13 +2,6 @@ require "rails_helper" RSpec.describe DiscourseSharedEdits::StateValidator do - before do - unless ActiveRecord::Base.connection.data_source_exists?(:shared_edit_revisions) - MigrateSharedEdits.new.up - ResizeSharedEditColumns.new.up - end - end - describe ".validate_state" do it "returns valid for a properly encoded state" do state = DiscourseSharedEdits::Yjs.state_from_text("Hello world")[:state] @@ -71,7 +64,7 @@ it "returns valid for a properly encoded update" do old_text = "Hello" new_text = "Hello world" - update = DiscourseSharedEdits::Yjs.update_from_text_change(old_text, new_text) + update = DiscourseSharedEdits::Yjs.update_from_text_change(old_text, new_text)[:update] result = described_class.validate_update(update) diff --git a/spec/lib/yjs_spec.rb b/spec/lib/yjs_spec.rb index 35630a1..f260530 100644 --- a/spec/lib/yjs_spec.rb +++ b/spec/lib/yjs_spec.rb @@ -52,24 +52,90 @@ old_text = "Hello World" new_text = "Hello Universe" - update = described_class.update_from_text_change(old_text, new_text) - expect(update).to be_present + result = described_class.update_from_text_change(old_text, new_text) + applied = described_class.apply_update(result[:state], result[:update]) + + expect(applied[:text]).to eq(new_text) end it "handles insertion at the beginning" do old_text = "World" new_text = "Hello World" - update = described_class.update_from_text_change(old_text, new_text) - expect(update).to be_present + result = described_class.update_from_text_change(old_text, new_text) + applied = described_class.apply_update(result[:state], result[:update]) + + expect(applied[:text]).to eq(new_text) end it "handles deletion" do old_text = "Hello World" new_text = "World" - update = described_class.update_from_text_change(old_text, new_text) - expect(update).to be_present + result = described_class.update_from_text_change(old_text, new_text) + applied = described_class.apply_update(result[:state], result[:update]) + + expect(applied[:text]).to eq(new_text) + end + + it "handles insertion at the end" do + old_text = "Hello" + new_text = "Hello World" + + result = described_class.update_from_text_change(old_text, new_text) + applied = described_class.apply_update(result[:state], result[:update]) + + expect(applied[:text]).to eq(new_text) + end + + it "handles complete replacement" do + old_text = "foo" + new_text = "bar" + + result = described_class.update_from_text_change(old_text, new_text) + applied = described_class.apply_update(result[:state], result[:update]) + + expect(applied[:text]).to eq(new_text) + end + + it "handles emoji content" do + old_text = "Hello 😎" + new_text = "Hello 🎉 World" + + result = described_class.update_from_text_change(old_text, new_text) + applied = described_class.apply_update(result[:state], result[:update]) + + expect(applied[:text]).to eq(new_text) + end + + it "handles empty to content" do + old_text = "" + new_text = "Hello World" + + result = described_class.update_from_text_change(old_text, new_text) + applied = described_class.apply_update(result[:state], result[:update]) + + expect(applied[:text]).to eq(new_text) + end + + it "handles content to empty" do + old_text = "Hello World" + new_text = "" + + result = described_class.update_from_text_change(old_text, new_text) + applied = described_class.apply_update(result[:state], result[:update]) + + expect(applied[:text]).to eq(new_text) + end + + it "handles multiline content" do + old_text = "Line 1\nLine 2\nLine 3" + new_text = "Line 1\nModified Line\nLine 3" + + result = described_class.update_from_text_change(old_text, new_text) + applied = described_class.apply_update(result[:state], result[:update]) + + expect(applied[:text]).to eq(new_text) end end diff --git a/spec/models/shared_edit_revision_spec.rb b/spec/models/shared_edit_revision_spec.rb index 8b84ca1..0e25c92 100644 --- a/spec/models/shared_edit_revision_spec.rb +++ b/spec/models/shared_edit_revision_spec.rb @@ -244,9 +244,92 @@ def fake_edit(post, user_id, new_text) post_id: post.id, user_id: user.id, client_id: user.id, - update: DiscourseSharedEdits::Yjs.update_from_text_change("Hello world", "Test"), + update: DiscourseSharedEdits::Yjs.update_from_text_change("Hello world", "Test")[:update], ) expect(post.reload.raw).to eq("Hello world") end + + describe ".revise! version conflict handling" do + fab!(:user) + fab!(:post) { Fabricate(:post, user: user, raw: "Hello world") } + + before { SharedEditRevision.init!(post) } + + it "retries on version conflict and succeeds" do + state = SharedEditRevision.where(post_id: post.id).order("version desc").first.raw + update = DiscourseSharedEdits::Yjs.update_from_state(state, "Hello world updated") + call_count = 0 + + allow(SharedEditRevision).to receive(:create!).and_wrap_original do |method, **args| + call_count += 1 + if call_count == 1 + raise ActiveRecord::RecordNotUnique.new("duplicate key value") + else + method.call(**args) + end + end + + result = + SharedEditRevision.revise!( + post_id: post.id, + user_id: user.id, + client_id: "test-client", + update: update, + ) + + expect(result).to be_present + expect(call_count).to eq(2) + end + + it "raises after max retries exhausted" do + state = SharedEditRevision.where(post_id: post.id).order("version desc").first.raw + update = DiscourseSharedEdits::Yjs.update_from_state(state, "Hello world updated") + + allow(SharedEditRevision).to receive(:create!).and_raise( + ActiveRecord::RecordNotUnique.new("duplicate key value"), + ) + + expect { + SharedEditRevision.revise!( + post_id: post.id, + user_id: user.id, + client_id: "test-client", + update: update, + ) + }.to raise_error(ActiveRecord::RecordNotUnique) + end + + it "handles concurrent revisions gracefully" do + state = SharedEditRevision.where(post_id: post.id).order("version desc").first.raw + update1 = DiscourseSharedEdits::Yjs.update_from_state(state, "Change from client 1") + update2 = DiscourseSharedEdits::Yjs.update_from_state(state, "Change from client 2") + + # Simulate two concurrent requests by having one succeed normally + result1 = + SharedEditRevision.revise!( + post_id: post.id, + user_id: user.id, + client_id: "client-1", + update: update1, + ) + + # The second one should also succeed (applying to the new state) + result2 = + SharedEditRevision.revise!( + post_id: post.id, + user_id: user.id, + client_id: "client-2", + update: update2, + ) + + expect(result1.first).to eq(2) + expect(result2.first).to eq(3) + + # Both changes should be in the final state (Yjs merges them) + final_version, final_text = SharedEditRevision.latest_raw(post.id) + expect(final_version).to eq(3) + expect(final_text).to be_present + end + end end diff --git a/support/fake_writer b/support/fake_writer new file mode 100755 index 0000000..aa0c9f2 --- /dev/null +++ b/support/fake_writer @@ -0,0 +1,460 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Simulates a user typing in a shared edit session. +# Useful for testing that shared edits work correctly. +# +# Usage: +# plugins/discourse-shared-edits/support/fake_writer POST_ID [OPTIONS] +# +# Options: +# --speed=SPEED Typing speed: slow, normal, fast (default: normal) +# --mode=MODE Edit mode: append, typewriter, random (default: typewriter) +# --user=USER User ID or username to write as (default: system user) +# --cursor=POS Cursor position: number, 'start', or 'end' (default: end) +# --marker=CHAR Cursor marker character (default: ▌) +# --reset Reset shared edit state before starting +# +# Examples: +# plugins/discourse-shared-edits/support/fake_writer 123 +# plugins/discourse-shared-edits/support/fake_writer 123 --speed=fast --cursor=0 +# plugins/discourse-shared-edits/support/fake_writer 123 --user=admin --cursor=start +# +# Cursor Tracking: +# The typewriter mode uses a visible cursor marker (▌) to track position. +# This lets you test OT by typing after the marker while the robot types before it. +# The marker moves naturally with the text through Yjs operational transformation. + +discourse_root = File.expand_path("../../../..", __FILE__) +Dir.chdir(discourse_root) do # rubocop:disable Discourse/NoChdir + require File.join(discourse_root, "config/environment") + + module FakeWriter + PHRASES = [ + "The quick brown fox jumps over the lazy dog. ", + "Hello world! ", + "Testing shared edits... ", + "This is a collaborative document. ", + "Multiple users can edit simultaneously. ", + "Real-time synchronization in action! ", + "Lorem ipsum dolor sit amet. ", + "Sphinx of black quartz, judge my vow. ", + "Pack my box with five dozen liquor jugs. ", + "How vexingly quick daft zebras jump! ", + ].freeze + + SPEEDS = { + "slow" => { char_delay: 0.15..0.3, phrase_delay: 1.0..2.0 }, + "normal" => { char_delay: 0.05..0.15, phrase_delay: 0.5..1.0 }, + "fast" => { char_delay: 0.01..0.05, phrase_delay: 0.1..0.3 }, + }.freeze + + MODES = %w[append typewriter random].freeze + DEFAULT_MARKER = "▌" + + class << self + def parse_args(args) + options = { + speed: "normal", + mode: "typewriter", + reset: false, + user: nil, + cursor: "end", + marker: DEFAULT_MARKER, + } + + post_id = nil + args.each do |arg| + case arg + when /^--speed=(.+)$/ + options[:speed] = $1 + when /^--mode=(.+)$/ + options[:mode] = $1 + when /^--user=(.+)$/ + options[:user] = $1 + when /^--cursor=(.+)$/ + options[:cursor] = $1 + when /^--marker=(.+)$/ + options[:marker] = $1 + when "--reset" + options[:reset] = true + when /^\d+$/ + post_id = arg.to_i + end + end + + [post_id, options] + end + + def random_delay(range) + sleep(rand(range)) + end + + def get_current_text(post_id) + version, text = SharedEditRevision.latest_raw(post_id) + [version, text || ""] + end + + def apply_edit(post_id, user_id, new_text) + latest = SharedEditRevision.where(post_id: post_id).order("version desc").first + return nil unless latest + + update = DiscourseSharedEdits::Yjs.update_from_state(latest.raw, new_text) + SharedEditRevision.revise!( + post_id: post_id, + user_id: user_id, + client_id: "fake_writer", + update: update, + ) + rescue StandardError => e + puts "\n[ERROR] Failed to apply edit: #{e.message}" + nil + end + + def find_marker_position(text, marker) + text.index(marker) + end + + def insert_at_marker(text, marker, insert_text) + pos = find_marker_position(text, marker) + return nil if pos.nil? + + text.dup.insert(pos, insert_text) + end + + def calculate_initial_cursor(text, cursor_option) + case cursor_option + when "start" + 0 + when "end" + text.length + when /^-?\d+$/ + pos = cursor_option.to_i + pos = 0 if pos < 0 + pos = text.length if pos > text.length + pos + else + text.length + end + end + + def setup_cursor(post_id, user_id, text, options) + marker = options[:marker] + + # Check if marker already exists + existing_pos = find_marker_position(text, marker) + if existing_pos + puts "Found existing cursor marker at position #{existing_pos}" + return text + end + + # Insert marker at specified position + cursor_pos = calculate_initial_cursor(text, options[:cursor]) + new_text = text.dup.insert(cursor_pos, marker) + + result = apply_edit(post_id, user_id, new_text) + if result + puts "Inserted cursor marker '#{marker}' at position #{cursor_pos}" + new_text + else + warn "Failed to insert cursor marker" + exit 1 + end + end + + def typewriter_mode(post_id, user_id, speed_config, options) + marker = options[:marker] + puts "[typewriter] Typing at cursor marker '#{marker}'..." + puts " (Text inserted AFTER the marker will stay after it - test OT!)" + puts "" + + phrase_index = 0 + + loop do + phrase = PHRASES[phrase_index % PHRASES.length] + phrase_index += 1 + + phrase.each_char do |char| + _version, current_text = get_current_text(post_id) + + marker_pos = find_marker_position(current_text, marker) + if marker_pos.nil? + puts "\n[WARN] Cursor marker lost! Re-inserting at end..." + new_text = current_text + marker + apply_edit(post_id, user_id, new_text) + next + end + + new_text = insert_at_marker(current_text, marker, char) + result = apply_edit(post_id, user_id, new_text) + + if result + print char + $stdout.flush + end + + random_delay(speed_config[:char_delay]) + end + + puts + random_delay(speed_config[:phrase_delay]) + end + end + + def append_mode(post_id, user_id, speed_config, options) + marker = options[:marker] + puts "[append] Appending phrases at cursor marker '#{marker}'..." + puts "" + + loop do + phrase = PHRASES.sample + + _version, current_text = get_current_text(post_id) + + marker_pos = find_marker_position(current_text, marker) + if marker_pos.nil? + puts "[WARN] Cursor marker lost! Re-inserting at end..." + new_text = current_text + marker + apply_edit(post_id, user_id, new_text) + next + end + + new_text = insert_at_marker(current_text, marker, phrase) + result = apply_edit(post_id, user_id, new_text) + + puts "+ #{phrase.strip}" if result + + random_delay(speed_config[:phrase_delay]) + end + end + + def random_mode(post_id, user_id, speed_config, options) + marker = options[:marker] + puts "[random] Random edits (marker '#{marker}' shows cursor position)..." + puts "" + + loop do + _version, current_text = get_current_text(post_id) + + # Remove marker temporarily for random operations + marker_pos = find_marker_position(current_text, marker) + text_without_marker = marker_pos ? current_text.sub(marker, "") : current_text + + action = %i[insert delete replace].sample + new_text_without_marker = perform_random_action(action, text_without_marker) + + if new_text_without_marker != text_without_marker + # Re-insert marker at same relative position (or end if position lost) + new_marker_pos = marker_pos ? [marker_pos, new_text_without_marker.length].min : new_text_without_marker.length + new_text = new_text_without_marker.dup.insert(new_marker_pos, marker) + + result = apply_edit(post_id, user_id, new_text) + puts result ? "✓" : "✗" + else + puts "skipped" + end + + random_delay(speed_config[:phrase_delay]) + end + end + + def perform_random_action(action, current_text) + case action + when :insert + perform_insert(current_text) + when :delete + perform_delete(current_text) + when :replace + perform_replace(current_text) + end + end + + def perform_insert(current_text) + pos = rand(0..current_text.length) + insert_text = PHRASES.sample.split.sample(rand(1..3)).join(" ") + " " + print "[insert@#{pos}] \"#{insert_text.strip}\" " + current_text.dup.insert(pos, insert_text) + end + + def perform_delete(current_text) + if current_text.length > 10 + start_pos = rand(0...[current_text.length - 5, 0].max) + delete_len = rand(1..[10, current_text.length - start_pos].min) + deleted = current_text[start_pos, delete_len] + print "[delete@#{start_pos}] \"#{deleted}\" " + current_text.dup.tap { |s| s[start_pos, delete_len] = "" } + else + print "[skip delete - text too short] " + current_text + end + end + + def perform_replace(current_text) + if current_text.length > 5 + start_pos = rand(0...[current_text.length - 3, 0].max) + replace_len = rand(1..[5, current_text.length - start_pos].min) + replacement = PHRASES.sample.split.sample || "X" + old_text_segment = current_text[start_pos, replace_len] + print "[replace@#{start_pos}] \"#{old_text_segment}\" -> \"#{replacement}\" " + current_text.dup.tap { |s| s[start_pos, replace_len] = replacement } + else + print "[skip replace - text too short] " + current_text + end + end + + def run(args) + post_id, options = parse_args(args) + + if post_id.nil? || post_id == 0 + print_usage + exit 1 + end + + validate_options!(options) + + post = Post.find_by(id: post_id) + if post.nil? + warn "Post #{post_id} not found" + exit 1 + end + + print_banner(post, options) + + user = find_user(options[:user]) + puts "Writing as: #{user.username} (id: #{user.id})" + + setup_shared_edits(post, options) + + _version, text = get_current_text(post_id) + text = setup_cursor(post_id, user.id, text, options) + + version, text = get_current_text(post_id) + puts "Current version: #{version}" + puts "Current text length: #{text.length} chars (including marker)" + puts "=" * 60 + puts "Starting simulation (Ctrl+C to stop)..." + puts "" + + run_mode(post_id, user.id, options) + rescue Interrupt + handle_interrupt(post_id, options) + end + + private + + def find_user(user_option) + if user_option.nil? + Discourse.system_user || User.where(admin: true).first + elsif user_option.match?(/^-?\d+$/) + user = User.find_by(id: user_option.to_i) + if user.nil? + warn "User with ID #{user_option} not found" + exit 1 + end + user + else + user = User.find_by(username: user_option) + if user.nil? + warn "User '#{user_option}' not found" + exit 1 + end + user + end + end + + def print_usage + warn "Usage: #{$PROGRAM_NAME} POST_ID [OPTIONS]" + warn "" + warn "Options:" + warn " --speed=SPEED slow, normal, fast (default: normal)" + warn " --mode=MODE append, typewriter, random (default: typewriter)" + warn " --user=USER User ID or username (default: system user)" + warn " --cursor=POS Cursor position: number, 'start', 'end' (default: end)" + warn " --marker=CHAR Cursor marker character (default: #{DEFAULT_MARKER})" + warn " --reset Reset shared edit state before starting" + warn "" + warn "Examples:" + warn " #{$PROGRAM_NAME} 123" + warn " #{$PROGRAM_NAME} 123 --speed=fast --cursor=start" + warn " #{$PROGRAM_NAME} 123 --user=admin --cursor=0" + warn "" + warn "Cursor Tracking:" + warn " The cursor marker shows where the robot is typing." + warn " Type AFTER the marker to test that OT keeps positions stable." + end + + def validate_options!(options) + if SPEEDS.exclude?(options[:speed]) + warn "Invalid speed: #{options[:speed]}. Valid options: #{SPEEDS.keys.join(", ")}" + exit 1 + end + + if MODES.exclude?(options[:mode]) + warn "Invalid mode: #{options[:mode]}. Valid options: #{MODES.join(", ")}" + exit 1 + end + end + + def print_banner(post, options) + puts "=" * 60 + puts "Fake Writer - Shared Edits Simulator" + puts "=" * 60 + puts "Post ID: #{post.id}" + puts "Post Title: #{post.topic&.title || "N/A"}" + puts "Speed: #{options[:speed]}" + puts "Mode: #{options[:mode]}" + puts "Cursor: #{options[:cursor]} (marker: #{options[:marker]})" + puts "=" * 60 + end + + def setup_shared_edits(post, options) + shared_edits_enabled = post.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED] + + if options[:reset] + puts "Resetting shared edit state..." + SharedEditRevision.toggle_shared_edits!(post.id, false) if shared_edits_enabled + SharedEditRevision.toggle_shared_edits!(post.id, true) + puts "Reset complete." + elsif !shared_edits_enabled + puts "Enabling shared edits on post..." + SharedEditRevision.toggle_shared_edits!(post.id, true) + else + puts "Shared edits already enabled." + end + end + + def run_mode(post_id, user_id, options) + speed_config = SPEEDS[options[:speed]] + + case options[:mode] + when "typewriter" + typewriter_mode(post_id, user_id, speed_config, options) + when "append" + append_mode(post_id, user_id, speed_config, options) + when "random" + random_mode(post_id, user_id, speed_config, options) + end + end + + def handle_interrupt(post_id, options) + marker = options[:marker] + puts "\n" + puts "=" * 60 + puts "Stopped by user." + + version, text = get_current_text(post_id) + marker_pos = find_marker_position(text, marker) + + puts "Final version: #{version}" + puts "Final text length: #{text.length} chars" + puts "Cursor marker position: #{marker_pos || "not found"}" + puts "" + puts "TIP: Remove the cursor marker '#{marker}' from the post when done." + puts "=" * 60 + end + end + end + + FakeWriter.run(ARGV) +end diff --git a/support/fake_writer.rb b/support/fake_writer.rb deleted file mode 100644 index 29da003..0000000 --- a/support/fake_writer.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true -require "fileutils" - -Dir.chdir(File.expand_path("../../../..", __FILE__)) do # rubocop:disable Discourse/NoChdir - require File.expand_path("../../config/environment", __FILE__) - - post_id = ARGV[0].to_i - - if post_id == 0 - STDERR.puts "Please specify a post id" - exit 1 - end - - puts "Simulating writing on #{post_id}" - - post = Post.find(post_id) - - revisions = %w[the quick brown fox jumped over the lazy fox.].map { |s| s + " " } - - revisions << { d: revisions.join.length } - - i = 0 - while true - rev = [revisions[i % revisions.length]] - ver = SharedEditRevision.where(post_id: post_id).maximum(:version) - SharedEditRevision.revise!( - post_id: post.id, - user_id: 1, - client_id: "a", - revision: rev, - version: ver, - ) - sleep(rand * 0.2 + 0.5) - print "." - i += 1 - end -end From 6380a4d019758bcd2be3109488956e51223fc224 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Thu, 27 Nov 2025 08:57:08 +1100 Subject: [PATCH 08/19] this is so complicated... but selection is getting there. --- .../discourse/services/shared-edit-manager.js | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/assets/javascripts/discourse/services/shared-edit-manager.js b/assets/javascripts/discourse/services/shared-edit-manager.js index 2aebafd..94a7d6d 100644 --- a/assets/javascripts/discourse/services/shared-edit-manager.js +++ b/assets/javascripts/discourse/services/shared-edit-manager.js @@ -187,16 +187,22 @@ export default class SharedEditManager extends Service { * @returns {void} */ #onTextareaMouseUp = () => { - const wasSelecting = this.#isSelecting; const hadSkippedUpdates = this.#skippedUpdatesDuringSelection; - const textareaSelection = this.#getTextareaSelection(); - this.#isSelecting = false; - this.#skippedUpdatesDuringSelection = false; - - // If we skipped updates while selecting, sync the textarea now - if (wasSelecting && hadSkippedUpdates) { - this.#syncTextareaAfterSelection(textareaSelection); + if (hadSkippedUpdates) { + // Keep #isSelecting true until after the browser has processed the click's + // selection change. Use requestAnimationFrame to defer capturing the selection + // so we get the correct post-click cursor position rather than the pre-click + // selection range. + requestAnimationFrame(() => { + const textareaSelection = this.#getTextareaSelection(); + this.#isSelecting = false; + this.#skippedUpdatesDuringSelection = false; + this.#syncTextareaAfterSelection(textareaSelection); + }); + } else { + this.#isSelecting = false; + this.#skippedUpdatesDuringSelection = false; } }; From 2867ea77c2ff6f4ca1314e91d158577df78e2c7b Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Thu, 27 Nov 2025 09:42:37 +1100 Subject: [PATCH 09/19] grab post raw content asap. --- .../discourse/initializers/shared-edits-init.js | 12 +++++++++++- test/javascripts/acceptance/composer-test.js | 7 +++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/assets/javascripts/discourse/initializers/shared-edits-init.js b/assets/javascripts/discourse/initializers/shared-edits-init.js index 2ede053..1ae5531 100644 --- a/assets/javascripts/discourse/initializers/shared-edits-init.js +++ b/assets/javascripts/discourse/initializers/shared-edits-init.js @@ -126,15 +126,25 @@ function initWithApi(api) { ); } - _handleSharedEditOnPost(post) { + async _handleSharedEditOnPost(post) { const draftKey = post.get("topic.draft_key"); const draftSequence = post.get("topic.draft_sequence"); + let raw; + try { + const result = await ajax(`/posts/${post.id}.json`); + raw = result.raw; + } catch (e) { + popupAjaxError(e); + return; + } + this.get("composer").open({ post, action: SHARED_EDIT_ACTION, draftKey, draftSequence, + reply: raw, }); } } diff --git a/test/javascripts/acceptance/composer-test.js b/test/javascripts/acceptance/composer-test.js index 71238aa..5b9a24f 100644 --- a/test/javascripts/acceptance/composer-test.js +++ b/test/javascripts/acceptance/composer-test.js @@ -10,6 +10,13 @@ acceptance(`Discourse Shared Edits | Composer`, function (needs) { helper.response({ success: "OK" }) ); + server.get("/posts/:id.json", () => + helper.response({ + id: 398, + raw: "initial post content", + }) + ); + server.get("/shared_edits/p/:id", () => helper.response({ state: "", From 16706340f9d1c63d7e08e970462c573fcd351bd9 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Thu, 27 Nov 2025 09:56:02 +1100 Subject: [PATCH 10/19] some clean up --- .../discourse/services/shared-edit-manager.js | 111 +----------------- 1 file changed, 2 insertions(+), 109 deletions(-) diff --git a/assets/javascripts/discourse/services/shared-edit-manager.js b/assets/javascripts/discourse/services/shared-edit-manager.js index 94a7d6d..d6a3ef2 100644 --- a/assets/javascripts/discourse/services/shared-edit-manager.js +++ b/assets/javascripts/discourse/services/shared-edit-manager.js @@ -111,10 +111,6 @@ function transformSelection(selection, delta) { return { start, end }; } -/** - * Coordinates collaborative post editing with Yjs and the Discourse message bus. - * @class SharedEditManager - */ export default class SharedEditManager extends Service { @service composer; @service messageBus; @@ -126,19 +122,14 @@ export default class SharedEditManager extends Service { pendingUpdates = []; suppressComposerChange = false; textObserver = null; + /** @type {Promise|null} - for eslint */ inFlightRequest = null; #pendingRelativeSelection = null; #isSelecting = false; #selectionListenersAttached = false; #skippedUpdatesDuringSelection = false; - /** - * Apply updates received from the message bus. - * @param {{client_id: string, update: string, action?: string, version?: number}} message - * @returns {void} - */ #onRemoteMessage = (message) => { - // Handle resync command from server (e.g., after recovery) if (message.action === "resync") { this.#handleResync(); return; @@ -148,8 +139,6 @@ export default class SharedEditManager extends Service { return; } - // Only capture relative selection if not actively selecting. - // During selection, we use #selectionStartRelative captured on mousedown. if (!this.#isSelecting) { this.#pendingRelativeSelection = this.#captureRelativeSelection(); } @@ -158,12 +147,6 @@ export default class SharedEditManager extends Service { window.Y.applyUpdate(this.doc, update, "remote"); }; - /** - * Queue outbound updates generated locally so they can be batched to the server. - * @param {Uint8Array} update - * @param {unknown} origin - * @returns {void} - */ #handleDocUpdate = (update, origin) => { if (origin !== this) { return; @@ -173,19 +156,11 @@ export default class SharedEditManager extends Service { this.#sendUpdatesThrottled(); }; - /** - * Handle mousedown on textarea - user may be starting a selection drag. - * @returns {void} - */ #onTextareaMouseDown = () => { this.#isSelecting = true; this.#skippedUpdatesDuringSelection = false; }; - /** - * Handle mouseup - user finished selecting. Sync textarea if updates were skipped. - * @returns {void} - */ #onTextareaMouseUp = () => { const hadSkippedUpdates = this.#skippedUpdatesDuringSelection; @@ -206,10 +181,6 @@ export default class SharedEditManager extends Service { } }; - /** - * Get current textarea selection. - * @returns {{start: number, end: number}|null} - */ #getTextareaSelection() { const textarea = document.querySelector(TEXTAREA_SELECTOR); if (!textarea) { @@ -218,11 +189,6 @@ export default class SharedEditManager extends Service { return { start: textarea.selectionStart, end: textarea.selectionEnd }; } - /** - * Sync textarea content after selection is complete, preserving selection position. - * @param {{start: number, end: number}|null} oldSelection - Selection in old content coordinates - * @returns {void} - */ #syncTextareaAfterSelection(oldSelection) { const textarea = document.querySelector(TEXTAREA_SELECTOR); if (!textarea || !this.text) { @@ -272,13 +238,6 @@ export default class SharedEditManager extends Service { } } - /** - * Transform selection coordinates from old text to new text based on diff. - * @param {string} oldText - * @param {string} newText - * @param {{start: number, end: number}} selection - * @returns {{start: number, end: number}} - */ #transformSelectionThroughDiff(oldText, newText, selection) { // Find common prefix length let prefixLen = 0; @@ -323,10 +282,6 @@ export default class SharedEditManager extends Service { }; } - /** - * Handle a resync command by reloading the document state from the server. - * @returns {Promise} - */ async #handleResync() { const postId = this.currentPostId || this.#postId; if (!postId) { @@ -344,10 +299,6 @@ export default class SharedEditManager extends Service { } } - /** - * Start syncing the current composer with the shared Yjs document for the post. - * @returns {Promise} - */ async subscribe() { try { const postId = this.#postId; @@ -375,10 +326,6 @@ export default class SharedEditManager extends Service { } } - /** - * Finalize the shared edit session and persist the composed content back to the post. - * @returns {Promise} - */ async commit() { const postId = this.currentPostId || this.#postId; @@ -403,12 +350,6 @@ export default class SharedEditManager extends Service { } } - /** - * Prepare a Yjs document for the session using the latest server state. - * @param {string} state base64 encoded Yjs update representing current state - * @param {string} raw fallback raw post text for empty states - * @returns {void} - */ async #setupDoc(state, raw) { this.#teardownDoc(); @@ -437,10 +378,6 @@ export default class SharedEditManager extends Service { this.suppressComposerChange = false; } - /** - * Remove observers and clear the current Yjs document. - * @returns {void} - */ #teardownDoc() { if (this.text && this.textObserver) { this.text.unobserve(this.textObserver); @@ -457,10 +394,6 @@ export default class SharedEditManager extends Service { this.textObserver = null; } - /** - * Attach listeners to track active selection state on the textarea. - * @returns {void} - */ #attachSelectionListeners() { if (this.#selectionListenersAttached) { return; @@ -477,10 +410,6 @@ export default class SharedEditManager extends Service { this.#selectionListenersAttached = true; } - /** - * Remove selection tracking listeners. - * @returns {void} - */ #detachSelectionListeners() { if (!this.#selectionListenersAttached) { return; @@ -495,17 +424,10 @@ export default class SharedEditManager extends Service { this.#isSelecting = false; } - /** - * @returns {number|undefined} id of the post currently being edited - */ get #postId() { return this.composer.model?.post.id; } - /** - * Reflect composer text changes into the shared Yjs document. - * @returns {void} - */ #onComposerChange() { if (!this.composer.model || !this.text || this.suppressComposerChange) { return; @@ -521,12 +443,6 @@ export default class SharedEditManager extends Service { this.doc.transact(() => applyDiff(this.text, current, next), this); } - /** - * Update composer text and selection when the shared document changes. - * @param {import("yjs").YTextEvent} event - * @param {import("yjs").Transaction} transaction - * @returns {void} - */ #handleTextChange(event, transaction) { if (transaction?.origin === this) { return; @@ -579,7 +495,7 @@ export default class SharedEditManager extends Service { textarea.selectionEnd = adjustedSelection.end; } - if (scrollTop !== undefined) { + if (scrollTop !== undefined && textarea.scrollTop !== scrollTop) { window.requestAnimationFrame(() => { textarea.scrollTop = scrollTop; }); @@ -587,10 +503,6 @@ export default class SharedEditManager extends Service { } } - /** - * Capture the current selection as Yjs relative positions so it survives remote updates. - * @returns {{ start: import("yjs").RelativePosition, end: import("yjs").RelativePosition, scrollTop?: number }|null} - */ #captureRelativeSelection() { const textarea = document.querySelector(TEXTAREA_SELECTOR); @@ -618,11 +530,6 @@ export default class SharedEditManager extends Service { }; } - /** - * Convert previously captured relative selection back to absolute indexes. - * @param {{ start: import("yjs").RelativePosition, end: import("yjs").RelativePosition, scrollTop?: number }|null} rel - * @returns {{ start: number, end: number, scrollTop?: number }|null} - */ #absoluteSelectionFromRelative(rel) { if (!rel) { return null; @@ -655,18 +562,10 @@ export default class SharedEditManager extends Service { }; } - /** - * Debounced enqueue of outbound updates to reduce request volume. - * @returns {void} - */ #sendUpdatesThrottled() { debounce(this, this.#sendUpdates, THROTTLE_SAVE); } - /** - * Immediately send any queued updates before shutting down the session. - * @returns {Promise} - */ async #flushPendingUpdates() { if (this.inFlightRequest) { await this.inFlightRequest; @@ -681,11 +580,6 @@ export default class SharedEditManager extends Service { } } - /** - * Send merged Yjs updates to the server. - * @param {boolean} immediate - * @returns {Promise} - */ async #sendUpdates(immediate = false) { const postId = this.currentPostId || this.#postId; @@ -723,7 +617,6 @@ export default class SharedEditManager extends Service { await this.inFlightRequest; } catch (e) { - // Handle state recovery response (409 Conflict) if ( e.jqXHR?.status === 409 && e.jqXHR?.responseJSON?.error === "state_recovered" From ded319ba6dd77526b749d15cd9bcd9c6cbd77c6d Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Thu, 27 Nov 2025 11:28:39 +1100 Subject: [PATCH 11/19] Avoid drift and leaks --- .../revision_controller.rb | 14 ++- app/models/shared_edit_revision.rb | 119 ++++++++++++++++-- .../discourse/services/shared-edit-manager.js | 7 +- spec/models/shared_edit_revision_spec.rb | 89 +++++++++++++ spec/requests/revision_controller_spec.rb | 51 ++++++++ 5 files changed, 270 insertions(+), 10 deletions(-) diff --git a/app/controllers/discourse_shared_edits/revision_controller.rb b/app/controllers/discourse_shared_edits/revision_controller.rb index ebd53a6..5e94e8f 100644 --- a/app/controllers/discourse_shared_edits/revision_controller.rb +++ b/app/controllers/discourse_shared_edits/revision_controller.rb @@ -44,10 +44,15 @@ def latest revision = SharedEditRevision.where(post_id: post.id).order("version desc").first end + # Include message_bus_last_id so clients can subscribe from the correct position + # to avoid missing any messages between fetching state and subscribing + message_bus_last_id = MessageBus.last_id("/shared_edits/#{post.id}") + render json: { raw: DiscourseSharedEdits::Yjs.text_from_state(revision.raw), version: revision.version, state: revision.raw, + message_bus_last_id: message_bus_last_id, } end @@ -122,6 +127,8 @@ def recover post.publish_message!( "/shared_edits/#{post.id}", { action: "resync", version: result[:new_version] }, + max_backlog_age: SharedEditRevision::MESSAGE_BUS_MAX_BACKLOG_AGE, + max_backlog_size: SharedEditRevision::MESSAGE_BUS_MAX_BACKLOG_SIZE, ) render json: result else @@ -136,7 +143,12 @@ def reset new_version = SharedEditRevision.reset_history!(post.id) # Notify connected clients to resync - post.publish_message!("/shared_edits/#{post.id}", { action: "resync", version: new_version }) + post.publish_message!( + "/shared_edits/#{post.id}", + { action: "resync", version: new_version }, + max_backlog_age: SharedEditRevision::MESSAGE_BUS_MAX_BACKLOG_AGE, + max_backlog_size: SharedEditRevision::MESSAGE_BUS_MAX_BACKLOG_SIZE, + ) render json: { success: true, version: new_version } end diff --git a/app/models/shared_edit_revision.rb b/app/models/shared_edit_revision.rb index 60b80b7..57b6392 100644 --- a/app/models/shared_edit_revision.rb +++ b/app/models/shared_edit_revision.rb @@ -4,6 +4,11 @@ class SharedEditRevision < ActiveRecord::Base belongs_to :post belongs_to :post_revision + MAX_HISTORY_AGE = 1.minute + MAX_HISTORY_COUNT = 200 + MESSAGE_BUS_MAX_BACKLOG_AGE = 600 + MESSAGE_BUS_MAX_BACKLOG_SIZE = 100 + def self.will_commit_key(post_id) "shared_revision_will_commit_#{post_id}" end @@ -54,21 +59,45 @@ def self.commit!(post_id, apply_to_post: true) latest = SharedEditRevision.where(post_id: post_id).order("version desc").first return if latest.nil? || latest.raw.nil? - return if latest.post_revision_id.present? && !apply_to_post - raw = DiscourseSharedEdits::Yjs.text_from_state(latest.raw) + # Validate state before attempting to extract text + validation = DiscourseSharedEdits::StateValidator.validate_state(latest.raw) + unless validation[:valid] + Rails.logger.warn( + "[SharedEdits] Cannot commit post #{post_id}: state is corrupted - #{validation[:error]}", + ) + return + end + + raw = validation[:text] - return raw if latest.post_revision_id.present? || !apply_to_post + return raw unless apply_to_post + + if latest.post_revision_id.present? + compact_history!(post_id) + return raw + end post = Post.find(post_id) revisor = PostRevisor.new(post) - opts = { bypass_rate_limiter: true, bypass_bump: true, skip_staff_log: true } + opts = { + bypass_rate_limiter: true, + bypass_bump: true, + skip_staff_log: true, + skip_validations: true, + } revised = revisor.revise!(Discourse.system_user, { raw: raw }, opts) - return raw unless revised + unless revised + compact_history!(post_id) + return raw + end post_revision = PostRevision.where(post: post).order("number desc").first - return raw if post_revision.nil? + if post_revision.nil? + compact_history!(post_id) + return raw + end SharedEditRevision.transaction do editor_usernames = collect_editor_usernames(post_id) @@ -76,6 +105,7 @@ def self.commit!(post_id, apply_to_post: true) latest.update!(post_revision_id: post_revision.id) end + compact_history!(post_id) raw end @@ -113,7 +143,7 @@ def self.update_edit_reason(post, post_revision, new_usernames) post_revision.modifications["edit_reason"] = [nil, new_reason] post_revision.save! - post.update!(edit_reason: new_reason) + post.update_column(:edit_reason, new_reason) end private_class_method :update_edit_reason @@ -157,6 +187,73 @@ def self.latest_raw(post_id) MAX_REVISION_RETRIES = 3 + def self.compact_history!(post_id) + latest = SharedEditRevision.where(post_id: post_id).order("version desc").limit(1).first + + return if latest.nil? + + # Validate the latest state before compaction - if it's invalid, don't compact + # as we may need older revisions for recovery + validation = DiscourseSharedEdits::StateValidator.validate_state(latest.raw) + unless validation[:valid] + Rails.logger.warn( + "[SharedEdits] Skipping compaction for post #{post_id}: latest state is invalid - #{validation[:error]}", + ) + return + end + + keep_ids = [latest.id] + + last_committed_id = + SharedEditRevision + .where(post_id: post_id) + .where.not(post_revision_id: nil) + .order("version desc") + .limit(1) + .pluck(:id) + .first + keep_ids << last_committed_id if last_committed_id + keep_ids.compact! + keep_ids.uniq! + + SharedEditRevision + .where(post_id: post_id) + .where("updated_at < ?", MAX_HISTORY_AGE.ago) + .where.not(id: keep_ids) + .delete_all + + remaining_scope = + SharedEditRevision.where(post_id: post_id).where.not(id: keep_ids).order("version desc") + + additional_limit = MAX_HISTORY_COUNT - keep_ids.length + if additional_limit.positive? + keep_ids.concat(remaining_scope.limit(additional_limit).pluck(:id)) + keep_ids.uniq! + end + + SharedEditRevision.where(post_id: post_id).where.not(id: keep_ids).delete_all + + # Verify post-compaction state integrity + post_compaction_latest = + SharedEditRevision.where(post_id: post_id).order("version desc").limit(1).first + + if post_compaction_latest.nil? + Rails.logger.error( + "[SharedEdits] Compaction error for post #{post_id}: no revisions remain after compaction", + ) + return + end + + post_validation = + DiscourseSharedEdits::StateValidator.validate_state(post_compaction_latest.raw) + unless post_validation[:valid] + Rails.logger.error( + "[SharedEdits] Compaction error for post #{post_id}: post-compaction state is invalid - #{post_validation[:error]}", + ) + end + end + private_class_method :compact_history! + def self.revise!(post_id:, user_id:, client_id:, update:) retries = 0 @@ -185,7 +282,13 @@ def self.revise!(post_id:, user_id:, client_id:, update:) client_id: client_id, user_id: user_id, } - post.publish_message!("/shared_edits/#{post.id}", message) + # Limit backlog to prevent unbounded Redis growth + post.publish_message!( + "/shared_edits/#{post.id}", + message, + max_backlog_age: MESSAGE_BUS_MAX_BACKLOG_AGE, + max_backlog_size: MESSAGE_BUS_MAX_BACKLOG_SIZE, + ) [revision.version, update] end diff --git a/assets/javascripts/discourse/services/shared-edit-manager.js b/assets/javascripts/discourse/services/shared-edit-manager.js index d6a3ef2..622bc44 100644 --- a/assets/javascripts/discourse/services/shared-edit-manager.js +++ b/assets/javascripts/discourse/services/shared-edit-manager.js @@ -317,9 +317,14 @@ export default class SharedEditManager extends Service { this.#setupDoc(data.state, data.raw); this.addObserver("composer.model.reply", this, this.#onComposerChange); + + // Subscribe starting from the message_bus_last_id returned with the state + // to ensure we don't miss any messages that arrived between fetching + // the state and subscribing this.messageBus.subscribe( `/shared_edits/${postId}`, - this.#onRemoteMessage + this.#onRemoteMessage, + data.message_bus_last_id ?? -1 ); } catch (e) { popupAjaxError(e); diff --git a/spec/models/shared_edit_revision_spec.rb b/spec/models/shared_edit_revision_spec.rb index 0e25c92..9cdff8c 100644 --- a/spec/models/shared_edit_revision_spec.rb +++ b/spec/models/shared_edit_revision_spec.rb @@ -166,6 +166,95 @@ def fake_edit(post, user_id, new_text) expect(post.reload.raw).to eq("Original content that is long enough") end + + it "removes revisions older than the compaction window" do + SharedEditRevision.init!(post) + user = Fabricate(:user) + fake_edit(post, user.id, "recent change") + fake_edit(post, user.id, "another change") + + stale_revision = SharedEditRevision.find_by(post_id: post.id, version: 2) + stale_revision.update_column(:updated_at, 2.minutes.ago) + + SharedEditRevision.commit!(post.id) + + expect(SharedEditRevision.exists?(stale_revision.id)).to eq(false) + expect(SharedEditRevision.where(post_id: post.id).order("version desc").first.version).to eq( + 3, + ) + end + + it "caps stored revisions to the configured history limit" do + SharedEditRevision.init!(post) + user = Fabricate(:user) + + (SharedEditRevision::MAX_HISTORY_COUNT + 10).times do |i| + fake_edit(post, user.id, "change #{i}") + end + + SharedEditRevision.commit!(post.id) + + expect(SharedEditRevision.where(post_id: post.id).count).to be <= + SharedEditRevision::MAX_HISTORY_COUNT + end + + it "maintains valid ydoc state after compaction" do + SharedEditRevision.init!(post) + user = Fabricate(:user) + + 5.times { |i| fake_edit(post, user.id, "Content version #{i + 1}") } + + SharedEditRevision + .where(post_id: post.id) + .where("version < ?", 5) + .update_all(updated_at: 2.minutes.ago) + + SharedEditRevision.commit!(post.id) + + latest = SharedEditRevision.where(post_id: post.id).order("version desc").first + validation = DiscourseSharedEdits::StateValidator.validate_state(latest.raw) + + expect(validation[:valid]).to eq(true) + expect(validation[:text]).to be_present + end + + it "skips compaction when latest state is corrupted" do + SharedEditRevision.init!(post) + user = Fabricate(:user) + fake_edit(post, user.id, "Valid content here") + + old_revision = SharedEditRevision.find_by(post_id: post.id, version: 1) + old_revision.update_column(:updated_at, 2.minutes.ago) + + latest = SharedEditRevision.where(post_id: post.id).order("version desc").first + latest.update_column(:raw, Base64.strict_encode64("corrupted data")) + + initial_count = SharedEditRevision.where(post_id: post.id).count + + SharedEditRevision.commit!(post.id, apply_to_post: false) + + expect(SharedEditRevision.where(post_id: post.id).count).to eq(initial_count) + end + + it "preserves ability to continue editing after compaction" do + SharedEditRevision.init!(post) + user = Fabricate(:user) + + 5.times { |i| fake_edit(post, user.id, "Edit #{i + 1}") } + + SharedEditRevision + .where(post_id: post.id) + .where("version < ?", 5) + .update_all(updated_at: 2.minutes.ago) + + SharedEditRevision.commit!(post.id) + + fake_edit(post, user.id, "Post-compaction edit works") + + version, text = SharedEditRevision.latest_raw(post.id) + expect(text).to eq("Post-compaction edit works") + expect(version).to be > 5 + end end it "can resolve complex edits and notify" do diff --git a/spec/requests/revision_controller_spec.rb b/spec/requests/revision_controller_spec.rb index 76777e2..5c2fa0c 100644 --- a/spec/requests/revision_controller_spec.rb +++ b/spec/requests/revision_controller_spec.rb @@ -109,6 +109,57 @@ def latest_state_for(post) get "/shared_edits/p/999999" expect(response.status).to eq(404) end + + it "includes message_bus_last_id in response" do + get "/shared_edits/p/#{post1.id}" + + expect(response.status).to eq(200) + expect(response.parsed_body).to have_key("message_bus_last_id") + expect(response.parsed_body["message_bus_last_id"]).to be_a(Integer) + end + + it "returns correct message_bus_last_id after revisions" do + get "/shared_edits/p/#{post1.id}" + initial_last_id = response.parsed_body["message_bus_last_id"] + + new_text = "Updated content here" + latest_state = latest_state_for(post1) + + put "/shared_edits/p/#{post1.id}", + params: { + client_id: "abc", + update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text), + } + + get "/shared_edits/p/#{post1.id}" + new_last_id = response.parsed_body["message_bus_last_id"] + + expect(new_last_id).to be > initial_last_id + end + + it "allows client to subscribe without missing messages using message_bus_last_id" do + get "/shared_edits/p/#{post1.id}" + last_id_at_fetch = response.parsed_body["message_bus_last_id"] + + new_text = "Edit made after fetch but before subscribe" + latest_state = latest_state_for(post1) + + messages = + MessageBus.track_publish("/shared_edits/#{post1.id}") do + put "/shared_edits/p/#{post1.id}", + params: { + client_id: "other_client", + update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text), + } + end + + expect(messages.length).to eq(1) + + backlog = MessageBus.backlog("/shared_edits/#{post1.id}", last_id_at_fetch) + + expect(backlog.length).to eq(1) + expect(backlog.first.data["version"]).to eq(2) + end end describe "#commit" do From dd319d342b0aad9613100ce17e1b72f5028f30dc Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Thu, 27 Nov 2025 14:37:39 +1100 Subject: [PATCH 12/19] mobile should just have a done button. let plugin know when post actions change --- app/models/shared_edit_revision.rb | 1 + .../composer-fields-below/shared-edit-buttons.gjs | 8 ++------ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/models/shared_edit_revision.rb b/app/models/shared_edit_revision.rb index 57b6392..a38a131 100644 --- a/app/models/shared_edit_revision.rb +++ b/app/models/shared_edit_revision.rb @@ -36,6 +36,7 @@ def self.toggle_shared_edits!(post_id, enable) post.custom_fields.delete(DiscourseSharedEdits::SHARED_EDITS_ENABLED) end post.save_custom_fields + post.publish_change_to_clients!(:acted) end def self.init!(post) diff --git a/assets/javascripts/discourse/connectors/composer-fields-below/shared-edit-buttons.gjs b/assets/javascripts/discourse/connectors/composer-fields-below/shared-edit-buttons.gjs index b008764..27ba376 100644 --- a/assets/javascripts/discourse/connectors/composer-fields-below/shared-edit-buttons.gjs +++ b/assets/javascripts/discourse/connectors/composer-fields-below/shared-edit-buttons.gjs @@ -2,11 +2,9 @@ import Component from "@glimmer/component"; import { action } from "@ember/object"; import { service } from "@ember/service"; import DButton from "discourse/components/d-button"; -import { i18n } from "discourse-i18n"; export default class SharedEditButtons extends Component { @service composer; - @service site; @action endSharedEdit() { @@ -18,10 +16,8 @@ export default class SharedEditButtons extends Component {
{{/if}} From 83c270ddb153a8e851b3952dcb4e68128a5c9500 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Fri, 28 Nov 2025 08:51:28 +1100 Subject: [PATCH 13/19] real browser for fake writer and throttle for sending updates. --- .../discourse/services/shared-edit-manager.js | 7 +- support/fake_writer | 621 ++++++------------ 2 files changed, 192 insertions(+), 436 deletions(-) diff --git a/assets/javascripts/discourse/services/shared-edit-manager.js b/assets/javascripts/discourse/services/shared-edit-manager.js index 622bc44..f353c0d 100644 --- a/assets/javascripts/discourse/services/shared-edit-manager.js +++ b/assets/javascripts/discourse/services/shared-edit-manager.js @@ -1,4 +1,4 @@ -import { debounce } from "@ember/runloop"; +import { throttle } from "@ember/runloop"; import Service, { service } from "@ember/service"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; @@ -568,7 +568,10 @@ export default class SharedEditManager extends Service { } #sendUpdatesThrottled() { - debounce(this, this.#sendUpdates, THROTTLE_SAVE); + // Use throttle instead of debounce so updates sync periodically during + // continuous typing, not just when typing stops. With immediate=true, + // the first call executes immediately, then at most once per THROTTLE_SAVE ms. + throttle(this, this.#sendUpdates, THROTTLE_SAVE, true); } async #flushPendingUpdates() { diff --git a/support/fake_writer b/support/fake_writer index aa0c9f2..db5cddc 100755 --- a/support/fake_writer +++ b/support/fake_writer @@ -1,460 +1,213 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Simulates a user typing in a shared edit session. -# Useful for testing that shared edits work correctly. +# rubocop:disable Discourse/Plugins/NamespaceConstants, Discourse/Plugins/NamespaceMethods + +# Browser-based shared edit simulator for testing Yjs operational transformation # -# Usage: -# plugins/discourse-shared-edits/support/fake_writer POST_ID [OPTIONS] +# Usage: fake_writer POST_ID [OPTIONS] # # Options: -# --speed=SPEED Typing speed: slow, normal, fast (default: normal) -# --mode=MODE Edit mode: append, typewriter, random (default: typewriter) -# --user=USER User ID or username to write as (default: system user) -# --cursor=POS Cursor position: number, 'start', or 'end' (default: end) -# --marker=CHAR Cursor marker character (default: ▌) -# --reset Reset shared edit state before starting -# -# Examples: -# plugins/discourse-shared-edits/support/fake_writer 123 -# plugins/discourse-shared-edits/support/fake_writer 123 --speed=fast --cursor=0 -# plugins/discourse-shared-edits/support/fake_writer 123 --user=admin --cursor=start +# --speed=slow|normal|fast Typing speed (default: normal) +# --headless=true|false Run headless browser (default: true) +# --user=USERNAME Login username (default: admin) +# --base-url=URL Discourse URL (default: http://localhost:4200) # -# Cursor Tracking: -# The typewriter mode uses a visible cursor marker (▌) to track position. -# This lets you test OT by typing after the marker while the robot types before it. -# The marker moves naturally with the text through Yjs operational transformation. - -discourse_root = File.expand_path("../../../..", __FILE__) -Dir.chdir(discourse_root) do # rubocop:disable Discourse/NoChdir - require File.join(discourse_root, "config/environment") - - module FakeWriter - PHRASES = [ - "The quick brown fox jumps over the lazy dog. ", - "Hello world! ", - "Testing shared edits... ", - "This is a collaborative document. ", - "Multiple users can edit simultaneously. ", - "Real-time synchronization in action! ", - "Lorem ipsum dolor sit amet. ", - "Sphinx of black quartz, judge my vow. ", - "Pack my box with five dozen liquor jugs. ", - "How vexingly quick daft zebras jump! ", - ].freeze - - SPEEDS = { - "slow" => { char_delay: 0.15..0.3, phrase_delay: 1.0..2.0 }, - "normal" => { char_delay: 0.05..0.15, phrase_delay: 0.5..1.0 }, - "fast" => { char_delay: 0.01..0.05, phrase_delay: 0.1..0.3 }, - }.freeze - - MODES = %w[append typewriter random].freeze - DEFAULT_MARKER = "▌" - - class << self - def parse_args(args) - options = { - speed: "normal", - mode: "typewriter", - reset: false, - user: nil, - cursor: "end", - marker: DEFAULT_MARKER, - } - - post_id = nil - args.each do |arg| - case arg - when /^--speed=(.+)$/ - options[:speed] = $1 - when /^--mode=(.+)$/ - options[:mode] = $1 - when /^--user=(.+)$/ - options[:user] = $1 - when /^--cursor=(.+)$/ - options[:cursor] = $1 - when /^--marker=(.+)$/ - options[:marker] = $1 - when "--reset" - options[:reset] = true - when /^\d+$/ - post_id = arg.to_i - end - end - - [post_id, options] - end - - def random_delay(range) - sleep(rand(range)) - end - - def get_current_text(post_id) - version, text = SharedEditRevision.latest_raw(post_id) - [version, text || ""] - end - - def apply_edit(post_id, user_id, new_text) - latest = SharedEditRevision.where(post_id: post_id).order("version desc").first - return nil unless latest - - update = DiscourseSharedEdits::Yjs.update_from_state(latest.raw, new_text) - SharedEditRevision.revise!( - post_id: post_id, - user_id: user_id, - client_id: "fake_writer", - update: update, - ) - rescue StandardError => e - puts "\n[ERROR] Failed to apply edit: #{e.message}" - nil - end - - def find_marker_position(text, marker) - text.index(marker) - end - - def insert_at_marker(text, marker, insert_text) - pos = find_marker_position(text, marker) - return nil if pos.nil? - - text.dup.insert(pos, insert_text) - end - - def calculate_initial_cursor(text, cursor_option) - case cursor_option - when "start" - 0 - when "end" - text.length - when /^-?\d+$/ - pos = cursor_option.to_i - pos = 0 if pos < 0 - pos = text.length if pos > text.length - pos - else - text.length - end - end - - def setup_cursor(post_id, user_id, text, options) - marker = options[:marker] - - # Check if marker already exists - existing_pos = find_marker_position(text, marker) - if existing_pos - puts "Found existing cursor marker at position #{existing_pos}" - return text - end - - # Insert marker at specified position - cursor_pos = calculate_initial_cursor(text, options[:cursor]) - new_text = text.dup.insert(cursor_pos, marker) - - result = apply_edit(post_id, user_id, new_text) - if result - puts "Inserted cursor marker '#{marker}' at position #{cursor_pos}" - new_text - else - warn "Failed to insert cursor marker" - exit 1 - end - end - - def typewriter_mode(post_id, user_id, speed_config, options) - marker = options[:marker] - puts "[typewriter] Typing at cursor marker '#{marker}'..." - puts " (Text inserted AFTER the marker will stay after it - test OT!)" - puts "" - - phrase_index = 0 - - loop do - phrase = PHRASES[phrase_index % PHRASES.length] - phrase_index += 1 - - phrase.each_char do |char| - _version, current_text = get_current_text(post_id) - - marker_pos = find_marker_position(current_text, marker) - if marker_pos.nil? - puts "\n[WARN] Cursor marker lost! Re-inserting at end..." - new_text = current_text + marker - apply_edit(post_id, user_id, new_text) - next - end - - new_text = insert_at_marker(current_text, marker, char) - result = apply_edit(post_id, user_id, new_text) - - if result - print char - $stdout.flush - end - - random_delay(speed_config[:char_delay]) - end - - puts - random_delay(speed_config[:phrase_delay]) - end - end - - def append_mode(post_id, user_id, speed_config, options) - marker = options[:marker] - puts "[append] Appending phrases at cursor marker '#{marker}'..." - puts "" - - loop do - phrase = PHRASES.sample - - _version, current_text = get_current_text(post_id) - - marker_pos = find_marker_position(current_text, marker) - if marker_pos.nil? - puts "[WARN] Cursor marker lost! Re-inserting at end..." - new_text = current_text + marker - apply_edit(post_id, user_id, new_text) - next - end - - new_text = insert_at_marker(current_text, marker, phrase) - result = apply_edit(post_id, user_id, new_text) - - puts "+ #{phrase.strip}" if result - - random_delay(speed_config[:phrase_delay]) - end - end - - def random_mode(post_id, user_id, speed_config, options) - marker = options[:marker] - puts "[random] Random edits (marker '#{marker}' shows cursor position)..." - puts "" - - loop do - _version, current_text = get_current_text(post_id) - - # Remove marker temporarily for random operations - marker_pos = find_marker_position(current_text, marker) - text_without_marker = marker_pos ? current_text.sub(marker, "") : current_text - - action = %i[insert delete replace].sample - new_text_without_marker = perform_random_action(action, text_without_marker) - - if new_text_without_marker != text_without_marker - # Re-insert marker at same relative position (or end if position lost) - new_marker_pos = marker_pos ? [marker_pos, new_text_without_marker.length].min : new_text_without_marker.length - new_text = new_text_without_marker.dup.insert(new_marker_pos, marker) - - result = apply_edit(post_id, user_id, new_text) - puts result ? "✓" : "✗" - else - puts "skipped" - end - - random_delay(speed_config[:phrase_delay]) - end - end - - def perform_random_action(action, current_text) - case action - when :insert - perform_insert(current_text) - when :delete - perform_delete(current_text) - when :replace - perform_replace(current_text) - end - end - - def perform_insert(current_text) - pos = rand(0..current_text.length) - insert_text = PHRASES.sample.split.sample(rand(1..3)).join(" ") + " " - print "[insert@#{pos}] \"#{insert_text.strip}\" " - current_text.dup.insert(pos, insert_text) - end - - def perform_delete(current_text) - if current_text.length > 10 - start_pos = rand(0...[current_text.length - 5, 0].max) - delete_len = rand(1..[10, current_text.length - start_pos].min) - deleted = current_text[start_pos, delete_len] - print "[delete@#{start_pos}] \"#{deleted}\" " - current_text.dup.tap { |s| s[start_pos, delete_len] = "" } - else - print "[skip delete - text too short] " - current_text - end - end - - def perform_replace(current_text) - if current_text.length > 5 - start_pos = rand(0...[current_text.length - 3, 0].max) - replace_len = rand(1..[5, current_text.length - start_pos].min) - replacement = PHRASES.sample.split.sample || "X" - old_text_segment = current_text[start_pos, replace_len] - print "[replace@#{start_pos}] \"#{old_text_segment}\" -> \"#{replacement}\" " - current_text.dup.tap { |s| s[start_pos, replace_len] = replacement } - else - print "[skip replace - text too short] " - current_text - end - end - - def run(args) - post_id, options = parse_args(args) - - if post_id.nil? || post_id == 0 - print_usage - exit 1 - end - - validate_options!(options) +# The shared-edits plugin throttles syncs every 350ms during continuous typing. + +rails_root = File.expand_path("../../../..", __FILE__) +ENV["RAILS_ENV"] ||= "development" +# rubocop:disable Discourse/NoChdir +Dir.chdir(rails_root) +require File.expand_path("config/environment", rails_root) + +require "playwright" + +MARKER = "▌" + +PHRASES = [ + "The quick brown fox jumps over the lazy dog. ", + "Hello world! This is a test. ", + "Testing shared edits functionality. ", + "Multiple users can edit simultaneously. ", + "Real-time synchronization in action! ", + "Lorem ipsum dolor sit amet. ", +].freeze + +# Speed configs +SPEEDS = { + "slow" => { + char_ms: 100..200, + phrase_ms: 800..1500, + }, + "normal" => { + char_ms: 30..80, + phrase_ms: 200..400, + }, + "fast" => { + char_ms: 10..30, + phrase_ms: 50..150, + }, +}.freeze + +def parse_args + opts = { speed: "normal", headless: true, user: "admin", base_url: "http://localhost:4200" } + post_id = nil + + ARGV.each do |arg| + case arg + when /^--speed=(.+)$/ + opts[:speed] = $1 + when /^--headless=(.+)$/ + opts[:headless] = $1 != "false" + when /^--user=(.+)$/ + opts[:user] = $1 + when /^--base-url=(.+)$/ + opts[:base_url] = $1 + when /^--post-id=(\d+)$/ + post_id = $1.to_i + when /^\d+$/ + post_id = arg.to_i + end + end - post = Post.find_by(id: post_id) - if post.nil? - warn "Post #{post_id} not found" - exit 1 - end + [post_id, opts] +end - print_banner(post, options) +def topic_for_post(post_id) + post = Post.includes(:topic).find_by(id: post_id) + unless post + warn "Post #{post_id} not found" + exit 1 + end - user = find_user(options[:user]) - puts "Writing as: #{user.username} (id: #{user.id})" + topic = post.topic + unless topic + warn "Topic for post #{post_id} not found" + exit 1 + end - setup_shared_edits(post, options) + { id: topic.id, slug: topic.slug, title: topic.title } +end - _version, text = get_current_text(post_id) - text = setup_cursor(post_id, user.id, text, options) +def type_char(page, char, marker) + input = page.query_selector("#reply-control .d-editor-input") + return false unless input + + content = input.evaluate("el => el.value") + pos = content.index(marker) + return false unless pos + + input.focus + page.evaluate( + "(pos) => { document.querySelector('#reply-control .d-editor-input').setSelectionRange(pos, pos); }", + arg: pos, + ) + page.keyboard.insert_text(char) + true +end - version, text = get_current_text(post_id) - puts "Current version: #{version}" - puts "Current text length: #{text.length} chars (including marker)" - puts "=" * 60 - puts "Starting simulation (Ctrl+C to stop)..." - puts "" +def insert_marker(page, marker) + input = page.query_selector("#reply-control .d-editor-input") + return unless input + + content = input.evaluate("el => el.value") + return if content.include?(marker) + + pos = content.length + js = + "(p) => { const el = document.querySelector('#reply-control .d-editor-input'); " \ + "el.value = el.value.slice(0,p) + '#{marker}' + el.value.slice(p); " \ + "el.dispatchEvent(new Event('input', {bubbles:true})); }" + page.evaluate(js, arg: pos) + puts "Inserted cursor marker at position #{pos}" + sleep 0.5 +end - run_mode(post_id, user.id, options) - rescue Interrupt - handle_interrupt(post_id, options) - end +# Main +post_id, opts = parse_args - private - - def find_user(user_option) - if user_option.nil? - Discourse.system_user || User.where(admin: true).first - elsif user_option.match?(/^-?\d+$/) - user = User.find_by(id: user_option.to_i) - if user.nil? - warn "User with ID #{user_option} not found" - exit 1 - end - user - else - user = User.find_by(username: user_option) - if user.nil? - warn "User '#{user_option}' not found" - exit 1 - end - user - end - end +unless post_id + puts "Usage: fake_writer POST_ID [--speed=slow|normal|fast] [--headless=true|false]" + exit 1 +end - def print_usage - warn "Usage: #{$PROGRAM_NAME} POST_ID [OPTIONS]" - warn "" - warn "Options:" - warn " --speed=SPEED slow, normal, fast (default: normal)" - warn " --mode=MODE append, typewriter, random (default: typewriter)" - warn " --user=USER User ID or username (default: system user)" - warn " --cursor=POS Cursor position: number, 'start', 'end' (default: end)" - warn " --marker=CHAR Cursor marker character (default: #{DEFAULT_MARKER})" - warn " --reset Reset shared edit state before starting" - warn "" - warn "Examples:" - warn " #{$PROGRAM_NAME} 123" - warn " #{$PROGRAM_NAME} 123 --speed=fast --cursor=start" - warn " #{$PROGRAM_NAME} 123 --user=admin --cursor=0" - warn "" - warn "Cursor Tracking:" - warn " The cursor marker shows where the robot is typing." - warn " Type AFTER the marker to test that OT keeps positions stable." - end +speed = SPEEDS[opts[:speed]] || SPEEDS["normal"] +topic = topic_for_post(post_id) +marker = MARKER + +puts "=" * 50 +puts "Fake Writer - Shared Edit Simulator" +puts "=" * 50 +puts "Post: #{post_id}" +puts "Topic: #{topic[:title]}" +puts "Speed: #{opts[:speed]} (char: #{speed[:char_ms]}ms, phrase: #{speed[:phrase_ms]}ms)" +puts "Headless: #{opts[:headless]}" +puts "=" * 50 + +Playwright.create(playwright_cli_executable_path: "./node_modules/.bin/playwright") do |playwright| + browser = + playwright.chromium.launch( + headless: opts[:headless], + args: %w[--no-sandbox --disable-dev-shm-usage --disable-gpu --mute-audio], + ) + page = browser.new_page + + begin + # Login + puts "Logging in as #{opts[:user]}..." + page.goto("#{opts[:base_url]}/session/#{opts[:user]}/become") + sleep 2 + + # Navigate to topic + puts "Opening topic..." + page.goto("#{opts[:base_url]}/t/#{topic[:slug]}/#{topic[:id]}") + sleep 2 + + # Enable shared edits if not already + unless page.query_selector(".shared-edit") + puts "Enabling shared edits..." + page.query_selector(".show-more-actions")&.click + sleep 0.3 + page.query_selector(".show-post-admin-menu")&.click + sleep 0.3 + page.query_selector(".admin-toggle-shared-edits")&.click + sleep 1 + end - def validate_options!(options) - if SPEEDS.exclude?(options[:speed]) - warn "Invalid speed: #{options[:speed]}. Valid options: #{SPEEDS.keys.join(", ")}" - exit 1 - end + # Open composer + puts "Opening shared edit composer..." + page.wait_for_selector(".shared-edit", timeout: 5000).click + sleep 2 + page.wait_for_selector("#reply-control.open", timeout: 10_000) + puts "Composer ready" - if MODES.exclude?(options[:mode]) - warn "Invalid mode: #{options[:mode]}. Valid options: #{MODES.join(", ")}" - exit 1 - end - end + # Insert marker if needed + insert_marker(page, marker) - def print_banner(post, options) - puts "=" * 60 - puts "Fake Writer - Shared Edits Simulator" - puts "=" * 60 - puts "Post ID: #{post.id}" - puts "Post Title: #{post.topic&.title || "N/A"}" - puts "Speed: #{options[:speed]}" - puts "Mode: #{options[:mode]}" - puts "Cursor: #{options[:cursor]} (marker: #{options[:marker]})" - puts "=" * 60 - end + puts "-" * 50 + puts "Typing... (Ctrl+C to stop)" + puts "-" * 50 - def setup_shared_edits(post, options) - shared_edits_enabled = post.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED] - - if options[:reset] - puts "Resetting shared edit state..." - SharedEditRevision.toggle_shared_edits!(post.id, false) if shared_edits_enabled - SharedEditRevision.toggle_shared_edits!(post.id, true) - puts "Reset complete." - elsif !shared_edits_enabled - puts "Enabling shared edits on post..." - SharedEditRevision.toggle_shared_edits!(post.id, true) - else - puts "Shared edits already enabled." - end - end + # Main typing loop + idx = 0 + loop do + phrase = PHRASES[idx % PHRASES.length] + idx += 1 - def run_mode(post_id, user_id, options) - speed_config = SPEEDS[options[:speed]] - - case options[:mode] - when "typewriter" - typewriter_mode(post_id, user_id, speed_config, options) - when "append" - append_mode(post_id, user_id, speed_config, options) - when "random" - random_mode(post_id, user_id, speed_config, options) - end + phrase.each_char do |char| + type_char(page, char, marker) + print char + $stdout.flush + sleep(rand(speed[:char_ms]) / 1000.0) end - def handle_interrupt(post_id, options) - marker = options[:marker] - puts "\n" - puts "=" * 60 - puts "Stopped by user." - - version, text = get_current_text(post_id) - marker_pos = find_marker_position(text, marker) - - puts "Final version: #{version}" - puts "Final text length: #{text.length} chars" - puts "Cursor marker position: #{marker_pos || "not found"}" - puts "" - puts "TIP: Remove the cursor marker '#{marker}' from the post when done." - puts "=" * 60 - end + puts + sleep(rand(speed[:phrase_ms]) / 1000.0) end + rescue Interrupt + puts "\n\nStopped by user." + ensure + browser.close end - - FakeWriter.run(ARGV) end From 1a30adf8820c5d52e30df32f4362a04d17ba875d Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Sat, 29 Nov 2025 17:12:18 +1100 Subject: [PATCH 14/19] fix throttle and add cleaner support for undo --- .../discourse/services/shared-edit-manager.js | 115 +++++++++++++++++- 1 file changed, 111 insertions(+), 4 deletions(-) diff --git a/assets/javascripts/discourse/services/shared-edit-manager.js b/assets/javascripts/discourse/services/shared-edit-manager.js index f353c0d..7dd4fb4 100644 --- a/assets/javascripts/discourse/services/shared-edit-manager.js +++ b/assets/javascripts/discourse/services/shared-edit-manager.js @@ -148,7 +148,7 @@ export default class SharedEditManager extends Service { }; #handleDocUpdate = (update, origin) => { - if (origin !== this) { + if (origin !== this && origin !== this.undoManager) { return; } @@ -161,6 +161,28 @@ export default class SharedEditManager extends Service { this.#skippedUpdatesDuringSelection = false; }; + #onTextareaKeydown = (event) => { + if (!this.undoManager) { + return; + } + + const isCtrl = event.ctrlKey || event.metaKey; + const isShift = event.shiftKey; + + if (isCtrl && !isShift && event.key.toLowerCase() === "z") { + event.preventDefault(); + this.undoManager.undo(); + } + + if ( + (isCtrl && isShift && event.key.toLowerCase() === "z") || + (isCtrl && !isShift && event.key.toLowerCase() === "y") + ) { + event.preventDefault(); + this.undoManager.redo(); + } + }; + #onTextareaMouseUp = () => { const hadSkippedUpdates = this.#skippedUpdatesDuringSelection; @@ -216,7 +238,9 @@ export default class SharedEditManager extends Service { this.composer.model?.set("reply", newText); this.suppressComposerChange = false; - textarea.value = newText; + if (oldText !== newText) { + this.#applyDiffToTextarea(textarea, oldText, newText); + } if (adjustedSelection) { // Clamp selection to valid range @@ -238,6 +262,34 @@ export default class SharedEditManager extends Service { } } + #applyDiffToTextarea(textarea, oldText, newText) { + // Find common prefix length + let prefixLen = 0; + const minLen = Math.min(oldText.length, newText.length); + while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) { + prefixLen++; + } + + // Find common suffix length (but don't overlap with prefix) + let suffixLen = 0; + while ( + suffixLen < oldText.length - prefixLen && + suffixLen < newText.length - prefixLen && + oldText[oldText.length - 1 - suffixLen] === + newText[newText.length - 1 - suffixLen] + ) { + suffixLen++; + } + + const replacement = newText.slice(prefixLen, newText.length - suffixLen); + textarea.setRangeText( + replacement, + prefixLen, + oldText.length - suffixLen, + "preserve" + ); + } + #transformSelectionThroughDiff(oldText, newText, selection) { // Find common prefix length let prefixLen = 0; @@ -376,6 +428,11 @@ export default class SharedEditManager extends Service { this.text.observe(this.textObserver); this.doc.on("update", this.#handleDocUpdate); + this.undoManager = new Y.UndoManager(this.text, { + trackedOrigins: new Set([this]), + captureTimeout: 500, + }); + this.#attachSelectionListeners(); this.suppressComposerChange = true; @@ -392,6 +449,11 @@ export default class SharedEditManager extends Service { this.doc.off("update", this.#handleDocUpdate); } + if (this.undoManager) { + this.undoManager.destroy(); + this.undoManager = null; + } + this.#detachSelectionListeners(); this.doc = null; @@ -410,6 +472,7 @@ export default class SharedEditManager extends Service { } textarea.addEventListener("mousedown", this.#onTextareaMouseDown); + textarea.addEventListener("keydown", this.#onTextareaKeydown); // Use document for mouseup to catch releases outside the textarea document.addEventListener("mouseup", this.#onTextareaMouseUp); this.#selectionListenersAttached = true; @@ -423,6 +486,7 @@ export default class SharedEditManager extends Service { const textarea = document.querySelector(TEXTAREA_SELECTOR); if (textarea) { textarea.removeEventListener("mousedown", this.#onTextareaMouseDown); + textarea.removeEventListener("keydown", this.#onTextareaKeydown); } document.removeEventListener("mouseup", this.#onTextareaMouseUp); this.#selectionListenersAttached = false; @@ -493,7 +557,50 @@ export default class SharedEditManager extends Service { this.suppressComposerChange = false; if (textarea) { - textarea.value = text; + const currentValue = textarea.value; + if (currentValue === text) { + // Already in sync (e.g. local edit echoed back or no change) + return; + } + + let appliedSurgically = false; + + if (event.delta) { + // Calculate expected length of "old" text (State A) from current length (State B) and delta + let expectedOldLength = text.length; + let insertLen = 0; + let deleteLen = 0; + + for (const op of event.delta) { + if (op.insert) { + insertLen += typeof op.insert === "string" ? op.insert.length : 0; + } else if (op.delete) { + deleteLen += op.delete; + } + } + // Length B = Length A + Inserts - Deletes + // Length A = Length B - Inserts + Deletes + expectedOldLength = expectedOldLength - insertLen + deleteLen; + + if (currentValue.length === expectedOldLength) { + let index = 0; + event.delta.forEach((op) => { + if (op.retain) { + index += op.retain; + } else if (op.insert) { + textarea.setRangeText(op.insert, index, index); + index += op.insert.length; + } else if (op.delete) { + textarea.setRangeText("", index, index + op.delete); + } + }); + appliedSurgically = true; + } + } + + if (!appliedSurgically) { + this.#applyDiffToTextarea(textarea, currentValue, text); + } if (adjustedSelection) { textarea.selectionStart = adjustedSelection.start; @@ -571,7 +678,7 @@ export default class SharedEditManager extends Service { // Use throttle instead of debounce so updates sync periodically during // continuous typing, not just when typing stops. With immediate=true, // the first call executes immediately, then at most once per THROTTLE_SAVE ms. - throttle(this, this.#sendUpdates, THROTTLE_SAVE, true); + throttle(this, this.#sendUpdates, THROTTLE_SAVE, false); } async #flushPendingUpdates() { From 4abd0284f488c33b9149cc57170c60d9a823d823 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Sun, 30 Nov 2025 09:38:15 +1100 Subject: [PATCH 15/19] some agent files --- AGENTS.md | 39 +++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + GEMINI.md | 1 + 3 files changed, 41 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 120000 GEMINI.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2286027 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Discourse Shared Edits Plugin – AI Coding Agent Guide + +- Always start by reading ../../AGENTS.md to understand Discourse-wide conventions. +- While working on the plugin always feel free to consult Discourse source for best practices, patterns, and utilities. +- NEVER make commits to the repo, always leave it to humans to commit the code. + +## Scope & Feature Flags +- Lives at `plugins/discourse-shared-edits`; everything here only runs when `SiteSetting.shared_edits_enabled` (defined in `config/settings.yml`) is true and the per-post custom field `shared_edits_enabled` has been toggled via `SharedEditRevision.toggle_shared_edits!`. +- Guardian hook (`lib/discourse_shared_edits/guardian_extension.rb`) restricts enable/disable/reset/recover endpoints to staff or trust level 4+. Reuse `guardian.ensure_can_toggle_shared_edits!` for any new privileged action. +- API routes live under `/shared_edits` (`plugin.rb`). Do not rename them without updating the Ember service and the Pretender fixtures in `test/javascripts`. + +## Backend Architecture & Expectations +- `app/controllers/discourse_shared_edits/revision_controller.rb` is the only HTTP surface. Every new server feature must enforce `requires_plugin`, `requires_login`, and `ensure_shared_edits` guards, and must return JSON (never 204 when clients expect a body). Respond with `message_bus_last_id` whenever clients need to subscribe after fetching state. +- `app/models/shared_edit_revision.rb` stores every Yjs update. Treat `raw` as the authoritative, base64-encoded document snapshot and `revision` as the individual update payload. Always use the provided class methods (`init!`, `revise!`, `commit!`, `toggle_shared_edits!`, `reset_history!`, etc.) so Redis scheduling (`ensure_will_commit` + `Jobs::CommitSharedRevision`), message bus fan-out, editor attribution, and compaction invariants stay intact. +- `lib/discourse_shared_edits/state_validator.rb` is the gatekeeper for base64/Yjs safety, `max_post_length`, health reports, and corruption recovery. Any code that manipulates Yjs blobs must run through the validator helpers (or add new helpers here) so that errors surface as `StateCorruptionError` and can trigger automatic recovery. +- `lib/discourse_shared_edits/yjs.rb` wraps a shared `MiniRacer::Context` that executes the bundled `public/javascripts/yjs-dist.js`. Never eval ad-hoc scripts elsewhere; if you need a new primitive, add it to this wrapper so both Ruby and Ember flows stay aligned on how docs are encoded. +- Background commits: updates are throttled client-side, but the server still schedules `Jobs::CommitSharedRevision` 10 seconds out using a Redis key per post. If you change commit timing, update both `ensure_will_commit` and the job to avoid duplicate commits or missed flushes. +- Recovery + maintenance endpoints: `health`, `recover`, and `reset` all use `StateValidator` and emit `/shared_edits/:post_id` message-bus resync events. When adding new maintenance operations, emit the same payload shape (`{ action: "resync", version: }`) so the Ember service understands it. +- Database: migrations live in `db/migrate`. The original table creation (`20200721001123_migrate_shared_edits.rb`) plus the column resize (`20251124000123_resize_shared_edit_columns.rb`) show expectations: always provide `down` paths, mark large operations `algorithm: :concurrently` when indexing, and protect edits on large tables. + +## Frontend Architecture & Expectations +- `assets/javascripts/discourse/services/shared-edit-manager.js` is the heart of the client: it lazy-loads Yjs via `/plugins/discourse-shared-edits/javascripts/yjs-dist.js`, mirrors composer text into a shared `Y.Doc`, throttles PUTs to `/shared_edits/p/:post_id`, and subscribes to `/shared_edits/:post_id` on `messageBus`. Preserve: message payload keys (`version`, `update`, `client_id`, `user_id`, `user_name`), selection/cursor broadcasting, throttling constants (`THROTTLE_SAVE`, `THROTTLE_SELECTION`), and cleanup of DOM listeners/cursor overlays to avoid leaks. +- Composer integration lives in `assets/javascripts/discourse/initializers/shared-edits-init.js` and `extend-composer-service.js`. Always guard new behavior with `siteSettings.shared_edits_enabled`, register hooks via `withPluginApi`, and respect `creatingSharedEdit`/`editingPost` semantics so we never leave the composer in a half-shared state. +- UI pieces: the post action replacement is in `components/shared-edit-button.gjs`; the composer “Done” button lives in `connectors/composer-fields-below/shared-edit-buttons.gjs`; shared styles are under `assets/stylesheets/common/discourse-shared-edits.scss`; cursor rendering utilities are in `assets/javascripts/discourse/lib/{caret-coordinates,cursor-overlay}.js`. Keep strings translatable (`shared_edits.*` keys exist on both client and server locales). +- Asset bundling: `public/javascripts/yjs-dist.js` is generated via `bin/rake shared_edits:yjs:build` (`lib/tasks/yjs.rake` wraps `pnpm exec esbuild …`). Never hand-edit the bundled file; re-bundle whenever `yjs` changes and commit the new artifact. + +## Testing, Linting & Tooling +- Ruby specs cover validators, model behavior, controller endpoints, and basic system flows. Run `bin/rspec plugins/discourse-shared-edits/spec/` (requires `LOAD_PLUGINS=1` when running outside the full suite). `spec/system` relies on page objects; avoid raw Capybara finders for new tests. +- Ember acceptance tests live at `plugins/discourse-shared-edits/test/javascripts/acceptance`. Execute them with `bin/qunit plugins/discourse-shared-edits/test/javascripts/acceptance/composer-test.js` (or the directory to run them all). +- Lint every file you touch: `bin/lint plugins/discourse-shared-edits/` for Ruby/JS/SCSS and `pnpm --filter discourse-shared-edits lint` if you need the plugin-level configs from `package.json`. Stylelint and template lint configs already live alongside the plugin—respect them when adding files. +- Node tooling: the plugin pins Node ≥ 22 and pnpm 9 (`package.json`). Use `pnpm install` inside the plugin when you add JS dependencies so lockfiles stay in `plugins/discourse-shared-edits/pnpm-lock.yaml`. + +## Operational Tips & Utilities +- Manual QA: `plugins/discourse-shared-edits/support/fake_writer` uses Playwright to simulate concurrent editors. Run `support/fake_writer POST_ID --speed=fast --headless=false` against a dev instance to reproduce race conditions before shipping protocol changes. +- Message bus hygiene: `SharedEditRevision::MESSAGE_BUS_MAX_BACKLOG_*` caps backlog size/age. Keep any new channels under the same limits or we risk unbounded Redis usage. +- Edit reasons: `SharedEditRevision.update_edit_reason` builds `shared_edits.reason` strings listing everyone who contributed between commits. If you change commit batching or editor attribution, update both the method and translations. +- Recovery workflow: corruption is surfaced in logs and bubbled to the client via `state_recovered` / `state_corrupted` error codes. When adding new error states, expose translated messaging in `config/locales/client.*` and wire them into the composer UI. +- Selection sharing: the Ember service currently attempts to PUT `/shared_edits/p/:post_id/selection`. The endpoint is not implemented yet, so requests are best-effort and errors are ignored; reuse that route if you decide to ship cursor/selection sync so the client code does not need changing. +- Knowledge sharing: keep this file current whenever you add new entry points, commands, or conventions. After completing any task that touches this plugin, spawn a review agent to compare your diff against `plugins/discourse-shared-edits/AGENTS.md` and confirm the instructions remain accurate. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 11420fb6fb6fada1e88df9f69858096be8478d92 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Sun, 30 Nov 2025 09:46:16 +1100 Subject: [PATCH 16/19] FEATURE: show remote cursor when user is typing --- app/models/shared_edit_revision.rb | 1 + .../discourse/lib/caret-coordinates.js | 114 ++++++++++ .../discourse/lib/cursor-overlay.js | 209 ++++++++++++++++++ .../discourse/services/shared-edit-manager.js | 56 ++++- .../common/discourse-shared-edits.scss | 40 ++++ .../acceptance/shared-edits-cursor-test.js | 75 +++++++ 6 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 assets/javascripts/discourse/lib/caret-coordinates.js create mode 100644 assets/javascripts/discourse/lib/cursor-overlay.js create mode 100644 test/javascripts/acceptance/shared-edits-cursor-test.js diff --git a/app/models/shared_edit_revision.rb b/app/models/shared_edit_revision.rb index a38a131..eee1be7 100644 --- a/app/models/shared_edit_revision.rb +++ b/app/models/shared_edit_revision.rb @@ -282,6 +282,7 @@ def self.revise!(post_id:, user_id:, client_id:, update:) update: update, client_id: client_id, user_id: user_id, + user_name: User.find(user_id).username, } # Limit backlog to prevent unbounded Redis growth post.publish_message!( diff --git a/assets/javascripts/discourse/lib/caret-coordinates.js b/assets/javascripts/discourse/lib/caret-coordinates.js new file mode 100644 index 0000000..79789f9 --- /dev/null +++ b/assets/javascripts/discourse/lib/caret-coordinates.js @@ -0,0 +1,114 @@ +const properties = [ + "direction", + "boxSizing", + "width", + "height", + "overflowX", + "overflowY", + "borderTopWidth", + "borderRightWidth", + "borderBottomWidth", + "borderLeftWidth", + "borderStyle", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "fontStyle", + "fontVariant", + "fontWeight", + "fontStretch", + "fontSize", + "fontSizeAdjust", + "lineHeight", + "fontFamily", + "textAlign", + "textTransform", + "textIndent", + "textDecoration", + "letterSpacing", + "wordSpacing", + "tabSize", + "MozTabSize", + "whiteSpace", + "wordBreak", + "overflowWrap", + "wordWrap", +]; + +const isBrowser = typeof window !== "undefined"; + +export default function getCaretCoordinates(element, position) { + if (!isBrowser) { + throw new Error( + "getCaretCoordinates should only be called in a browser context" + ); + } + + const div = document.createElement("div"); + div.id = "input-textarea-caret-position-mirror-div"; + document.body.appendChild(div); + + const style = div.style; + const computed = window.getComputedStyle(element); + + properties.forEach((prop) => { + style[prop] = computed[prop]; + }); + + style.whiteSpace = "pre-wrap"; + style.overflowWrap = "break-word"; + style.position = "absolute"; + style.visibility = "hidden"; + + // Use getBoundingClientRect to get the precise fractional width of the element. + // This is critical because clientWidth rounds to an integer, which can cause + // incorrect line wrapping in the mirror div if the actual width is fractional + // (common on high-DPI displays or with percentage widths). + const rect = element.getBoundingClientRect(); + style.width = `${rect.width}px`; + style.boxSizing = "border-box"; + style.overflow = "hidden"; + + // Adjust padding-right to account for the scrollbar if it exists. + // The mirror div has overflow:hidden, so it won't have a scrollbar. + // We must reserve that space manually to match the text wrapping area. + // offsetWidth and clientWidth are integers, so the scrollbar width calculation is safe. + const borderLeft = parseFloat(computed.borderLeftWidth); + const borderRight = parseFloat(computed.borderRightWidth); + const scrollbarWidth = + element.offsetWidth - element.clientWidth - borderLeft - borderRight; + + if (scrollbarWidth > 0) { + style.paddingRight = `${ + parseFloat(computed.paddingRight) + scrollbarWidth + }px`; + } + + let pos = parseInt(position, 10); + if (isNaN(pos)) { + pos = 0; + } + + div.textContent = element.value.substring(0, pos); + + const span = document.createElement("span"); + // Use Zero Width Space to prevent the probe character from causing a wrap + // if the line is exactly full. + span.textContent = element.value.substring(pos) || "\u200b"; + div.appendChild(span); + + const coordinates = { + top: span.offsetTop + parseInt(computed.borderTopWidth, 10), + left: span.offsetLeft + parseInt(computed.borderLeftWidth, 10), + height: parseInt(computed.lineHeight, 10), + }; + + if (isNaN(coordinates.height)) { + coordinates.height = parseInt(computed.fontSize, 10) * 1.2; // Fallback + } + + document.body.removeChild(div); + + return coordinates; +} diff --git a/assets/javascripts/discourse/lib/cursor-overlay.js b/assets/javascripts/discourse/lib/cursor-overlay.js new file mode 100644 index 0000000..9f83d7b --- /dev/null +++ b/assets/javascripts/discourse/lib/cursor-overlay.js @@ -0,0 +1,209 @@ +import getCaretCoordinates from "../lib/caret-coordinates"; + +export default class CursorOverlay { + constructor(textarea) { + this.textarea = textarea; + this.container = document.createElement("div"); + this.container.className = "shared-edits-cursor-overlay"; + + this.updateContainerPosition(); + + const parent = textarea.parentElement; + if (getComputedStyle(parent).position === "static") { + parent.style.position = "relative"; + } + parent.appendChild(this.container); + + this.cursors = new Map(); + this.activeTypists = new Map(); + + this.boundOnScroll = this.onScroll.bind(this); + this.textarea.addEventListener("scroll", this.boundOnScroll, { + passive: true, + }); + + // Observe resize to update container dimensions + this.resizeObserver = new ResizeObserver(() => { + this.updateContainerPosition(); + this.refresh(); + }); + this.resizeObserver.observe(textarea); + } + + updateContainerPosition() { + this.container.style.top = `${this.textarea.offsetTop}px`; + this.container.style.left = `${this.textarea.offsetLeft}px`; + this.container.style.width = `${this.textarea.offsetWidth}px`; + this.container.style.height = `${this.textarea.offsetHeight}px`; + } + + onScroll() { + this.cursors.forEach((cursor) => this.renderCursor(cursor)); + } + + refresh() { + const Y = window.Y; + this.cursors.forEach((cursor) => { + if (cursor.relativePosition && cursor.doc) { + const absolutePosition = Y.createAbsolutePositionFromRelativePosition( + cursor.relativePosition, + cursor.doc + ); + + if (absolutePosition) { + this.calculateCursorPosition(cursor, absolutePosition.index); + } + } + }); + } + + updateCursor(clientId, origin, relativePosition, doc) { + const Y = window.Y; + let cursor = this.cursors.get(clientId); + + // Handle user change for same client (rare but possible) + if (cursor && cursor.user.username !== origin.user_name) { + cursor.element.remove(); + this.cursors.delete(clientId); + cursor = null; + } + + const isNew = !cursor; + if (isNew) { + cursor = this.createCursorElement({ + user_id: origin.user_id, + user_name: origin.user_name, + }); + this.cursors.set(clientId, cursor); + } + + cursor.clientId = clientId; + cursor.relativePosition = relativePosition; + cursor.origin = origin; + cursor.doc = doc; + + const absolutePosition = Y.createAbsolutePositionFromRelativePosition( + relativePosition, + doc + ); + + if (absolutePosition) { + this.markTypist(clientId); + this.calculateCursorPosition(cursor, absolutePosition.index); + } + + if (isNew) { + this.container.appendChild(cursor.element); + } + } + + markTypist(clientId) { + const now = Date.now(); + const typist = this.activeTypists.get(clientId) || {}; + + if (typist.timeout) { + clearTimeout(typist.timeout); + } + + typist.lastTyped = now; + typist.timeout = setTimeout(() => { + const cursor = this.cursors.get(clientId); + if (cursor) { + cursor.element.style.display = "none"; + } + }, 5000); + + this.activeTypists.set(clientId, typist); + } + + calculateCursorPosition(cursor, index) { + // Only show if active typist (typed within last 5 seconds) + const typist = this.activeTypists.get(cursor.clientId); + const isActive = typist && Date.now() - typist.lastTyped < 5000; + + if (!isActive) { + cursor.element.style.display = "none"; + return; + } + + // Calculate absolute coordinates (relative to content start) + const viewCoords = this.getViewCoords(index); + if (viewCoords) { + cursor.absoluteTop = viewCoords.top; + cursor.absoluteLeft = viewCoords.left; + cursor.height = viewCoords.height; + + // Check if on first line (approximate check using a safe threshold, e.g., < 1.5em) + const isFirstLine = viewCoords.top < (viewCoords.height || 20) * 1.2; + if (isFirstLine) { + cursor.label.classList.add("shared-edits-cursor__label--bottom"); + } else { + cursor.label.classList.remove("shared-edits-cursor__label--bottom"); + } + cursor.element.style.display = "block"; + this.renderCursor(cursor); + } else { + cursor.element.style.display = "none"; + } + } + + renderCursor(cursor) { + const top = cursor.absoluteTop - this.textarea.scrollTop; + const left = cursor.absoluteLeft - this.textarea.scrollLeft; + + cursor.element.style.transform = `translate(${left}px, ${top}px)`; + if (cursor.height) { + cursor.element.style.height = `${cursor.height}px`; + } + } + + getViewCoords(index) { + return getCaretCoordinates(this.textarea, index); + } + + createCursorElement(user) { + const el = document.createElement("div"); + el.className = "shared-edits-cursor"; + + const color = this.getColor(user.user_id); + el.style.borderColor = color; + + const label = document.createElement("div"); + label.className = "shared-edits-cursor__label"; + label.textContent = user.user_name; label.style.backgroundColor = color; + + el.appendChild(label); + + return { element: el, label, user }; + } + + removeCursor(clientId) { + const cursor = this.cursors.get(clientId); + if (cursor) { + cursor.element.remove(); + this.cursors.delete(clientId); + } + } + + clearPosition(clientId) { + const cursor = this.cursors.get(clientId); + if (cursor) { + cursor.relativePosition = null; + cursor.element.style.display = "none"; + } + } + + getColor(id) { + const index = (id || 0) % 7; + return `var(--shared-edit-color-${index + 1})`; + } + destroy() { + this.textarea.removeEventListener("scroll", this.boundOnScroll); + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + this.container.remove(); + this.cursors.clear(); + this.activeTypists.clear(); + } +} diff --git a/assets/javascripts/discourse/services/shared-edit-manager.js b/assets/javascripts/discourse/services/shared-edit-manager.js index 7dd4fb4..0445d3a 100644 --- a/assets/javascripts/discourse/services/shared-edit-manager.js +++ b/assets/javascripts/discourse/services/shared-edit-manager.js @@ -3,6 +3,7 @@ import Service, { service } from "@ember/service"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import loadScript from "discourse/lib/load-script"; +import CursorOverlay from "../lib/cursor-overlay"; const THROTTLE_SAVE = 350; const TEXTAREA_SELECTOR = "#reply-control textarea.d-editor-input"; @@ -144,7 +145,12 @@ export default class SharedEditManager extends Service { } const update = base64ToUint8Array(message.update); - window.Y.applyUpdate(this.doc, update, "remote"); + window.Y.applyUpdate(this.doc, update, { + type: "remote", + client_id: message.client_id, + user_id: message.user_id, + user_name: message.user_name, + }); }; #handleDocUpdate = (update, origin) => { @@ -435,6 +441,11 @@ export default class SharedEditManager extends Service { this.#attachSelectionListeners(); + const textarea = document.querySelector(TEXTAREA_SELECTOR); + if (textarea) { + this.cursorOverlay = new CursorOverlay(textarea); + } + this.suppressComposerChange = true; this.composer.model.set("reply", this.text.toString()); this.suppressComposerChange = false; @@ -456,6 +467,11 @@ export default class SharedEditManager extends Service { this.#detachSelectionListeners(); + if (this.cursorOverlay) { + this.cursorOverlay.destroy(); + this.cursorOverlay = null; + } + this.doc = null; this.text = null; this.textObserver = null; @@ -513,6 +529,41 @@ export default class SharedEditManager extends Service { } #handleTextChange(event, transaction) { + // Update remote cursors based on text changes + if ( + transaction.origin && + transaction.origin.type === "remote" && + this.cursorOverlay + ) { + const origin = transaction.origin; + let index = 0; + event.delta.forEach((op) => { + if (op.retain) { + index += op.retain; + } + if (op.insert) { + const length = typeof op.insert === "string" ? op.insert.length : 1; + index += length; + } + }); + + // Create a relative position for the remote cursor so it sticks to this text + const relativePosition = window.Y.createRelativePositionFromTypeIndex( + this.text, + index, + -1 + ); + + this.cursorOverlay.updateCursor( + origin.client_id, + origin, + relativePosition, + this.doc + ); + } + + this.cursorOverlay?.refresh(); + if (transaction?.origin === this) { return; } @@ -602,6 +653,9 @@ export default class SharedEditManager extends Service { this.#applyDiffToTextarea(textarea, currentValue, text); } + // Refresh cursor overlay positions as text layout may have changed + this.cursorOverlay?.refresh(); + if (adjustedSelection) { textarea.selectionStart = adjustedSelection.start; textarea.selectionEnd = adjustedSelection.end; diff --git a/assets/stylesheets/common/discourse-shared-edits.scss b/assets/stylesheets/common/discourse-shared-edits.scss index ab900f4..29726b3 100644 --- a/assets/stylesheets/common/discourse-shared-edits.scss +++ b/assets/stylesheets/common/discourse-shared-edits.scss @@ -1,3 +1,13 @@ +:root { + --shared-edit-color-1: #ff0000; + --shared-edit-color-2: #00aa00; + --shared-edit-color-3: #0000ff; + --shared-edit-color-4: #ff00ff; + --shared-edit-color-5: #00aaaa; + --shared-edit-color-6: #ffa500; + --shared-edit-color-7: #800080; +} + #reply-control.composer-action-shared-edit { .save-or-cancel { button, @@ -19,3 +29,33 @@ margin-right: 0.5em; } } + +.shared-edits-cursor-overlay { + position: absolute; + pointer-events: none; + overflow: hidden; + z-index: 100; +} + +.shared-edits-cursor { + position: absolute; + height: 1.2em; + will-change: transform; + border-left-width: 2px; + border-left-style: solid; +} + +.shared-edits-cursor__label { + position: absolute; + top: -1.6em; + left: -2px; + font-size: 0.75em; + padding: 1px 4px; + border-radius: 3px; + white-space: nowrap; + color: var(--secondary); + + &--bottom { + top: 1.2em; + } +} diff --git a/test/javascripts/acceptance/shared-edits-cursor-test.js b/test/javascripts/acceptance/shared-edits-cursor-test.js new file mode 100644 index 0000000..39fd698 --- /dev/null +++ b/test/javascripts/acceptance/shared-edits-cursor-test.js @@ -0,0 +1,75 @@ +import { click, visit, triggerEvent, waitUntil, getContext } from "@ember/test-helpers"; +import { test } from "qunit"; +import { acceptance, publishToMessageBus } from "discourse/tests/helpers/qunit-helpers"; + +acceptance(`Discourse Shared Edits | Cursors & Selection`, function (needs) { + needs.user(); + let pretenderServer; + + needs.pretender((server, helper) => { + pretenderServer = server; + + server.put("/shared_edits/p/:id/enable.json", () => + helper.response({ success: "OK" }) + ); + + server.get("/posts/:id.json", () => + helper.response({ + id: 398, + raw: "initial post content", + }) + ); + + server.get("/shared_edits/p/:id", () => + helper.response({ + state: "", + raw: "content", + version: 1, + message_bus_last_id: 0, + }) + ); + + server.put("/shared_edits/p/:id", () => helper.response({ success: "OK" })); + server.put("/shared_edits/p/:id/selection", () => helper.response({ success: "OK" })); + server.put("/shared_edits/p/:id/commit", () => helper.response({ success: "OK" })); + }); + + + test("displays remote cursor when remote update is received", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".show-more-actions"); + await click(".show-post-admin-menu"); + await click(".admin-toggle-shared-edits"); + await click(".shared-edit"); + + assert.dom(".shared-edits-cursor-overlay").exists("Cursor overlay container created"); + + // Wait for Yjs to be loaded by the application + await waitUntil(() => window.Y); + const Y = window.Y; + + // Create a valid Yjs update + const doc = new Y.Doc(); + const text = doc.getText("post"); + text.insert(0, " remote edit"); + const update = Y.encodeStateAsUpdate(doc); + // Convert Uint8Array to base64 + const base64Update = btoa(String.fromCharCode.apply(null, update)); + + // Simulate remote message + await publishToMessageBus("/shared_edits/398", { + client_id: "remote-client-1", + user_id: 123, + user_name: "remoteuser", + update: base64Update, + }); + + // The update should trigger the text observer -> CursorOverlay.updateCursor -> Render + + // Wait for UI update + await waitUntil(() => document.querySelector(".shared-edits-cursor")); + + assert.dom(".shared-edits-cursor").exists("Remote cursor element created"); + assert.dom(".shared-edits-cursor__label").hasText("remoteuser", "Cursor label shows username"); + }); +}); From 43cc5e5ec9e2ef73463573f79bd05533229c4c57 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Sun, 30 Nov 2025 10:26:30 +1100 Subject: [PATCH 17/19] added linting and a missing test --- AGENTS.md | 6 ++ .../discourse/lib/cursor-overlay.js | 28 +++++---- .../common/discourse-shared-edits.scss | 10 +-- spec/models/shared_edit_revision_spec.rb | 30 +++++++++ spec/system/edit_spec.rb | 12 +--- support/lint | 63 +++++++++++++++++++ .../acceptance/shared-edits-cursor-test.js | 30 +++++---- 7 files changed, 140 insertions(+), 39 deletions(-) create mode 100644 support/lint diff --git a/AGENTS.md b/AGENTS.md index 2286027..4bfe6b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,11 @@ - While working on the plugin always feel free to consult Discourse source for best practices, patterns, and utilities. - NEVER make commits to the repo, always leave it to humans to commit the code. +## Linting + +- run support/lint to lint files +- run support/lint --fix to attempt fixes + ## Scope & Feature Flags - Lives at `plugins/discourse-shared-edits`; everything here only runs when `SiteSetting.shared_edits_enabled` (defined in `config/settings.yml`) is true and the per-post custom field `shared_edits_enabled` has been toggled via `SharedEditRevision.toggle_shared_edits!`. - Guardian hook (`lib/discourse_shared_edits/guardian_extension.rb`) restricts enable/disable/reset/recover endpoints to staff or trust level 4+. Reuse `guardian.ensure_can_toggle_shared_edits!` for any new privileged action. @@ -37,3 +42,4 @@ - Recovery workflow: corruption is surfaced in logs and bubbled to the client via `state_recovered` / `state_corrupted` error codes. When adding new error states, expose translated messaging in `config/locales/client.*` and wire them into the composer UI. - Selection sharing: the Ember service currently attempts to PUT `/shared_edits/p/:post_id/selection`. The endpoint is not implemented yet, so requests are best-effort and errors are ignored; reuse that route if you decide to ship cursor/selection sync so the client code does not need changing. - Knowledge sharing: keep this file current whenever you add new entry points, commands, or conventions. After completing any task that touches this plugin, spawn a review agent to compare your diff against `plugins/discourse-shared-edits/AGENTS.md` and confirm the instructions remain accurate. +- Tip: Run `plugins/discourse-shared-edits/support/lint` from the repo root (add `--fix`/`-f` to trigger auto-fix variants) to execute the full GitHub lint suite without guessing binaries. diff --git a/assets/javascripts/discourse/lib/cursor-overlay.js b/assets/javascripts/discourse/lib/cursor-overlay.js index 9f83d7b..ade8da5 100644 --- a/assets/javascripts/discourse/lib/cursor-overlay.js +++ b/assets/javascripts/discourse/lib/cursor-overlay.js @@ -134,12 +134,12 @@ export default class CursorOverlay { cursor.height = viewCoords.height; // Check if on first line (approximate check using a safe threshold, e.g., < 1.5em) - const isFirstLine = viewCoords.top < (viewCoords.height || 20) * 1.2; - if (isFirstLine) { - cursor.label.classList.add("shared-edits-cursor__label--bottom"); - } else { - cursor.label.classList.remove("shared-edits-cursor__label--bottom"); - } + const isFirstLine = viewCoords.top < (viewCoords.height || 20) * 1.2; + if (isFirstLine) { + cursor.label.classList.add("shared-edits-cursor__label--bottom"); + } else { + cursor.label.classList.remove("shared-edits-cursor__label--bottom"); + } cursor.element.style.display = "block"; this.renderCursor(cursor); } else { @@ -168,9 +168,10 @@ export default class CursorOverlay { const color = this.getColor(user.user_id); el.style.borderColor = color; - const label = document.createElement("div"); - label.className = "shared-edits-cursor__label"; - label.textContent = user.user_name; label.style.backgroundColor = color; + const label = document.createElement("div"); + label.className = "shared-edits-cursor__label"; + label.textContent = user.user_name; + label.style.backgroundColor = color; el.appendChild(label); @@ -193,10 +194,11 @@ export default class CursorOverlay { } } - getColor(id) { - const index = (id || 0) % 7; - return `var(--shared-edit-color-${index + 1})`; - } + getColor(id) { + const index = (id || 0) % 7; + return `var(--shared-edit-color-${index + 1})`; + } + destroy() { this.textarea.removeEventListener("scroll", this.boundOnScroll); if (this.resizeObserver) { diff --git a/assets/stylesheets/common/discourse-shared-edits.scss b/assets/stylesheets/common/discourse-shared-edits.scss index 29726b3..294c889 100644 --- a/assets/stylesheets/common/discourse-shared-edits.scss +++ b/assets/stylesheets/common/discourse-shared-edits.scss @@ -1,9 +1,9 @@ :root { - --shared-edit-color-1: #ff0000; - --shared-edit-color-2: #00aa00; - --shared-edit-color-3: #0000ff; - --shared-edit-color-4: #ff00ff; - --shared-edit-color-5: #00aaaa; + --shared-edit-color-1: #f00; + --shared-edit-color-2: #0a0; + --shared-edit-color-3: #00f; + --shared-edit-color-4: #f0f; + --shared-edit-color-5: #0aa; --shared-edit-color-6: #ffa500; --shared-edit-color-7: #800080; } diff --git a/spec/models/shared_edit_revision_spec.rb b/spec/models/shared_edit_revision_spec.rb index 9cdff8c..c6b02e8 100644 --- a/spec/models/shared_edit_revision_spec.rb +++ b/spec/models/shared_edit_revision_spec.rb @@ -421,4 +421,34 @@ def fake_edit(post, user_id, new_text) expect(final_text).to be_present end end + + describe ".ensure_will_commit" do + fab!(:post) + + let(:redis) { Discourse.redis } + let(:key) { SharedEditRevision.will_commit_key(post.id) } + + before { redis.del(key) } + + it "schedules a job and sets a key if not already scheduled" do + expect(redis.get(key)).to be_nil + + expect { SharedEditRevision.ensure_will_commit(post.id) }.to change( + Jobs::CommitSharedRevision.jobs, + :size, + ).by(1) + + expect(redis.get(key)).to eq("1") + expect(redis.ttl(key)).to be_within(5).of(60) + end + + it "does not schedule a job if already scheduled" do + redis.setex(key, 60, "1") + + expect { SharedEditRevision.ensure_will_commit(post.id) }.not_to change( + Jobs::CommitSharedRevision.jobs, + :size, + ) + end + end end diff --git a/spec/system/edit_spec.rb b/spec/system/edit_spec.rb index 6d57db8..3c4a857 100644 --- a/spec/system/edit_spec.rb +++ b/spec/system/edit_spec.rb @@ -17,9 +17,7 @@ try_until_success do revision = SharedEditRevision.find_by(post_id: post.id, version: 1) expect(revision).to be_present - expect(DiscourseSharedEdits::Yjs.text_from_state(revision.raw)).to eq( - "lorem ipsum\n", - ) + expect(DiscourseSharedEdits::Yjs.text_from_state(revision.raw)).to eq("lorem ipsum\n") expect(revision.revision).to eq("") expect(SharedEditRevision.count).to eq(1) end @@ -31,9 +29,7 @@ try_until_success do revision = SharedEditRevision.find_by(post_id: post.id, version: 2) expect(revision).to be_present - expect( - DiscourseSharedEdits::Yjs.text_from_state(revision.raw), - ).to eq("lorem ipsum\nfoo") + expect(DiscourseSharedEdits::Yjs.text_from_state(revision.raw)).to eq("lorem ipsum\nfoo") expect(SharedEditRevision.count).to eq(2) end @@ -41,9 +37,7 @@ try_until_success do revision = SharedEditRevision.find_by(post_id: post.id, version: 3) expect(revision).to be_present - expect( - DiscourseSharedEdits::Yjs.text_from_state(revision.raw), - ).to eq("lorem ipsum\nfoo bar") + expect(DiscourseSharedEdits::Yjs.text_from_state(revision.raw)).to eq("lorem ipsum\nfoo bar") expect(SharedEditRevision.count).to eq(3) end diff --git a/support/lint b/support/lint new file mode 100644 index 0000000..f0df0f8 --- /dev/null +++ b/support/lint @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel) +cd "$repo_root" + +usage() { + cat <<'HELP' +Usage: support/lint [--fix] + +Runs the same lint steps as the GitHub CI workflow from the repo root. +Passing --fix (or -f) swaps in the autofix-friendly variants where available. +HELP +} + +fix=false +while (( $# )); do + case "$1" in + -f|--fix) + fix=true + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 1 + ;; + esac + shift +done + +run_cmd() { + printf '\n>>> %s\n' "$*" + "$@" +} + +readarray -t ruby_files < <(git ls-files '*.rb' '*.rake' '*.thor') + +do_stree() { + local subcommand=$1 + if (( ${#ruby_files[@]} > 0 )); then + run_cmd bundle exec stree "$subcommand" Gemfile "${ruby_files[@]}" + else + run_cmd bundle exec stree "$subcommand" Gemfile + fi +} + +if $fix; then + run_cmd bundle exec rubocop --force-exclusion -A + do_stree write + run_cmd pnpm prettier -w "assets/**/*.{scss,js,gjs,hbs}" + run_cmd pnpm eslint --fix --no-error-on-unmatched-pattern {test,assets,admin/assets}/javascripts + run_cmd pnpm stylelint --fix --allow-empty-input "assets/**/*.scss" +else + run_cmd bundle exec rubocop --force-exclusion + do_stree check + run_cmd pnpm prettier --check "assets/**/*.{scss,js,gjs,hbs}" + run_cmd pnpm eslint --no-error-on-unmatched-pattern {test,assets,admin/assets}/javascripts + run_cmd pnpm stylelint --allow-empty-input "assets/**/*.scss" +fi diff --git a/test/javascripts/acceptance/shared-edits-cursor-test.js b/test/javascripts/acceptance/shared-edits-cursor-test.js index 39fd698..464e0ce 100644 --- a/test/javascripts/acceptance/shared-edits-cursor-test.js +++ b/test/javascripts/acceptance/shared-edits-cursor-test.js @@ -1,14 +1,13 @@ -import { click, visit, triggerEvent, waitUntil, getContext } from "@ember/test-helpers"; +import { click, visit, waitUntil } from "@ember/test-helpers"; import { test } from "qunit"; -import { acceptance, publishToMessageBus } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + publishToMessageBus, +} from "discourse/tests/helpers/qunit-helpers"; acceptance(`Discourse Shared Edits | Cursors & Selection`, function (needs) { needs.user(); - let pretenderServer; - needs.pretender((server, helper) => { - pretenderServer = server; - server.put("/shared_edits/p/:id/enable.json", () => helper.response({ success: "OK" }) ); @@ -30,11 +29,14 @@ acceptance(`Discourse Shared Edits | Cursors & Selection`, function (needs) { ); server.put("/shared_edits/p/:id", () => helper.response({ success: "OK" })); - server.put("/shared_edits/p/:id/selection", () => helper.response({ success: "OK" })); - server.put("/shared_edits/p/:id/commit", () => helper.response({ success: "OK" })); + server.put("/shared_edits/p/:id/selection", () => + helper.response({ success: "OK" }) + ); + server.put("/shared_edits/p/:id/commit", () => + helper.response({ success: "OK" }) + ); }); - test("displays remote cursor when remote update is received", async function (assert) { await visit("/t/internationalization-localization/280"); await click(".show-more-actions"); @@ -42,7 +44,9 @@ acceptance(`Discourse Shared Edits | Cursors & Selection`, function (needs) { await click(".admin-toggle-shared-edits"); await click(".shared-edit"); - assert.dom(".shared-edits-cursor-overlay").exists("Cursor overlay container created"); + assert + .dom(".shared-edits-cursor-overlay") + .exists("Cursor overlay container created"); // Wait for Yjs to be loaded by the application await waitUntil(() => window.Y); @@ -65,11 +69,13 @@ acceptance(`Discourse Shared Edits | Cursors & Selection`, function (needs) { }); // The update should trigger the text observer -> CursorOverlay.updateCursor -> Render - + // Wait for UI update await waitUntil(() => document.querySelector(".shared-edits-cursor")); assert.dom(".shared-edits-cursor").exists("Remote cursor element created"); - assert.dom(".shared-edits-cursor__label").hasText("remoteuser", "Cursor label shows username"); + assert + .dom(".shared-edits-cursor__label") + .hasText("remoteuser", "Cursor label shows username"); }); }); From 11d6bfedd9ee03904873a25b489f8105fd3a193a Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Sun, 30 Nov 2025 11:05:57 +1100 Subject: [PATCH 18/19] disable spell checking when remote edits come in to work around firefox --- .../discourse/services/shared-edit-manager.js | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/assets/javascripts/discourse/services/shared-edit-manager.js b/assets/javascripts/discourse/services/shared-edit-manager.js index 0445d3a..b3cb8a9 100644 --- a/assets/javascripts/discourse/services/shared-edit-manager.js +++ b/assets/javascripts/discourse/services/shared-edit-manager.js @@ -7,6 +7,7 @@ import CursorOverlay from "../lib/cursor-overlay"; const THROTTLE_SAVE = 350; const TEXTAREA_SELECTOR = "#reply-control textarea.d-editor-input"; +const SPELLCHECK_SUSPEND_DURATION_MS = 1000; let yjsPromise; @@ -129,6 +130,9 @@ export default class SharedEditManager extends Service { #isSelecting = false; #selectionListenersAttached = false; #skippedUpdatesDuringSelection = false; + #spellcheckTimeoutId = null; + #spellcheckRestoreValue = null; + #spellcheckTextarea = null; #onRemoteMessage = (message) => { if (message.action === "resync") { @@ -140,6 +144,10 @@ export default class SharedEditManager extends Service { return; } + if (message.update) { + this.#temporarilyDisableSpellcheck(); + } + if (!this.#isSelecting) { this.#pendingRelativeSelection = this.#captureRelativeSelection(); } @@ -209,6 +217,11 @@ export default class SharedEditManager extends Service { } }; + willDestroy() { + this.#resetSpellcheckSuppression(); + super.willDestroy(...arguments); + } + #getTextareaSelection() { const textarea = document.querySelector(TEXTAREA_SELECTOR); if (!textarea) { @@ -452,6 +465,8 @@ export default class SharedEditManager extends Service { } #teardownDoc() { + this.#resetSpellcheckSuppression(); + if (this.text && this.textObserver) { this.text.unobserve(this.textObserver); } @@ -728,6 +743,51 @@ export default class SharedEditManager extends Service { }; } + #temporarilyDisableSpellcheck() { + const textarea = document.querySelector(TEXTAREA_SELECTOR); + + if (!textarea) { + return; + } + + if (this.#spellcheckRestoreValue === null) { + this.#spellcheckRestoreValue = textarea.spellcheck; + } + + this.#spellcheckTextarea = textarea; + textarea.spellcheck = false; + + if (this.#spellcheckTimeoutId) { + clearTimeout(this.#spellcheckTimeoutId); + } + + this.#spellcheckTimeoutId = setTimeout(() => { + this.#spellcheckTimeoutId = null; + this.#applySpellcheckRestore(); + }, SPELLCHECK_SUSPEND_DURATION_MS); + } + + #applySpellcheckRestore() { + if ( + this.#spellcheckTextarea?.isConnected && + this.#spellcheckRestoreValue !== null + ) { + this.#spellcheckTextarea.spellcheck = this.#spellcheckRestoreValue; + } + + this.#spellcheckTextarea = null; + this.#spellcheckRestoreValue = null; + } + + #resetSpellcheckSuppression() { + if (this.#spellcheckTimeoutId) { + clearTimeout(this.#spellcheckTimeoutId); + this.#spellcheckTimeoutId = null; + } + + this.#applySpellcheckRestore(); + } + #sendUpdatesThrottled() { // Use throttle instead of debounce so updates sync periodically during // continuous typing, not just when typing stops. With immediate=true, From fb25f414e79df23567dc1fcea3f7b8bbb9ca5302 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Sun, 30 Nov 2025 15:35:04 +1100 Subject: [PATCH 19/19] Simple spec for testing job --- spec/jobs/commit_shared_revision_spec.rb | 41 ++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 spec/jobs/commit_shared_revision_spec.rb diff --git a/spec/jobs/commit_shared_revision_spec.rb b/spec/jobs/commit_shared_revision_spec.rb new file mode 100644 index 0000000..5160325 --- /dev/null +++ b/spec/jobs/commit_shared_revision_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Jobs::CommitSharedRevision do + fab!(:post) { Fabricate(:post, raw: "Original content") } + fab!(:user) + + before do + SiteSetting.shared_edits_enabled = true + SharedEditRevision.init!(post) + end + + def latest_state + SharedEditRevision.where(post_id: post.id).order("version desc").first.raw + end + + it "commits pending revisions and clears the deferred commit sentinel" do + redis = Discourse.redis + key = SharedEditRevision.will_commit_key(post.id) + redis.setex(key, 60, "1") + + update = DiscourseSharedEdits::Yjs.update_from_state(latest_state, "Edited content") + + SharedEditRevision.revise!( + post_id: post.id, + user_id: user.id, + client_id: "test-client", + update: update, + ) + + expect { described_class.new.execute(post_id: post.id) }.to change { post.reload.raw }.to( + "Edited content", + ) + + expect(redis.get(key)).to be_nil + + latest_revision = SharedEditRevision.where(post_id: post.id).order("version desc").first + expect(latest_revision.post_revision_id).to be_present + end +end